Log inGet started

The UI window system

Updated October 15, 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), the gui toolbox'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 — onCallback top-level in the component module, beside awake / update, or
  • a scene entrypoint — onCallback 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. (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 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.

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 own text, and a source with neither shows a translucent outline of itself.
  • dragGhost = false turns that ghost off, for a source that draws its own feedback — an icon, a preview of the item — from ui.dragState().
  • onDrop (a callback id) makes the widget a target: while a payload hovers it the widget outlines, and on release onCallback(id, data) fires with data.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 same data.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 (with dragGhost = false on the source so the engine's ghost does not draw beside it). Its x, y are window points, the space ui.getLayoutInfo answers in. To place a ghost inside your screen, read ui.pointerWidget(): its x, y are the pointer in the canvas your screen lays out in, which is what an area's pos takes — 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 from ui.registerScreen or gui.panel is 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 an editorPanel asset (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'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 the gui toolbox'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, in ui.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) and revision (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 absolute pos is still screen space: offset it by the rect's x/y to 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, and ui.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 read ui.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 } (density is 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, and ui.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 }: current is the shape of the window the app presents in, lock the lock in force, and enforced whether 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 (reason then names the browser's refusal); a desktop window never does and presents in the shape it is given. rotation is 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, and ui.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, and ui.safeArea() follows it.
  • 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) or 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 the gui toolbox's validate tool and capture source = "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:

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 of the pass that drew it (the row's 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.

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").

  • documentation
  • guide