Skip to content

UI

The @yagejs/ui package provides screen-space UI powered by Yoga flexbox layout. Build menus, HUDs, and overlays with a fluent builder API.

For a React-based alternative, see UI (React).

import { UIPlugin } from "@yagejs/ui";
engine.use(new UIPlugin());

The plugin depends on @yagejs/renderer.

UIPanel is the root UI component. Add it to an entity to create a UI tree.

import { UIPanel, Anchor } from "@yagejs/ui";
const ui = this.spawn("hud");
ui.add(new Transform());
ui.add(new UIPanel({
anchor: Anchor.TopLeft,
direction: "column",
gap: 8,
padding: 16,
background: { color: 0x000000, alpha: 0.7, radius: 8 },
}));

Panel options:

PropertyTypeDefaultDescription
anchorAnchorScreen-space position
offset{ x, y }Pixel offset from anchor
direction"row" | "column""column"Flex direction
gapnumberSpace between children
paddingnumber | PaddingInner padding
alignItemsstringCross-axis alignment
justifyContentstringMain-axis alignment
overflow"visible" | "hidden""visible"Overflow behavior
backgroundBackgroundOptionsColor or texture background
layerstringRender layer name
visiblebooleantrueInitial visibility

The Anchor enum positions panels relative to the screen:

import { Anchor } from "@yagejs/ui";
Anchor.TopLeft Anchor.TopCenter Anchor.TopRight
Anchor.CenterLeft Anchor.Center Anchor.CenterRight
Anchor.BottomLeft Anchor.BottomCenter Anchor.BottomRight

Add an offset to fine-tune position:

new UIPanel({
anchor: Anchor.TopRight,
offset: { x: -16, y: 16 },
})

A panel’s position comes from two independent choices: which layer it lives on (screen-space HUD or world-space overlay) and its positioning option (viewport-anchored or Transform-driven).

anchor resolves against the viewport (virtualSize), offset is a pixel nudge. This is the classic HUD / menu behavior and the default for a reason — it’s what HUDs want.

The panel’s root container is positioned at entity.get(Transform).worldPosition in the target layer’s local coord space, and anchor is reinterpreted as the pivot on the panel itself:

  • Anchor.Center → panel’s center sits at the Transform.
  • Anchor.BottomCenter → panel’s bottom-center sits at the Transform (the natural “hovers above this entity” primitive for nameplates and health bars).

offset is still a pixel nudge, applied after the pivot. The entity must have a Transform or the panel throws at add time.

This option is orthogonal to the layer’s space:

  • Screen-space layer + positioning: "transform": pair with ScreenFollow from @yagejs/renderer. ScreenFollow writes camera.worldToScreen(target) + offset to the Transform each frame (the offset is in screen pixels, applied after projection), so the UI tracks a target entity but stays axis-aligned and constant-size regardless of camera zoom or rotation. This is the canonical billboard pattern.
  • World-space layer + positioning: "transform": the UI is pinned to a real world coordinate and scales / rotates with the camera like any other world object. Useful for genuinely diegetic UI — a sign in the world, an LED on a machine.

The common “nameplate above an enemy” pattern:

import { ScreenFollow } from "@yagejs/renderer";
class Enemy extends Entity {
setup(params: {
x: number; y: number; label: string; camera: CameraEntity;
}) {
this.add(new Transform({ position: new Vec2(params.x, params.y) }));
this.add(new Health({ max: 100 }));
// Body, nameplate, and HP bar are all siblings under this entity.
// Parenting expresses "these belong to this enemy" structurally —
// so when the enemy is destroyed, cascade-destroy cleans the UI
// children up automatically. Positioning still flows through
// ScreenFollow for the UI siblings so they stay axis-aligned and
// constant-size under camera zoom/rotation, regardless of parenting.
this.spawnChild("body", EnemyBody, { color: 0xff6b6b });
this.spawnChild("nameplate", EnemyNameplate, {
target: this,
camera: params.camera,
label: params.label,
});
}
}
class EnemyNameplate extends Entity {
setup(params: {
target: Entity; camera: CameraEntity; label: string;
}) {
this.add(new Transform());
this.add(new ScreenFollow({
target: params.target,
camera: params.camera,
offset: new Vec2(0, -40), // 40 screen px above the target, at any zoom
}));
const panel = this.add(new UIPanel({
positioning: "transform",
anchor: Anchor.BottomCenter,
padding: 4,
background: { color: 0x000000, alpha: 0.6, radius: 4 },
}));
panel.text(params.label, { fontSize: 11, fill: 0xffffff });
}
}

The offset is in screen pixels — 40 world units above at zoom=1 and 40 screen pixels above at zoom=2 are different things, and screen-pixel offsets are what nameplates almost always want. See the world-ui example for a runnable demo with zoom and rotation controls.

Add text to a panel with the builder API:

const panel = entity.get(UIPanel);
const label = panel.text("Score: 0", {
fontSize: 24,
fill: 0xffffff,
fontFamily: "monospace",
});
// Update text later
label.setText("Score: 100");
label.setStyle({ fill: 0x00ff00 });

For a UIText directly (or via the React <Text>), two extra props control rasterisation:

import { UIText } from "@yagejs/ui";
// Crisp pixel-art text — draws pre-baked glyph quads instead of a
// blurry bilinear-sampled canvas texture. `true` bakes a dynamic font
// from the style; `{ font }` uses an installed/loaded bitmap font.
new UIText({ children: "SCORE", bitmap: true, style: { fontFamily: "monospace", fontSize: 12 } });
new UIText({ children: "READY", bitmap: { font: "PressStart", size: 16 } });
// Per-text canvas resolution.
new UIText({ children: "HUD", resolution: window.devicePixelRatio });

Word-wrap and the truncate?: "clip" | "ellipsis" overflow modes work the same on the bitmap path as on canvas text.

const btn = panel.button("Start Game", {
width: 200, // optional — omit to shrink-to-content
height: 50, // optional — omit to shrink-to-content
background: { color: 0x4444aa, radius: 6 },
hoverBackground: { color: 0x5555cc, radius: 6 },
pressBackground: { color: 0x333388, radius: 6 },
textStyle: { fontSize: 18, fill: 0xffffff },
onClick: () => {
engine.scenes.push(new GameScene());
},
});
// Disable/enable
btn.setDisabled(true);
// Change label
btn.setText("Loading...");

Buttons support three background states: default, hover, and press.

Omitting width and/or height lets Yoga shrink the button to fit its content, with a small default padding around the label. Pass explicit dimensions when you need a fixed-size button (e.g. for a grid of equally-sized toolbar buttons).

UIButton is itself a flex container — .addElement() accepts any UIElement (text, image, nested panels) for icon + label compositions:

import { UIImage } from "@yagejs/ui";
const saveBtn = panel.button("Save", { onClick: () => {} });
saveBtn.addElement(new UIImage({ texture: iconTex, width: 16, height: 16 }));

Use a texture background for scalable button artwork:

panel.button("Play", {
width: 180,
height: 48,
background: {
texture: buttonTexture,
mode: "nine-slice",
nineSlice: { left: 12, top: 12, right: 12, bottom: 12 },
},
onClick: () => { /* ... */ },
});

UIPanel provides builder methods for .text(), .button(), and .panel(). For other elements, create them directly and add via addElement:

import { UIImage, UIProgressBar, UICheckbox } from "@yagejs/ui";
const img = new UIImage({
texture: iconTexture,
width: 32,
height: 32,
tint: 0xffffff,
});
panel.addElement(img);
const bar = new UIProgressBar({
width: 200,
height: 20,
value: 0.75, // 0–1
trackBackground: { color: 0x333333 },
fillBackground: { color: 0x44cc44 },
});
panel.addElement(bar);
// Update value
bar.update({ value: 0.5 });
const cb = new UICheckbox({
label: "Fullscreen",
checked: false,
size: 24,
boxColor: 0x666666,
checkColor: 0x44cc44,
onChange: (checked) => {
console.log("fullscreen:", checked);
},
});
panel.addElement(cb);

Build complex layouts with nested panels:

const menu = entity.get(UIPanel);
// Header row
const header = menu.panel({ direction: "row", gap: 12, alignItems: "center" });
header.text("Settings", { fontSize: 28, fill: 0xffffff });
// Content column
const content = menu.panel({ direction: "column", gap: 8, padding: 12 });
content.text("Volume", { fontSize: 16, fill: 0xaaaaaa });
// Button row
const buttons = menu.panel({ direction: "row", gap: 12 });
buttons.button("Save", { width: 100, height: 40, onClick: () => { /* ... */ } });
buttons.button("Cancel", { width: 100, height: 40, onClick: () => { /* ... */ } });

Toggle panel visibility at runtime:

const panel = entity.get(UIPanel);
panel.visible = false; // hide
panel.visible = true; // show

Child elements also support visibility:

label.visible = false;
btn.visible = true;

Every element accepts position, left, top, right, bottom via the shared LayoutProps. Use them to lift an element out of its parent’s flex flow and pin it against the parent’s content box — handy for overlays, badges, and HUD markers.

// Pin a notification badge to the top-right of its parent.
const badge = panel.panel({
position: "absolute",
top: 8,
right: 8,
padding: 4,
background: { color: 0xff3344, radius: 8 },
});
badge.text("3", { fontSize: 12, fill: 0xffffff });

The parent must be position: "relative" (the default) for absolute children to resolve against it. position: "absolute" children do not contribute to the parent’s main-axis advance, so siblings keep their original layout.

PropertyTypeDescription
position"relative" | "absolute"Default "relative".
left / top / right / bottomnumberPixel offset from the named edge of the parent’s content box.

Backgrounds can be a solid color or a texture:

// Solid color with rounded corners
{ color: 0x222222, alpha: 0.9, radius: 8 }
// Texture (nine-slice for scaling)
{
texture: panelTexture,
mode: "nine-slice",
nineSlice: { left: 16, top: 16, right: 16, bottom: 16 },
}