Guide

GuideRecipesUI 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:

examples/guides/ui-patterns/dialog.ts
override init(): void {
  this.lines = ['Commander, the anomaly has entered low orbit.', 'All wings hold formation and await my signal.'];

  this.portrait = new Sprite(this.loader.get('image/portrait.png')).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 = this.loader.get('audio/beep.ogg');

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

override update(delta: Seconds): void {
  if (this.done) return;

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

  while (this.timer > 0.035 && this.chars < line.length) {
    this.timer -= 0.035;
    this.chars++;
    this.app.audio.play(this.beep, { 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:

examples/guides/ui-patterns/dialog.ts
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 system, 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:

examples/showcase/loading-progress-with-shader.ts
override init(): void {
  const app = this.app;
  const { width, height } = app;

  this.progress = { v: 0 };
  this.label = new Text('0%', { fillColor: Color.white, fontSize: 42, align: 'center' });
  this.label.setAnchor(0.5, 0.5).setPosition(width / 2, height / 2);
  this.ring = new Sprite(this.loader.get('image/uv-grid-256.png'))
    .setAnchor(0.5)
    .setScale(2.4)
    .setPosition(width / 2, height / 2);
  this.filter = new ShaderFilter({ glsl: { fragment: glsl }, wgsl, uniforms: { uProgress: 0 } });
  this.ring.filters = [this.filter];
  app.tweens.create(this.progress).to({ v: 1 }, 2.4).start();
}

override update(): void {
  this.filter.setUniform('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 SystemPointerOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Seconds, Sound, Sprite, Text } from '@codexo/exojs';
import { mountControlPanel, mountControls } from '@examples/runtime';

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 init(): void {
    const app = this.app;
    const { width, height } = app;

    // 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(this.loader.get(assets.demo.textures.shipA))
      .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 = this.loader.get(assets.demo.sound.uiConfirm);

    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);

    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 {
    const app = this.app;
    app.audio.play(this.beep, { 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: Seconds): void {
    const app = this.app;
    if (!this.done && !this.awaitingChoice) {
      this.timer += delta;
      while (this.timer > 0.035 && this.chars < lines[this.lineIndex].text.length) {
        this.timer -= 0.035;
        this.chars++;
        app.audio.play(this.beep, { 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: RenderingContext): void {
    context.render(this.portrait);
    context.render(this.namePlate);
    context.render(this.box);
    if (this.choicePrompt.visible) {
      context.render(this.choicePrompt);
    }
  }
}

const app = new Application({
  scenes: { DialogSystemScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(20, 24, 34),
});

await app.start(DialogSystemScene);

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

Typewriter TextOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sound, Text } from '@codexo/exojs';

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 progress!: { count: number };
  private last = 0;
  private tapPrompt!: Text;

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

    this.sound = this.loader.get('audio/ui-click.ogg');
    this.text = new Text('', { fillColor: Color.white, fontSize: 40, lineHeight: 1.4, maxWidth: 900 });
    this.text.setAnchor(0, 0.5).setPosition(width * 0.12, height / 2);
    this.progress = { 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);
    app.tweens
      .create(this.progress)
      .to({ count: message.length }, 2.4)
      .onUpdate(() => {
        const n = this.progress.count | 0;
        if (n > this.last) app.audio.play(this.sound, { playbackRate: 1.6 });
        this.last = n;
        this.text.text = message.slice(0, n);
      })
      .start();
  }

  override draw(context: RenderingContext): void {
    const app = this.app;
    context.render(this.text);

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

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

await app.start(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.