Game screens and UI
ts
import { defineGame, type UiNode } from 'gameable';
const menu: UiNode = {
type: 'panel',
id: 'menu',
anchor: 'center',
modal: true,
width: 320,
title: 'Ready?',
children: [{ type: 'button', id: 'play', text: 'Play', variant: 'primary', height: 44 }],
};
let playing = false;
export default defineGame({
init() {
playing = false;
},
systems: [
(ctx) => {
if (ctx.ui.clicked('play')) playing = true;
if (playing) ctx.ui.clear();
else ctx.ui.set(menu);
},
],
});For a scored answer grid and a draggable card, follow Build a game screen.
UI is a JSON tree of plain objects. The host draws it as DOM over the game. Game code uses ctx.ui or the equivalent imported ui facade, without DOM calls or callbacks in the tree. Use the HUD for passive text rows, health bars, a crosshair and a message. Use UI for menus, answer grids, cards, inventories, shops, text entry and pause dialogs. Both channels can be visible together.
Nodes
ui.set(node) accepts one node; ui.set(nodes) accepts an ordered array. Later top-level nodes draw on top. ui.clear() removes the tree; ui.invalidate() makes the next set emit again.
type | Key fields | Use |
|---|---|---|
stack | children, direction, gap, align, justify, wrap | Transparent layout box. |
grid | columns, children, gap | Equal-width columns, filled row by row. |
panel | title?, children?, stack layout fields | A surface for a menu or dialog. |
text | text (string or number), size, tone, align | A label or paragraph. Sizes: sm, md (default), lg, xl, display. Tones: default, muted, accent, danger. |
button | id, text, variant, disabled, selected | Clickable action. Variants: primary, secondary (default), ghost, danger. |
card | title?, body?, children?, stack layout fields, selected, disabled, tone | Clickable with an id; draggable with draggable: true. Tones: default, accent, danger. |
list | children, maxHeight, gap | Vertical list that scrolls past maxHeight. |
bar | value, max, label?, tone | Progress or timer. Defaults to mint, pink at or below one third unless tone is explicit (accent or danger). |
input | id, placeholder, value?, maxLength | One-line entry. Omit value to preserve what the player types; set it to replace the text. |
Every node also accepts id, hidden, width, height, padding, grow, draggable and dropTarget. Sizes are CSS pixels. Top-level nodes also accept anchor, offset and modal. hidden: true leaves a node out of the display.
Layout and anchors
Stacks, panels and cards default to direction: 'column'. Use 'row' for a horizontal group. gap sets spacing between children; omit it for the theme's gap. align controls the cross axis: start, center, end, or stretch (default). justify controls the main axis: start (default), center, end, or between. wrap: true lets a full row continue on another line. grow: true on a child takes remaining space in its parent stack. A grid uses columns and gap instead of stack layout fields.
The nine screen anchors are top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right. The default is center. fill covers the viewport. A top-level offset: [x, y] shifts the node in CSS pixels from its anchor.
ts
import type { UiNode } from 'gameable';
const nameplate: UiNode = {
type: 'text',
id: 'merchant-label',
text: 'Shop',
anchor: { entity: 1, offset: [0, 2, 0] },
};
const placeLabel: UiNode = {
type: 'text',
text: 'Market',
anchor: { position: [0, 3, -5] },
};Replace 1 with an entity your game owns. World anchors project every rendered frame. Their bottom centre sits on the world point; offset inside the anchor adds world metres. entity takes precedence over position. A world-anchored node hides when its point is behind the camera.
modal: true on a top-level node dims the game and takes every pointer event. Use it for a menu or pause dialog. It does not pause your simulation: keep a pause flag in game state and gate gameplay systems yourself. Interactive nodes consume pointer events so clicking a button does not fire the game's mouse input. Elsewhere the overlay lets pointer events reach the canvas.
Themes
Pass overrides with ui.set(nodes, { theme }). Keep the options and theme in variables too. Omitted tokens use the Gameable look: dark surfaces, mint actions, pink errors and focus, Plus Jakarta Sans, pill buttons and 16px cards.
| Token | Gameable default | Use |
|---|---|---|
text | #f2f2f4 | Primary text. |
muted | #9a9ba3 | Secondary labels. |
accent | #74ecc6 | Primary actions, selection, progress. |
onAccent | #0e1f1a | Text on mint. |
danger | #ff7ab8 | Errors, low bars, focus. |
surface | rgba(28, 29, 34, 0.6) | Glass panels and cards. |
surfaceRaised | #24252b | Secondary buttons, tracks, inputs. |
border | #2e2f36 | Borders. |
backdrop | rgba(20, 21, 25, 0.6) | Modal dimming. |
radius | 16 | Panel and card radius in CSS pixels. |
gap | 12 | Child spacing in CSS pixels. |
font | 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif | Font family. |
scale | 1 | Multiplies sizes. |
Events and helpers
Read events in a system, during the tick they arrive. Each event always has kind, id, target, value, x and y. id identifies the source node. Unused target and value are empty strings. Pointer coordinates are CSS pixels from the game view's top-left; keyboard events use zero coordinates.
kind | When it fires | Fields carrying data |
|---|---|---|
click | A button or clickable card is activated. | id; pointer x, y when applicable. |
drag-start | A draggable is picked up. | id of the draggable, x, y. |
drop | A draggable is released on a drop target. | id of the draggable, target of the slot, x, y. |
drag-cancel | A drag is cancelled or ends without a target. | id of the draggable, x, y. |
focus | A control gains focus. | id. |
blur | A control loses focus. | id. |
change | An input is edited. | id, value containing the current text. |
submit | Enter submits an input. | id, value containing the current text. |
ui.clicked(id)returns whether that button or card was clicked this tick.ui.dropped(id)takes the draggable's id and returns its first drop event this tick ornull. Check the returnedtargetbefore accepting the move.ui.submitted(id)returns whether that input was submitted this tick.ui.value(id)returns its latest changed or submitted text across ticks, ornullbefore any edit. An empty string means the player cleared it.ui.eventscontains this tick's events in arrival order. Walk by index to handle multiple events or inspect focus and cancellation. Its array is reused; do not retain it across ticks.
Drag, drop and stable ids
ts
import type { UiNode } from 'gameable';
const hand: UiNode = {
type: 'stack',
children: [
{ type: 'card', id: 'ace', title: 'Ace', draggable: true, height: 60 },
{ type: 'panel', id: 'slot', title: 'Drop here', dropTarget: true, height: 60 },
],
};Set draggable: true on the source and dropTarget: true on its destination. The host uses pointer events with pointer capture, so dragging works with mouse and touch. A drop is a request: update your card's game state, then rebuild the tree to show its new location. Reject an invalid target by leaving state alone.
Give every interactive node an id: buttons, inputs, clickable cards, draggables and drop targets. Ids must be unique within the model. Keep them stable across rebuilds so focus, scroll and drags survive. Use a card's identity as its id, not its current array index. Nodes without ids fall back to tree position.
Cost and state
Rebuild the model only when your state changed. Keep it in a variable and call ui.set with it every frame; see the recipe's dirty flag. Build labels and arrays only on that change, never unconditionally in a system. Unchanged models allocate nothing inside the facade and send no JSON across the boundary. ui.set returns true when it schedules JSON, false when the model matches. The current facade compares against a private snapshot by value, up to 24 levels; reusing your variable avoids your own per-frame tree allocation.
Multiplayer and phones
The page draws UI. Put screen logic in { on: 'client', run } systems for a room. To act on a click, call ctx.net.send(messageDefinition, payload) to send it to the authority, which validates it and changes shared state. Render the reply on the client. The authority's own UI output is never drawn in a room, online or in Play Solo (its in-page authority has no page to draw on). A single-player game without rooms is its own authority and client, so every system's UI is drawn there. A bare system function in a multiplayer game runs on the authority, so it cannot drive a room's buttons. See Play with friends and client systems.
Use pointer events through the facade, without mouse-only game input for UI. Give buttons at least 44px height. Keep fixed-width panels and their padding inside a 375px viewport; use two grid columns, wrapping rows and scrolling lists instead of a wide desktop-only row. Test tapping and dragging on a phone.
Limits
The UI spec's deferred list (specs/2026-10-22-ui-system.md, “Deferred (not in this slice)”) includes images and sprites, per-player UI from the authority, and gamepad focus navigation. These are not available yet. Tab and Enter work; a gamepad stick does not navigate controls. Hover and drag-move events, animated transitions, sliders, toggles, dropdowns and rich text are also deferred.