The UI window system
Why immediate-mode: there is no retained widget graph to fall out of sync with your state, no create/destroy/diff to manage. The tree is a pure function of your state; render it, and what you see is exactly what you declared. The cost is that geometry exists only after a render pass — see "The runtime model".
A screen is a widget tree
A node is a table with a type, a props table (its data and behaviour), a
style table (its looks and layout), and children:
ui.registerScreen("hud", {
type = "vertical",
style = { gap = 8, padding = 16, background = "#0e1116" },
children = {
{ type = "label", props = { text = "Health 100" }, style = { fontSize = 28, color = "#e6e6e6" } },
{ type = "button", props = { text = "Inventory", onClick = "open_inv" } },
},
}, 10) -- optional layer: a higher number draws on top
ui.showScreen("hud")
ui.updateScreen("hud", buildTree()) -- swap the whole tree to re-render
ui.hideScreen("hud")
propscarries the widget's data and behaviour: a label'stext, a slider'svalue/min/max, an interactive widget's callback ids (onClick,onChange),visible,wrap.stylecarries everything visual and geometric — the whole CSS surface in the styling guide. A visual key placed inprops(or a data key instyle) does nothing and logs a warning; keep the two straight.childrennests the tree;horizontalandverticalare the flex row and column that lay them out.id— a top-level key on any node — names that element. It is the name every addressing surface answers to:ui.getLayoutInfo(id),gui bounds,gui clickElement/hoverElement/dragElement,gui captureElement, and the node's entry ingui elementTree. Without one the engine generates<screen>/<type>@<path>, which addresses the node just as well but moves when the tree's shape changes — so name the nodes you intend to drive or capture.
Stacking — what layer decides
layer sorts every screen against every other one, whatever their root widgets
are, and it resolves in bands: below 0 sits behind everything, 0–99 is the
ordinary app depth, 100–999 is always-on-top chrome (a taskbar, a persistent
overlay), and 1000+ is menu and popup depth. A screen in a higher band covers
a screen in a lower one, so a card at layer = 800 composites over a radar
panel at layer = 20 whether either is rooted in a vertical, a docked
topPanel, an area or a window.
Inside one band the root type finishes the order. A floating root — an area, a
window, and the anchored overlays a screen builds — floats over the ordinary
content of its own band, which is what makes a window sit on the page it belongs
to. Ordinary content in one band draws in layer order; floating roots in one
band order by interaction, where clicking one raises it and
ui.bringAreaToFront(id) does the same from code.
A modal root takes the top of the stack and dims what it covers whatever its
screen's layer says — that is what makes it modal.
Do not memorise the widget set — query it. gui widgetTypes lists every
type; gui widgetProps <type> lists a type's props with descriptions. There
are containers (vertical, horizontal, grid, scrollArea, the screen-edge
panels), text and media leaves (label, svg, image), interactive widgets
(button, slider, checkbox, input), and windowing types (area, modal,
popup). Read a working screen from @builtin::examples.ui.* before building
from nothing — a dashboard, a sci-fi HUD, an RPG inventory and more, each a
complete CSS-parity screen showing the real structure and style in use. Load
them all live with layers.load("@builtin::examples.ui.gallery").
Interactivity — a callback needs an owner
An interactive widget carries a string callback id, and onClick works on
any widget, not just a button — put onClick = "id" on an image (a
clickable icon), a vertical / horizontal (a whole clickable row or card), or
a label (a menu item). The engine senses the click over that widget's box; you
never overlay a transparent button.
When the user acts, the engine delivers that id to a top-level function named
onCallback(callbackId, data) — and where that function lives is the part
that trips people up. Callbacks reach a persistent owner:
- a component —
onCallbacktop-level in the component module, besideawake/update, or - a scene entrypoint —
onCallbacktop-level in the scene script.
A bare execute/REPL call that registers a screen is not a persistent
environment, so its clicks go nowhere. This is the rule to internalise: a
standalone interactive UI is authored as a component (or a scene entrypoint),
never as a loose registerScreen from the REPL. Register the screens in the
component's awake/update and handle every id in its onCallback. (gui click <id> fires one by hand for testing.)
local function buildTree() ... end -- tree = f(state)
function awake() ui.registerScreen("app", buildTree()) end
function update(dt) ui.updateScreen("app", buildTree()) end -- on state change
function editorUpdate(dt) if engine.paused then update(dt) end end -- tick while the clock is held
function onCallback(id, data) -- every onClick/onChange lands here
if id == "open_inv" then state.invOpen = true end
end
Interaction — like all gameplay — runs in play, not edit. Wire a handler,
then enter play (wld play) and actually use it: a handler that is never
invoked looks identical to a correct one until you drive it. Hover and press are
styled without a callback (see interaction states in the styling guide); gui state <id> reads a node's live hover/press/focus snapshot.
Building a full-screen app
Anything that owns the viewport — a HUD, a menu, a dashboard — is a
component: its lifecycle functions are top-level (no self), and
module-level locals hold its state. Two presentation facts that are discoverable
but not obvious:
- Give the root an opaque background. Containers are transparent by default
(like a CSS element with no
background), so an unpainted root shows the scene through it. Set an opaquebackgroundon the root, or use acentralPanelroot, which fills the viewport opaquely. - Drive live content from
update/editorUpdate. A clock or a data readout recomputes its label and callsupdateScreen. Put per-frame content in its own small screen and refresh only that — re-registering a whole screen every frame (especially a window whose position the user can drag) is wasted work and easy to get subtly wrong.
Windows — draggable, resizable areas
A floating window is its own screen whose root is an area. The area's
keys — id, pos, movable, resizable, pivot, interactable — sit at the
top level of the node, not inside props. Give it an explicit id:
ui.getAreaPos(id) / ui.setAreaPos(id, x, y) and ui.getAreaSize(id) /
ui.setAreaSize(id, w, h) / ui.resetAreaSize(id) key off it, and without one a
dragged position or size is never recorded.
The declared pos/width/height are re-seat controls: changing the value
moves or resizes the window there, while re-registering with the same value
leaves a window the user dragged or resized exactly where they left it — so a
normal per-frame re-render never yanks a window around. A docked bottomPanel /
topPanel (a taskbar) always draws under a floating window; a maximised window
fills the work area (screen minus the taskbar), so it never fights the
taskbar's z-order.
The screen-edge panels (topPanel, bottomPanel, leftPanel, rightPanel,
centralPanel) are root-only: the renderer docks them from the screen root,
so each must be a screen's root node — nested inside a vertical/horizontal
it renders as [<kind> '<id>' must be screen root, not nested]. To build a
toolbar-body-status shell, use a
full-height vertical of styled rows (a header row, a flex = 1 body, a footer
row) rather than nested panels; reserve the edge panels for their own taskbar
screens.
The runtime model — what to know before you are surprised
- One coordinate space:
ui.screenSize(). Every geometric value — an area'spos, a widget'swidth/height,ui.getLayoutInforects, anchors — lives in one logical canvas whose size isui.screenSize()(which can be large, e.g. thousands of units). Never measure off a capture image: a capture is a downscaled copy of that canvas, so a position read from the screenshot's pixels is wrong by the downscale factor. To place things, compute againstui.screenSize(); to check where something landed, readui.getLayoutInfo(id)(canvas space) — orgui bounds <id>,gui elementTree. - A screen is observable the next frame, not the same call.
registerScreen/updateScreenqueue the change; it applies at the frame boundary, and a screen's layout only exists after a render pass. Soui.getLayoutInfo(id)/gui elementTreecalled in the sameexecutethat registered the screen read the last published frame and do not see it yet. Register in one step, query in a later one — acaptureor a follow-up call is already frames later. This is the immediate-mode model, not a bug. - A blank screen is a positive signal. A registered screen that renders
nothing is transparent (no
background), sized to zero, off-canvas, or failed validation — not merely "not shown". Checkgui validateand capturesource = "screen".
Why is my widget not showing, and what did my click hit
ui.getLayoutInfo and gui elementTree answer where layout put a widget.
ui.observe() and ui.diagnose(id) answer what the paint stage did with it.
ui.diagnose("hud-healthbar")
-- { reason = "occluded", detail = "'hud-backdrop' paints later and fills the
-- whole box at full alpha", occludedBy = "hud-backdrop",
-- rect = {...}, clip = {...}, visible = {...}, paintIndex = 41, ... }
reason comes from a closed set — ui.invisibilityReasons() lists it:
| reason | what the engine found |
|---|---|
painted | it reached the frame |
noSuchWidget | no registered screen's element tree carries the id |
hiddenByAuthor | props.visible = false on it or an ancestor kept it out |
screenNotShown | its screen is registered and not shown |
neverLaidOut | its screen is shown and the frame laid out no box |
zeroSize | its own box has no area |
offViewport | its box sits entirely outside the viewport |
clippedByAncestor | an ancestor's clip removed the whole box |
occluded | a later widget covered it with an opaque fill |
screenNotOnViewport | its box is fine and the camera holding the viewport excludes its screen's render layer, so the frame measured it without drawing it |
Nearest cause wins: a widget both clipped away and covered reports the clip.
Three fields carry the geometry the layout rect alone cannot: clip is the
clip chain the widget painted under, visible is the part of its box that
survived that clip and the viewport, and paintIndex is the order it painted
in — which is what separates two widgets whose rects are identical
(ui.paintOrder(a, b) compares two directly).
For the pointer: ui.hitTest(x, y) names the widget a point belongs to and the
stack under it, in the space ui.screenSize() reports — the same space
gui clickAt takes. Where boxes overlap, the one painting last takes the point
and the containers holding it follow, so the answer is the widget a click there
lands on rather than the smallest box around the coordinate.
ui.pointerWidget() is the pointer counterpart of
ui.focusedWidget(): where the pointer sits, whether the UI claimed it, and
which widget the frame reported it over.
The whole reading is ui.observe(), one row per widget, and
/zero/runtime/ui/<screen>/paint.json serves the same rows to a non-Luau
reader. generation advances once per re-rendered frame, so two reads
reporting the same number describe the same frame.
Seeing and driving it
You judge a UI by its pixels and its behaviour, not by its tree. The gui
toolbox is the whole operational surface: capture source="screen" (and
captureElement for one element or a composite) to see it; elementTree /
bounds / validate / state to read it; clickElement / clickAt /
hoverElement / dragElement / key to drive it, and scrollElement <id> <offsetY> to scroll a specific scrollArea to an offset; show / hide /
toggle / setTheme to manage it. wld play / wld edit flip modes.
UI in the 3D world
For world-space UI — a nameplate, a floating health bar — add a UiPanel
component to an entity with a tree:
entity(id).component.add("UiPanel", { mode = "3d", tree = { type = "label", props = { text = "NPC" } } })
When a script errors
An uncaught error surfaces as a top-centre __task_error_overlay screen so a
failure is visible rather than a silent freeze, and it is logged — the logs
toolbox's errors tool has the full stack. In play the overlay auto-clears;
in edit (paused) dismiss it
with ui.unregisterScreen("__task_error_overlay").