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 });
const btn = panel.button("Start Game", {
width: 200,
height: 50,
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.

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;

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 },
}