---
name: incanto-hud
description: Screen-space HUD widgets (HudLayer + UiText / UiBar / UiBanner) — health bars, score text, wave banners declared in scene JSON, no hand-rolled DOM or CSS. Use for any in-game UI overlay in 2D or 3D games.
---

# HUD widgets

Declare your HUD in the scene JSON — no `index.html` markup, no CSS, no
`document.querySelector` in behaviors:

```json
{
  "name": "HUD", "type": "HudLayer",
  "children": [
    { "name": "Health", "type": "UiBar",
      "props": { "anchor": "topLeft", "value": 100, "max": 100, "label": "HP" } },
    { "name": "Score", "type": "UiText",
      "props": { "anchor": "topRight", "text": "Score: 0", "size": 20 } },
    { "name": "Banner", "type": "UiBanner" }
  ]
}
```

`HudLayer` is a fixed, pointer-transparent DOM overlay above the canvas — it
works identically over the 2D and 3D renderers and is a silent no-op in
headless tests (core nodes, no three.js). Widgets pick one of 9 anchors:
`topLeft top topRight left center right bottomLeft bottom bottomRight`.

**Camel case here, hyphens in `UILayer`.** The 2D world-space layer spells the
same nine `top-left`/`bottom-right`, and neither prop accepts the other's
spelling — both fail at load listing their own set, so you are one edit from
right. Worth knowing before you spend the edit.
Widgets stacked on the same anchor form a column.

## Driving widgets from the scene JSON (no TypeScript)

The widgets take the gameplay signals directly, so a score line and a health bar
are `connections[]` entries — not a behavior whose whole job is one assignment:

```json
"connections": [
  { "signal": "scoreChanged",  "from": ".",      "to": "HUD/Score", "handler": "setText" },
  { "signal": "healthChanged", "from": "Player", "to": "HUD/Hp",    "handler": "setValue" },
  { "signal": "livesChanged",  "from": ".",      "to": "HUD/Lives", "handler": "setText" }
]
```

**`livesChanged`, not `lifeLost`, for the counter.** `lifeLost` is the EVENT — a
life was just spent, flash the screen, respawn — and it does not fire when a
SAVE is loaded, so a counter wired to it still read `3` after a Continue at one
life left. `livesChanged` is the COUNT, and fires on both.

```json
{ "name": "Score", "type": "UiText",
  "props": { "anchor": "topRight", "text": "Gems 0 / 8", "format": "Gems {} / 8" } }
```

- **`UiText.setText(value)`** — writes the whole line, or fills the `{}` slot in
  `format` when you set one. `format` is resolved at PAINT like `text`, so
  `"@t:hud.gems"` works and switching locale re-reads it. Until the first value
  arrives it shows `text`, which is why you write both (`"Gems 0 / 8"` is the
  opening line, `"Gems {} / 8"` is the template).
- **`UiBar.setValue(current, max?)`** — takes the max as a second argument
  because that is the shape `healthChanged` already has, so a raised ceiling
  needs no second wire. `UiBar.setMax(max)` moves it alone.
- **`UiBanner.show(text)`** — already a method, already wireable.

**Wire `healthChanged`, not `damaged`.** `damaged(amount, current)` and
`healed(amount, current)` lead with the DELTA — wire either to `setValue` and
the bar paints the damage as the health and looks like it works. `setValue`
refuses a non-number and names the right signal in the message, but a plausible
wrong NUMBER is the trap worth knowing. `healthChanged(current, max)` also fires
for regeneration, which the other two never do.

Which signal carries what, at a glance: `ScoreKeeper.scoreChanged(score)` ·
`ScoreKeeper.lifeLost(lives)` · `Collector.totalChanged(total)` ·
`Health.healthChanged(current, max)` — all value-first, all wireable as-is.

## Driving widgets from behaviors

Plain node access — same as everything else:

```ts
import type { UiBanner, UiBar, UiText } from 'incanto';

const hp = this.node.getNode('%Health') as UiBar;
hp.value = health.current;          // fill animates; turns red below 30%

(this.node.getNode('%Score') as UiText).text = `Score: ${score}`;

const banner = this.node.getNode('%Banner') as UiBanner;
banner.show('WAVE 2', { color: '#f87171', seconds: 2 }); // queued, fades
banner.show('YOU DIED', { color: '#ef4444', seconds: 0 }); // sticky until next show()
```

## Widget reference

- **UiText** — `text`, `size` (px), `color`, `shadow` (readability outline).
- **UiBar** — `value`/`max` (ratio clamped 0..1), `width`/`height`, `color`,
  `lowColor` + `lowThreshold` (default 0.3), `background`, `label` caption.
- **UiBanner** — `show(text, {color, seconds})` queues center-screen
  announcements with fade in/out; `seconds: 0` = sticky; `clear()` empties
  the queue; signal `bannerShown(text)`. Props: `size` (font px), `seconds`
  (default duration).

**Showing and hiding a widget is `visible`, not a method.** There is no
`hide()`/`show()` pair on a widget: `UiBanner.show(text)` announces a LINE, and
every widget's own visibility is the ordinary `visible` prop it inherits from
every other node —

```ts
(this.node.getNode('%Pause') as UiPanel).visible = false;   // a whole menu, and
                                                            // everything under it
```

A panel hides its children with it, so one write takes a whole screen away.
(HUD widgets are screen-space, so `framing()` leaves them out entirely — asking
whether the camera can see one is a category error.)

Pair with the `Health` / `ScoreKeeper` gameplay behaviors: listen to their
signals and write the widget props — that is the whole HUD wiring.

## On a phone

The nine anchors keep out of the device's own furniture: each is 16 px from its
edge **plus** that edge's `env(safe-area-inset-*)`. Every scaffolded
`index.html` carries `viewport-fit=cover` — which is what makes a game fill a
phone edge to edge, and what would otherwise put `anchor: "bottom"` under the
home indicator, where iOS takes the gesture and your button never hears the tap.
`topRight` behind the notch and `left`/`right` under the rounded corners are the
same story in landscape, which is how a phone is held for a game.

The insets are ADDED, and every desktop browser reports 0 for all four, so
nothing moves where there is nothing to avoid. (The touch-control overlay has
done this since 0.42; the HUD, which is the overlay with the buttons you
actually tap, joined it much later.)

**A widget authored in pixels still fits the screen.** `UiPanel`, `UiBar`,
`UiImage`, `UiSlider` and `UiDialogue` all cap at `calc(100vw - 32px)` — a pause
panel at `width: 520` on a 390 px phone would otherwise hang 130 px off the
edge, taking its buttons with it, and a panel too TALL scrolls rather than
hiding the row that says Resume.

**Every tappable widget is at least 44x44.** Measured in Chrome, a `UiButton`
used to come out 131x37 and a dialogue choice ~50x29, while the engine's own
touch buttons have been 64x64 and its stick 120x120 for years — the surface a
finger actually lands on was the one nobody had sized for a finger. Apple asks
44, Material 48, WCAG 2.5.5 44. It is a MINIMUM: a button with more text stays
as wide as its text, and an authored `width` still wins.

The layer also takes the GESTURES over itself: every widget that receives taps
sets `touch-action: none`, and the layer turns off text selection, the iOS
long-press callout and the tap flash. `claimCanvasGestures` has done that for
the canvas for a while — but the HUD is a separate overlay ABOVE it, and a
swipe that starts on the score at the top of the screen never reaches the
canvas at all. It used to scroll the page or pull-to-refresh.

## Interactive widgets: UiButton & UiDialogue

```json
{ "name": "Talk", "type": "UiDialogue" },
{ "name": "Start", "type": "UiButton",
  "props": { "anchor": "center", "text": "START" } }
```

```ts
const talk = this.node.getNode('%Talk') as UiDialogue;
talk.say('Elder', 'Welcome to Lumina Village...');
talk.say('Elder', 'Will you help us?', ['Yes', 'No']);
talk.on('choiceMade', (i) => { if (i === 0) startQuest(); });
talk.on('dialogueFinished', () => player.frozen = false);
// while talk.active, skip player input — the box eats clicks to advance

(this.node.getNode('%Start') as UiButton).on('pressed', () => flow.restart());
```

Typewriter reveal at `charsPerSecond` (0 = instant); clicking the box
reveals the line then advances; choice lines render buttons and wait for
`choose(i)`. Buttons opt into pointer events — the rest of the HUD stays
click-through.

**A line that moves on by itself** — `autoAdvanceSeconds` (default `0` = wait
for the click) keeps a fully-shown line up that long and then advances it. A
cutscene has nobody clicking: the camera is on a rail and the controller is
off. A line with choices always waits.

```json
{ "name": "Talk", "type": "UiDialogue", "props": { "autoAdvanceSeconds": 2.6 } }
```

**Cinematic bars** are a property of the LAYER, not two panels: `HudLayer`'s
`letterbox` (0..0.5) is the fraction of the layer's height each bar covers,
drawn full-bleed at the top and bottom edges over every widget, and animated
by whatever writes it (`hud.letterbox = 0.11` on a cutscene's first frame,
`0` on its last). Two `UiPanel`s sat in their anchor slots — inside the layer's
padding, above the dialogue box — and never reached the edge.

**Driving them with no screen** — a HUD widget is DOM, so a headless harness
cannot click one. Both take the gesture directly:

```ts
(hud.getNode('Start') as UiButton).press();   // emits `pressed`, as a click would
(hud.getNode('Talk') as UiDialogue).advance(); // reveal the rest, then next line
```

That is how a title screen, a pause menu or a conversation gets tested in
`runScript` — `click` reaches world nodes through the picker, and a widget is
not one of those.

## Menus, options and inventories (UiPanel + the value widgets)

The HUD widgets can SAY things. These arrange them and take a value back — which
is what a title screen, pause menu, options panel, shop and inventory grid are.
Before them every one of those was hand-rolled DOM, which drops the whole screen
out of scene JSON, out of the editor, out of `incanto-check` and out of every
headless check you have.

**`UiPanel` is the box.** Widgets normally mount into the HudLayer's anchor slot
no matter how the tree is shaped; anything under a panel mounts into the PANEL,
so structure in the tree becomes structure on screen. Panels nest.

```json
{ "name": "Pause", "type": "UiPanel",
  "props": { "anchor": "center", "layout": "column", "gap": 12, "padding": 20 },
  "children": [
    { "name": "Title", "type": "UiText", "props": { "text": "PAUSED", "size": 28 } },
    { "name": "Volume", "type": "UiSlider", "props": { "label": "volume", "value": 0.8 } },
    { "name": "Invert", "type": "UiToggle", "props": { "label": "invert Y" } },
    { "name": "Quality", "type": "UiSelect", "props": { "options": "low,medium,high", "value": "high" } },
    { "name": "Resume", "type": "UiButton", "props": { "text": "Resume" } }
  ] }
```

| node | props | signal |
| --- | --- | --- |
| `UiPanel` | `layout` (column/row/**grid**), `columns`, `gap`, `padding`, `background`, `radius`, `width`, `height`, `border` | — |
| `UiImage` | `src` (url or `$assetKey`), `width`, `height`, `fit`, `tint`, `opacity` | — |
| `UiSlider` | `label`, `value`, `min`, `max`, `step`, `width`, `color` | `changed(value)` |
| `UiToggle` | `label`, `value` | `changed(bool)` |
| `UiSelect` | `label`, `options` (`"low,medium,high"`), `value` | `changed(value)` |

`UiSelect.value` must be one of its `options` — a value the list cannot show is
a hard load error naming them, because the browser would show the FIRST option
while the node went on answering with the value it was given, and `changed`
only fires when a person moves it. (Writing one at runtime is reported rather
than refused: a behaviour may widen `options` a frame later, and it goes quiet
when it does.) `""` is "nothing chosen yet", which is a choice.
### The ones that need no TypeScript at all

| node | props | follows |
| --- | --- | --- |
| `UiVolumeSlider` | `bus` (`master`/`sfx`/`music`), plus every `UiSlider` prop | `engine.audio[bus]` |
| `UiMuteToggle` | `label` | `engine.audio.muted` |
| `UiQualitySelect` | — | `engine.settings` `quality` |
| `UiFrameCapSelect` | — | `engine.settings` `maxFps` |
| `UiRenderScaleSelect` | — | `engine.settings` `renderScale` |
| `UiLanguageSelect` | — | `engine.locale` (options are the scene's own locales) |

These are **already wired** in both directions: they show what the game is set
to, and setting one changes the game. A whole settings screen is those six nodes
and no TypeScript.

**They follow the engine HEADLESS too**, so a harness can drive one: set
`slider.value`, step, and assert `engine.audio.master` moved. These used to live
on the painting side of the widget, below `update`'s early
return for "no DOM element" — so every one of them was inert in `runScript`,
`incanto-playtest` and `incanto-verify`, showing a `UiSlider`'s 0.5 default next
to a master volume of 1.

**An inventory is a grid panel**: `"layout": "grid", "columns": 5`, one child per
slot, each a small `UiPanel` holding a `UiImage` (`tint` greys out what you
cannot afford) — and `Clickable` is on the world node, not the widget; for a
widget use `UiButton`'s `pressed`.

Setting `.value` from a behavior updates the control and does **NOT** re-emit
`changed` — restoring a saved setting must not fire the handler that saved it.

**`choose(value)` is the other half**: set it as a PERSON would, and say so.
`UiButton` has always had `press()`; the value widgets had nothing, so a preset
button, a "reset to defaults", a tutorial that moves a slider for you, or a
harness checking its own options screen had no public route at all.

```ts
(hud.getNode('%Volume') as UiSlider).choose(0.25);      // clamped, emits changed
(hud.getNode('%Invert') as UiToggle).choose(true);
(hud.getNode('%Quality') as UiSelect).choose('high');   // an option it does not
                                                        // offer is refused
```

Both are silent when nothing changes.

**Every prop is live.** `color`, `size`, `width`, `background`, `label` and
`anchor` are re-read each frame, so flashing the score red, growing a health bar
or moving a widget to another corner all work from a behavior — and from the
editor's inspector, which is the same code path. They used to be baked in when
the widget was built and could never be changed afterwards.

## A minimap (UiMinimap)

Every open world, every stealth game, every wave shooter wants one, and the
HUD had text, bars, banners, images and buttons — no map:

```json
{ "name": "Map", "type": "UiMinimap",
  "props": { "anchor": "topRight", "size": 160, "radius": 40,
             "dots": { "enemy": "#ff5050", "pickup": "#ffd54f", "goal": "#3aa0ff" },
             "heading": "%Skin" } }
```

| prop | default | what it does |
|---|---|---|
| `size` | `160` | diameter, px |
| `radius` | `40` | world units from the centre to the rim (m in 3D, px in 2D) |
| `follow` | `""` | the node at the centre; `""` = the first node in the `player` group |
| `dots` | `{}` | group → colour: every node in a listed group is a dot (not `groups` — that is the node's own membership) |
| `heading` | `""` | a node whose yaw turns the map so its "ahead" is up (+Z-forward — the skin the controller turns); `""` = north (−z) up |
| `self` / `dotSize` / `background` / `shape` | `#ffffff` / `6` / dark / `circle` | the look |

- The plane is x/z in 3D and x/y in 2D; things beyond `radius` are not drawn;
  a group you do not list is not drawn. It follows moving parents (an enemy
  under a spawner's group node is where it is drawn).
- **`markers()` is the same list headless** — `{x, y, color, group, node}`
  in canvas px — so a harness can ask what the map shows (`examples/survivor-3d`
  asserts the husks that reached you are on it), and `centre()` is the node
  it is following. Draws on a `<canvas>` in the browser; nothing in a test.
- A dot's colour that is not a string is a load error: the keys of `dots`
  are your group names (any is right), the values must be colours.

## A waypoint — the objective marker (UiWaypoint)

Every open world puts a marker over the objective; every example here told
the player where to go in a sentence at the top of the screen. One widget:

```json
{ "name": "Mark", "type": "UiWaypoint",
  "props": { "target": "/root/Shop1", "label": "bakery", "hideWithin": 4, "arriveWithin": 3 } }
```

| prop | default | what it does |
|---|---|---|
| `target` | `''` | the node the marker floats over — change it from a behavior as the objective moves on |
| `label` / `color` / `size` | `''` / `#ffd54f` / `22` | the text under the diamond, its colour, its size in px |
| `distance` | `true` | show the metres left under it |
| `clamp` / `margin` | `true` / `40` | off screen or behind the camera, slide to a rectangle `margin` px in from the edge and turn the arrow that way (behind: mirrored through the centre first, so it points the way to turn) |
| `hideWithin` | `0` | hide when you are this close (m); 0 = never |
| `arriveWithin` | `0` | `arrived` fires once when `from` comes this close, and again after leaving and coming back; 0 = never |
| `from` | `''` | who the distance is measured from — the first node in the `player` group by default |

- It floats over its target through the renderer's own projection
  (`engine.toScreen`), so `anchor` means nothing here and it never sits in a
  HUD column.
- **`screen()` is the same answer headless** — `{x, y, angle, behind,
  onScreen, clamped, distance, visible}` in canvas px — from whatever
  `toScreen` a test installs (`engine.toScreen = (w) => ({ x, y, behind })`),
  so a harness can ask where the marker is and which way it points
  (`examples/errands-3d` asks that the marker moves from shop to shop);
  `distanceNow()` is the metres alone, projection or not.

## Playable on a controller (focus navigation)

A menu you can only click is not playable on a gamepad, and "add controller
support" was not something a JSON scene could express at all.

```json
{ "name": "Hud", "type": "HudLayer", "props": { "focusNavigation": true }, "children": [ … ] }
```

Arrow keys / d-pad move the focus between the **focusable** widgets under that
layer — and only the ones the player can actually SEE: a hidden widget hides
everything under it, and a hidden layer has no ring at all. (Until 0.67 the walk
recursed into closed panels, so Enter on a title screen could press a button in
the shop and spend the gold.) `hud.focusables()` returns the ring, so a game can
ask what it is stuck with.

A **`UiDialogue` takes focus while it is up**: Enter/A picks the highlighted
choice, left/right move between them, and a line with no choices advances. Its
choices are DOM buttons inside the widget rather than nodes, so this is the
widget's own key handling — before 0.67 a choice could only be answered with a
mouse, and `charsPerSecond: 0` rendered no buttons at all, which was an
unanswerable soft-lock.

 `Enter` / `A` activates, and the focused one wears a ring.
`UiButton`/`UiSlider`/`UiToggle`/`UiSelect` are focusable by default;
`UiText`/`UiBar`/`UiImage`/`UiPanel` are not, so arrowing never lands on a label.

What activation MEANS is per widget: a button presses, a toggle flips (left/right
sets it explicitly, which reads better on a pad), a select walks its list, a
slider nudges by one `step`.

**A `disabled` widget is out of the ring** — a title screen's CONTINUE before
there is a save is the ordinary case, and stopping on it gave a pad player a
button where `A` does nothing and nothing on screen said why. Enable it and it
is back in the ring on the same frame; `hud.focus()` will not move onto one
either.

**A `GameFlow` pause panel arms it for you** — that screen IS a menu, it knows
exactly when it opens and closes, and it puts the layer's own value back on
resume. Every game with a pause menu was otherwise writing the same two lines,
or shipping a menu that answered only to a mouse.

**It is OFF by default**, deliberately: a game whose HUD happens to contain a
button must not lose its arrow keys the moment one exists. Turn it on for the
screens that ARE menus and off again when play resumes —
`hud.focusNavigation = false`. `hud.focus(widget)` sets the starting item, since
opening a menu should land somewhere rather than nowhere.

**With two layers, the TOPMOST armed one takes the key** — last in the tree,
which is the one drawn on top, and the one a modal is. Keyboard focus is
singular by nature (a browser has one focused element); when both a HUD and a
modal were armed, a single Enter activated the focused widget in EACH, so the
pause menu's RESUME and whatever the screen underneath had focused both fired.
A HIDDEN layer is not armed at all, so a closed menu never eats the key from the
HUD it covers.

The gamepad codes are the ones the engine already produces from a pad
(`Pad12`–`Pad15` d-pad, `Pad0` = A), so nothing extra is declared.

## Dragging things between slots (inventory)

An inventory is the one screen where "click it" is not enough, and every game
that wanted one dropped out of scene JSON to hand-roll pointer handlers. Two
props and four signals, on the widgets you already have — no new node type:

```json
{ "name": "SlotA", "type": "UiPanel", "props": { "dropTarget": true }, "children": [
  { "name": "Potion", "type": "UiImage", "props": { "src": "$potion", "draggable": true } }
] },
{ "name": "SlotB", "type": "UiPanel", "props": { "dropTarget": true } }
```

| signal | on | args |
| --- | --- | --- |
| `dragStarted` | the dragged widget | itself |
| `droppedOn` | the dragged widget | the target it landed on |
| `dropped` | the drop target | the widget that landed |
| `dragCancelled` | the dragged widget | itself (landed on nothing) |

Both ends are told, because both usually have work to do: the item leaves its old
slot, the slot takes it. **Who owns the item is your game's business** — these
report the GESTURE, not a model, so an inventory that stacks, swaps or refuses is
your `connections` and a behavior, not a prop nobody could have guessed.

A drop on a CHILD of a slot counts as a drop on the slot — the cursor lands on
the icon inside it, which is the normal case, and the icon is itself a widget.
A drop on nothing cancels, and so does putting a thing back into the slot it
came from: that is not a move, and firing `dropped` for it would make every
mis-grab look like a transfer.

**A harness can make the gesture.** This is the one screen the paragraph above
says a click cannot express, and for a long time it was also the one screen no
headless check could reach at all — every one of the four signals lived on a
DOM listener, so `runScript`, `incanto-playtest` and every rung of the ladder
were blind to an inventory.

```ts
{ atMs: 200, do: (ctx) => (ctx.getNode('%Potion') as UiImage)
    .dropOnto(ctx.getNode('%SlotB') as UiPanel) }
```

`dropOnto(target)` is to a drag what `press()` is to a click: the same rules as
the pointer path — a non-`draggable` source does nothing, a target that is not a
`dropTarget` cancels, and putting a thing back where it came from is not a move
— and it returns whether the drop was taken.



## The shell every shipped game has

A title screen, a pause menu, options that persist, a minimap and an objective
marker are all HudLayer widgets in scene JSON — `examples/shell-3d` composes the
lot in one game, and it is the shortest thing to copy:

- **One panel per screen** (`Title`, `PauseMenu`, `Options`), and ONE place in
  your behaviour that writes their `visible`. A screen two places can show is a
  screen that gets stuck.
- **`GameFlow` owns every screen**, and none of it is TypeScript: `pauseAction`
  toggles the pause and shows `pausePanelPath`, `titlePanelPath` holds the world
  at boot, `screen(path)` pushes options over whichever menu asked and `back()`
  returns to it. Wire the buttons with `connections` — `pressed → resume`,
  `pressed → screen ["…/Options"]`, `pressed → back`, `pressed → restart` — and
  author every panel that is not up at boot `"visible": false`. See "The whole
  shell, without a script" in `incanto-gameplay-behaviors.md`.
- **The options are the engine's own widgets** — `UiVolumeSlider`,
  `UiMuteToggle`, `UiQualitySelect`, `UiFrameCapSelect`, `UiRenderScaleSelect`
  read and write `engine.settings` and persist themselves. Anything you ADD
  (look sensitivity, difficulty) is two lines: set the widget's `value` from
  `settings` at ready, and `settings.set` on `changed`.
- **`focusNavigation` goes ON for a menu and OFF for play**, or the arrow keys
  belong to the menu while the player is trying to walk.
- **`UiWaypoint.screen()`** answers where the marker is — `onScreen`, `behind`,
  `clamped` — and a headless run can ask it, so "does my objective marker stay
  on screen when the objective is behind me" is a check and not a hope.
- **Give the start button a name a machine can read** — PLAY, START, RESUME.
  A title screen holds the world at `timeScale` 0, and `incanto-feel` and the
  facing check press their way past it before measuring (`HudLayer.focusables()`
  in that order, then everything else); a button they cannot start reports
  `the clock never ran` instead of numbers. `incanto-playtest`'s bot presses
  widgets on its own.

## More than one language

Any text prop here can name a translation key instead of a literal:

```json
{ "name": "Score", "type": "UiText", "props": { "text": "@t:hud.score" } }
```

Strings live in the scene header's `strings` block, English is the base, and a
key a locale omits falls back to English silently — which is the RIGHT answer
whenever the English term is more precise or the translation would not fit.
`UiLanguageSelect` is the picker, already wired.

Read `incanto-localization.md` before adding a second language.
