Guide

Guide Recipes HUD overlay

HUD overlay

Compose gameplay and UI layers without coupling scene logic.

Intermediate ~2 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 by using two scenes on the stack.

Approach

Push a second scene on top of the game scene. The game scene renders the world. The HUD scene renders only UI elements — positioned in screen-pixel coordinates, independent of any camera view. The HUD scene uses 'overlay' mode so both scenes render and update.

class GameScene extends Scene {
    init() {
        this._angle = 0;
    }

    update(delta) {
        this._angle += delta.seconds * 90;
    }

    draw(context) {
        context.backend.clear(new Color(20, 32, 58));
        // ... draw game world ...
    }
}

class HudScene extends Scene {
    init() {
        this._bar = new Graphics();
        this._text = new Text('SCORE 1240', { fill: 'white', fontSize: 22 });
        this._text.setPosition(18, 14);
    }

    draw(context) {
        this._bar.clear();
        this._bar.fillColor = new Color(0, 0, 0, 0.45);
        this._bar.drawRectangle(0, 0, 800, 56);
        this._bar.drawRectangle(18, 40, 220, 8); // health bar
        context.render(this._bar);
        context.render(this._text);
    }
}

Push the HUD scene after the game scene starts:

const game = new GameScene();
const hud = new HudScene();

await app.start(game);
await app.scene.pushScene(hud, { mode: 'overlay' });

Why two scenes, not draw-order

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

  • Independent init/destroy lifecycles — the HUD can be pushed, popped, and replaced without touching game code.
  • Camera independence — the HUD scene’s root is always in screen space, never affected by the game view.

Mini-map with a mask

A mini-map is a special HUD element: it needs a second View to render the world from a zoomed-out perspective into a RenderTexture, which is then displayed as a sprite in the corner of the HUD scene. The capture is a CallbackRenderPass with a { target }, run once per frame from the game scene’s pipeline:

// In GameScene: build the capture pass once, run it each frame from the pipeline.
const miniRt = new RenderTexture(260, 260);
const capturePass = new CallbackRenderPass(
    (context) => {
        // Render the world from a wide view into miniRt (immediate-mode draws via context.backend)
        this.drawWorld(context.backend);
    },
    { target: miniRt }, // redirects the callback's output off-screen; clears the target first if you pass `clear`
);

// In HudScene:
const miniSprite = new Sprite(miniRt).setPosition(530, 20);
// Clamp to a circle with a mask
const mask = new Graphics();
mask.fillColor = Color.white;
mask.drawCircle(660, 150, 120);
miniSprite.mask = mask;

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 Scene Open in Playground View source
import { Application, Color, Graphics, Scene, Text } from '@codexo/exojs';

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

class GameScene extends Scene {
    private angle = 0;
    private ring!: Graphics;

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

    override update(delta): void {
        this.angle += delta.seconds * 90;
    }

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

        context.backend.clear(new Color(20, 32, 58));
        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);
    }
}

class HudScene extends Scene {
    private bar!: Graphics;
    private text!: Text;

    override init(): void {
        this.bar = new Graphics();
        this.text = new Text('HUD Overlay', { fillColor: Color.white, fontSize: 22 });
        this.text.setPosition(18, 14);
    }

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

        this.bar.clear();
        this.bar.fillColor = new Color(0, 0, 0, 0.45);
        this.bar.drawRectangle(0, 0, width, 56);
        this.bar.fillColor = new Color(80, 220, 120);
        this.bar.drawRectangle(18, 40, 220, 8);
        context.render(this.bar);
        context.render(this.text);
    }
}

const gameScene = new GameScene();
const hudScene = new HudScene();

void app.start(gameScene).then(() => app.scene.pushScene(hudScene, { mode: 'overlay' }));

A game scene with a rotating ring, a HUD scene with a top-bar overlay, and transparent input so the HUD is visible but doesn’t block interaction.

Where to go next

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