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('', { fillColor: Color.white, fontSize: 30, maxWidth: 600, lineHeight: 1.4, }); 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 extra scenes — 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:
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.
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.app.audio.play(this.sound, { 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.