Build

Game UI built from ordinary components: UIElement, UILayout, UISlider, UIToggle, and keyboard/gamepad focus navigation via ctx.ui.

Hearth’s game UI (HUDs, menus, settings screens) is built from ordinary entities and components (UIElement, UILayout, UISlider, UIToggle), not a parallel widget tree. Visuals still come from Text and SpriteRenderer; UIElement just repositions an entity into screen space and, optionally, wires it into pointer and focus input. Everything here works identically in the editor preview, headless playtests, and exported games.

UIElement: the screen-space basics

Give any entity a UIElement and its position becomes screen-space, unaffected by camera position or zoom:

FieldDefaultMeaning
anchortop-leftOne of the nine screen positions: top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right
offset{x:0, y:0}Pixels from the anchor point, in the game’s buildSettings width×height space
interactivefalseReceives pointer events; the entity’s Script gets onUiEvent
focusablefalseJoins keyboard/gamepad focus navigation via ctx.ui

Transform.position is ignored once an entity has a UIElement (scale and rotation still apply). interactive and focusable are independent: a button is usually both, a display-only HUD label is neither, and a focusable-but-not-interactive element can receive focus but never fires click-family events (see Focus and ctx.ui below).

Z-order (both for rendering and for which element wins a pointer hit when two overlap) comes from layer on whichever visual/widget component the entity has (SpriteRenderer.layer, Text.layer, UISlider.layer, or UIToggle.layer), not from UIElement itself, which has no layer field. Full schema defaults for every component: components.md.

UILayout: stacking containers

A UILayout entity has no visuals of its own: give it a UIElement for its own anchor/offset, then parent the widgets you want stacked to it. The runtime reflows its UIElement children on demand, on pointer events, focus navigation, and (in the pixi host) every render tick, along direction (vertical/horizontal), spaced by gap, inset by padding, cross-aligned by align (start/center/end). A stacked child’s own anchor/offset are ignored. Its position comes entirely from the layout:

// A vertical menu: title, then three buttons, 12px apart.
{
  "name": "MenuStack",
  "components": {
    "UIElement": { "anchor": "center", "offset": { "x": 0, "y": 0 } },
    "UILayout": { "direction": "vertical", "gap": 12, "align": "center" }
  }
}

Hit-testing and focus navigation both use each child’s reflowed position, so drag/click/moveFocus all behave exactly as they would if you’d hand-placed the same coordinates.

UISlider

A draggable value widget the runtime renders and hit-tests itself (track

  • fill + handle). No SpriteRenderer needed. Put it on an entity with UIElement{interactive: true} (and usually focusable: true):
FieldDefaultMeaning
min / max0 / 1Value range
value0.5Current value
step0Snap increment; 0 = no snapping (any value in range)
width160Track width, pixels (scales with Transform.scale.x)
trackColor / fillColor / handleColor#3a3a3a / #f76b15 / #ecececColors
layer0Higher renders/hit-tests on top

Dragging the handle (pointer down, then move) maps the pointer position onto the track and writes value, firing onUiEvent {type: 'change', value} whenever it actually changes. value is in the slider’s own min-max range, not normalized to 0..1.

function script.onUiEvent(ctx, event)
  if event.type == "change" then
    ctx.audio.setMusicVolume(event.value)
  end
end

UIToggle

A boolean checkbox widget, same self-rendering deal as UISlider:

FieldDefaultMeaning
valuefalseCurrent state
size20Box size, pixels
color / checkColor#3a3a3a / #f76b15Box and check-mark colors
layer0Higher renders/hit-tests on top

A click flips value and fires onUiEvent {type: 'change', value} with the new boolean:

function script.onUiEvent(ctx, event)
  if event.type == "change" then
    ctx.vars.muted = event.value
  end
end

Focus and ctx.ui

focusable: true on a UIElement lets it participate in keyboard/gamepad navigation, driven entirely from scripts. Hearth has no built-in controller-to-menu wiring; you write it with ctx.ui and your own input actions:

MemberWhat it is
ctx.ui.focus(idOrName)Focus a focusable element by id/name; nil/null clears focus
ctx.ui.getFocused()Currently focused entity’s id, or nil/null
ctx.ui.moveFocus(direction)Move to the nearest focusable candidate in "up"|"down"|"left"|"right"
ctx.ui.activate()Click the focused element
ctx.ui.adjust(delta)Nudge a focused UISlider’s value

moveFocus picks the nearest candidate strictly in that direction (by screen position) from the current focus (or the top-left-most candidate if nothing is focused yet), with no wraparound: moving off an edge with nothing further that way is a no-op. activate replays a full press/release/click at the focused element (so it fires the same onUiEvents a real click would: a focused UIToggle flips, a focused button’s script runs its click handler); it warns and does nothing if the focused element isn’t interactive. adjust(delta) only affects a focused UISlider (moves value by delta steps, or a tenth of its range if step is 0). It’s a no-op on anything else, including a focused UIToggle (use activate to flip one).

-- Pause menu navigation driven from input actions.
local script = {}

function script.onUpdate(ctx, dt)
  if ctx.input.justPressed("menuDown") then ctx.ui.moveFocus("down") end
  if ctx.input.justPressed("menuUp") then ctx.ui.moveFocus("up") end
  if ctx.input.justPressed("menuLeft") then ctx.ui.adjust(-1) end
  if ctx.input.justPressed("menuRight") then ctx.ui.adjust(1) end
  if ctx.input.justPressed("confirm") then ctx.ui.activate() end
end

return script

A menu that opens should focus its first control explicitly (ctx.ui.focus("Resume")) and a menu that closes should clear it (ctx.ui.focus(nil)). Focus doesn’t move or clear itself.

onUiEvent reference

Script.onUiEvent(ctx, event) fires on an entity with interactive and/or focusable UIElement. event is { type, x, y, value? }:

typeFires whenvalue
enter / exitThe hovered element under the pointer changes
press / releasePointer down / up on this element
clickPointer up lands on the same element it went down on
dragPointer moves while pressed on this element (whether or not still hovering it)
changeA UISlider/UIToggle’s value actually changed (drag, click, or ctx.ui.adjust)New value: number (slider) or boolean (toggle)
focus / blurThis element gained / lost keyboard-gamepad focus, via ctx.ui

x/y are always screen coordinates in the buildSettings width×height space; for focus/blur (which have no real pointer position behind them) they’re the element’s own resolved screen position. value is only present on the event object when it applies: check event.value ~= nil (Lua) / event.value !== undefined (JS) rather than assuming a type.

Playtests

Beyond press/release/click (see cli.md), two steps exercise widgets and focus directly:

StepWhat it does
{ "type": "drag", "from": {x,y}, "to": {x,y}, "frames": 5 }A real pointer gesture: down at from, frames interpolated moves toward to (default 5, one frame each), then up at to — exercises slider dragging exactly like a mouse would
{ "type": "assertFocus", "entity": "Resume" }Asserts the focused entity’s id or name matches (entity: null asserts nothing is focused)
{
  "steps": [
    { "type": "assertFocus", "entity": "VolumeSlider" },
    { "type": "drag", "from": { "x": 400, "y": 300 }, "to": { "x": 460, "y": 300 }, "frames": 6 },
    { "type": "assertProperty", "entity": "VolumeSlider", "property": "UISlider.value", "greaterThan": 0.5 }
  ]
}

HUD vs. menu patterns

A HUD element (score label, health bar) is typically UIElement with interactive: false and no focusable: it’s positioned in screen space and updated from scripts (ctx.getComponent("Text").content = tostring(score)), never clicked. A menu control is interactive: true (and usually focusable: true) so it responds to both a mouse/touch click and controller/keyboard focus navigation. Build one UILayout per menu screen, parent its buttons/sliders/toggles to it, and drive focus from a single script the way Focus and ctx.ui shows above. See packages/examples/drift-cellar for a complete pause menu (a UILayout stack, a UISlider for music volume, a UIToggle, and focus navigation wired from input actions) exercised by real playtests.