Guide

Guide Runtime UI & widgets

UI & widgets

Build HUDs and menus on scene.ui with Panel, Button, Label, and ProgressBar widgets — anchoring, layout, clicks, and keyboard focus.

Intermediate ~3 min read

What you'll learn

  • build a screen-fixed HUD and menus on scene.ui
  • compose Panel, Button, Label, and ProgressBar widgets
  • anchor and stack widgets, and route clicks and keyboard focus

Before you start

UI & widgets

Every scene owns a screen-fixed UI layer, scene.ui, that is rendered automatically on top of the world. It lives in screen space (origin top-left, 0..width × 0..height), so its contents never scroll or zoom with the camera. Add anything to it with scene.ui.addChild(...).

On top of that layer, ExoJS ships a small set of widgets — Panel, Button, Label, ProgressBar, a Stack layout container, a ScrollContainer for overflowing content, and a hover Tooltip helper — for HUDs, menus, and pause screens without hand-built hit-tests.

Building blocks

Each widget takes an options object and exposes the handful of properties you tend to change at runtime:

import { Button, Label, Panel, ProgressBar } from '@codexo/exojs';

const score = new Label('Score: 0', { fontSize: 24 });
score.text = 'Score: 120';

const health = new ProgressBar({ width: 240, height: 14, value: 1 });
health.value = 0.6; // fill fraction, clamped to [0, 1]

const panel = new Panel({ width: 280, height: 160, cornerRadius: 16 });

const start = new Button({ label: 'Start', width: 160, height: 44 });
start.onClick.add(() => console.log('clicked'));

A Label wraps a runtime Text, so it accepts the same style options (fontSize, fillColor, align, …). A Panel is a rounded background you can add children to. A Button is a clickable panel with a centered label and hover / pressed / disabled states.

Anchoring to the screen

Widgets extend Widget, which adds an explicit layout size and screen-edge anchoring. widget.anchorIn(scene.ui, anchor, offsetX, offsetY) pins a widget to a corner or edge and re-applies the position whenever the canvas resizes:

import { Label, ProgressBar, Scene } from '@codexo/exojs';

class HudScene extends Scene {
    init() {
        const score = new Label('Score: 0', { fontSize: 24 });
        score.anchorIn(this.ui, 'top-left', 24, 20); // top-left, 24×20 px margin
        this.ui.addChild(score);

        const health = new ProgressBar({ width: 240, height: 14, value: 1 });
        health.anchorIn(this.ui, 'bottom-right', -24, -24); // 24 px from the bottom-right
        this.ui.addChild(health);
    }
}

The anchor accepts 'top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', and 'bottom-right'.

Stacking widgets

A Stack flows its children in a row or column with even spacing and sizes itself to fit. Use it for menus and button groups:

import { Button, Panel, Scene, Stack } from '@codexo/exojs';

class MenuScene extends Scene {
    init() {
        const menu = new Stack({ direction: 'column', spacing: 10, padding: 14 });
        menu.addItem(new Button({ label: 'Resume' }));
        menu.addItem(new Button({ label: 'Restart' }));
        menu.addItem(new Button({ label: 'Quit' }));

        const panel = new Panel();
        panel.setSize(menu.uiWidth, menu.uiHeight);
        panel.addChild(menu);
        panel.anchorIn(this.ui, 'center');
        this.ui.addChild(panel);
    }
}

Scrolling content

A ScrollContainer clips its content to a fixed width × height and scrolls it with the mouse wheel — useful for inventories, logs, or any list longer than its box. Add children to scroll.content, not to the container itself:

import { Label, Scene, ScrollContainer } from '@codexo/exojs';

class InventoryScene extends Scene {
    init() {
        const scroll = new ScrollContainer({ width: 280, height: 320, direction: 'vertical' });
        scroll.anchorIn(this.ui, 'center');

        for (let i = 0; i < 20; i++) {
            const item = new Label(`Item ${i}`, { fontSize: 16 });
            item.setPosition(12, i * 28);
            scroll.content.addChild(item);
        }

        this.ui.addChild(scroll);
    }
}

scroll.scrollTo(x, y) and scroll.scrollBy(dx, dy) move the content programmatically, clamped so it never scrolls past its edges; direction restricts the wheel to 'vertical', 'horizontal', or 'both'.

Clicks and keyboard focus

UI nodes are hit-tested in screen space before the world, so a Button on scene.ui is clickable even under a panned or zoomed camera. Listen to button.onClick:

import { Button, Scene } from '@codexo/exojs';

class GameScene extends Scene {
    init() {
        const pause = new Button({ label: 'Pause' });
        pause.anchorIn(this.ui, 'top-right', -16, 16);
        pause.onClick.add(() => {
            this.paused = !this.paused;
        });
        this.ui.addChild(pause);
    }
}

Widgets are also keyboard-focusable. Mark any node focusable and give it a tabIndex; it then receives focus and routed key events:

import { Button, Keyboard } from '@codexo/exojs';

const field = new Button({ label: 'Name' });
field.focusable = true;
field.tabIndex = 1; // lower values are visited first
field.onFocus.add(() => { /* highlight */ });
field.onKeyDown.add(event => {
    if (event.channel === Keyboard.Enter) { /* submit */ }
});

The per-application focus service, app.focus, tracks the focused node, moves focus with Tab / Shift+Tab, activates a focused button on Enter / Space, and exposes app.focus.focus(node) to focus programmatically.

Tooltips

A Tooltip attaches to any interactive node and shows a small text label near the pointer after a short delay, hiding again as soon as the pointer leaves. It parents itself to the nearest UIRoot ancestor of its target, so it always renders above other content — the target just needs to already be in the scene tree:

import { Button, Scene, Tooltip } from '@codexo/exojs';

class ShopScene extends Scene {
    init() {
        const upgrade = new Button({ label: 'Upgrade', width: 160, height: 44 });
        upgrade.interactive = true;
        upgrade.anchorIn(this.ui, 'top-left', 24, 20);
        this.ui.addChild(upgrade);

        this.tooltip = new Tooltip(upgrade, { text: 'Costs 50 gold', delay: 0.3 });
    }
}

Options cover the delay before showing (delay, in seconds), the offset from the pointer (offsetX / offsetY), and basic styling (background, textColor, padding, fontSize). Call tooltip.destroy() to remove the listeners it attached to its target — e.g. when the target itself is destroyed.

Pause overlays

A pause menu is the canonical combination: freeze the world with scene.paused = true — which skips update and the scene’s systems while it keeps drawing — and show an overlay built from widgets on scene.ui:

import { Keyboard, Label, Panel, Scene } from '@codexo/exojs';

class GameScene extends Scene {
    init() {
        this.pausePanel = new Panel({ width: 420, height: 140, cornerRadius: 18 });
        this.pausePanel.anchorIn(this.ui, 'center');
        this.pausePanel.visible = false;
        this.ui.addChild(this.pausePanel);

        this.pauseLabel = new Label('PAUSED', { fontSize: 56 });
        this.pauseLabel.anchorIn(this.ui, 'center');
        this.pauseLabel.visible = false;
        this.ui.addChild(this.pauseLabel);

        this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause());
    }

    togglePause() {
        this.paused = !this.paused;
        this.pausePanel.visible = this.paused;
        this.pauseLabel.visible = this.paused;
    }
}
ui/hud-and-widgets Open in Playground View source

Preview is paused until you click Play.

// Missing source: undefined/ui/hud-and-widgets.js