HUD overlay
Compose gameplay and UI layers without coupling scene logic.
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.
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:
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
initare 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:
Draw with context.backend inside a target pass
Inside a { target } render redirect, issue your draws through context.backend directly. A context.render(node) call there resets the active view back to the camera, so the off-screen capture would render from the wrong viewpoint.
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
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.
