Guide

Guide Recipes UI patterns

UI patterns

Build reusable dialog and text-flow UI behavior in-scene.

Intermediate ~3 min read

UI patterns

In-canvas UI means text boxes, dialog panels, progress indicators, and interactive elements built from ExoJS primitives — Text, Graphics, Sprite, Container — and rendered inside the scene. This recipe covers two patterns (dialog system with typewriter reveal, tween-driven progress) and explains when DOM UI is a better choice.

Dialog system with typewriter reveal

A dialog system shows text one character at a time, accompanied by a subtle sound. It’s built from three primitives: a Text node for the dialog text, a Sound for the character tick, and a timer (or tween) to drive the reveal:

init(loader) {
    this.lines = [
        'Commander, the anomaly has entered low orbit.',
        'All wings hold formation and await my signal.',
    ];

    this.portrait = new Sprite(loader.get(Texture, 'portrait'))
        .setAnchor(0.5).setScale(1.7).setPosition(170, 420);

    this.box = new Text('', {
        fill: 'white', fontSize: 30,
        wordWrap: true, wordWrapWidth: 600,
        lineHeight: 40,
    });
    this.box.setPosition(270, 360);

    this.beep = loader.get(Sound, 'beep');

    this.lineIndex = 0;
    this.chars = 0;
    this.timer = 0;
    this.done = false;
}

update(delta) {
    if (this.done) return;

    this.timer += delta.seconds;
    const line = this.lines[this.lineIndex];

    while (this.timer > 0.035 && this.chars < line.length) {
        this.timer -= 0.035;
        this.chars++;
        this.beep.play({ playbackRate: 1.9, volume: 0.14 });
    }

    this.box.text = line.slice(0, this.chars);

    if (this.chars >= line.length) {
        this.done = true;
    }
}

A click advances to the next line or skips the current reveal to show the full line immediately:

this.app.input.onPointerTap.add(() => {
    const line = this.lines[this.lineIndex];

    if (!this.done) {
        // Skip reveal — show full line
        this.chars = line.length;
        this.done = true;
        return;
    }

    // Advance to next line
    this.lineIndex = (this.lineIndex + 1) % this.lines.length;
    this.chars = 0;
    this.done = false;
});

The pattern is self-contained: the scene owns the dialog state, update drives the reveal, input handles progression. No modal state manager, no scene-stack push — just a flag and a counter.

Tween-driven progress indicator

A radial progress ring using a custom shader filter and a tween. The shader draws a circular arc — a uProgress uniform controls how much of the circle is filled. A tween drives uProgress from 0 to 1:

init(loader) {
    this.progress = { v: 0 };
    this.label = new Text('0%', { fill: 'white', fontSize: 42 });
    this.label.setPosition(360, 410);

    this.ring = new Sprite(loader.get(Texture, 'uv')).setScale(2.2);
    this.filter = new WebGl2ShaderFilter({
        fragmentSource: progressGlsl,
        uniforms: { uProgress: 0 },
    });
    this.ring.filters = [this.filter];

    this.app.tweens.create(this.progress)
        .to({ v: 1 }, 2.4)
        .start();
}

update() {
    this.filter.uniforms.uProgress = this.progress.v;
    this.label.text = `${(this.progress.v * 100) | 0}%`;
}

The tween interpolates { v: 0 }{ v: 1 } over 2.4 seconds. Each frame, update reads this.progress.v and pushes it into the shader uniform. The text label updates to show a percentage. When progress reaches 1, the tween completes.

This pattern generalises: any uniform-driven visual can be tweened by creating a plain object as the tween target and forwarding its values to the shader in update.

When DOM UI is a better fit

In-canvas UI is right for:

  • Game-coupled elements that move, animate, or react to game state in real time (dialog boxes that follow characters, health bars that flash on hit).
  • Simple text and shape overlays that don’t need complex layout (score text, pause indicators, progress rings).

DOM UI (HTML/CSS rendered as overlays on top of the canvas) is better for:

  • Complex layouts with CSS grid/flexbox (inventory grids, settings panels).
  • Form elements (text inputs, dropdowns, sliders) — these don’t exist in ExoJS’s canvas rendering.
  • Text-heavy content with accessibility requirements (screen readers, selection, copy-paste).
  • Static UI that doesn’t need per-frame updates.

The two can coexist: the canvas handles the game, a DOM layer on top handles menus. Coordinate the two by listening to app.input.onCanvasFocusChange and toggling pointer-events: none on the DOM overlay when the game needs the pointer. For a purely in-canvas overlay (score bar, health ring, mini-map) that avoids DOM coordination entirely, see HUD overlay.

Examples

Dialog System Pointer Audio Open in Playground View source
import { Application, Color, Scene, Sound, Sprite, Text, Texture } from '@codexo/exojs';
import { mountControlPanel, mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
});

interface DialogLine {
    speaker: string;
    text: string;
}

const lines: DialogLine[] = [
    { speaker: 'Commander Vale', text: 'Commander, the anomaly has entered low orbit.' },
    { speaker: 'Commander Vale', text: 'All wings hold formation and await my signal.' },
    { speaker: 'Commander Vale', text: 'If this goes wrong, burn every gate behind us.' },
];

const choices = ['Hold formation', 'Burn the gates'];

class DialogSystemScene extends Scene {
    private portrait!: Sprite;
    private namePlate!: Text;
    private box!: Text;
    private choicePrompt!: Text;
    private beep!: Sound;
    private hud!: ReturnType<typeof mountControls>;
    private panel!: ReturnType<typeof mountControlPanel>;
    private lineIndex = 0;
    private chars = 0;
    private timer = 0;
    private done = false;
    private awaitingChoice = false;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { portrait: assets.demo.textures.shipA });
        await loader.load(Sound, { beep: assets.demo.sound.uiConfirm });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        // Portrait sits on the left; the dialog column runs to its right and
        // fills the wider 16:9 frame as a classic VN bottom-third box.
        this.portrait = new Sprite(loader.get(Texture, 'portrait')).setAnchor(0.5).setScale(2.4).setPosition(width * 0.16, height * 0.62);

        const textX = width * 0.3;

        // Name plate sits just above the dialog body, like a classic VN UI.
        this.namePlate = new Text(lines[0].speaker, { fillColor: new Color(255, 214, 120), fontSize: 26, fontWeight: 'bold' });
        this.namePlate.setPosition(textX, height * 0.5);

        this.box = new Text('', { fillColor: Color.white, fontSize: 32, lineHeight: 1.3 }, { maxWidth: width * 0.55 });
        this.box.setPosition(textX, height * 0.56);

        this.choicePrompt = new Text('', { fillColor: new Color(150, 220, 255), fontSize: 20 });
        this.choicePrompt.setPosition(textX, height * 0.78);

        this.beep = loader.get(Sound, 'beep');

        this.hud = mountControls({
            title: 'Dialog System',
            controls: [{ keys: 'Click', action: 'advance / reveal' }],
            hint: 'Click anywhere to skip the typewriter, then click again to continue.',
        });

        // Choice buttons live in a predictable DOM panel so they never compete
        // with the canvas pointer for "advance" taps. They stay hidden until the
        // final line finishes typing.
        this.panel = mountControlPanel({ title: 'Your reply', corner: 'bottom-left' });
        for (const choice of choices) {
            this.panel.addButton({ label: choice, onClick: () => this.choose(choice) });
        }
        this.setChoicesVisible(false);

        this.app.input.onPointerTap.add(() => this.advance());
    }

    private advance(): void {
        if (this.awaitingChoice) {
            return;
        }

        if (!this.done) {
            // First click reveals the rest of the current line instantly.
            this.chars = lines[this.lineIndex].text.length;
            this.done = true;
            return;
        }

        if (this.lineIndex < lines.length - 1) {
            this.lineIndex++;
            this.startLine();
            return;
        }

        // The last line is read — offer the choice row.
        this.awaitingChoice = true;
        this.setChoicesVisible(true);
    }

    private startLine(): void {
        this.chars = 0;
        this.timer = 0;
        this.done = false;
        this.namePlate.text = lines[this.lineIndex].speaker;
    }

    private choose(choice: string): void {
        this.beep.play({ playbackRate: 1.2, volume: 0.3 });
        this.choicePrompt.text = `You chose: ${choice}`;
        this.hud.setStatus(`Reply: ${choice}`);
        this.awaitingChoice = false;
        this.setChoicesVisible(false);
        // Loop back to the top so the demo can be replayed without a reload.
        this.lineIndex = 0;
        this.startLine();
    }

    private setChoicesVisible(visible: boolean): void {
        this.panel.element.style.display = visible ? '' : 'none';
        this.choicePrompt.visible = !visible && this.choicePrompt.text.length > 0;
    }

    override update(delta): void {
        if (!this.done && !this.awaitingChoice) {
            this.timer += delta.seconds;
            while (this.timer > 0.035 && this.chars < lines[this.lineIndex].text.length) {
                this.timer -= 0.035;
                this.chars++;
                this.beep.play({ playbackRate: 1.9, volume: 0.14 });
            }
            this.done = this.chars >= lines[this.lineIndex].text.length;
        }
        this.box.text = lines[this.lineIndex].text.slice(0, this.chars);
    }

    override draw(context): void {
        context.backend.clear(new Color(20, 24, 34));
        context.render(this.portrait);
        context.render(this.namePlate);
        context.render(this.box);
        if (this.choicePrompt.visible) {
            context.render(this.choicePrompt);
        }
    }
}

app.start(new DialogSystemScene());

A dialog box with character-by-character reveal, portrait sprite, and a beep on each character. Click to advance or skip.

Typewriter Text Audio Open in Playground View source
import { Application, Color, Scene, Sound, Text } from '@codexo/exojs';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

const message = 'ExoJS gives you explicit rendering control with a compact scene and asset workflow.';

class TypewriterTextScene extends Scene {
    private sound!: Sound;
    private text!: Text;
    private state!: { count: number };
    private last = 0;
    private tapPrompt!: Text;

    override async load(loader): Promise<void> {
        await loader.load(Sound, { tick: 'audio/ui-click.ogg' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.sound = loader.get(Sound, 'tick');
        this.text = new Text('', { fillColor: Color.white, fontSize: 40, lineHeight: 56 }, { maxWidth: 900 });
        this.text.setAnchor(0, 0.5).setPosition(width * 0.12, height / 2);
        this.state = { count: 0 };

        // Shown while the browser still blocks audio (`app.audio.locked`); the
        // first click or keypress unlocks it and the queued tick sounds play.
        this.tapPrompt = new Text('Click or press any key to enable the typing sound', { fillColor: Color.white, fontSize: 22, align: 'center' })
            .setAnchor(0.5, 0.5)
            .setPosition(width / 2, height - 64);
        this.app.tweens
            .create(this.state)
            .to({ count: message.length }, 2.4)
            .onUpdate(() => {
                const n = this.state.count | 0;
                if (n > this.last) this.sound.play({ playbackRate: 1.6 });
                this.last = n;
                this.text.text = message.slice(0, n);
            })
            .start();
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.text);

        if (this.app.audio.locked) {
            context.render(this.tapPrompt);
        }
    }
}

app.start(new TypewriterTextScene());

A single text block revealed character-by-character via a tween — the simplest version of the pattern.

Where to go next

The next recipe, Cinematics, covers scripted sequences — how to choreograph tweens, camera moves, audio, and overlays into a cutscene using the tween chain and timer primitives.