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), theguitoolbox's bounds, clickElement, hoverElement, dragElement and captureElement tools, and the node's entry in 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.
Space — the camera's picture or the window
A screen is part of one of two surfaces. Content UI lives in the camera's picture: it lays out over the target the viewport camera renders into, on a canvas 1280 points wide whatever the target's pixel size (narrower on a phone, where a point covers at least the display's density), and is drawn into that target — so in the editor it sits inside the Scene panel, scales with it, and is in every capture of the camera, the way a player sees it. The editor's own chrome lives on the window: it lays out over the whole surface whatever presents the scene. Where the scene presents on the window itself — play mode full screen, a runtime build — the two coincide.
The space follows the render layer: a screen on the EditorUI layer is chrome
and lives on the window, any other screen is content and lives in the camera's
picture. ui.setScreenSpace(name, "window") moves a content screen onto the
window — an overlay that has to cover the editor too — and "camera" puts one
back. ui.getLayoutInfo answers in window points either way, so a click aimed
by a rect lands wherever the screen is drawn, and a text field in the picture
takes the keys while it holds the caret.
Do not memorise the widget set — query it. The gui toolbox's
widgetTypes tool lists every type, and widgetProps lists one 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").
A long list goes in a virtual scroll area. Put the rows straight under a
scrollArea with virtual = true and give each row an id: only the rows in
view are laid out and painted, and the rest are placed at the heights they
took, so a log, a feed or a leaderboard costs the frame what its visible rows
cost rather than what all of them cost. Rows may differ in height; a
rowHeight says they don't.
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.
(ui.click(callbackId) and the gui toolbox's click tool fire one by hand
for testing, by the callback id the widget emits (its onClick / onChange);
the gui toolbox's press tool presses a widget by its element id.)
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
A screen belongs to the component instance whose own code registers it.
"Own code" is the instance's hooks and methods and every closure they create,
such as a listener its awake subscribes to a module or a task it spawns. That
instance's onCallback receives the screen's callbacks, and the engine
unregisters the screen when the instance ends: its entity despawns, the
component is removed, or the scene is cleared. The log says so at info level,
naming the screens and the instance. A screen registered by any other code (a
scene entrypoint, a module function, a listener a module or an entrypoint
subscribed) belongs to no instance and stays until something unregisters it.
That holds even when a component's hook is what ran that code: a coin's
update that calls score.add(1) runs the score module's listeners, and the
HUD one of them registers is the module's, so the HUD stays when the coin
despawns. The same rule covers a helper module a component calls to put its
screen up: the helper's ui.registerScreen is the module's, so for the
component to own that screen, have the helper return the tree and call
ui.registerScreen in the component. A UiPanel whose source names a
library does this for the screen the library's mount registers, so that
screen is the panel's. The engine's own library (every @builtin::modules.*
module, the ui namespace itself included) acts for the code that calls it: a
screen one of its functions registers belongs to the component instance whose
own code made that call, as if that code had called ui.registerScreen
itself. Registering a screen that an instance
already owns from such code keeps that owner. /runtime/ui/<name>/meta.json
names a screen's owner (the instance id, or null). A screen unregistered
because its owner ended reads back exactly as one ui.unregisterScreen
removed: ui.listScreens() leaves it out, its meta.json is gone and
ui.getScreenTree returns nil. To give a component's screen a longer life,
register it from code that outlives the component; to end an unowned screen,
call ui.unregisterScreen (from the entrypoint's onUnload, for example).
Interaction — like all gameplay — runs in play, not edit. Wire a handler,
then enter play (the wld toolbox's play tool) 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); the gui toolbox's state tool 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.
Drag and drop
Any widget can be a drag source and any widget a drop target — the props sit in
props, next to onClick:
dragPayload(a string) makes the widget draggable, carrying that string. Encode what the drop needs in it ("item|sword_01").dragLabel(a string) is what the ghost under the pointer reads while the widget is dragged — the item's name, "3 items". A source without one shows its owntext, and a source with neither shows a translucent outline of itself.dragGhost = falseturns that ghost off, for a source that draws its own feedback — an icon, a preview of the item — fromui.dragState().onDrop(a callback id) makes the widget a target: while a payload hovers it the widget outlines, and on releaseonCallback(id, data)fires withdata.value = { payload, u, v }— the payload string and the release point normalised to the target's rect (0..1), so a grid turns it into a cell and a list into an index. The innermost target under the pointer receives the drop. To test a handler without a pointer,ui.click(id, { payload = "..." })delivers the samedata.value.ui.dragState()is{ payload, x, y }while a drag is in flight, nil otherwise. Poll it to draw feedback of your own: a slot highlight, a placement preview in the world, or a custom ghost (withdragGhost = falseon the source so the engine's ghost does not draw beside it). Itsx, yare window points, the spaceui.getLayoutInfoanswers in. To place a ghost inside your screen, readui.pointerWidget(): itsx, yare the pointer in the canvas your screen lays out in, which is what anarea'spostakes — the two spaces differ wherever the scene is drawn inside a panel, as in the editor.
A widget can carry both, which is how a list reorders: each row is a source and a target at once.
The ghost is drawn at the live pointer, on the window. A capture re-renders the
frame without the pointer, so it never shows a ghost; a drag is checked by
reading ui.dragState() while it is held — its label is the text the ghost
reads and ghost whether one draws.
Two UIs: the game's and the editor's
Every screen draws in one of two spaces:
"camera"— the game's UI. It draws in the game view's own picture, through the camera: a HUD, a menu, a chat box. In the editor it sits inside the Scene panel; in a build with no editor it is the whole window. A screen fromui.registerScreenorgui.panelis here unless it says otherwise."window"— the editor's UI. It draws in the window around the game view: the dock and its panels (edui), the top bar, an agent's terminal. An editor panel is aneditorPanelasset (guides { path = "types/editorPanel" }).
ui.listScreens() (and gui.screens) reports each screen's space, and
ui.setScreenSpace(name, space) places one. The two carry separate focus: a
field in the game's UI and one in the editor's each hold the keyboard for
their own UI. ui.focusedWidget() returns the focused widget and its space,
ui.blur() reaches both, and Zin.state.uiWantsKeyboard("camera") asks
whether the game's own UI has the keys — the question a game asks before
treating a keystroke as a command. A key typed into either UI never reaches
the scene's controls.
Element ids are shared by both spaces, so give each element an id no other
screen uses. Two screens recording the same id leave ui.getLayoutInfo
answering for whichever painted last: the engine logs it,
ui.idCollisions() lists every shared id with its screens, and the gui
element tools refuse a shared id rather than act on the wrong element.
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) — or theguitoolbox's bounds and elementTree tools. - Screens lay out inside the safe area:
ui.safeArea(). On a phone the status bar, the navigation bar or gesture handle, a camera cutout and the on-screen keyboard cover parts of the screen.ui.safeArea()reports what they leave, inui.screenSize()space:{ top, right, bottom, left }(how far in from each edge they reach),{ x, y, width, height }(the rect they leave),cutouts(each camera hole or notch as a rect) andrevision(advances on every change: a rotation, the bars shown or hidden, the keyboard opening). A screen's root, its anchors, its top-level%sizes and a window's default position all resolve inside that rect, so a HUD anchored to a corner keeps clear of a cutout with no extra work. An absoluteposis still screen space: offset it by the rect'sx/yto keep it inside. The area is all zero on a desktop window;ui.simulateSafeArea({ top = 40, bottom = 30, cutouts = { ... } })lays the UI out as a phone would on any engine, andui.simulateSafeArea()returns to the platform's own. - A phone sizes the UI by its density:
ui.displayScale(). A handheld display (a phone, a tablet, a page in a touch browser) states its density, the physical pixels one density-independent point covers, and a UI point then covers at least that many pixels in either orientation, so 13 point text and a 48 point button keep their physical size on any phone. The canvas is narrower there: about 410 points wide on a phone held upright, so readui.screenSize()rather than assuming 1280. The user's text size setting multiplies every font size the UI typesets (labels, buttons, inputs, editor chrome; canvas text is drawn at the size it names), and on a handheld display a touch reaches an interactive widget anywhere within half the 48 point touch target of it.ui.displayScale()reports{ density, textScale, touchTarget, pixelRatio }(densityis nil on a desktop window, which is sized by its width alone);ui.simulateDisplayScale({ density = 2.625, textScale = 1.3 })sizes the UI as that phone would on any engine, andui.simulateDisplayScale()returns to the platform's own. - A world plays in the orientation it states:
world.orientation. Set it to"landscape","portrait"or"any"(the default) while editing; in play mode the display is locked to it (both directions of it, so turning the phone over turns the game with it), and in edit mode it follows the device.ui.orientation()reports{ current, lock, enforced, reason, rotation, revision }:currentis the shape of the window the app presents in,lockthe lock in force, andenforcedwhether the platform holds it. Android always does; a browser does once it grants the Screen Orientation lock, which most grant only to a page in fullscreen (reasonthen names the browser's refusal); a desktop window never does and presents in the shape it is given.rotationis the display's turn from its natural orientation in degrees, nil on a desktop window.ui.lockOrientation(lock)changes the lock while the world runs, andui.onOrientationChange(fn)calls back with each new reading, for a layout that rearranges itself between the two shapes. A turn also moves the safe area, andui.safeArea()follows it. - 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)or elementTree called 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". Check theguitoolbox's validate tool and capturesource = "screen".
Why is my widget not showing, and what did my click hit
ui.getLayoutInfo and the gui toolbox's elementTree tool 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 of the pass that drew it (the row's 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.
The camera's render-layer mask decides the screens in the camera's picture.
With no Scene panel presenting the game viewport, the window is that picture
too, and the mask decides every screen on it. While a Scene panel presents the
game viewport, the window around it is the editor's own picture: a screen
placed on the window (ui.setScreenSpace, and the default for a screen on
EditorUI) paints there whatever the camera's mask holds, and reads painted.
A screen placed in the camera's picture reads screenNotOnViewport under a
camera that excludes its layer, with or without a Scene panel.
Four 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, viewport is the region the pass that
drew it paints into, 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).
Every rect here, and every ui.getLayoutInfo / gui.bounds rect, is in
logical points on the window, wherever the widget's screen draws. A screen on
the window has the whole window as its viewport. A screen in the camera's
picture lays out on the camera's own canvas (the size ui.screenSize()
reports), and while a Scene panel presents that picture its rects are mapped
onto the window through the panel, so its viewport is the panel's rect on
the window. Times ui.pixelRatio() a rect is in the window's physical pixels:
the space Zin.test's mouse primitives and the inputSim toolbox's click /
mouseMove with target "ui" place a pointer in.
For the pointer: ui.hitTest(x, y) names the widget a point belongs to and the
stack under it, in logical points on the window: the space gui.bounds
answers in. 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 and state to read it; clickElement, clickAt,
hoverElement, dragElement and key to drive it, and scrollElement
to scroll a specific scrollArea to an offset; show, hide, toggle
and setTheme to manage it.
To use a UI the way its logic sees it — press a button, type into a field,
set a toggle — address the element: press, type and set deliver the
element's own onClick / onChange / onSubmit with no pointer moved and
nobody's keyboard focus touched, on an editor panel ("window" space) or the
game's UI ("camera" space) alike, so an agent can build and exercise a panel
while a person keeps working beside it. They act only on what the frame drew.
The three verbs that aim a pointer (clickElement, hoverElement and
dragElement) are the pointer-level route — hit areas, hover, a drag across
widgets — and share the pointer with anyone at the window. They aim at the
widget's layout rect, and a rect
exists for every widget the layout measured whether the frame drew it or not.
Each one therefore reads ui.diagnose(id) first and sends the pointer only on
a painted verdict; any other verdict comes back as ok = false carrying the
reason and its detail, so a row scrolled past the canvas or a screen on a
render layer the viewport camera excludes reads as the state it is in. On a
painted widget the aim is the middle of the part that reached the frame, so a
row a scroll area cut to a band of itself is pressed inside that band.
clickAt presses a point whatever stands there.
A raw pointer aimed from a widget's rect goes through the inputSim toolbox
with target "ui", in window pixels:
local b = tools.use("gui", "bounds", "play-button")
local r = ui.pixelRatio()
tools.use("inputSim", "click", 0, b.centerX * r, b.centerY * r, nil, "ui")
The wld toolbox's play and edit tools 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").