Log inGet started

The UI window system

Updated 5 September 2026

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")
  • props carries the widget's data and behaviour: a label's text, a slider's value / min / max, an interactive widget's callback ids (onClick, onChange), visible, wrap.
  • style carries everything visual and geometric — the whole CSS surface in the styling guide. A visual key placed in props (or a data key in style) does nothing and logs a warning; keep the two straight.
  • children nests the tree; horizontal and vertical are 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 in gui 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, 099 is the ordinary app depth, 100999 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 componentonCallback top-level in the component module, beside awake / update, or
  • a scene entrypointonCallback top-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 opaque background on the root, or use a centralPanel root, which fills the viewport opaquely.
  • Drive live content from update / editorUpdate. A clock or a data readout recomputes its label and calls updateScreen. 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's pos, a widget's width/height, ui.getLayoutInfo rects, anchors — lives in one logical canvas whose size is ui.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 against ui.screenSize(); to check where something landed, read ui.getLayoutInfo(id) (canvas space) — or gui bounds <id>, gui elementTree.
  • A screen is observable the next frame, not the same call. registerScreen / updateScreen queue the change; it applies at the frame boundary, and a screen's layout only exists after a render pass. So ui.getLayoutInfo(id) / gui elementTree called in the same execute that registered the screen read the last published frame and do not see it yet. Register in one step, query in a later one — a capture or 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". Check gui validate and capture source = "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:

reasonwhat the engine found
paintedit reached the frame
noSuchWidgetno registered screen's element tree carries the id
hiddenByAuthorprops.visible = false on it or an ancestor kept it out
screenNotShownits screen is registered and not shown
neverLaidOutits screen is shown and the frame laid out no box
zeroSizeits own box has no area
offViewportits box sits entirely outside the viewport
clippedByAncestoran ancestor's clip removed the whole box
occludeda later widget covered it with an opaque fill
screenNotOnViewportits 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").

  • documentation
  • guide