Build reusable dialog and text-flow UI behavior in-scene.
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
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.
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.