Guide

GuideRecipesHUD overlay

HUD overlay

Compose gameplay and UI layers without coupling scene logic.

Intermediate~3 min read

HUD overlay

A HUD (heads-up display) renders UI elements on top of the game world — health bars, score text, mini-maps, debug readouts — without those elements moving or scaling with the game camera. The recipe separates world-space content from screen-space content using scene.ui, a screen-fixed layer that every scene owns and that is automatically rendered above the scene’s world content.

Approach

Add HUD elements directly to scene.ui instead of creating a separate overlay scene. The world (game nodes, sprites, graphics) lives under scene.root; the HUD (labels, progress bars, panels) lives on scene.ui and is composited on top automatically — no extra scene or stack needed.

A scene with a screen-fixed HUD
class GameScene extends Scene {
  private angle = 0;
  private time = 0;
  private ring!: Graphics;
  private health!: ProgressBar;

  override init(): void {
    this.ring = new Graphics();

    const title = new Label('HUD Overlay', { fontSize: 22 });
    title.anchorIn(this.ui, 'top-left', 18, 14);
    this.ui.addChild(title);

    this.health = new ProgressBar({ width: 240, height: 12, value: 1 });
    this.health.anchorIn(this.ui, 'top-left', 18, 48);
    this.ui.addChild(this.health);
  }

  override update(delta: Seconds): void {
    this.angle += delta * 90;
    this.time += delta;
    this.health.value = (Math.sin(this.time) + 1) / 2;
  }

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

    this.ring.clear();
    this.ring.lineWidth = 20;
    this.ring.lineColor = new Color(90, 180, 255);
    this.ring.drawArc(width / 2, height / 2, 160, 0, (this.angle * Math.PI) / 180);
    context.render(this.ring);
  }
}

Start the scene as normal — scene.ui is rendered automatically:

Register and start the scene
const app = new Application({
  scenes: { GameScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(20, 32, 58),
});

void app.start(GameScene);

Why scene.ui, not manual draw-order

The alternative — rendering HUD elements after the world in a single scene’s draw method — works for simple cases. scene.ui gives you:

  • Screen-space anchoring via widget.anchorIn(this.ui, 'top-left' | 'center' | 'bottom-right' | …, offsetX, offsetY) — widgets stay pinned to corners or the center regardless of canvas size.
  • Hit-testing in screen space — UI widgets are clickable and focusable without needing camera-inverse transforms.
  • Independent lifecycle — widgets added in init are automatically cleaned up when the scene is destroyed.

Mini-map with a mask

A mini-map is a special HUD element: render the world into a RenderTexture, then compose that texture as a screen-positioned sprite. With a custom scene pipeline, a CallbackRenderPass captures the world and later node passes draw the masked sprite and its frame at logical screen coordinates:

A masked minimap render pipeline
override init(): void {
  const app = this.app;
  const { width } = app;

  // Park the round minimap in the top-right corner of the 16:9 canvas.
  const miniSize = 260;
  const miniX = width - miniSize - 20;
  const miniY = 20;
  const centerX = miniX + miniSize / 2;
  const centerY = miniY + miniSize / 2;
  const radius = 120;

  this.world = new Graphics();
  this.player = new Graphics();
  this.rt = new RenderTexture(miniSize, miniSize);
  this.mini = new Sprite(this.rt).setPosition(miniX, miniY).setScale(1);
  this.mask = new Graphics();
  this.mask.fillColor = Color.white;
  this.mask.drawCircle(centerX, centerY, radius);
  this.mini.mask = this.mask;
  this.frame = new Graphics();
  this.frame.lineWidth = 3;
  this.frame.lineColor = Color.white;
  this.frame.drawCircle(centerX, centerY, radius);

  this.pipeline = new RenderPipeline()
    .addPass(
      new CallbackRenderPass(
        context => {
          context.backend.clear();
          this.drawWorld(context.backend);
        },
        { target: this.rt },
      ),
    )
    .addPass(
      new CallbackRenderPass(context => {
        this.drawWorld(context.backend);
      }),
    )
    .addPass(new RenderNodePass(this.mini))
    .addPass(new RenderNodePass(this.frame));
}

The CallbackRenderPass with { target } redirects the callback’s drawing into the texture; inside a target redirect, issue immediate-mode draws through context.backend (a context.render(node) there would reset the view back to the camera). The mask clips the mini-map to a circle. See Render targets and Masks for details.

When DOM UI is a better fit

In-canvas HUD works well when the UI is tightly coupled to game state — health bars that react to damage, score text that animates, mini-maps that track player position. For static UI with complex layout (settings menus, inventory grids, text-heavy dialogs), standard HTML/CSS rendered as DOM overlays on top of the canvas is often simpler. The two approaches coexist: the canvas handles the game, DOM handles the menus. Focus and event routing between them requires coordination (pointer-events: none on the DOM when the game needs the pointer), but the architectural separation is clean.

Examples

Hud Overlay SceneOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, Graphics, Label, ProgressBar, type RenderingContext, Scene, type Seconds } from '@codexo/exojs';

/**
 * A screen-fixed HUD on `scene.ui` sits above the world automatically - no
 * separate overlay scene or stack. The world (a spinning arc) is drawn from
 * `scene.root`; the HUD (a label + a live health bar) lives on `scene.ui` and
 * is auto-rendered on top.
 */
class GameScene extends Scene {
  private angle = 0;
  private time = 0;
  private ring!: Graphics;
  private health!: ProgressBar;

  override init(): void {
    this.ring = new Graphics();

    const title = new Label('HUD Overlay', { fontSize: 22 });
    title.anchorIn(this.ui, 'top-left', 18, 14);
    this.ui.addChild(title);

    this.health = new ProgressBar({ width: 240, height: 12, value: 1 });
    this.health.anchorIn(this.ui, 'top-left', 18, 48);
    this.ui.addChild(this.health);
  }

  override update(delta: Seconds): void {
    this.angle += delta * 90;
    this.time += delta;
    this.health.value = (Math.sin(this.time) + 1) / 2;
  }

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

    this.ring.clear();
    this.ring.lineWidth = 20;
    this.ring.lineColor = new Color(90, 180, 255);
    this.ring.drawArc(width / 2, height / 2, 160, 0, (this.angle * Math.PI) / 180);
    context.render(this.ring);
  }
}
const app = new Application({
  scenes: { GameScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(20, 32, 58),
});

void app.start(GameScene);

A game scene with a rotating ring, a Label and a live ProgressBar on scene.ui — the HUD is always on top with no separate overlay scene.

Where to go next

The next recipe, Camera follow & parallax, covers how to move the camera and create depth with layered parallax.