# bindings
Binding primitives and evaluators. A binding names which physical input
satisfies a control on one device class — the value a `.inputBinding`
record's `kbm` / `gamepad` / `touch` field holds. A binding is a plain
descriptor table (e.g. `{ kind = "key", code = "KeyW" }`); the evaluators
(`evalHeld`, `evalPressed`, `evalReleased`, `evalAxis`, `evalVector`)
dispatch on `kind` and read `Zin.state` / `Zin.virtual` to produce the live
signal. `Zin.scheme` runs them once per tick for every live binding, and
they are public so chords, axes and library authors can evaluate a binding
without reimplementing the dispatch.
```luau
-- boost.inputBinding/init.luau
local B = require("@builtin::modules.zinput.bindings")
return {
label = "Boost",
kind = "button",
kbm = { B.key("ShiftLeft") },
gamepad = { B.padButton("left_shoulder") },
touch = { B.touchButton({ zone = "right-lower" }) },
}
```
## Two frames, and which binding speaks which
- **Stick bindings** (`vector`, `touchStick`, `padStick`) report the
direction the player *means* — forward is `+y`. A `move` control carrying
a keyboard class and a stick class drives both devices the same way.
- **Delta bindings** (`mouseDelta`, `touchDrag`, `scroll`) report movement
that happened — down is `+y`, because that is what the pointer did.
They also differ in magnitude: a stick is a position bounded at 1.0, a
delta is however far the device moved this frame. A control carrying both —
look, zoom — declares `as = "delta"` on its held class with the
`unitsPerSecond` a full deflection is worth, and `scale` on a delta class
that counts in its own units (a pinch in pixels beside a wheel in notches).
`Zin.scheme` applies both, so one sensitivity covers every device.
## Exports
Keyboard and mouse constructors:
- `M.key(code: string) -> Binding`
- `M.mouse(button: string) -> Binding` — `"left"` / `"right"` / `"middle"`.
- `M.modKey(spec: string) -> Binding` — `"Ctrl+S"`, `"Shift+Alt+P"`, `"Escape"`.
- `M.axis(plus: Binding, minus: Binding, opts: StickOpts?) -> Binding`
- `M.vector(right, left, up, down: Binding) -> Binding`
- `M.wasd() -> Binding`
- `M.arrowKeys() -> Binding`
- `M.mouseDelta(axis: string?) -> Binding` — this frame's mouse delta.
- `M.scroll(axis: string?) -> Binding` — scrollwheel delta.
- `M.pointerLocked() -> Binding` — boolean, true while locked.
Touch constructors (fed by `Zin.virtual`, which the on-screen controls write):
- `M.touchStick(opts: { zone, axis? }) -> Binding` — vector.
- `M.touchButton(opts: { zone?, label?, icon?, priority?, size?, group? }) -> Binding` — boolean.
- `M.touchDrag(opts: { zone, axis? }) -> Binding` — per-frame delta vector.
- `M.touchPinch(opts: StickOpts?) -> Binding` — scalar pinch delta.
Gamepad constructors (addressed by canonical position; `slot` names one pad,
omitting it means any connected pad):
- `M.padButton(button: string, slot: number?) -> Binding`
- `M.padAxis(axis: string, opts: StickOpts?) -> Binding`
- `M.padStick(stick: string, opts: StickOpts?) -> Binding`
- `M.padTrigger(trigger: string, slot: number?) -> Binding`
Predicates:
- `M.isBoolean(b) -> boolean`
- `M.isAxis(b) -> boolean`
- `M.isVector(b) -> boolean`
Evaluators:
- `M.evalHeld(b) -> boolean`
- `M.evalPressed(b) -> boolean`
- `M.evalReleased(b) -> boolean`
- `M.evalAxis(b) -> number`
- `M.evalVector(b) -> Vec2`
Pretty-printers:
- `M.format(b: any) -> string` — single binding ("Space", "Ctrl+S", "WASD").
- `M.formatAll(bindings: any?, sep: string?) -> string` — joined summary; non-table arg returns `"<no bindings>"`.
Types:
- `Binding = { [string]: any }` — descriptor table with `kind` plus type-specific fields.
- `Vec2 = { x: number, y: number }`
- `StickOpts = { slot: number?, as: string?, unitsPerSecond: number?, scale: number? }`
## Notes
- Bindings are pure data tables — they serialize cleanly, compare with
`Json.encode`, and have no metatables.
- `modKey` normalises modifier order in `format()` so a key written as
`Shift+Ctrl+KeyS` displays the same as `Ctrl+Shift+KeyS`. The
internal `mods = { ctrl, shift, alt }` flags are the canonical
representation.
- Mouse-delta, scroll, drag and pinch values are raw physical deltas, not
clamped to `[-1, 1]`. `as = "delta"` + `unitsPerSecond` on the stick
class beside them is what puts every class in one unit.
- `as = "delta"` needs `unitsPerSecond` and has no default: the number that
makes a pad feel right is the point of declaring the conversion, and a
default would be right for one control and quietly wrong for the next.
- Reading a member this module does not have raises immediately, naming the
nearest members and the full list — a wrong guess at a constructor
surfaces at the call rather than as `attempt to call a nil value` later.
- `format()` always returns a string, never throws — returns
`"<invalid>"` for malformed input.
# zinput
zinput is the engine's Luau-side input framework and the **only** public
Luau input API. It sits on top of the internal `__zero_input.*` FFI
(snapshot, events, frameId, pointer-lock, simulate\*) and builds the
control model above it in pure Luau: control assets grouped into maps, the
live set of activated maps and its per-frame dispatch, per-device-class
binding evaluation, the on-screen touch overlay, gesture recognisers, and
the tick coordinator that advances all of it.
The top-level module re-exports every submodule under a single table and
owns the tick coordinator (`Zin.tick`) plus the unified handle namespace
for `Zin.disconnect`.
## The control model
A control is a `<name>.inputBinding/` folder inside a `<name>.inputMap/`
folder. It carries a `label`, a `kind` (`button` / `axis1` / `axis2`), and
a binding for each of the three device classes — `kbm`, `gamepad`, `touch`.
A class set to `false` is a deliberate refusal.
Nothing is live until something activates a map. A world activates the map
it needs and subscribes to the controls it uses:
```luau
local controls
function awake()
controls = self.inputMap:activate()
controls.move:onInput(function(v, dt, active)
if not active then self.velocity = vec3.zero return end
self:translate(v * self.speed * dt)
end)
controls.jump:onPressed(function() self:jump() end)
end
function onDestroy()
self.inputMap:deactivate(controls)
end
```
What `activate` returned is this holder's claim on the map, and handing it
back to `deactivate` disconnects the subscriptions made through it while
the map stays live for whoever else holds it. Every activation reads the
map's binding children again, so a control edited since it came up reaches
the running set and the holders already standing on it.
Several maps are live at once and compose: the player's surface is the
union of them, so picking up a gun adds Fire beside the movement stick and
dropping it takes away Fire alone. A map declares the `group` its controls
belong to and the groups it `suppresses` while live, which is how a car
stands walking down without owning the walk map. `Zin.scheme` holds the
live set and runs the dispatch.
`man topics/input` is the full walkthrough.
## Exports
Subsystems (re-exported from the corresponding submodule):
- `M.scheme` — the live set of activated maps and their per-frame dispatch.
- `M.bindings` — binding constructors, evaluators, and the legend formatter.
- `M.state` — per-frame snapshot reads plus held-time and synthetic repeat.
- `M.events` — frame-event readers and `on`/`off` edge subscriptions.
- `M.input` — `onBegan` / `onEnded` / `onChanged` / `onTextInput` listeners
and `lastInputType()`.
- `M.context` — the input context stack.
- `M.chords` — sequence and simultaneous recognisers.
- `M.gestures` — tap / double-tap / long-press / swipe / pinch / pan
recognisers.
- `M.touch` — per-finger contacts and `touch.*` listeners.
- `M.surface` — touch-vs-kbm device-class classification.
- `M.gamepad` — connected pads, family, and button legends.
- `M.virtual` — the per-tick virtual-control store on-screen controls write.
- `M.touchControls` — the on-screen touch overlay (stick, drag-look zone,
buttons) and the API that adds a control while a world runs.
- `M.emulation` — the map-driven key-emulation floor.
- `M.pointer` — pointer-lock mutations.
- `M.test` — synchronous test-input helpers.
- `M.controllers` — `free` / `orbit` / `fps` controller factories.
- `M.profile` — binding profiles, saved as JSON and loadable from
`@builtin::profiles.<name>`.
- `M.rebind` — interactive binding-capture sessions.
- `M.conflicts` — the binding-collision reverse index.
- `M.autoTick` — the opt-in per-frame tick worker.
- `M.utils` — pure helpers.
- `M.actions` / `M.axes` / `M.map` — the name-registered action, axis and
map registry. A control asset replaced them; each carries its own
`--!deprecated` message naming the replacement.
Tick coordinator:
- `M.tick(dt: number?)` — advance every stateful subsystem.
- `M.lastTickAt() -> number` / `M.lastTickFrameId() -> number` — test/debug accessors.
- `M._resetTickGuard()` / `M._resetLiveArmed()` — test-only.
Handles:
- `M.disconnect(handle: number) -> boolean` — accepts any Zin handle (input listener OR action handler) and routes to the right `disconnect`.
Constants:
- `M.VERSION` — the library version string.
- `M.SPEC` — the VFS path of the architecture spec.
- `M.ROADMAP` — the VFS path of the roadmap document.
## Raw state
`Zin.state` and `Zin.events` are the snapshot and event layer that
bindings evaluate against — the layer the emulation floor, rebind capture,
and dev tooling operate on directly. Gameplay code subscribes to controls;
polling `Zin.state.keyDown` from a world makes that world keyboard-only and
draws the `engine-raw-key-poll` diagnostic at author time.
## Notes
- `Zin.tick(dt)` is the single hub that advances every stateful subsystem
each frame. Stateless features (state / events / utils / bindings /
context) work without ticking; the live binding dispatch, axes, chords,
subscriptions, held-time, repeat, and rebind are dormant until something
ticks.
- The first real input read arms the input system on its own: it starts
`Zin.autoTick`, so dispatch, the touch overlay and the emulation floor
advance with no host component and no explicit tick call. A host script
component can still call `Zin.tick(dt)` from its `update(dt)` hook, and
`Zin.autoTick.start()` remains available directly; both stay idempotent
alongside the self-arming path. `Zin.test` engagement suppresses the
arming so suites keep explicit control over tick cadence.
- Same-frame re-tick dedup: `__zero_input.events()` is non-draining, so
running the observe-and-dispatch pipeline twice in one engine frame would
double-fire subscribers. The frame-id guard in `M.tick` and the autoTick
worker keeps this from happening.
- The handle namespace is unified: every handle from `Zin.input.on*` and
`Zin.actions.bind` is drawn from a single counter, and `Zin.disconnect`
routes any of them to the correct `disconnect`. `Zin.input.disconnect`
and `Zin.actions.disconnect` are also wired to the unified dispatcher.
- Library users can either grab the whole table
(`require("@builtin::modules.zinput")`) or pull one submodule
(`require("@builtin::modules.zinput.state")`).
- `__zero_input` is an internal namespace — reachable from Luau but hidden
from `/docs/api/`; production game code goes through Zin.
# touchControls
On-screen touch overlay: a renderer + worker, not an event-driven widget net.
The worker reads raw touches (`Zin.touch.slots()`) and routes them into zone
geometry derived from the live bindings, writing `Zin.virtual`
(stick/button/drag). `Zin.tick` runs the worker early — after
`Virtual._beginFrame()` clears the previous tick's drag deltas and before the
binding evaluators read `Zin.virtual` — so the touch binding kinds consume
those values on the SAME tick they're written.
The renderer (a `canvas` screen) only draws — it has no widget event handlers
of its own, so it never competes with egui's pointer-focus gate. Backs
`Zin.touchControls`.
## Coordinate space
Touch contacts (`Zin.touch.slots()` — `t.x`/`t.y`/`t.dx`/`t.dy`) carry
PHYSICAL framebuffer pixels — `InputResource` keeps the raw platform
coordinates unconverted, the same space `getViewportSize()` reports. Every
widget geometry computation — button/stick positions, the canvas root's
`style.width/height` — lives in `ui.screenSize()`'s LOGICAL space (the space
`ui.registerScreen` / `ui.getLayoutInfo` use), which differs from the
physical space whenever the display's `pixels_per_point ~= 1` (HiDPI). An
internal `logicalScale` helper computes the `screenSize / viewportSize`
ratio once per tick, and every touch coordinate this module reads is scaled
by it before use.
## Zone geometry (`W`/`H` = `ui.screenSize()`)
- **stick** — activation region `x < 0.4*W and y > 0.4*H` (left 40%, bottom
60%). A floating stick: the base spawns at the touch's start point, the
thumb clamps to a 96px radius, and the vector fed to
`Zin.virtual.setStick` is `offset / 96` with `y` screen-down positive
(matching `setStick`'s documented convention).
- **buttons** — one circle per `touchButton` binding among the LIVE bindings
(`Zin.scheme.bindings()` — what awake components asked for, so every
button corresponds to a control some world actually consumes), budgeted
into a two-column band along the right edge, between 45% screen height and
the bottom inset. Buttons sort by `(priority, id)` — the touchButton's
`priority` option, authored default 50, synthesized default 100 — with
same-`group` buttons kept adjacent, then fill the two columns growing
inward from the bottom-right corner. Each circle is the radius ITS OWN
`size` option names (`small` 40px, `medium` 56px, `large` 72px), so a
scheme mixing a `large` action button with a `small` pause pip draws them
at 72 and 40 whatever else is live; the largest of those radii is the slot
pitch and the row budget the column packs on, so the set decides where a
button sits and the button's own declaration decides how big it is. More
than 4 authored buttons walk every button one step down its own size,
which `layout()` reports as `appliedSize`. Anything past the two-column
budget doesn't render individually — the last slot becomes a `...` fan
button; tapping it opens a rows-of-3 grid sheet holding the overflow (same
tap/hold behavior as a regular button), and tapping the fan again, or any
layout-invalidating context/map change, closes it.
- **drag** — everything else at `x >= 0.4*W` (accumulates into
`Zin.virtual.addDrag` every tick from the contact's per-frame delta).
- the remaining top-left quadrant (`x < 0.4*W and y <= 0.4*H`) is unclaimed —
free for other UI.
A control is drawn only when it is live AND subscribed: a button offered for
a control nothing listens to is an offer that cannot be kept, so the layout
tracks `Zin.scheme.subscriberEpoch()` alongside `Zin.scheme.generation()`.
Only bindings whose `context` matches the current input context
(`Zin.context.current()`) contribute controls, so a vehicle-context Brake
button neither renders nor claims touches while the default context is on
top. The layout rebuilds when the live set, the subscriber count, the
context, or the screen size changes.
A record-shaped map applied through `Zin.map.activate` contributes its touch
classes too. A control whose name is already live as an asset is skipped, so
a world carrying both never draws the same control twice under two ids.
A contact is assigned to a region on its `began` phase only (a contact-id ->
assignment map) and released on `ended` / `cancelled` / vanishing from
`Zin.touch.slots()`. Button circle hit-tests (visible buttons, the fan, the
open sheet's entries) take precedence over the stick/drag zone checks — a
button is a precise target and wins wherever it lands, including inside a
zone (the open sheet grows leftward and can reach the stick zone on a narrow
screen). A contact claimed by the stick or a button never also feeds the
drag zone (a contact is assigned to exactly one region), so its
projected-mouse / `touchDrag` state never competes with a look/aim consumer
reading `Zin.virtual.drag` — no extra suppression is needed beyond the
region assignment itself.
A pinch and the drag region share the same space, so the two are arbitrated
once per tick. The contacts the stick and the buttons hold are published to
`Zin.gestures`, which forms its two-finger pair out of what is left; while
the active map declares a `touchPinch` control and that pair is down, the
drag region reports nothing. A pinch made over the look surface therefore
reaches the pinch control alone, a thumb resting on the movement stick is no
part of a pinch so the other thumb keeps looking, and lifting one finger from
a pinch drags again.
## Rendering
One screen (`"zin_touch_controls"`, layer 200) whose root is a single
full-screen transparent `canvas` sized to `ui.screenSize()`. Commands rebuild
only when the draw state actually changed (a dirty flag) — buttons are
always drawn while mounted; the stick draws only while a stick contact is
live.
## Auto-mount
Every `_advance()` checks `Zin.surface.current()` — `"touch"` registers
(lazily, once) and shows the screen; anything else hides it. Visibility is
purely the surface class, in edit and play alike. `M._forceMount(true)`
bypasses the surface gate for headless testing. Touch routing itself is
unconditional: it runs every tick regardless of mount state, so a forced or
real touch always drives `Zin.virtual` even before the overlay is visible.
## Adding a control while the world runs
The overlay draws a button for a control that is live and subscribed, so
adding one at runtime is adding a control asset, activating it, and
subscribing to it — the three steps a component takes in `awake`, in one
call:
```luau
local h = Zin.touchControls.addControl({
name = "cast", label = "Cast",
kbm = Zin.bindings.key("KeyF"),
gamepad = Zin.bindings.padButton("north"),
onPressed = function() castSpell() end,
})
Zin.touchControls.removeControl(h)
```
The control becomes a real `<name>.inputMap/<name>.inputBinding/` under
`/source/runtimeControls/`, so it is inspectable, rebindable and publishable
like any other, and its name is its id in `Zin.scheme.bindings()` /
`Zin.scheme.fired()`. `removeControl` releases it — off the screen, out of
the live set, every subscription dropped — and leaves the asset, so
re-adding the same name reuses it and its guid.
`M.button(opts)` is the same idea against a record-shaped map applied through
`Zin.map.activate`, in two flavors:
- `{ action = "jump" }` attaches an extra touchButton binding to an existing
action on the active map.
- `{ emit = "KeyF", label = "Cast" }` registers an overlay-only button: a
synthetic `emit:KeyF` action carrying both the kbm key binding and the
touchButton, so the emulation floor drives `KeyF` through the same
binding/emulation path every other control uses.
Both return a handle for `M.removeButton(handle)`.
## Exports
- `M.addControl(opts: ControlOpts) -> number` / `M.removeControl(handle) -> boolean`
— add or release a control while the world runs.
- `M.controls() -> { { handle, name, path } }` — every control `addControl`
currently holds, in the order they were added.
- `M.button(opts) -> number` / `M.removeButton(handle) -> boolean` — the
button API described above.
- `M.claimed() -> { number }` — the contact ids currently claimed by the
stick, a button, or the drag zone.
- `M.primaryClaimed() -> boolean` — whether an on-screen control owns the
primary contact. `Zin.bindings` consults this so a tap on a button does
not also fire whatever the world bound to left-click.
- `M.layout() -> { buttons, fan?, stick?, drag?, pinch?, width, height }` — the
current control layout (a copy): `buttons` is the budgeted, VISIBLE stack
in draw order, each entry `{ id, label, actionName, priority, size,
appliedSize, group, cx, cy, radius }` in `ui.screenSize()` space — `size`
is the step the binding declared and `appliedSize` the one `radius` came
from, which differ only where a rich scheme's step-down moved a button one
size down; `fan` is nil unless the button set exceeded the two-column
budget, else `{ open, cx, cy, radius, buttons }` — `open` is whether the
overflow sheet is expanded, `buttons` the overflowed entries at their sheet
positions (tappable only while `open`); `stick` / `drag` name the axis +
virtual zone each region feeds, and `pinch` the axis the two-finger
recognizer feeds. Backs custom control-placement UI and
headless tests that aim simulated touches at a button's (or the fan's)
center.
- `M._advance()` — internal: one tick (touch routing, layout, auto-mount,
repaint). Wired into `Zin.tick`, before `Zin.emulation._advance()`.
- `M._forceMount(on: boolean)` — test-only: force mounting regardless of
`Zin.surface.current()`.
- `M._setViewportOverride(w?, h?)` — test-only: override the logical screen
size the layout budget/fan math reads, without resizing the real window.
Pass nil/nil to clear.
- `M._reset()` — test-only: unmount, clear routing/layout state, drop
buttons, release every runtime control, close the fan, and clear the
viewport override.
# state
Synchronous per-frame input state. Thin wrapper over the internal
`__zero_input.snapshot()` FFI; backs `Zin.state`. Pointer-lock mutations
live on `Zin.pointer`; the read-side `pointerLocked()` query lives here
with the rest of the snapshot accessors. Held-time and synthetic-repeat
state is owned by this module and updated via `_observeEvent` from the
`Zin.tick` coordinator.
## Exports
- `M.get() -> Snapshot` — full per-frame snapshot table; empty when the FFI is unavailable.
- `M.keyDown(key: string) -> boolean` — held this frame.
- `M.keyPressed(key: string) -> boolean` — edge-pressed this frame.
- `M.keyReleased(key: string) -> boolean` — edge-released this frame.
- `M.mousePosition() -> (number, number)` — current mouse `(x, y)`.
- `M.mouseDelta() -> (number, number)` — `(dx, dy)` since last frame.
- `M.scrollDelta() -> number` — wheel delta this frame.
- `M.mouseButtonDown(idx: number?) -> boolean` — held; default idx = 0.
- `M.mouseButtonPressed(idx: number?) -> boolean` — edge-pressed.
- `M.mouseButtonReleased(idx: number?) -> boolean` — edge-released.
- `M.pointerLocked() -> boolean` — pointer-lock query (writes are on `Zin.pointer`).
- `M.uiWantsPointer() -> boolean` — `true` when the UI layer claims pointer focus.
- `M.setRepeatDefaults(opts: RepeatOpts?)` — set default delay / period for synthetic repeat.
- `M.getRepeatDefaults() -> RepeatDefaults` — read the active synthetic-repeat defaults.
- `M.keyHeldTime(code: string) -> number?` — seconds since the key was pressed (or nil).
- `M.mouseButtonHeldTime(idx: number?) -> number?` — seconds since a mouse button was pressed.
- `M.keyRepeatFired(code: string, opts: RepeatOpts?) -> boolean` — should a synthetic key-repeat fire this frame?
- `M._observeEvent(ev: any)` — internal: per-event observer, wired from the tick coordinator.
- `M._reset()` — test-only: clear held-time and synthetic-repeat state.
Types:
- `Snapshot = { [string]: any }`
- `RepeatOpts = { delay: number?, period: number? }`
- `RepeatDefaults = { delay: number, period: number }`
## Usage
```luau
local State = require("@builtin::modules.zinput.state")
-- Rebind-capture-style probe: how long has a key been held, and is a
-- synthetic repeat due this frame?
local heldFor = State.keyHeldTime("Space")
if heldFor and State.keyRepeatFired("Space", { delay = 0.4, period = 0.08 }) then
-- charge-attack tick, key-repeat menu navigation, etc.
end
```
## Notes
- Mouse-button indices are 0-based and follow `InputResource.mouse_buttons[]`
(0=left, 1=right, 2=middle). Winit's `0=L, 1=M, 2=R` is converted in Rust
before reaching Luau.
- `mousePosition` / `mouseDelta` return two numbers, not a table.
- Held-time / repeat state is updated by the `Zin.tick` coordinator via
`_observeEvent` — without a tick, those queries return `nil` / `false`.
- `keyRepeatFired` advances state on every call — call once per frame per
consumer.
- All entry points are no-ops or return zero values when `__zero_input` is
missing (off-host execution).
- Gameplay subscribes to controls through an activated `.inputMap`; this
module is the raw snapshot layer those bindings evaluate against, used
directly by rebind capture, the emulation floor, and dev tooling.
Polling it from a world makes that world keyboard-only and draws the
`engine-raw-key-poll` diagnostic at author time.
# utils
Pure helpers for the `Zin` input library. Mouse-button name↔index
conversion, key-code normalization, held-modifier matching against a frame
snapshot, and the shaping a reading passes through on its way to a
consumer. No FFI; safe to call from any context. Stateless.
## Exports
- `M.buttonName(idx: number) -> string?` — convert a 0-based mouse button index to its name.
- `M.buttonIndex(name: string) -> number?` — convert a mouse button name to its 0-based index.
- `M.normalizeKey(code: string) -> string` — canonicalize a key code: a single ASCII letter becomes its `Key<L>` code, a single digit its `Digit<N>` code; multi-character codes pass through.
- `M.matchModifiers(snapshot: Snapshot, mods: Modifiers) -> boolean` — test whether the snapshot's held keys match the requested modifier set.
- `M.applyCurve(curve, x: number) -> number` — apply a response curve to a reading.
- `M.applyDeadzoneScalar(x: number, deadzone: number?) -> number` — 0 below the threshold, the reading at or above it.
- `M.applyDeadzoneVector(v: Vector2, deadzone: number?) -> Vector2` — the same, measured **radially** on the magnitude of the pair.
- `M.shapeScalar(x: number, deadzone: number?, curve, invert: boolean?) -> number` — deadzone, then curve, then inversion.
- `M.shapeVector(v: Vector2, deadzone: number?, curve, invert: boolean?) -> Vector2` — the same for a pair.
- `M.smoothToward(current: number, target: number, dt: number, tau: number) -> number` — one step of an exponential approach with time constant `tau`.
A `curve` is `"linear"`, `"quadratic"`, `"cubic"`, a function of the
reading, or `nil` for linear.
Types:
- `Snapshot = { [string]: any }` — the raw frame-snapshot table returned by `Zin.state.get()`.
- `Modifiers = { ctrl: boolean?, shift: boolean?, alt: boolean? }` — modifier subset to require in `matchModifiers`.
- `Vector2 = { x: number, y: number }` — a two-component reading.
## Shaping
The order is deadzone, curve, invert, then the approach toward the shaped
target. A scalar takes its deadzone per value; a pair takes it radially, so
a diagonal is not clipped into a cross by two independent thresholds.
`"quadratic"` squares while keeping the sign, so a half-pushed stick reads
a quarter in the direction it was pushed. A curve function that raises, or
answers with anything other than a number, leaves the reading as it was.
This is the one implementation of that shaping: `Zin.axes` reads it for a
registered axis and `Zin.scheme` for an `axis1` / `axis2` control, so an
axis and a control declaring the same numbers read the same.
## Usage
```luau
local Utils = require("@builtin::modules.zinput.utils")
local name = Utils.buttonName(0) -- "left"
local idx = Utils.buttonIndex("right") -- 1
local key = Utils.normalizeKey("w") -- "KeyW"
local hit = Utils.matchModifiers(snap, { ctrl = true, shift = false })
```
## Notes
- Pure functions — no state, no side effects.
- Mouse-button indexing is 0-based and matches `Zin.state.mouseButtonDown(idx)` (0=left, 1=right, 2=middle).
- `matchModifiers` ignores fields set to `nil` in `mods` — only `true`/`false` are constraints.
- `normalizeKey` rejects any other single character: no bound key code is
one character long, so `"?"` raises rather than resolving to a phantom
key nothing consumes.
# gestures
Touch-gesture recognizers over the touch event stream + contact
snapshot; backs `Zin.gestures`. Discrete gestures (tap, double-tap,
long-press, swipe) fire subscriptions; continuous gestures (pinch, pan)
expose per-tick deltas computed from the two-finger contact state.
Advanced by the `Zin.tick` pipeline — dormant until ticked.
## Discrete vs continuous
- **Discrete** (tap / double-tap / long-press / swipe) — recognized on
a release or hold-duration edge, delivered via `on*` subscriptions on
the tick they fire.
- **Continuous** (pinch / pan) — recomputed every tick from the
two-finger contact positions; read via `pinchDelta()` / `panDelta()`,
reset to zero at the start of each tick. The pair is formed from the
contacts no on-screen widget is working, the ones `Zin.touchControls`
says its stick and buttons hold having been left out, so a thumb on a
movement stick is no part of a pinch.
## Config
`Zin.gestures.configure` overrides recognition thresholds (unspecified
fields keep their current values):
| Field | Default | Meaning |
|---|---|---|
| `tapMaxDuration` | `0.25` s | press→release within this = tap candidate |
| `tapMaxMovement` | `16` px | total movement below this = tap candidate |
| `doubleTapWindow` | `0.3` s | two taps within this = double-tap |
| `longPressDuration` | `0.5` s | held past this (still) = long-press |
| `swipeMinDistance` | `60` px | release travel at/above this = swipe candidate |
| `swipeMinVelocity` | `800` px/s | release velocity at/above this = swipe |
## Exports
- `M.configure(opts: { [string]: number })` — override recognition thresholds.
- `M.getConfig() -> GestureConfig` — current thresholds (a copy).
- `M.onTap(fn) -> number` — subscribe to taps; `fn(ev)` with `ev = { x, y, id, duration }`.
- `M.onDoubleTap(fn) -> number` — subscribe to double-taps; `ev = { x, y, id }`.
- `M.onLongPress(fn) -> number` — subscribe to long-presses (fires once per contact); `ev = { x, y, id }`.
- `M.onSwipe(fn) -> number` — subscribe to swipes (fires on release); `ev = { direction, dx, dy, velocity, id }`.
- `M.off(handle: number) -> boolean` — unsubscribe a handle from any `on*` function.
- `M.pinchDelta() -> number` — this tick's two-finger pinch delta in px (positive = spreading).
- `M.panDelta() -> (number, number)` — this tick's two-finger pan delta `(dx, dy)` in px.
- `M._beginTick()` — internal: clear per-tick continuous deltas; wired by `Zin.tick`.
- `M._observeEvent(ev: any)` — internal: builds contact tracks and classifies discrete gestures on release; wired by `Zin.tick`.
- `M._advanceTime(dt: number)` — internal: time-based recognition (long-press) + continuous deltas; wired by `Zin.tick`.
- `M._setWidgetContacts(ids: { [number]: boolean })` — internal: the contacts an on-screen widget is working; published each tick by `Zin.touchControls`.
- `M._pairDown() -> boolean` — internal: whether two contacts no widget is working are down, which is the pair a two-finger gesture reads.
- `M._dispatchFires()` — internal: deliver this tick's discrete gesture fires; wired by `Zin.tick`.
- `M._reset()` — test-only: clear all tracks, fires, deltas, and subscriptions.
Types:
- `GestureConfig = { tapMaxDuration, tapMaxMovement, doubleTapWindow, longPressDuration, swipeMinDistance, swipeMinVelocity }` (all `number`)
## Usage
```luau
local Gestures = require("@builtin::modules.zinput.gestures")
Gestures.onTap(function(ev)
print("tap at", ev.x, ev.y)
end)
Gestures.onSwipe(function(ev)
if ev.direction == "left" then nextPage() end
end)
-- inside a per-frame update:
local zoom = Gestures.pinchDelta()
local dx, dy = Gestures.panDelta()
```
## Notes
- Pinch and pan deltas are zero unless exactly two contacts are down
this tick that no on-screen widget is working.
- Long-press fires once per contact — the contact must stay within
`tapMaxMovement` for the full `longPressDuration` and is marked so it
never also fires a tap on release.
- Every qualifying release fires `tap`; the second tap of a double-tap
fires `doubleTap` and `tap` on the same tick.
- Handler errors during dispatch are caught and logged, not propagated.
# virtual
Per-tick virtual-control values. On-screen controls (and tests) write stick
vectors, button states, and drag deltas here; the `touchStick` / `touchButton`
/ `touchDrag` / `touchPinch` binding kinds in `Zin.bindings` read them. Backs
`Zin.virtual`.
Three families, each keyed by the control's binding identity:
- **Sticks** — zone string (`"left"`, `"right"`, ...). A vector clamped to
`[-1, 1]` per component, persisting until set again or reset.
- **Buttons** — id string: a `touchButton`'s `id` (stamped with the
control's name when its map activates), else its `label`, else its
`zone`. A held boolean; `buttonPressed` / `buttonReleased` derive edges
from the previous frame boundary.
- **Drags** — zone string. A delta vector that accumulates within a tick
(`addDrag` sums repeated calls) and clears at the next frame boundary —
the same per-frame-delta semantics as `Zin.state.mouseDelta()`.
## Edge-at-frame-boundary semantics
`_beginFrame()` shifts `_buttons` into `_buttonsPrev` and clears drag deltas.
It runs once per engine frame from the `Zin.tick` coordinator's gated begin
block, before any binding evals happen. On-screen overlay code (script phase)
calls `setButton` earlier in the same frame it wants the press recognized;
`buttonPressed` reads true starting the frame `_beginFrame` next observes the
transition — matching how a real key's press edge surfaces on the frame
after the OS event, not the same frame the write happened.
## Exports
- `M.setStick(zone: string, x: number, y: number)` — set a stick's vector (clamped).
- `M.stick(zone: string) -> (number, number)` — read a stick's `(x, y)`; `(0, 0)` when unset.
- `M.setButton(id: string, held: boolean)` — set a button's held state.
- `M.button(id: string) -> boolean` — held state.
- `M.buttonPressed(id: string) -> boolean` — press edge this frame.
- `M.buttonReleased(id: string) -> boolean` — release edge this frame.
- `M.addDrag(zone: string, dx: number, dy: number)` — accumulate a drag delta.
- `M.drag(zone: string) -> (number, number)` — read a zone's accumulated `(dx, dy)` this frame.
- `M._beginFrame()` — internal: frame-boundary edge shift + drag clear, called by `Zin.tick`.
- `M._reset()` — test-only: clear all virtual state.
## Usage
```luau
local Virtual = require("@builtin::modules.zinput.virtual")
Virtual.setStick("left", 0.4, -0.9)
local x, y = Virtual.stick("left")
Virtual.setButton("Jump", true)
if Virtual.buttonPressed("Jump") then
-- fires once, on the frame after the set
end
```
# emulation
Map-driven input-emulation floor. Reads `Zin.virtual` (the store on-screen
touch controls write) each tick and emulates the equivalent kbm keys / mouse
delta, so raw-key-polling worlds (`Zin.state.keyDown`) move from touch input
without themselves reading `Zin.virtual` or `Zin.bindings`. Backs
`Zin.emulation`.
## What it derives, per tick, from the active effective map
Only entries whose `context` matches the current input context
(`Zin.context.current()`) participate — the same activity rule
`Zin.actions` applies, so a vehicle-context Brake button emulates nothing
while the default context is on top.
- **touchButton actions** — the OR of every touchButton binding in an
action's touch class drives its first kbm `key` binding's code, emulated
on the held state's transitions. Actions with no kbm key binding are
skipped.
- **touchStick axes** whose kbm class is a `vector` of four `key` arms (the
`B.wasd()` / `B.arrowKeys()` shape) — each stick component thresholded at
`+-0.5` drives the matching direction key. The stick's y is screen-down
positive (`Virtual.setStick`'s convention), so negative y (stick pushed
up) drives the vector's `up` arm and positive y its `down` arm.
- **touchDrag axes** whose kbm class is `mouseDelta` — the drag delta,
scaled by `M.setSensitivity`, emulated via `input.emulateMouseDelta`
whenever it's non-zero.
A control that is already a live binding (`Zin.scheme.has(name)`) is
skipped: its touch class drives it directly, and synthesising a key for it
as well drives it twice, from opposite conventions. The judgement is per
control, because a world can subscribe to some things and poll raw keys for
others.
Emitted keys are tracked locally and diffed each tick — a held key is
emulated once, on the press transition, never re-emitted every tick a
control stays held. Switching the active map (name change between ticks)
or the input context (top-of-stack change) releases everything emitted
under the previous map/context.
## Exports
- `M.setSensitivity(n: number)` / `M.getSensitivity() -> number` — the
touchDrag -> mouse-delta multiplier (default `1.0`).
- `M._advance()` — internal: one tick of the floor, called by `Zin.tick`.
- `M._reset()` — test-only: release everything emitted, clear state.
## Usage
```luau
local Zin = require("@builtin::modules.zinput")
Zin.map.ensureActive()
Zin.emulation.setSensitivity(1.5)
-- Zin.tick(dt) advances the floor; a virtual Jump press then reads as
-- Zin.state.keyDown("Space") for any world that only polls kbm state.
```
# gamepad
The pad-facing surface: which controllers are connected, what family they
belong to, and what a legend should call a canonical button on them. Backs
`Zin.gamepad`.
Bindings never come through here. A scheme binds `B.padButton("south")` and
evaluates against whichever pad is connected; this module answers the
questions a UI asks — how many players have controllers, and which letter to
draw on a prompt.
## Position, not letter
The canonical layout names a button by POSITION, because position is what
every pad shares — `south` is the lower face button on all of them. The
letter printed on it is not shared: `south` is `A` on an Xbox pad, `Cross`
on a PlayStation one, and `B` on a Nintendo one. `label()` resolves that
from the device name the platform reported, so a binding stays
device-independent and only the drawn glyph changes.
Anything unrecognised reads as `xbox`, because the standard mapping every
backend normalises to is the Xbox layout.
## Exports
- `M.list() -> { Pad }` — every connected pad, in slot order.
- `M.count() -> number` — how many pads are connected.
- `M.get(slot) -> Pad?` — the pad in one slot.
- `M.family(slot?) -> string` — `"xbox"` | `"playstation"` | `"nintendo"`.
- `M.label(button, slot?) -> string` — the legend for a canonical button.
- `M.available() -> boolean` — whether the session has ever seen a pad.
Latched by the first connection, so unplugging one does not flip a
scheme's prompts back to keyboard glyphs on a cable knock.
Types:
- `Pad = { slot, name, buttons, buttons_pressed, buttons_released, axes,
simulated }` — the button fields are arrays of canonical names, `axes` a
map of canonical axis name to number, and `simulated` marks a pad that
came from the `inputSim` toolbox rather than a device.
## Usage
```luau
local Zin = require("@builtin::modules.zinput")
if Zin.gamepad.count() >= 2 then
startLocalCoop()
end
ui.text("Press " .. Zin.gamepad.label("south") .. " to jump")
```
# actions
A name-registered action registry over `Zin.bindings` + `Zin.context`.
A name maps to a list of binding descriptors and one declared context;
polling queries (`held`, `pressed`, `released`, `value`, `heldTime`,
`repeated`) evaluate those bindings against the live snapshot, and the
handler surface (`bind` / `onPressed` / `onReleased` / `onChanged` /
`onHeld`) fires the same state from inside `Zin.tick`, after axes and
chords advance.
A control asset replaced this. A `<name>.inputBinding/` folder inside an
`.inputMap/` carries the label, the kind, and a binding for
keyboard-and-mouse, gamepad and touch, and a world activates the map and
subscribes to the controls it uses:
```luau
local controls
function awake()
controls = self.inputMap:activate()
controls.jump:onPressed(function() self:jump() end)
controls.move:onInput(function(v, dt, active)
if not active then self.velocity = vec3.zero return end
self:translate(v * self.speed * dt)
end)
end
function onDestroy()
self.inputMap:deactivate(controls)
end
```
`Zin.map.bake(name)` writes the currently active map out as an `.inputMap`
asset. `man topics/input` covers the control model.
## Exports
Registry:
- `M.define(spec: ActionSpec)` — define / replace actions.
- `M.remove(name: string)` — remove one action (also tears down handlers).
- `M.clear()` — wipe every action + handler.
- `M.has(name: string) -> boolean`
- `M.names() -> { string }`
- `M.get(name: string) -> ActionEntry?` — the registry record behind a name.
- `M.active(name: string) -> boolean` — true iff the action's context matches the top of the stack.
Polling queries (gated by context):
- `M.held(name) / pressed(name) / released(name) -> boolean`
- `M.value(name) -> number | Vec2 | 0/1`
- `M.heldTime(name) -> number?`
- `M.repeated(name, opts?) -> boolean`
Event-driven handlers:
- `M.bind(name, fn, opts?) -> ActionHandle?` — register a handler.
- `M.disconnect(handle: ActionHandle) -> boolean`
- `M.onPressed / onReleased / onChanged / onHeld(name, fn, opts?) -> ActionHandle?`
- `M.handlerCount(name) -> number`
Internal hooks (wired by `zinput/init.luau`):
- `M._setAllocator(fn: () -> number)` — shared id allocator.
- `M._setEnsureBindingsFn(fn)` / `M._setEnsureLiveFn(fn)` — the cold-read hooks.
- `M._owns(handle: number) -> boolean`
- `M._dispatchHandlers(firstTickThisFrame: boolean?)` — called by `Zin.tick`.
- `M._clearHandlers()` — test isolation.
Types:
- `ActionEntry = { context: string, bindings: { any } }`
- `ActionSpec = { [string]: any }` — define-spec entry shapes.
- `ActionState = "Begin" | "Change" | "End" | "Held"`
- `BindOpts = { priority: number?, fire: { ActionState }?, once: boolean? }`
- `ActionHandle = number`
## Notes
- Define-spec shorthand:
- `binding` — single binding under the default context.
- `{ binding1, binding2 }` — multiple bindings under the default context.
- `{ context = "...", binding = ... }` / `{ context, bindings = {...} }` — explicit context.
- Context gating is strict: the action's context string must equal
`Zin.context.current()`. There is no inheritance up the stack.
- Handlers run from `Zin.tick` after axes/chords advance. With no tick
host (or `Zin.autoTick.start()`), handlers never fire.
- Returning `"sink"` from a handler stops further (lower-priority)
handlers on the same action this tick. Anything else continues.
- `remove(name)` and `clear()` also wipe handlers bound to those
actions — handler state never outlives its action.
- The handler allocator is swapped at boot (`_setAllocator`) so handles
drawn here share one namespace with `Zin.input.on*`.
# autoTick
Opt-in per-frame tick loop for zinput. Spawns a `task.spawnSystem` worker
that invokes a registered tick function (`Zin.tick`) once per frame, so
code-mode snippets and tests without a host script component still drive
the stateful subsystems — live binding dispatch, the touch overlay, the
emulation floor, axes, chords, rebind, action handlers, event subscribers.
`task.spawnSystem` (not `task.spawn`) makes the loop system-owned, so it
keeps ticking while the editor pauses gameplay.
The first real input read arms this worker on its own. A host script
component can also call `Zin.tick(dt)` from its `update(dt)` hook, and
`autoTick.start` can be called directly; all three are idempotent
alongside each other.
## Exports
- `M.start() -> boolean` — spawn the worker if not already running. Returns true when a new loop was started.
- `M.stop()` — flag the loop to exit on its next yield. No-op if not running.
- `M.isRunning() -> boolean` — current status.
- `M._setTickFn(fn: (number?) -> ())` — internal hook. Called once by `zinput/init.luau` to register `Zin.tick`.
- `M._setLastTickFrameIdFn(fn: () -> number)` — internal hook. Wires the frame-id accessor so the worker can dedup itself against explicit ticks within the same engine frame.
## Usage
```luau
local Zin = require("@builtin::modules.zinput")
-- One-shot test / code-mode setup:
Zin.autoTick.start()
-- ... later ...
Zin.autoTick.stop()
```
## Notes
- Single-flight: calling `start()` twice is a no-op — there is at most
one worker per VM.
- Cross-coroutine dedup: when an explicit `Zin.tick` ran in the same
engine frame, the worker skips that frame so `onHeld` handlers and
event subscribers don't double-fire.
- Module-local `_running` survives Luau require-cache reload, so a
re-required `autoTick.module` still reports the loop is live.
`restart_instance` resets the VM and clears everything.
- `start()` raises if `_setTickFn` hasn't been wired yet — always
`require("@builtin::modules.zinput")` first.
# scheme
The live set of activated input maps, and the per-frame dispatch that turns
their bindings into the events a controller subscribed to. Backs
`Zin.scheme`.
## Nothing is live until something activates a map
A controller holds an `inputMap` ref, activates it when it takes control,
and releases it when it gives control up. The live binding set is exactly
the union of what current holders asked for, so an empty world has an empty
input surface and an empty screen — no scheme is active by default, and
nothing appears on a phone that no component consumes.
For a component that drives the thing it lives on, taking control and being
awake are the same moment, so `awake` / `onDestroy` are the hooks. For
anything a player enters and leaves — a car, a turret, a menu — they are
not: the car is still there after the player gets out, so the map goes live
on entry and stands down on exit.
```luau
local controls
function awake()
controls = self.inputMap:activate()
controls.move:onInput(function(v, dt, active)
if not active then self.velocity = vec3.zero return end
self:translate(v * self.speed * dt)
end)
controls.jump:onPressed(function() self:jump() end)
end
function onDestroy()
self.inputMap:deactivate(controls)
end
```
What `activate` returned is this holder's **claim** on the map. Keep it and
hand it back: releasing the claim disconnects the subscriptions made through
it, and the map stays live for whoever else holds it.
## Several maps compose by all being live
The player's surface is the union of every live map, which is what a game
needs: picking up a gun activates a weapon map and Fire appears beside the
movement stick; dropping it takes away Fire and nothing else. Two
controllers that each activate a map with a movement stick produce two
sticks — visible, attributable to the second controller, and the importer's
to resolve.
Two components activating the *same* map read one set of controls, each
through its own claim. A claim's release disconnects what that holder
subscribed and leaves the other holders' handlers attached; the map leaves
the live set when its last claim goes. `Zin.scheme.live()` reports each live
map's `holders` — how many claims are still standing on it — and `heldBy`,
which says who each of them is: `kind` (`"component"` or `"execute"`),
`entity`, `source` as the `chunk:line` the activation was opened at, and the
`label` the caller passed to `activate("weapon-swap")`.
## An activation reads the map's children as they stand now
Every `activate` re-reads the map's `<name>.inputBinding/` children, so a
control added, edited or removed since the map came up reaches the running
set on the next activation. The controls are edited in place, so it reaches
the holders already standing on the map too: their handles keep working and
every subscription they made carries on across the edit.
## A map can stand another one down
Composition cannot say "not while this is happening". A map declares the
`group` its controls belong to and the groups it `suppresses` while live:
```luau
-- driving.inputMap/init.luau
return { group = "vehicle", suppresses = { "player" } }
```
Every live map in a suppressed group reads nothing and draws nothing for as
long as the suppressor is live. It is **not** released: it keeps its
holders, its handles and every subscription, and resumes untouched when the
suppressor goes. So a car stands down walking without ever finding, owning
or releasing the walk map — and a menu stands down the whole game with one
declaration.
A map that declares no `group` belongs to one named after itself, so it
collides with nothing and can still be named by something that wants to
stand it down. Declaring neither field composes with everything.
The drop frame runs on the tick a map stands down, so a stick held at that
moment reports its neutral value once and whatever it drove stops rather
than sticking.
## Dispatch
`_advance(dt)` evaluates every live binding once per tick, across every
device class it carries — a tablet with a keyboard attached and a desktop
with a pad plugged in both answer to whichever the player actually used,
rather than to a nominated primary.
| Event | Fires | Cadence |
|---|---|---|
| `input` | Once while the binding is active with `(value, dt, true)`, and once more on the frame it goes inactive with the neutral value and `false` | Per engine frame |
| `pressed` / `released` | A button's edges | Per engine frame |
| `changed` | When the value differs from the previous tick | Per tick |
`input` firing on the drop frame is what lets one handler both start and
stop the motion it drives, without a second subscription to notice the
release.
A frame carries more than one tick whenever a host calls `Zin.tick` beside
the tick the engine already runs, or fast-forwards several ticks to
converge smoothing. Evaluation follows the tick, so the smoothing lands and
`changed` reports each step of it. `input`, `pressed` and `released` are
stamped with the engine frame they were reported for, so the frame carries
one of each however many ticks ran in it. Both edge sources hold this
frame's edge for as long as the frame lasts: the input snapshot's `pressed`
flag, and `Zin.virtual`'s previous-frame button table. That is what a
second reporting pass reads as another press.
`Zin.scheme.fired`'s `count` is the number of engine frames a control was
active since it last cleared, so a button held across two frames reads 2
for one press. The `pressed` / `released` subscriptions are what count
edges.
A binding that declares a `context` is live only while that context is on
top, the same activity rule actions apply.
An `axis1` / `axis2` control's own `deadzone`, `curve`, `invert` and
`smoothing` are applied here, in that order, to the reading its classes
produced — after `as` / `unitsPerSecond` / `scale` have brought every class
into the control's unit, so one deadzone written in that unit covers the
stick, the mouse and the finger together. A deadzone is per value on an
`axis1` and **radial** on an `axis2`. The shaping maths is `Zin.utils`',
shared with the axis registry.
`smoothing` is the one piece of the four that is stateful: the value is
held per control across frames and approaches whatever the devices report
next over the declared time constant. A control that leaves its context,
whose group stands down, or whose gate closes has a neutral target, so it
drains rather than cutting — and reports its drop frame when it arrives.
## Validation happens before anything registers
Activation reads every child binding, collects every fault across all of
them, and raises once with the lot. A map with three broken bindings names
all three rather than one per attempt, and a map that fails validation
registers nothing — a half-live map is worse to debug than one that refused
to come up.
## Exports
- `M.activate(mapRef, label?) -> { [name]: Handle }` — activate a map,
return its handles. `label` names this activation in `M.live()`.
- `M.deactivate(mapRef) -> boolean` — release a claim on a map.
- `M.live() -> { { guid, name, group, suppresses, suppressedBy, holders,
heldBy, bindings } }` — every live map in activation order. What tells an
author why two sticks are on screen, or why the controls they activated
answer to nothing, or who is holding the map standing them down.
- `M.bindings() -> { { map, group, name, label, kind, context, suppressedBy,
subscribers, record } }` — every live binding in activation order. The
touch overlay builds from this, so what is on screen is exactly what some
awake component asked for and nothing is standing down.
- `M.suppressed() -> { [group]: mapName }` — which groups are standing down
and what put each one down.
- `M.has(name) -> boolean` — whether a named control is live anywhere.
- `M._advance(dt)` — one tick of dispatch; wired into `Zin.tick`.
- `M._childBindings(mapRef) -> { ref }` — the map's `.inputBinding`
children, in name order.
- `M._reset()` — test-only: drop every live map without firing anything.
Types:
- `Handle = { onInput, onPressed, onReleased, onChanged, label, kind }` —
a thin view over the binding asset's own event facade, so a subscription
made through a handle is the same one `ref.events.input:connect` would
make and ends the same way.
# surface
Active input-surface classification. Answers "what device class is the
player using right now" — `"touch"` or `"kbm"` — and notifies on
change; backs `Zin.surface`. Virtual touch controls mount whenever the
surface is `"touch"`, in edit and play alike.
## The frame rule
Resolution runs once per engine frame from the `Zin.tick` pipeline: a
per-event observer collects which device classes produced events this
frame, and the frame resolver applies one rule — **touch wins a frame
that contains any touch event**. The primary touch contact also drives
the mouse path (a projected `mouse.down`/`move`/`up` in the same
frame), so classifying by the latest single event would flip to `kbm`
on every tap; the frame rule keeps projection-generated mouse events
from masking the touch that caused them.
## Capability default
Before any input arrives, the class defaults to `"touch"` on sessions
with a touch source (`touch_capable` — latched by the first contact or
declared at boot by Android / the web client) and `"kbm"` otherwise, so
touch-first devices present touch controls from the first frame.
## Exports
- `M.current() -> string` — the active surface class, `"touch"` or `"kbm"`.
- `M.onChange(fn: (string, string?) -> ()) -> number` — subscribe to class flips; `fn(now, prev)` where `prev` is the previously reported class (the first-tick capability default when no event had resolved yet). Returns a handle.
- `M.off(handle: number) -> boolean` — unsubscribe an `onChange` handle.
- `M._beginFrame()` — internal: clear this frame's per-class flags; wired by `Zin.tick`.
- `M._observeEvent(ev: any)` — internal: per-event observer; wired by `Zin.tick`.
- `M._resolveFrame()` — internal: resolve this frame's class and fire `onChange` on a flip; wired by `Zin.tick`.
- `M._reset()` — test-only: clear resolution state and subscriptions.
Types:
- `SurfaceClass = "touch" | "kbm"`
## Usage
```luau
local Surface = require("@builtin::modules.zinput.surface")
if Surface.current() == "touch" then
showTouchOverlay()
end
Surface.onChange(function(now, prev)
print("surface flipped to", now)
end)
```
## Notes
- `onChange` fires from the `Zin.tick` pipeline on the frame the class
flips; without a tick, resolution never advances and `current()`
keeps returning the capability default.
- The change signal compares against the last reported class, whose
initial value is the capability default captured on the first tick of
the session — a phone's first contact confirms the default silently,
while a desktop touchscreen's first contact reports kbm→touch.
- Handler errors during `onChange` dispatch are caught and logged, not
propagated.
# chords
Chord and sequence state machines. Two kinds:
- `sequence` — ordered steps with a per-step window and an overall
window (Konami-style codes).
- `simultaneous` — N bindings pressed inside a tolerance window,
order-insensitive (piano-chord / fighter-combo style).
Chords are edge-triggered — `fired(name)` is `true` for exactly one
frame when the full pattern completes. A continuous combo like "Ctrl+S
held" is a `button` control whose `kbm` class is `B.modKey("Ctrl+S")`: a
chord recognizes a pattern over time, which is a different question from
whether a combination is down right now.
## Exports
Lifecycle:
- `M.define(spec: ChordSpec)` — define / replace chords.
- `M.remove(name) / M.clear()`
- `M.has(name) -> boolean` / `M.names() -> { string }` / `M.get(name) -> ChordDef?`
Queries:
- `M.fired(name) -> boolean`
- `M.progress(name) -> number` (0..1)
- `M.cancel(name)` — reset state.
Subscriptions:
- `M.onFire(name, callback, opts: OnFireOpts?) -> OnFireHandle`
- `M.off(handle: OnFireHandle)`
Tick hooks (called by `Zin.tick`):
- `M._beginTick()` — clears per-frame fire flags.
- `M._observeEvent(ev)` — advance state machines on an input event.
- `M._advanceTime(dt)` — age out stale recognizers.
- `M._dispatchFires()` — invoke onFire callbacks for chords that fired this tick.
Types:
- `ChordDef = { kind, context, steps?, stepWindowSec?, windowSec?, bindings?, toleranceSec? }`
- `ChordSpec = { [string]: any }`
- `OnFireOpts = { context: string?, once: boolean? }`
- `OnFireHandle = { id: number, name: string, off: () -> () }`
## Usage
```luau
local Zin = require("@builtin::modules.zinput")
local B = Zin.bindings
Zin.chords.define({
konami = {
kind = "sequence",
steps = { B.key("ArrowUp"), B.key("ArrowUp"), B.key("ArrowDown"),
B.key("ArrowDown"), B.key("ArrowLeft"), B.key("ArrowRight"),
B.key("ArrowLeft"), B.key("ArrowRight"),
B.key("KeyB"), B.key("KeyA") },
stepWindowSec = 0.5,
windowSec = 4.0,
},
})
Zin.chords.onFire("konami", function() unlockBonus() end)
```
## Notes
- Without a tick host (or `Zin.autoTick.start()`) chords never fire —
every state machine is dormant until `Zin.tick` drives it.
- A wrong key in a sequence resets to step 0. If the wrong key
happens to match step 1, the recogniser restarts from step 1 on the
same event (so a long input run can self-recover).
- For simultaneous chords, the tolerance window starts on the first
matched press. If all bindings haven't matched before
`toleranceSec` elapses the state resets — `firstPressAt` is reset
too so the next press starts a fresh window.
- Subscriber errors are caught and logged via `log.warn`.
# pointer
Pointer-lock mutation API. Wraps the internal
`__zero_input.requestPointerLock` / `releasePointerLock` primitives and
backs `Zin.pointer`. The read side lives on `Zin.state.pointerLocked()`;
this module owns the write side plus a convenience read alias.
## Exports
- `M.lock()` — request pointer lock (cursor grab + hide). Native takes effect immediately; WASM queues for the next user gesture.
- `M.unlock()` — release pointer lock (cursor ungrab + show).
- `M.locked() -> boolean` — convenience read alias, identical to `Zin.state.pointerLocked()`.
## Usage
```luau
local Pointer = require("@builtin::modules.zinput.pointer")
Pointer.lock()
if Pointer.locked() then
-- pointer captured
end
Pointer.unlock()
```
## Notes
- Native: `lock()` takes effect immediately. WASM: queued for the next
user gesture (browser policy). Both routes share the same underlying
FFI mutation; the platform enforces the difference.
- Write side and read side are intentionally split: `Zin.state.pointerLocked()`
stays alongside the rest of the per-frame snapshot accessors; mutation
lives here for discoverability.
- All entry points are no-ops when the `__zero_input` FFI is absent
(off-host script execution).
# axes
A name-registered axis registry over `Zin.bindings`. Any axis or vector
binding becomes a `Zin.axes` entry with optional deadzone, exponential
smoothing, curve shaping, invert, context gating, and a per-frame gate
function. Stateful — `Zin.tick` calls `advance(dt)` each frame to evolve
the smoothed reading.
A control asset replaced this. An `axis1` / `axis2` `<name>.inputBinding/`
inside an `.inputMap/` carries a binding for keyboard-and-mouse, gamepad
and touch, so one control reads a stick and a dragging thumb as well as a
key, and the binding's own `as` / `unitsPerSecond` / `scale` bring every
class into one unit. It declares the same `deadzone`, `smoothing`, `curve`
and `invert`, applied by `Zin.scheme` through the same `Zin.utils` maths
this reads:
```luau
local controls
function awake()
controls = self.inputMap:activate()
controls.move:onInput(function(v, dt, active)
if not active then self.velocity = vec3.zero return end
self:translate(v * self.speed * dt)
end)
end
function onDestroy()
self.inputMap:deactivate(controls)
end
```
`Zin.map.bake(name)` writes the currently active map out as an `.inputMap`
asset. `man topics/input` covers the control model.
## Exports
Lifecycle:
- `M.define(spec: AxisSpec)` — define / replace axes.
- `M.remove(name: string)` / `M.clear()` — drop one / all axes.
- `M.has(name) -> boolean` / `M.names() -> { string }` / `M.get(name) -> AxisDef?`
Reads:
- `M.value(name: string) -> any` — smoothed value (`number` for scalar, `{ x, y }` for vector).
- `M.raw(name: string) -> any` — raw unsmoothed binding value.
Tick hook:
- `M.advance(dt: number)` — internal; called by `Zin.tick`.
- `M._setEnsureBindingsFn(fn)` / `M._setEnsureLiveFn(fn)` — internal cold-read hooks.
- `M._resetGateWarnings()` — test helper.
Types:
- `Curve = string | (number) -> number` — `"linear"` / `"quadratic"` / `"cubic"` / custom.
- `AxisDef = { binding, deadzone, smoothing, curve, invert, context, gate, kind }`
- `AxisSpec = { [string]: any }` — define-spec.
## Notes
- `smoothing` is the exponential time constant in seconds. `0` means
"snap to target instantly". Higher = laggier.
- `deadzone` is radial for vector bindings — magnitude under the
threshold zeros both axes — and scalar for scalar bindings.
- Context gating is strict: the axis's declared context must equal
`Zin.context.current()`. Mismatch snaps target to 0, and `advance`
smooths current down toward 0.
- A throwing `gate` falls through to "ungated" for that frame and
logs one warning per axis per session (state cleared via
`_resetGateWarnings()`).
- Without a tick host (or `Zin.autoTick.start()`), `value()` returns
the last computed snapshot; `raw()` always reads live.
# conflicts
Binding-conflict detection — reverse index over the `Zin.actions` /
`Zin.axes` registries. Two bindings "conflict" when they map to the same
canonical leaf key (e.g. both bind `Space`, or an action binds `Space` and
an axis's `plus` side is `Space`). Backs `Zin.conflicts`.
Rows carry a `kind = "real" | "intentional"` classification: two or
more action owners in the *same context* are `real`; action + axis
overlaps, cross-context actions, and explicitly marked pairs are
`intentional`. `realOnly()` is the noise-filtered view the Input tab
uses; `forActiveProfile()` is the full audit list.
## Exports
Marking:
- `M.markIntentional(actionA: string, actionB: string) -> (boolean, string?)`
- `M.unmarkIntentional(actionA: string, actionB: string) -> boolean`
- `M.isIntentional(actionA: string, actionB: string) -> boolean`
- `M.listIntentional() -> { { string } }`
Probing:
- `M.bindingKey(b: any) -> string?` — `"key:Space"`, `"modKey:Ctrl+KeyS"`, `"mouse:left"`, or `nil`.
- `M.find(binding: any) -> { Owner }` — owners that share any leaf key with the probe.
- `M.forActiveProfile() -> { ConflictRow }` — every shared-key row.
- `M.realOnly() -> { ConflictRow }` — subset where `kind == "real"`.
Internal (wired by `zinput/init.luau`):
- `M._setPersistFn(fn)` — registers the save-back closure.
- `M._loadIntentionalPairs(pairs_)` — seed on profile activate.
- `M._serializeIntentional() -> { { string } }` — for profile save.
- `M._resetIntentional()` — test isolation.
Types:
- `Owner = { target: string, name: string, slot: any }` — `target` is `"action"` or `"axis"`.
- `ConflictRow = { key: string, owners: { Owner }, kind: string }` — `kind` is `"real"` or `"intentional"`.
## Usage
```luau
local Zin = require("@builtin::modules.zinput")
-- Audit view (e.g. inside the Input tab):
for _, row in ipairs(Zin.conflicts.forActiveProfile()) do
print(row.key, row.kind, #row.owners)
end
-- Suppress an intentional same-context overlap:
Zin.conflicts.markIntentional("crouch", "slide")
```
## Notes
- `modKey` leaf keys list modifiers alphabetically (`Alt+Ctrl+Shift`),
so `Shift+Ctrl+KeyS` and `Ctrl+Shift+KeyS` compare equal.
- Continuous-source bindings (`mouseDelta`, `scroll`, `pointerLocked`)
never appear in conflict reports — two axes reading `mouseDelta` is
by-design.
- Persistence: `_setPersistFn` is wired by `zinput/init.luau` so
marks flow into the active profile JSON without conflicts needing a
hard dep on profile.
- The classifier counts every same-context action pair. If even one
pair is unmarked, the row is `"real"`; otherwise `"intentional"`.
# input
Event-driven listeners for whole categories of raw input, on top of
`Zin.events.on`. Sugar for the most-common filter shapes (`onBegan` /
`onEnded` / `onChanged` / `onTextInput`) plus a `lastInputType()`
accessor. Backs `Zin.input`.
These sit beneath the control model: they answer "any key went down"
rather than "the Jump control fired", which is what rebind capture, dev
tooling and text-adjacent UI need. Gameplay subscribes to controls through
an activated map — `man topics/input` covers that path.
## Exports
- `M.lastInputType() -> string?` — userInputType of the most recent input event (`"Keyboard"` / `"Mouse"` / `"Touch"` / `nil`).
- `M.onBegan(fn, opts?) -> Handle` — fires on `key.down` / `mouse.down`.
- `M.onEnded(fn, opts?) -> Handle` — fires on `key.up` / `mouse.up`.
- `M.onChanged(fn, opts?) -> Handle` — fires on `mouse.move` / `mouse.wheel`.
- `M.onTextInput(fn, opts?) -> Handle` — fires on committed text input; handler signature is `(text, gpe)`.
- `M.disconnect(handle: Handle) -> boolean` — tear down a subscription; idempotent.
- `M.subscriberCount() -> number` — number of active composite subscriptions.
- `M._reset()` — test-only: clear `lastInputType`.
- `M._disconnectAll()` — test-only: tear down every subscription this session.
- `M._observeEvent(ev: any)` — internal observer driven by `Zin.tick`.
- `M._setAllocator(fn: () -> number)` — internal: wire the shared handle-id allocator.
- `M._owns(handle: number) -> boolean` — internal: handle-ownership check for cross-API disconnect.
Types:
- `Filter = string | { [string]: any } | (any) -> boolean`
- `SubOpts = { priority: number?, context: string?, once: boolean? }`
- `Handle = number` — composite-handle id, drawn from a shared allocator with `Zin.actions`.
## Usage
```luau
local Input = require("@builtin::modules.zinput.input")
local h = Input.onBegan(function(io, gpe)
if io.kind == "key.down" and io.code == "Space" then
print("jumped")
end
end, { priority = 10 })
Input.disconnect(h)
```
## Notes
- Handler signatures: `fn(io, gpe)` for `onBegan` / `onEnded` /
`onChanged`; `fn(text, gpe)` for `onTextInput`. `gpe` is the
event's own `consumed_by_ui` marker, stamped as the event arrived
against the focus the UI holds over that event's surface: pointer focus
for a mouse or touch event, keyboard focus for a key or text event, and
never for a pad.
- Return `"sink"` from a handler to stop further (lower-priority)
handlers receiving the same event.
- A single `on*` call installs N underlying `Zin.events.on` subscribers
but returns ONE composite handle — `disconnect` tears them all down
together.
- Handle ids share a namespace with `Zin.actions` (`_setAllocator` wires
this at boot), so `Zin.actions.disconnect` accepts handles returned
here.
- `lastInputType` is updated by the tick observer; text events do not
bump it (the preceding key.* event already did).
# quiesce
The dirty signal `Zin.tick`'s quiescence gate reads. A mutation of the
mapping layer's structure outside the tick itself — a map activation, a
context change, a virtual-control write, a forced surface class, a new axis
over an already-held key — calls `mark()`, and the tick runs a full pass
while a mark is pending. A tick with no pending mark and no live input
events skips its pass.
`mark()` raises the signal, `pending()` answers whether a full pass is owed,
and the generation pair (`generation()` / `ack(g)`) keeps a mark raised
DURING a full pass pending for the next one, so a mutation racing a tick is
never lost.
Internal to the zinput package: the mapping-layer modules mark it, the tick
drains it.
# arming
The predicate a look-style control hands to its `gate`, in the one place it
is written. Backs `Zin.arming`.
A mouse always has a delta, so an ungated mouse-look turns the camera
whenever the cursor crosses the window. The gate answers "is the player
steering?" — and every scheme answers it with the same list of gestures in a
different combination. `Zin.arming.gate(opts)` takes the combination and
returns the predicate.
```luau
local Arming = require("@builtin::modules.zinput.arming")
return {
label = "Look",
kind = "axis2",
gate = Arming.gate({ dragZone = "right", stick = "right" }),
kbm = B.mouseDelta(),
gamepad = B.padStick("right", { as = "delta", unitsPerSecond = 1200 }),
touch = B.touchDrag({ zone = "right" }),
}
```
A locked pointer always arms: a cursor the scheme took is one the player
gave to the camera. The rest is named in `opts` — `dragZone` for a finger
inside a touch zone, `stick` for a deflected pad stick, `padButtons` for a
trigger, `virtualButtons` for an on-screen button.
## The right mouse button
`rightButton` is the field the schemes disagree about, because the button
means two different things.
| Value | Arms while | Used by |
|-------|-----------|---------|
| `"free"` (default) | the button is down and no other control that can answer on it declares it | the player scheme |
| `"held"` | the button is down | the editor scheme, where holding it IS the fly gesture |
| `"off"` | never | a control that reads the button through nothing but pointer lock |
`"free"` is what lets a world bind the right button to a game action. The
engine's default scheme arms mouse-look on that button, and a world that
also binds it would otherwise get the camera turn and its own action from
one gesture, with no way to decline the camera half short of standing down
its whole control group. Under `"free"` the world's binding is the claim:
declare the button in a live `.inputMap` control, or in the `Zin.actions` /
`Zin.axes` registry, and the arming stands down for as long as that control
can answer on it. A control whose map is standing down for a suppressor, or
whose context is not the one on top, is one the player cannot reach — the
same two terms the tick's own dispatch reads before it evaluates a control —
so the button is free of it while that holds and the camera keeps the drag.
`Zin.arming.rightButtonClaimants()` names who is holding it, which is the
answer to "why did my right-drag stop turning the camera":
```luau
Zin.arming.rightButtonClaimants() --> { "map:torch.torchToggle" }
```
The engine's own arming gesture — the `lookDrag` control on the standard
scheme, and the `look_drag` action beside it in the registry — is what
`"free"` arms FROM, so those two names are passed over by the search.
A claim is decided on the frame the button goes down and holds until it
comes up. A map activating mid-drag therefore cannot take a gesture away
from the camera halfway through it, and the search runs once per press
rather than once per gated control per frame.
# context
The input context stack. Pushing a named context ("ui", "menu",
"vehicle", ...) stands down everything whose declared context doesn't
match the top of the stack; popping restores the previous mode. The bottom
is permanently `"default"` so unbalanced pops never leave the stack empty.
A control, an action, an axis or an event subscriber can declare one
context. `Zin.scheme` honours it on every live binding, so a
vehicle-context Brake neither fires nor draws its touch button while the
default context is on top.
A map standing another map's `group` down is the coarser instrument beside
this one: `group` / `suppresses` on an `.inputMap` decides which whole
schemes answer, while a context decides which entries within a live scheme
do.
## Exports
- `M.default() -> string` — the always-on context name (`"default"`).
- `M.push(name: string)` — push a context. No-op when `name` is not a non-empty string.
- `M.pop() -> string?` — pop the top context; returns `nil` when only `default` remains.
- `M.current() -> string` — name at the top of the stack.
- `M.contains(name: string) -> boolean` — true if `name` is anywhere in the stack.
- `M.stack() -> { string }` — a copy of the live stack, bottom→top.
- `M.reset()` — pop everything except `default`.
- `M.with<T...>(name: string, fn: () -> T...) -> T...` — run `fn` with `name` pushed, pop guaranteed.
## Usage
```luau
local Zin = require("@builtin::modules.zinput")
-- menuClose.inputBinding/init.luau declares `context = "ui"`, so it reads
-- only while "ui" is on top.
Zin.context.push("ui")
Zin.context.current() -- "ui"
Zin.context.pop()
Zin.context.with("ui", function()
-- inside this block the "ui" controls are the ones that read
end)
```
## Notes
- The stack lives in the module's upvalues, so it persists across
Luau script runs that share the require cache. A `restart_instance`
resets it (matches the standard caching contract).
- Pushing the same context twice requires two pops to fully remove —
matches typical UI nesting (a sub-menu pushes "ui" again).
- A declared context is compared strictly equal to `current()`; there's no
inheritance up the stack.
# controllers
Reusable controller factories on top of the `Zin.actions` handler,
`Zin.context` and `Zin.state` surfaces. Each factory returns a stateful
controller object with the `:start / :stop / :update / :isActive`
lifecycle and a small set of intent readers. They read keyboard and mouse;
the `gamepadIndex` opt is accepted and ignored.
## Exports
Factories (module-level):
- `M.free(opts: FreeOpts) -> FreeController` — free-fly designer/debug camera (WASD plane + Q/E vertical + Shift sprint + mouse-look). Writes the entity transform directly.
- `M.orbit(opts: OrbitOpts) -> OrbitController` — orbit camera around a target (drag-rotate, scroll-zoom, optional pan).
- `M.fps(opts: FpsOpts) -> FpsController` — first-person walker. Produces motion intent for the host (`getDesiredVelocity`, `consumeJumpRequest`, `isSprinting`, `isCrouching`); convenience `applyToTransform` writes directly.
Controller-object methods (returned by the factories; called with `:`):
- `:start()` — define actions, push context, optionally request pointer-lock.
- `:stop()` — drop actions, pop context, release pointer-lock if `:start` took it.
- `:isActive() -> boolean`
- `:update(dt: number)` — per-frame; no-op when context doesn't match.
- `:actionNames() -> { string }`
- `FreeController`: `:setMoveSpeed`, `:setLookSensitivity`, `:getCameraYaw`, `:getCameraPitch`, `:getVelocity`.
- `OrbitController`: `:getDistance`, `:getYaw`, `:getPitch`, `:setDistance`, `:setTarget`.
- `FpsController`: `:setMoveSpeed`, `:setLookSensitivity`, `:getCameraYaw`, `:getCameraPitch`, `:getDesiredVelocity`, `:isSprinting`, `:isCrouching`, `:consumeJumpRequest`, `:applyToTransform`.
Types:
- `FreeOpts`, `OrbitOpts`, `FpsOpts` — opts tables; `entity` is the only required field.
- `FreeController`, `OrbitController`, `FpsController` — opaque (typed as `any` so the metatable plumbing doesn't fight the LSP).
## Usage
```luau
local Zin = require("@builtin::modules.zinput")
local cam = Zin.controllers.free({ entity = cameraEntityId })
cam:start()
-- per frame:
Zin.tick(dt)
cam:update(dt)
```
## Notes
- Each controller's actions are namespaced under `actionPrefix` (default
`"free_fly."`, `"orbit."`, `"fps."`). Two co-existing controllers
with overlapping prefixes will trample each other — override the
prefix when stacking.
- The context push/pop pair makes `Zin.context.push("ui")` cleanly
suppress every controller without `:stop`. The controller's
`:update` early-outs when `Zin.context.current()` doesn't match.
- Rotation convention: yaw=0, pitch=0 → `(0, 0, -1)` (-Z forward).
Mouse Δx adds to yaw; mouse Δy subtracts from pitch (invert with
`invertY = true`). Pitch is clamped to `[-π/2 + ε, π/2 - ε]`.
- `FpsController` produces *intent* — gravity, collision, and the
jump impulse are the host's responsibility. The
`consumeJumpRequest()` accessor returns `true` once per press, so
the physics step can fire its impulse exactly once.
- Free / FPS request pointer-lock at `:start` (opt out with
`requestPointerLock = false`) and only release it on `:stop` when
they were the ones who acquired it.
# zinput / clock
The wall-clock time source for zinput's time-window features — held-time and
synthetic key repeat (`state`), chord step / total windows (`chords`), and the
tick's fallback delta (`init`).
`nowSeconds()` reads the engine's per-frame `getTime()` (seconds since engine
start, published from the native clock), which advances with wall time on every
platform. `os.clock` measures CPU time and barely moves across scheduler yields
on wasm, so it is only the fallback for contexts with no engine time surface.
## API
- `nowSeconds() -> number` — current wall-clock seconds for input timing.
- `_setClock(fn: (() -> number)?)` — internal; override the clock with `fn`
(returns seconds) or `nil` to restore the default. For deterministic timing:
fixed-step replay and tests that need a controllable clock.
# map
A record-shaped map: `Zin.actions` / `Zin.axes` entries organized per
device class (`kbm` | `gamepad` | `touch`) with single-parent `extends`,
materialized into an EFFECTIVE map and applied as one binding set at a
time. Backs `Zin.map`.
An `.inputMap` asset with `.inputBinding` children is the map, and
`Zin.scheme` is the live set: several maps are live at once and compose,
each declaring the `group` its controls belong to and the groups it
`suppresses` while live. `Zin.map.bake(name)` writes the map active here
out as an `.inputMap` asset, with synthesis made explicit and editable.
`man topics/input` covers the control model.
Resolution order per action/axis and device class:
1. the map's own class bindings
2. the `extends` parent's (recursively)
3. synthesis from the `kbm` shape (touch class only)
A vector `kbm` binding synthesizes a `touchStick`; a boolean `kbm` binding
synthesizes a `touchButton`; a vector-shaped `mouseDelta` synthesizes a
`touchDrag`; `scroll` synthesizes a `touchPinch`; an `axis`-kind `kbm`
binding and a single-axis `mouseDelta` are left unsynthesized (ambiguous
which touch shape to guess). A synthesized `touchButton` carries
`priority = 100` — an authored `B.touchButton`'s default priority (50)
always outranks synthesis on the touch-controls overlay, so a hand-tuned
scheme's own layout hints win over whatever filled a gap automatically.
A profile record (actions with `bindings`, axes with `binding`) is accepted
anywhere a map record is — its bindings are treated as the `kbm` class.
## Activation and the `<axis>@<class>` convention
`activate` flattens every class's bindings into each action/axis's single
bindings list and applies it through `Zin.profile`, so any device fires the
action. For axes — which evaluate exactly one binding — the primary slot is
the first of `kbm` / `touch` / `gamepad` present; any OTHER class's binding
registers as a class-suffixed sibling axis (`look` + `look@touch`). A
consumer that wants both device values reads both names; a kbm-only world's
primary axis (`Zin.axes.value("move")`) behaves identically whether or not a
touch class exists.
`Zin.profile.ensureActive("default")` delegates to `Zin.map.ensureActive()`
when the resolved scheme is the builtin default, so it carries every device
class rather than kbm alone. `Zin.map.ensureActive()` / `Zin.map.activate`
win regardless of call order (`activate` re-registers the flattened profile
under the same name and forces a reactivate).
## Exports
- `M.materialize(record: any) -> effective map` — resolve `extends` +
synthesize, without activating.
- `M.activate(record: any) -> effective map` — materialize + apply as the
live binding set (registers/activates a `Zin.profile`).
- `M.effective() -> effective map?` — the active map's materialized form.
- `M.activeName() -> string?` — the active map's name.
- `M.bindingsFor(eff, name, class) -> bindings?` — one action/axis's
bindings for one device class.
- `M.ensureActive() -> effective map` — keep the current map, else activate
`@builtin::inputMaps.default`.
- `M.bake(name: string) -> AssetRef` — write the active effective map as a
new `.inputMap` instance (`asset.create("inputMap", name, { record = ... })`).
- `M.addTouchButton(actionName, buttonOpts, emitKey?) -> binding` — add a
touchButton binding to an action's touch class on the live effective map
(creating the action if absent) and re-activate. Used by
`Zin.touchControls.button`.
- `M.removeTouchButton(actionName, binding) -> boolean` — remove a binding
added via `addTouchButton` and re-activate; drops the action entry once
every class is empty.
- `M._reset()` — test-only: clear active-map state.
## Usage
```luau
local Zin = require("@builtin::modules.zinput")
Zin.map.ensureActive()
local eff = Zin.map.effective()
print(eff.actions.jump.classes.touch ~= nil) -- synthesized or explicit
-- Write the active scheme out as a control-asset map:
Zin.map.bake("my_scheme")
```
# test
Test-input helpers that simulate keyboard / mouse / scroll events and
yield one engine frame so the queued events drain into the next
snapshot. Backs `Zin.test`. Helpers do NOT call `Zin.tick()` —
explicit dispatch control is left to the caller.
## Exports
- `M.runFrame()` — yield one engine frame so simulated events drain.
- `M.pressKey(code: string)` — hold the key DOWN and leave it held. A second
`pressKey` on a held key produces no new press edge — pair every `pressKey`
with a `releaseKey`, or use `tapKey` for a full press.
- `M.releaseKey(code: string)` — simulate a key release.
- `M.tapKey(code: string, duration: number?)` — press + optional hold +
release. The helper for "the player taps a key".
- `M.pressMouse(button: (number | string)?)` — simulate a mouse button press
(default 0 = left). The index and the name (`"left"`/`"right"`/`"middle"`)
reach the same button.
- `M.releaseMouse(button: (number | string)?)` — simulate a mouse button release.
- `M.clickMouse(button: (number | string)?, x: number?, y: number?, duration: number?)`
— full click, optionally moving the cursor to `(x, y)` first and holding for
`duration` seconds.
- `M.moveMouse(x: number, y: number)` — set absolute cursor position.
- `M.moveMouseBy(dx: number, dy: number, steps: number?)` — move the pointer by
a relative motion. The motion lands in this frame's `mouse_delta` and the
position advances by the same amount, so the same `(dx, dy)` repeated keeps
producing look movement. `steps` spreads it over that many frames, each
carrying its share; 1 when omitted.
- `M.scroll(dy: number)` — simulate a wheel scroll.
## Usage
```luau
local Test = require("@builtin::modules.zinput.test")
Test.tapKey("KeyM") -- press + release
Zin.tick(1 / 60) -- explicit dispatch
assert(#Zin.scheme.fired() > 0)
Test.pressKey("Space") -- key goes DOWN and stays held...
Zin.tick(1 / 60)
Test.releaseKey("Space") -- ...until released
Test.clickMouse(0, 100, 200)
```
## Notes
- Each helper queues the simulated event and yields one frame
(`task.wait(0)`) so the snapshot reflects the input on return.
- Simulated key state persists like real key state: after `pressKey` the key
stays held until `releaseKey`, and only the release re-arms the next press
edge. `tapKey` is the edge-per-call helper.
- Helpers do NOT call `Zin.tick()` — a test that relies on a control's
handlers, or on `Zin.input.on*` / `Zin.actions.bind`, must run a tick
itself. `Zin.scheme.fired()` is what turns a simulated input into an
assertion: it reports which controls fired since it last read, and from
which device class.
- Mouse button index is 0-based (0=left, 1=right, 2=middle) following the
`InputResource.mouse_buttons` convention.
- All helpers are no-ops when the `__zero_input.simulate*` FFI is missing
(off-host execution).
# events
Frame-event readers and edge-event subscriptions. Wraps the internal
`__zero_input.events()` FFI with per-kind filters and a push-style
`on/off` API that fires from `Zin.tick`. Backs `Zin.events`.
`__zero_input.events()` returns the current frame's events in
dispatch order; reading does not consume them. Subscription dispatch
happens during `Zin.tick` (or `Zin.autoTick.start()`'s worker), so
without a tick host `on()` callbacks never fire.
## Exports
- `M.frame() -> { InputEvent }` — this frame's events in dispatch order.
- `M.iter() -> iterator` — `ipairs` over `frame()`; reads better at call sites.
- `M.keysDown / keysUp / mouseDowns / mouseUps / mouseMoves / mouseWheels / texts() -> { InputEvent }` — per-kind filters.
- `M.on(filter: EventFilter, callback, opts: SubOpts?) -> SubHandle` — register an edge subscriber, returns handle.
- `M.off(handle: SubHandle | number)` — cancel a subscriber.
- `M.clearSubscribers()` — drop all subscribers (test isolation).
- `M.subscriberCount() -> number` — current subscriber count.
- `M._dispatch(events)` — internal; called by `Zin.tick`.
Types:
- `InputEvent = { [string]: any }` — one entry of the frame event log.
- `EventFilter = string | { [string]: any } | (any) -> boolean`
- `SubOpts = { context: string?, once: boolean?, priority: number? }`
- `SubHandle = { id: number, off: () -> () }`
## Usage
```luau
local Zin = require("@builtin::modules.zinput")
for _, ev in ipairs(Zin.events.frame()) do
if ev.kind == "key.down" and ev.code == "Space" then
-- jump on the press edge, not on every frame the key is held
end
end
local h = Zin.events.on({ kind = "key.down", code = "Escape" }, function(ev, gpe)
if not gpe then closeMenu() end
end, { context = "ui" })
-- later: h.off()
```
## Notes
- Reading `frame()` is non-destructive — multiple consumers can iterate
the same frame's events. The log clears at end-of-frame and is rebuilt
next frame.
- Subscribers dispatch in priority-desc order; equal priorities preserve
insertion order. Returning `"sink"` from a callback halts further
dispatch for that single event (lower-priority subscribers skipped).
- `once = true` subscribers auto-unsubscribe after their first matching
dispatch.
- Subscriber errors are caught and logged via `log.warn`; one bad
handler doesn't break dispatch.
# profile
Binding profile registry — bundles of named actions / axes / chords that
can be loaded from `@builtin::profiles.<name>` Luau modules or saved as
JSON under `/zero/profiles/<name>.json`. Activating a profile
reconfigures `Zin.actions` / `Zin.axes` / `Zin.chords` in one shot. The
active profile is per-client session state held in memory — each session
resolves it fresh from the device-appropriate default (or an explicit
`activate`), so it is never written to the world source tree.
This is the persistence and activation layer for those three registries;
`Zin.map.activate` applies a materialized map through it. A scheme is
authored as an `.inputMap` of `.inputBinding` controls and goes live
through `Zin.scheme` — `man topics/input` covers that path.
## Exports
- `M.register(name: string, profile: any) -> (boolean, string?)` — validate + register a profile descriptor.
- `M.load(name: string) -> (boolean, any?)` — load + register a profile from `@builtin::profiles.<name>` or `/zero/profiles/<name>.json`.
- `M.activate(name: string, opts: ActivateOpts?) -> (boolean, string?)` — apply a profile's sections + set the active name for this session.
- `M.current() -> string?` — active profile name for this session, or nil.
- `M.get(name: string?) -> any?` — descriptor for the active profile or the named profile.
- `M.ensureActive(fallback: string?) -> (boolean, string?)` — bootstrap helper for controllers.
- `M.save(name: string, opts: SaveOpts?) -> (boolean, string?)` — capture current Zin.actions / Zin.axes / Zin.chords to JSON.
- `M.delete(name: string) -> (boolean, string?)` — remove a user profile's JSON file (built-ins refused).
- `M.export(name: string, vfsPath: string) -> (boolean, string?)` — write a serializable JSON snapshot to any VFS path.
- `M.import(vfsPath: string, opts: ImportOpts?) -> (boolean, string)` — read + register a profile JSON from any VFS path.
- `M.list() -> { string }` — sorted union of built-in + user profile names.
- `M.onChange(cb: ChangeCallback) -> number` — subscribe to activation / re-register events; returns a handle.
- `M.off(handle: number)` — drop a subscription.
- `M._reset()` — test-only: clear in-memory state.
Types:
- `Profile = { name, description?, actions?, axes?, chords?, intentional_pairs? }`
- `ActivateOpts = { reactivate: boolean? }`
- `SaveOpts = { description: string?, overwrite: boolean? }`
- `ImportOpts = { overrideName: string?, activate: boolean? }`
- `ChangeCallback = (string, any) -> ()`
## Usage
```luau
local Profile = require("@builtin::modules.zinput.profile")
Profile.register("my-bindings", {
name = "my-bindings",
actions = { jump = { context = "default", bindings = { { kind = "key", code = "Space" } } } },
})
Profile.activate("my-bindings")
local h = Profile.onChange(function(name) print("active:", name) end)
Profile.off(h)
```
## Notes
- Built-in profiles ship as Luau modules at `@builtin::profiles.<name>`
and can include functions (gate, curve). User profiles are pure JSON
at `/source/profiles/<name>.json` — function fields are dropped on save.
- Activation is no-op when the requested name is already active unless
`opts.reactivate = true`.
- `save` / `export` VFS writes use the bare `/source/...` mount — the
`/zero/...` prefix is read-only and silently drops writes.
- The conflicts module's `_setPersistFn` hook is wired here so
mark/unmark of intentional pairs persists into the active user
profile automatically.
# touch
Per-finger touch contacts. Thin wrapper over the `touches` array of the
internal `__zero_input.snapshot()` FFI plus pre-baked `touch.*` event
listeners; backs `Zin.touch`.
## Exports
- `M.slots() -> { TouchPoint }` — all active contacts this frame, in begin order.
- `M.count() -> number` — number of active contacts this frame.
- `M.byId(id: number) -> TouchPoint?` — look up an active contact by finger id.
- `M.primary() -> TouchPoint?` — the primary contact (slot 0), if a finger is down.
- `M.capable() -> boolean` — whether the session has (or has declared) a touch source.
- `M.onBegan(fn, opts?) -> handle` — subscribe to `touch.down` events.
- `M.onMoved(fn, opts?) -> handle` — subscribe to `touch.move` events.
- `M.onEnded(fn, opts?) -> handle` — subscribe to `touch.up` events.
- `M.onCancelled(fn, opts?) -> handle` — subscribe to `touch.cancel` events.
- `M.off(handle)` — unsubscribe a handle returned by any `on*` function.
Types:
- `TouchPoint = { id: number, slot: number, x: number, y: number, dx: number, dy: number, pressure: number, phase: string }`
## Contact record fields
| Field | Meaning |
|---|---|
| `id` | The platform's stable finger id for the contact. |
| `slot` | Lowest index free when the contact began; 0 = primary (also drives the mouse path). |
| `x`, `y` | Screen position. |
| `dx`, `dy` | This frame's accumulated delta. |
| `pressure` | Normalized 0..1 (1 when the platform reports none). |
| `phase` | `"began"` \| `"moved"` \| `"stationary"` \| `"ended"` \| `"cancelled"` (ended/cancelled contacts survive exactly one frame). |
## Usage
```luau
local Touch = require("@builtin::modules.zinput.touch")
for _, t in ipairs(Touch.slots()) do
print(t.slot, t.x, t.y)
end
local h = Touch.onBegan(function(ev, gpe)
print(ev.id, ev.x, ev.y)
end)
```
## Notes
- `onBegan` / `onMoved` / `onEnded` / `onCancelled` are pre-baked kind
filters over `Zin.events.on` — handlers dispatch from the `Zin.tick`
pipeline and receive `(ev, gpe)` where `gpe` is the UI-focus flag at
fire time. Return `"sink"` to stop lower-priority handlers from
seeing the same event.
- All entry points return empty / falsy values when `__zero_input` is
missing (off-host execution).
# rebind
Interactive binding-capture sessions. A session listens for the next
keyboard or mouse event and produces a binding descriptor; the host can
then `apply()` it back into a `Zin.actions` action or a `Zin.axes` axis.
Backs `Zin.rebind`. Drives the editor's Input tab rebinding UI and is
usable from any Luau code. Modifier-aware (captures `modKey` when
Ctrl/Shift/Alt is held) and timeout-bounded (default 30 s).
## Exports
- `M.begin(opts: BeginOpts?) -> Session` — start a capture session; subscribes to `Zin.events.on` and returns a session table.
- `M.cancelAll()` — cancel every live session.
- `M.liveCount() -> number` — number of capturing sessions still active.
- `M._advanceTime(dt: number)` — internal: per-frame timeout sweep wired by `Zin.tick`.
Types:
- `Session = { target, name, slot, mode, status, timeoutSec, result, consume, apply, cancel, conflicts, ... }`
- `Owner = { target: string, name: string, slot: any }`
- `BeginOpts = { target?, name, slot?, mode?, timeoutSec?, filter?, applyOnCommit?, onCommit?, ... }`
## Usage
```luau
local Rebind = require("@builtin::modules.zinput.rebind")
local s = Rebind.begin({
target = "action",
name = "jump",
timeoutSec = 10,
applyOnCommit = true,
onCommit = function(session)
print("captured:", session.result())
end,
})
-- The session subscribes itself; just wait for the next tick.
if s.status == "capturing" then
-- ...
end
```
## Notes
- Press Escape during a capture to cancel; a pure modifier press is
ignored (the session waits for the base key).
- A session that captures with Ctrl/Shift/Alt held returns a `modKey`
binding rather than a plain `key` binding.
- `session.conflicts()` is self-filtered — rebinding an action to a key
it already owns reports no conflict.
- Hot-reload safety: the live-sessions list survives require-cache
reload; the `_advanceTime` wiring from `zinput/init.luau` keeps
timeouts firing across re-requires.
- `applyOnCommit = true` calls `apply()` automatically after commit;
errors during apply are still visible via `result()` and
`conflicts()`.
# observe
What became of a device event, and why a live control did not fire. Backs
`Zin.observe`.
A control can be live, its device can be producing input this frame, and the
game can still receive nothing. The input layer works out why every frame —
a gate said no, a live map stood the group down, the UI took the pointer,
nothing subscribed — and until something reads that decision it is thrown
away. This module keeps it.
## The one question
```luau
local Zin = require("@builtin::modules.zinput")
Zin.observe.whySilent("look")
--> {
-- name = "look",
-- layer = "scheme",
-- map = "default",
-- reason = "gateRefused",
-- means = "the control's own gate answered no. A look control gates on
-- the player steering, so a cursor crossing the window reads
-- nothing.",
-- kind = "axis2",
-- subscribers = 1,
-- declaredClasses = { "kbm", "gamepad", "touch" },
-- carriedBy = { "scheme" },
-- }
```
One reason, from a closed set, with the particulars behind it.
`Zin.observe.reasons()` lists the whole set with a sentence each, so what an
answer means is readable from inside the engine rather than only from the
source.
## The closed set, nearest cause first
| Reason | What it says |
|---|---|
| `noMapActive` | No input map is activated at all, so there are no controls. |
| `unknownControl` | No live map declares a control under that name. |
| `groupSuppressed` | A live map stands this control's group down. |
| `contextInactive` | The control declares a context that is not on top of the stack. |
| `heldOnArrival` | A device the control binds was already held when the control went live. |
| `gateErrored` | The control's own gate raised. |
| `gateRefused` | The control's own gate answered no. |
| `noBindingForDevice` | The control declares no binding for any device class this session has. |
| `belowDeadzone` | The devices produced a reading and the control's own deadzone zeroed it. |
| `noSubscriber` | The control is delivering and nothing subscribed to it. |
| `atRest` | The control is live and its devices are not being driven. |
| `delivering` | The control is delivering its value to its subscribers. |
`atRest` and `unknownControl` are the two an outside reader could never tell
apart: a value reader answers `0` for both. This is where they separate.
## Which layer answered
Three naming layers coexist: the live maps' controls, and the `Zin.actions`
and `Zin.axes` registries a control asset replaced. Their name sets are
separate — `toolMove` is a live control and not an action, `move_forward` is
an action and not a control — so a name that is live in one reads as silence
in the others. Every answer carries `layer` (the one that answered) and
`carriedBy` (every layer that has the name at all).
## One control, everything about it
```luau
Zin.observe.control("move")
--> {
-- name = "move", live = true, carriedBy = { "scheme" },
-- entries = { { map = "default", kind = "axis2", subscribers = 1,
-- gated = false, classes = { kbm = {...}, gamepad = {...} } } },
-- lastTick = { value = { x = 1, y = 0 }, active = true, delivered = true },
-- why = { reason = "delivering", ... },
-- }
```
## The frame
```luau
Zin.observe.frame()
--> { frameId = 41207, window = "one tick of binding dispatch — the most
-- recent one", context = "default", maps = {...}, controls = {...},
-- cost = { frameId = 41207, maps = 3, controls = 27, ms = 0.09 } }
```
The window is **one tick** — the most recent one — rather than a sum since
anything last looked, and reading takes nothing away from the next reader.
Two observers in the same frame both get the truth.
`cost.ms` is that one tick's own milliseconds. The same number lands in
`profiler.stats("*zin.scheme.advance*")` as `script.zin.scheme.advance`,
where it carries the average and the peak across every tick as well.
## Reading it without running a script
`/runtime/input` serves the same answers as a file, beside the engine's own
account of the frame's device events:
```
/runtime/input the whole document
/runtime/input/events the frame's device events, each with `consumed_by_ui`
/runtime/input/device pointer lock, UI focus, the session's device classes
/runtime/input/mapping this module's account of the tick
/runtime/input/controls/<name> one control's entry from that account
```
`input.observe()` returns the same document to a script.
Building this layer's half costs a frame of work, so it is built only while
something is reading: a read of either surface arms it for a window of
frames, and the document's `mappingStatus` says whether the half it carries
is `current`, `stale`, `arming` (this read armed it — read again), or
`unarmed`. `Zin.observe.armedFrames()` is how many frames that window has
left; each frame that passes takes one off it, and the per-frame publish
spends the window rather than renewing it, so it runs out once the last
reader stops asking. A window that runs out — or that `Zin.observe.arm(false)`
closes — drops the report the engine was carrying with it, so a frame nobody
observes costs what it cost before anything ever read. `Zin.observe.arm(true)`
holds it open for a tool that wants it continuously.
## Which frame an answer covers
`Zin.observe.frame()` and `Zin.observe.control()` report the most recent
tick of binding dispatch. `Zin.observe.whySilent()` resolves against the
devices as they are at the moment of the call, so it answers for a control
the tick has never reached and for a name that does not exist.
The engine publishes its own half at the end of a frame, after the frame has
been drawn and before the frame's events are cleared. A read taken from a
script during frame N therefore answers for frame N-1.
# canvas
Immediate-mode 2D-paint widget. The body is a sequence of paint
commands (`line`, `bezier`, `polyline`, `rect`, `circle`, `text`)
drawn in widget-local coordinates with origin at the top-left and +y
going down (egui-standard, opposite of plot). Wires through pointer,
drag, double-click, key, and scroll handlers, and forwards `role` /
`tag` / `aria*` props for accessibility.
## Exports
- `canvas(id: string?, opts: CanvasOpts?) -> WidgetNode` — build a canvas widget node from a list of paint commands plus optional interaction handlers.
Types:
- `CanvasOpts = { commands?, width?, height?, onClick?, onPointerDown?, onPointerUp?, onPointerMove?, onDrag?, onDoubleClick?, onKey?, onScroll?, role?, tag?, style?, class?, classes? }`
## Usage
```luau
local canvas = require("@builtin::modules.zui.widget.canvas")
canvas("my-canvas", {
commands = {
{ kind = "line", a = {10,10}, b = {200,150}, color = "#7AA8FF", width = 2 },
{ kind = "circle", center = {100,100}, radius = 30, fill = "#FF8855" },
{ kind = "text", pos = {10,180}, text = "hello", color = "#fff", fontSize = 14 },
},
onClick = "canvas:click",
style = { width = 600, height = 400, background = "#161B22" },
})
```
## Notes
- Pointer events surface widget-local cursor coords as either a `"x,y"` string (legacy) or a structured Luau table (modern handlers).
- `onClick` / `onPointerDown` / `onPointerUp` carry `data.button` (0 = left, 1 = right, 2 = middle) identifying which mouse button triggered the event.
- `onKey` fires only while the canvas has keyboard focus; `onScroll` fires while it is hovered.
- Any `opts.aria*` key passes through verbatim — no wrapper-side allowlist needed.
- Command kinds and per-kind fields are defined in `crates/zero_ui_protocol/src/canvas.rs`.
# widget
Widget namespace — re-exports every widget under `zui.widget.<name>`.
Library users can either pull the whole namespace (`local W =
require("modules.zui.widget")`, then `W.button(...)`) or grab a single
widget directly (`local btn = require("modules.zui.widget.button")`).
Each widget lives in its own `.module/` folder so authors adding a new
widget edit exactly one file. The shared primitive every widget builds
on is `zui.widget.node` — pulled into a user-defined widget the same
way it's pulled in here.
## Exports
This module is a namespace — every field is an assignment from the
matching sibling module. No typed functions live here directly; the
typed surface lives on each sibling.
- `M.node` — core widget-table constructor (`modules.zui.widget.node`).
- Content widgets: `label`, `button`, `icon`, `iconBtn`, `iconButton`,
`chip`, `badge`, `card`, `colorSwatch`, `dialogueBox`, `kbd`, `input`,
`codeEditor`, `slider`, `dragValue`, `checkbox`, `toggle`, `dropdown`,
`datePicker`, `radioGroup`, `selectableList`, `image`, `viewport`,
`progressBar`, `richText`.
- DAW widgets: `fader`, `knob`, `meter`.
- Game-HUD widgets: `healthBar`, `hotbar`, `minimap`.
- Layout containers: `hbox`, `vbox`, `grid`, `split`, `sides`, `panel`,
`section`, `scroll`, `collapsible`, `window`, `modal`, `popup`,
`contextMenu`, `scene`, `area`, `anchor`, `topPanel`, `bottomPanel`,
`leftPanel`, `rightPanel`, `centralPanel`.
- Leafs: `spacer`, `flex`, `sep`.
- Plot / Graph: `plot`, `graph`.
- Drag-and-drop list: `dndList`.
- Canvas: `canvas`.
- Composite widgets: `tabs`, `tree`, `filterRow`, `statRow`, `statusLabel`.
- Sub-namespaces: `lsp`, `fs`.
## Usage
```luau
local W = require("@builtin::modules.zui.widget")
return W.vbox{
W.label("Name:"),
W.input(""),
W.hbox{ W.button("Save", { onClick = "save" }), W.button("Cancel") },
}
-- Or pull single widgets directly:
local btn = require("@builtin::modules.zui.widget.button")
return btn("Save", { onClick = "save" })
```
## Notes
- The exposed `M.node` is the canonical primitive for user-defined
widgets — `require("modules.zui.widget.node")` returns the same value.
- Sub-namespaces (`lsp`, `fs`) collect tightly-coupled widget sets that
only make sense together — isolating them keeps the top-level surface
clean.
- Plot and Graph are pure-Luau builders over canvas; no `egui_plot`
dependency.
- DndList composes per-row canvases + a transparent overlay canvas for
drag-source detection and hover insertion indicators.
- All re-exports are static — adding a new widget means adding a new
`M.<name> = require(...)` line here (and creating the sibling module).
# engine_records
The engine's own records: the tables a subsystem keeps as its account of
what stands in the running engine, and the classes whose every instance is
one. The input maps activated, a signal's handlers and connections, the
entity and asset event registries and the renderer's registry of live
features are records; each follows whoever holds the thing it records.
`declare(t)` makes a table a record, `declareClass(mt)` makes every table
carrying that metatable one, and `holds(v)` answers whether a value is a
record. `engine.snapshotModuleState` carries a record by reference without
walking it, and `engine.restoreModuleState` leaves it as it stands, so what
went away since a reading stays away and what arrived stays in place.
A leaf with no requires, so a module that loads before the `engine` global
exists declares its records as it loads.
# asset_events
Per-asset runtime for declared asset events. An assetType's `behavior.luau`
declares an `events` schema the same way a component declares one; this
module turns that schema into the live objects an asset's ref fires and
listens on.
## Three faces, one Signal — keyed by the asset
The construction is `component_events`': one `Signal` per declared event,
reachable through a private fire-capable table, an owner-side emitter the
type's own behavior fires with, and a subscribe-only facade every other
holder of the ref sees. The facade exposes `connect` / `once` / `wait` and
has no `fire` at any key, so firing authority stays with the type.
What differs is the owner. A component event belongs to one instance on one
entity; an asset event belongs to the **asset**, so the runtime is keyed by
the asset's stable guid. Every resolver of that guid subscribes to the same
Signals, and a ref that is reclaimed and re-resolved re-attaches to the
subscriptions already there — the same reason `asset_ref`'s `runtime` table
is guid-keyed rather than stored on the envelope.
Both tables reject an undeclared event name: the private table's metatable
raises on a bad key and the facade raises on a bad key, so a typo surfaces
at the subscribe call instead of returning nil.
## Declaring events on a type
```lua
-- <name>.assetType/behavior.luau
local M = {}
M.events = {
changed = { payload = { value = Field.number(0, NoSync) } },
}
M.ref = {
poke = function(self)
local emitter = require("@builtin::assetTypes.assetType.shared.events").emitter(self.guid)
if emitter ~= nil then emitter.changed:fire({ value = 1 }) end
end,
}
return M
```
```lua
-- any holder of the ref
local ref = asset.resolve("@builtin::…")
ref.events.changed:connect(function(p) print(p.value) end)
```
## Exports
- `M.forAsset(guid, eventSchema)` — the runtime for one guid, built on first
use and reused after. A later call with a different schema reconciles:
surviving events keep their Signal and their subscribers, new events get a
fresh one, removed events are disconnected and dropped.
- `M.facade(guid)` — the subscribe-only view, or nil before a runtime exists.
- `M.emitter(guid)` — the owner-side `:fire` view, or nil before a runtime
exists.
- `M.has(guid)` — whether a guid holds a live runtime.
- `M.teardown(guid)` — drop one asset's runtime, disconnecting subscribers.
- `M.teardownAll()` — drop every runtime. Called on an engine mode flip so a
play-mode subscription does not survive into edit.
- `M.liveGuids()` — every guid holding a runtime, sorted.
## Notes
- Subscriptions made through the facade are ordinary `signal.module`
connections and disconnect the same way any other does.
- The runtimes table is held strongly, and its lifetime is bounded by the
mode flip that clears it.
# entity_signals
Per-entity destroy signals, surfaced on the entity proxy as
`entity(id).onDestroying` and `entity(id).onDestroyed`, plus the dispatch
the despawn pipeline calls to fire them.
```lua
local boss = entity.find("Boss")
boss.onDestroying:Connect(function()
-- runs BEFORE teardown — final state is still readable
saveBossStats(boss.component.get("Combatant").public)
end)
boss.onDestroyed:Connect(function()
log.info("boss fully cleaned up")
end)
entity.despawn(boss.id) -- onDestroying fires, then teardown, then onDestroyed
```
## Order
`onDestroying` and `onDestroyed` are `Signal`s (see `signal.module`),
created on first access. The despawn pipeline drives the order. For a
cascade rooted at one `entity.despawn(root)` call:
1. `onDestroying` fires across the whole subtree, root first — before any
component teardown, so handlers read final state.
2. components tear down (`onDestroy(self)` per component), leaves first.
3. `onDestroyed` fires per entity as it is removed, leaves first.
`onDestroying` handlers must not yield, and the entity is frozen against
reparenting / re-mutation during the destroying window.
An entity nobody observes costs nothing and fires nothing.
# transform
Math helpers for positions, rotations, and directions on transforms.
Exposed as the global `Transform` table via `--!global Transform` — no
explicit require needed in user code. Functions that take an entity
accept either an entity ID string or an entity proxy table from
`entity("id")`.
## Exports
Look-at and entity-aware helpers:
- `Transform.lookAtQuat(fx, fy, fz, tx, ty, tz) -> (qx?, qy?, qz?, qw?)` — quaternion from origin toward target. Nil when degenerate.
- `Transform.lookAt(entity, txOrTarget, ty?, tz?) -> (boolean, string?)` — make an
entity face a world position or another entity. Both slots read world space:
the subject and an entity target are read as `entity(id).position` and the aim
is written as `entity(id).rotation`, so a parent under either one still leaves
the aim on the point named. Returns whether the rotation was written, and the
reason when it was not.
- `Transform.distance(x1, y1, z1, x2, y2, z2) -> number` — Euclidean distance between two points.
- `Transform.distanceBetween(entityA, entityB) -> number?` — distance between two entities' world positions. Nil when either is unresolvable.
- `Transform.direction(fromX, fromY, fromZ, toX, toY, toZ) -> (dx, dy, dz)` — unit direction vector.
- `Transform.directionBetween(entityA, entityB) -> (dx, dy, dz)` — unit world-space direction between two entities' world positions.
Rotation shapes:
A quaternion **constructor** here returns the four components as four separate
values, so a caller either names them or braces the call to make one table:
```lua
local qx, qy, qz, qw = Transform.quatFromAxisAngle(0, 1, 0, math.rad(90))
entity("cam").localRotation = { Transform.quatFromAxisAngle(0, 1, 0, math.rad(90)) }
```
A rotation-taking **surface** reads that table through
`Transform.toQuaternion`, which also takes euler DEGREES — so
`{ qx, qy, qz, qw }`, `{ x =, y =, z =, w = }`, `{ pitch, yaw, roll }` and
`{ pitch =, yaw =, roll = }` all mean the same thing wherever a rotation is
assigned: `entity(id).rotation` / `.localRotation`, `entityOps.spawn`,
`entityOps.transform`, and the capture viewpoints.
- `Transform.toQuaternion(rotation, label?) -> { qx, qy, qz, qw }` — the shared reading of a rotation a caller wrote. Raises when the value matches no form, naming what arrived; a value that is one of the shapes a quaternion helper returns is named as such along with the packing it goes in as.
- `Transform.tryQuaternion(rotation, label?) -> ({ qx, qy, qz, qw } | nil, message?)` — the same reading without raising, for a surface that wants to raise the message at its own caller's line.
- `Transform.readVec3(value, label?) -> { x, y, z }` — the same for a vector.
- `Transform.snapVec3(v, step) -> { x, y, z }` — quantize a vector to a step grid.
Quaternion construction / conversion:
- `Transform.quatFromYaw(yaw)`, `Transform.quatFromYawPitch(yaw, pitch)`, `Transform.quatFromAxisAngle(ax, ay, az, angle)` — quaternion constructors.
- `Transform.quatIdentity()` — identity quaternion.
- `Transform.euler(qx, qy, qz, qw) -> (yaw, pitch, roll)` and the named alias `Transform.quatToEuler`.
- `Transform.eulerToQuat(yaw, pitch?, roll?)` — euler-to-quaternion in YXZ order.
Lerps and interpolation:
- `Transform.lerp(ax, ay, az, bx, by, bz, t) -> (x, y, z)` — vec3 lerp.
- `Transform.lerp1(a, b, t) -> number` — scalar lerp.
- `Transform.normalizeAngle(a) -> number` — wrap angle into `[-pi, pi]`.
- `Transform.lerpAngle(a, b, t) -> number` — shortest-arc angle lerp.
- `Transform.slerp(ax, ay, az, aw, bx, by, bz, bw, t) -> (qx, qy, qz, qw)` — quaternion slerp with shortest-path and near-parallel fallback.
Quaternion operations:
- `Transform.quatMul(...) -> (qx, qy, qz, qw)` — `qa * qb` composition.
- `Transform.quatInverse(qx, qy, qz, qw) -> (qx, qy, qz, qw)` — inverse (= conjugate for unit quats).
- `Transform.quatRotateVec(qx, qy, qz, qw, vx, vy, vz) -> (x, y, z)` — rotate a vec3 by a quaternion.
Pose helpers:
- `Transform.orbit(centerX, centerY, centerZ, radius, height, angle) -> (x, y, z, qx, qy, qz, qw)` — orbital pose facing the center.
- `Transform.worldToLocal(...)` / `Transform.localToWorld(...)` — pose-space conversions.
Nested `Transform.vec.*` namespace (component-wise vec3):
- `Transform.vec.add`, `sub`, `scale`, `dot`, `cross`, `length`, `normalize`.
Types:
- `Vec3 = { x: number, y: number, z: number }`
- `EntityRef = string | { entityId: string }`
## Usage
```luau
-- Look-at by coordinates or by target entity:
Transform.lookAt("cam", 0, 1, 0)
-- an entity target resolves to that entity's world position
local aimed, why = Transform.lookAt("cam", "box")
-- Orbit pose around a point:
local x, y, z, qx, qy, qz, qw = Transform.orbit(0, 1, 0, 5, 2, t)
entity.find("cam").localPosition = { x, y, z }
entity.find("cam").localRotation = { qx, qy, qz, qw }
-- Quaternion math:
local qx, qy, qz, qw = Transform.quatFromYawPitch(math.pi / 4, 0)
local sx, sy, sz, sw = Transform.slerp(0, 0, 0, 1, qx, qy, qz, qw, 0.5)
-- Component-wise vec3 helpers:
local nx, ny, nz = Transform.vec.normalize(1, 1, 0)
```
## Notes
- The `--!global Transform` directive promotes the module's typed
functions onto the runtime universe's globals bucket, so `Transform.*`
is available without any per-source `require`.
- Entity-aware functions (`lookAt`, `distanceBetween`,
`directionBetween`) report a missing entity or a missing transform in
their return value rather than raising: `lookAt` answers
`false, "unresolved"` / `"no-transform"` / `"incomplete-target"` /
`"degenerate"`, `distanceBetween` answers `nil`, and
`directionBetween` answers zeros.
- Quaternion APIs operate on raw `(qx, qy, qz, qw)` tuples for parity
with the entity proxy's `localRotation.get`/`set`. Use
`Transform.quatIdentity()` rather than hand-rolling `(0, 0, 0, 1)`.
- `Transform.slerp` flips the second quaternion if `dot < 0` to take
the shortest path, and falls back to lerp+normalize when the inputs
are within `dot > 0.9995` to avoid `1/0` near-parallel issues.
# json
JSON encode/decode library for Luau. Encodes Lua values to JSON
strings and decodes JSON strings back to Lua values. Used for
communication with the Rust side of the engine, the VFS read/write
bridge, and any wire-format that needs JSON.
Pure Luau, no engine dependencies. Compact and pretty-printed
encoders, plus a hand-rolled decoder that streams the input by position
so it works under WASM as well as native.
## Exports
- `Json.encode(value: any, indent?: string, currentIndent?: string) -> string` — compact encode. Functions / unknown types and NaN/Inf encode as `null`.
- `Json.encodePretty(value: any, indentStr?: string) -> string` — pretty-printed encode with sorted object keys (diff-friendly).
- `Json.encodeArgs(...: any) -> string` — encode varargs as a JSON array.
- `Json.decode(str: string) -> any` — decode a JSON string. Returns the decoded value, or `nil` + error message on failure.
## Usage
```luau
local Json = require("@builtin::modules.json")
local widget = { type = "button", text = "Click Me" }
local compact = Json.encode(widget) -- '{"text":"Click Me","type":"button"}'
local pretty = Json.encodePretty(widget, " ")
local decoded = Json.decode(compact)
local v, err = Json.decode("oops") -- v = nil, err = error message
```
## Notes
- Object keys are sorted alphabetically in both encoders for consistent
output across runs.
- Numeric keys on objects are stringified at encode time (JSON has no
numeric keys). Pure-integer key sets get detected as arrays via
`isArray` and encoded with brackets.
- NaN, +Inf, -Inf encode as `null` — JSON has no representation. Round
trips through `decode` recover `null` (Lua `nil`), so they don't
preserve.
- Unicode `\uXXXX` escapes decode to UTF-8 by hand to stay WASM-safe.
Only the BMP is covered; supplementary planes via surrogate pairs
are not.
- Functions encode as `null`.
- Decode is character-streamed — no regex, no `string.match` patterns
on the whole input — so the line-and-column information needs to be
reconstructed from the position offset.
# node
Core widget-table constructor used by every zui widget. Returned
tables are interoperable with hand-written widget trees, so user-built
widgets can reuse the exact same primitive without depending on the
rest of zui.
## Exports
- `node(widgetType: string, opts: Opts?, children: any?) -> any` — build the widget table. Returned directly by the module.
Types:
- `Opts = { id?: string, classes?: any, class?: any, props?: any, style?: any }`
## Usage
```luau
local node = require("@builtin::modules.zui.widget.node")
-- User-built widget composed on top of node:
return function(text, opts)
return node("label", { props = { text = text } })
end
```
## Notes
- Accepts either a single child table or an array of children; single
children are wrapped automatically (matches `scroll.module`'s fix
from #2331).
- `classes` may be a string (space-separated) or an array — both
normalise to an array of class names.
- `class` is accepted as an alias for `classes` for ergonomic call
sites.
- Returning a function (not a table) keeps consumers terse and avoids
callers needing to know whether the constructor lives on `M.x` or
the module itself.
# zui
> **Deprecated for authoring content.** `zui` predates the engine's CSS-parity
> `ui.*` surface and writes unlike CSS, producing flatter results. Author screens
> as raw widget trees (`{ type, style, props, children }`) styled with `ui.*`, and
> read `@builtin::examples.ui.*` for complete worked screens. `zui` remains in use
> internally by the editor; it will be rebuilt on the CSS-parity core.
Convenience UI library on top of the engine's `ui.*` Rust bindings.
Ships Layer 1 (per-widget builders), Layer 2 (theme tokens), Layer 3
(app lifecycle), router with unhandled-warning, pre-built shells, and
debug overlay. The engine's Rust UI surface is the substrate — `zui` is
one library on top of it. Any third-party Lua UI library can build on
the same primitives; the engine has no concept of "the zui library",
only widget types and a stable contract. See
[`docs/specs/ui-v3-architecture.md`](../../../../docs/specs/ui-v3-architecture.md).
## Exports
Layer 1 (widget builders) — short aliases on `Z.*`:
- `Z.lbl`, `Z.btn`, `Z.icon`, `Z.iconBtn`, `Z.iconButton`, `Z.chip`,
`Z.badge`, `Z.card`, `Z.colorSwatch`, `Z.dialogueBox`, `Z.kbd`,
`Z.input`, `Z.codeEditor`, `Z.slider`, `Z.dragValue`, `Z.checkbox`,
`Z.toggle`, `Z.dropdown`, `Z.datePicker`, `Z.radioGroup`,
`Z.selectableList`, `Z.image`, `Z.viewport`, `Z.progressBar`,
`Z.richText`, `Z.fader`, `Z.knob`, `Z.meter`, `Z.healthBar`,
`Z.hotbar`, `Z.minimap`, `Z.hbox`, `Z.vbox`, `Z.grid`, `Z.split`,
`Z.sides`, `Z.panel`, `Z.section`, `Z.scroll`, `Z.collapsible`,
`Z.window`, `Z.modal`, `Z.popup`, `Z.contextMenu`, `Z.scene`,
`Z.area`, `Z.anchor`, `Z.topPanel`, `Z.bottomPanel`, `Z.leftPanel`,
`Z.rightPanel`, `Z.centralPanel`, `Z.spacer`, `Z.flex`, `Z.sep`,
`Z.plot`, `Z.graph`, `Z.dndList`, `Z.canvas`, `Z.tabs`, `Z.tree`,
`Z.filterRow`, `Z.statRow`, `Z.statusLabel`, `Z.node`
- `Z.lsp` — LSP composite-widget namespace.
- `Z.fs` — Filesystem composite-widget namespace.
- `Z.widget` — full widget namespace (for explicit access).
Layer 2 (theme tokens):
- `Z.theme` — active default token table.
- `Z.themeWith(overrides)` — derive a theme with token overrides.
- `Z.themeNames()` — list known token names.
- `Z.Theme` — the underlying module.
Shared utils (re-exported):
- `Z.round`, `Z.fmt`, `Z.fmtVec3`, `Z.id`, `Z.eventValue`, `Z.map`,
`Z.when`, `Z.compact`, `Z.Utils`.
Layers 3-7 (app lifecycle + advanced features):
- `Z.app(name, builder)` — App lifecycle wrapper (`Z.App.create`).
- `Z.dynamic(list, fn)` — one-screen-per-list-item bucket.
- `Z.dataSource(fetchFn, opts)` — TTL + gated cached fetcher.
- `Z.highlight` — syntax-highlighting dispatch and per-language modules.
- `Z.shell` — pre-built shell shapes (docked, canvas, inspector).
- `Z.tags`, `Z.screens` — Luau-owned screen tag registry + lifecycle helpers.
- `Z.defineWidget(name, fn)` / `Z.unregisterWidget(name)` — register Luau builders for raw `{ type = name, ... }` widget tables.
- `Z.widgetState(id, key, default)` / `Z.widgetState.set(id, key, value)` — per-widget reactive state.
- `Z.VERSION`, `Z.SPEC` — version string and spec link.
## Usage
```luau
local Z = require("@builtin::modules.zui")
local C = Z.theme
local function tree()
return Z.centralPanel({
Z.topPanel({
Z.hbox({
Z.lbl("◈ MY APP", { color = C.accent, fontSize = 14, bold = true }),
Z.flex(),
Z.btn("Save", "save:click", { bg = C.accent, color = C.bg, bold = true }),
}, { style = { gap = 8, padding = { 6, 12, 6, 12 } } }),
}),
Z.section("CONTROLS", {
Z.slider("vol", 0.5, 0, 1, { onChange = "vol:set" }),
Z.btn("Reset", "vol:reset"),
}, { bg = C.panel, border = C.border, padding = { 8, 12, 8, 12 } }),
})
end
ui.registerScreen("my-app", tree(), 0)
ui.showScreen("my-app")
```
## Notes
- This module's surface is mostly re-exports: every `Z.*` widget alias
is `Widget.*` (see `widget.module/`), every theme alias is
`Theme.*`, every util is `Utils.*`. The aliases exist for ergonomics
only — explicit access via `Z.widget`, `Z.Theme`, `Z.Utils`, etc.
works the same way.
- The module registers a handful of `ui.defineWidget` handlers at
load time (tabs, card, sides, badge, colorSwatch, separator,
progressBar, iconButton, dialogueBox, hotbar) so raw
`{ type = "<name>", ... }` widget tables in existing demos and editor
tools keep rendering after their Rust-side definitions were deleted.
Re-running registration on hot-reload is safe — the engine replaces
the previous closure.
- `Z.defineWidget` requires the engine `ui` global; off-host execution
raises a clear error.
- `Z.widgetState` is a callable read path with a `.set` write
sub-method — no metatables on the caller side. Returns the `default`
when the engine global is missing.
- The library is built on a stable Rust substrate (`ui.registerScreen`,
`ui.getToken`, `ui.defineWidget`, etc.). Other Lua UI libraries can
build on the same substrate and coexist with `zui`; the engine has
no concept of a privileged library.
- See `docs/specs/ui-v3-architecture.md` for the full layered design,
Rust binding gaps, and follow-up roadmap.
## Tall content inside an anchored panel
A `Z.anchor("Center", ...) { Z.panel(rows) }` whose `rows` exceed the
viewport will silently clip top + bottom — the player has no scrollbar
and no way to reach the off-screen entries. Opt into in-place scrolling
on the panel:
```luau
Z.anchor("Center", {}, {
Z.panel(rows, { scroll = true, maxHeight = 480 }),
})
```
`scroll = true` wraps the children in a `scrollArea` so overflow stays
reachable. Without `scroll = true`, `maxHeight` only clips. See the
[panel README](widget.module/panel.module/README.md) for `scrollMaxHeight`,
header/footer pinning, and the full pattern (closes #3270).
# assetType runtime
The machinery that reads a `<typename>.assetType/behavior.luau` and turns its
declarations into live behaviour.
A `behavior.luau` is a table of declarations — `ref` methods, `modules` shared
code, an `events` schema, a `namePattern`, `refShapes`, and the `onCreate` /
`onRegister` / `onChange` / `onDelete` / `validate` / `settingsDefaults` /
`settingsCorrections` hooks. None of it runs itself. This is the code that
runs it, and it lives here because reading a type's declarations is the
**assetType type's own behaviour** — the same way `material.assetType` owns
what a `.material` does.
## Sub-modules
| Module | Reads | Responsibility |
|---|---|---|
| `ref.module` | `ref`, `modules` | Builds every `AssetRef` envelope, attaches the shared metatable, and dispatches per-type methods and shared modules onto it. Also owns edit-mode write-behind persistence. |
| `create.module` | `onCreate`, `namePattern`, `validate` | Backs `asset.create`: resolves the type, checks the name against its pattern, runs `onCreate` (or clones `template/`), and writes the instance. `validate.module` beside it checks a creation `opts` table against the type's `createSchema`. Before running `onCreate`, resolves the type's settings chain (below) and hands the result to the hook as `opts.settings`. |
| `settings.module` | `settings`, `settingsDefaults`, `settingsCorrections` | A type's declared settings schema and where its values live (`type.yaml`'s `settings:` block). `Settings.resolveForCreate` layers the schema's own defaults, the type's own `settingsDefaults(name, opts)` hook (a DEFAULT the schema can't express — a name-derived convention, a fact read off a payload the caller handed it; any later layer overrides it), the Preset Manager, an importer's settings, the caller's `settings` table, then the type's own `settingsCorrections(values, name, opts)` hook (a CROSS-FIELD CONSTRAINT the schema can't express, applied last over the fully merged values). A correction over a default/preset/importer value lands silently; a correction that would override what the caller's own `settings` table explicitly asked for refuses the creation instead, naming the field and why. Neither hook is ever handed a table to mutate; each returns what it wants changed (`settingsCorrections` may also return a `{ [field]: reason }` table for that refusal message). `Settings.cloneValues` deep-copies a values table — `create.module` hands `onCreate` a copy, never the table it stamps. `Settings.valuesOf` / `writeValues` read and write an asset's own values file. |
| `typeYaml.module` | `type.yaml` | The folder a type lives in and its parsed `type.yaml`, decoded once per content version of the file. `settings.module` and `inspector.module` read their blocks through it. |
| `inspector.module` | `type.yaml`'s `inspector:` block | Which inspector a type declares for its assets (`inspector: { type: ... }` or `{ instance: ... }`), and the declaration that file returns — the sections it adds, the generic ones it replaces, and their order — or the problem that kept it from loading. |
| `onRegister.module` | `onRegister` | Fires an instance's once-only registration hook — on arrival, on world load, and for `@builtin` library instances. |
| `changeDispatch.module` | `onChange`, `onDelete`, `refShapes` | Routes a VFS source write or a folder delete to the enclosing typed asset's type hooks. |
| `refShapes.module` | `refShapes`, `ref` | Publishes what every `AssetRef<category>` answers to, so a member read on an asset-typed value is checked against that category's real surface. |
| `events.module` | `events` | The per-asset event runtime behind `ref.events`. |
| `instantiable.module` | `ref.instantiate` | The instantiation contract an asset type opts into: `root` stands the root entity, `place` applies the base placement opts to one the type adopted, and `result` shapes the `(root, idMap)` every `instantiate` returns. Also registers the `sceneInstantiable` field-constraint validator. |
| `declaration.module` | `type.yaml` | The type's decoded declaration, read once and held until a write inside an `.assetType` folder; what every question about a type as a whole reads. |
| `previewRecipe.module` | `type.yaml` `preview:` | How an instance's `preview.png` is produced: which of the four recipes (`instantiate`, `image`, `surface`, `custom`) the type declares, and what that recipe needs. |
## Reaching it
```luau
local rt = require("@builtin::assetTypes.assetType.shared")
rt.ref.loadTypeBehavior("material")
```
Fields resolve lazily. The prelude requires the sub-modules directly, in
dependency order (`ref`, then `changeDispatch`, `onRegister.install()`,
`refShapes.install()`) — a table that required all six on load would take that
ordering away from it.
# luau_introspect (module)
Parses Luau/asset source TEXT into structured declarations. Pure
string/pattern parsing (`string.find` / `string.match` / `string.gmatch`)
over the literal source — no compiling, no VM spawn, no runtime state, no
engine FFI. Given source text, it extracts what is written in the file.
This is the shared parser every per-assetType `M.ref.inspect` hook uses to
turn a component/module/tool's own source into the structured record
`asset.inspect` returns.
## Exports
- `docstrings(src) -> DocMap` — scans `--!desc` / `--!arg` / `--!return` /
`--!example` comment runs and binds each run to the name of the
declaration on the next non-comment line (the identifier after
`function`, `public:`, `local function`, or `M.`).
- `publicFields(src) -> { FieldEntry }` — parses `public = { name =
Field.<kind>(default, mode), ... }` table-literal entries and
module-scope `public.<name> = Field.<kind>(...)` assignments.
- `events(src) -> { EventEntry }` — parses `events = { name =
Event(payloadSchema?, syncMode?), ... }` table-literal entries.
- `methods(src) -> { MethodEntry }` — parses `function public:<name>(...)`
/ `typed function public:<name>(...)` declarations (params + optional
return type).
- `lifecycleHooks(src, catalog) -> { string }` — top-level `function
<name>(` declarations whose name is in the caller-supplied `catalog`
array.
- `moduleExports(src) -> { ExportEntry }` — parses `M.<name> = function`
assignments, `function M.<name>(...)` declarations, and a trailing
`return { foo = foo, ... }` export table.
- `forSource(src, computeFn) -> any` — memoises `computeFn(src)` keyed on
`src` itself in a module-local table. The same source text returns the
cached result without re-running `computeFn`; different source text
recomputes. Keyed on the source content (not an asset checksum) because
composite/container assets (component folders, scene folders, etc.) all
hash to the empty-string checksum — a checksum-keyed cache collapses
every such asset's detail onto whichever one was inspected first.
## Usage
```luau
local Introspect = require("modules.luau_introspect")
local src = self:getInitScript()
local detail = Introspect.forSource(src, function(s)
return {
fields = Introspect.publicFields(s),
events = Introspect.events(s),
methods = Introspect.methods(s),
hooks = Introspect.lifecycleHooks(s, lifecycleCatalog),
}
end)
```
## Notes
- Every function is a pure string→table transform: same source text in,
same structured table out, every time. No reads of live component
state, no entity/world queries.
- Structural scanning runs over a comment- and string-aware **mask** of
the source (a same-length copy where comment bodies and string-literal
contents are blanked to spaces, delimiters and newlines preserved).
Pattern matches and brace/paren balancing run on the mask — so a brace,
`Field.`, `Event(`, or a whole entry that appears inside a comment or a
string literal never registers — while captured values are sliced from
the original source at the same (aligned) indices. This makes the
parser robust to apostrophes in comments, commented-out entries, string
defaults containing `}` / `Field.` / `Event(`, and `[[ ]]` long
strings.
- `default` / `category` are raw trimmed source-text slices (with any
trailing comment stripped), not evaluated values.
- For a **ref constructor** (`Field.assetRef` / `dataRef` / `resource`
/ `componentRef`) the FIRST positional argument is the category /
contract / componentType selector, captured under `category`; the
SECOND argument is captured under `default`.
- For every **other** Field kind the first argument is the `default`
and `category` is nil.
- A trailing `Sync` / `NoSync` identifier is captured as `sync`,
tolerating a trailing comma or a trailing comment after the last
argument.
- Field/Event entries whose key is a computed or indirect expression
(`[expr] = Field.number(...)`) are omitted — only literal identifier
keys are recognised.
- `methods` captures a `...` variadic parameter (as `name = "..."`), with
its `: <type>` annotation when present.
- A `--!desc` written above a field/event declaration (`<name> =
Field.<kind>(...)` / `<name> = Event(...)`) or a `public.<name> =`
assignment binds to that name in `docstrings`, and is folded into the
corresponding `publicFields` / `events` entry's `desc`.
- `lifecycleHooks` never hardcodes the lifecycle-callback catalog; the
caller passes the list of names to match against.
- `forSource`'s memo is bounded (oldest-key eviction) so a long session
touching many distinct asset versions can't grow it without limit.
# inputMap assetType
A control scheme, expressed as an asset. A `<name>.inputMap/` folder whose
`init.luau` returns the map record, and whose **controls are the
`<name>.inputBinding/` child folders beside it** — the same containment a
`.toolbox` has with its `.tool` children. Containment records the
reference, so a scene carrying its map carries every control it declares.
```
racer.inputMap/
init.luau -- what the map is, and how it composes
boost.inputBinding/
init.luau
steer.inputBinding/
init.luau
```
```luau
-- racer.inputMap/init.luau
return {
name = "racer",
description = "Throttle, steering and boost.",
-- The group these controls belong to, and the groups this map stands
-- down while it is live. Declaring neither composes with everything.
group = "vehicle",
suppresses = { "player" },
}
```
Each control carries `kbm`, `gamepad` and `touch`; the `inputBinding`
assetType covers the record shape.
## Reading it
A controller activates the map it needs and subscribes to the controls it
drives. Nothing is live until something asks for it.
```luau
local controls
function awake()
controls = self.inputMap:activate()
controls.boost:onPressed(function() self:boost() end)
controls.steer:onInput(function(v, dt, active)
if not active then return end
self:steer(v, dt)
end)
end
function onDestroy()
self.inputMap:deactivate(controls)
end
```
What `activate` returned is this holder's **claim** on the map. Keep it and
hand it back: releasing the claim disconnects the subscriptions made
through it, and the map stays live for whoever else holds it.
A control the map does not declare reads as `nil`, so a typo raises at the
subscribe call. A controller written against more than one map tests for
an optional control first — `if controls.crouch then controls.crouch:onInput(...) end`.
Several maps can be live at once, and the player's surface is the union of
them. `group` / `suppresses` are how one map stands another down — a car
takes the player over by naming the `player` group, and walking resumes the
moment the car releases. `Zin.scheme.live()` reports each live map's
`group`, `suppresses` and `suppressedBy`, and `heldBy` — who opened each
activation still standing on it, so a control that goes quiet under a
suppression leads to the activation doing it rather than to the map.
## Whose keys a map reads
A key belongs to the surface it was pressed on. In the editor, where the
scene shares the window with panels, the scene holds the keys pressed while
it was the active input context — the last click landed on the viewport, or
play just started. A key pressed after a click on a panel is the editor's,
and a game's controls never see it. A map reads the scene's keys unless it
declares otherwise:
```luau
return {
label = "Editor",
group = "editor",
surface = "editor", -- every key no focused widget took
}
```
`surface = "editor"` is for controls that belong to the editor around the
scene — its hotkeys, its viewport camera — and answer wherever the author
last clicked. Pads and touch belong to no panel and read the same on either
surface. `Zin.scheme.live()` reports each map's `surface`.
## Ref methods
- `:activate(label?)` — load every `<name>.inputBinding/` child, validate
them, add them to the live binding set, and return the handles keyed by
control name. Every fault across every child is reported in one error.
Activating a map already live adds a holder, so two components sharing
one map read one set of controls — each through its own handles, which
are the claim it releases by. Every activation reads the children again,
so a binding added, edited or removed since the map came up reaches the
running set here and reaches the holders already standing on it. `label`
names this activation in `Zin.scheme.live()`.
- `:deactivate(claim)` — release one holder's claim, disconnecting every
subscription that claim made. The map stays live while another holder
remains; the last one out takes the controls out of the live set. Name
no claim and the caller's own most recent one goes.
- `:controls()` — `{ name, label, kind, classes, problems }` per child,
read without activating anything, so an authoring tool can show a map's
whole surface and everything wrong with it before it goes live.
- `:record()` — the raw record its `init.luau` returns.
- `:effective()` — the materialized effective map, without activating it.
- `:bindingsFor(name, class)` — the bindings a named entry has for one
device class in this map's effective form.
## Writing one
The `inputAuthor` toolbox writes a map and its controls together, filling
in canonical defaults for a control whose name it recognises:
```luau
tools.use("inputAuthor", "map", "racer", { "throttle", "steer", "boost" },
{ group = "vehicle", suppresses = { "player" } })
tools.use("inputAuthor", "check", "/zero/source/inputMaps/racer.inputMap")
```
`asset.create("inputMap", name, { record = ... })` bakes a record into both
shapes a map is read in — `init.luau` beside one `<name>.inputBinding/`
child per entry, each carrying all three device classes, a class the record
had nothing for written as `false` with the reason beside it. That is the
hook `Zin.map.bake(name)` drives to turn the active effective map into an
editable asset.
The engine's built-in schemes live at `@builtin::inputMaps.default`,
`.editor`, `.editorMeta`, `.vehicle` and `.flight`.
## In the Inspector
Controls: whether this map is the one taking input now, how many controls it declares, and each control's name, label, kind, device classes and problem count.
# knob
Rotary knob built on `canvas` interaction props. Renders a 270° dial
as a track polyline + fill polyline + needle line, and wires
drag/reset handlers via `widgetState`. Auto-registers per-id handlers
on the current `Z.app` so callers only need to supply `onChange`.
## Exports
- `knob(id: string, value: number, lo: number?, hi: number?, opts: KnobOpts?) -> WidgetNode` — module returns the builder function directly.
Options:
- `defaultValue: number?` — restored on double-click. Omit to disable reset.
- `onChange: string?` — callback id; fires with `{ value = number }` on drag/reset commit.
- `tooltip: string?` — when set, wraps the canvas in a transparent tooltip panel.
- `width: number?`, `height: number?` — canvas size. Default `34 × 34`.
- `arcWidth: number?` — track/fill stroke width. Default `4`.
- `trackColor: string?`, `fillColor: string?`, `capColor: string?` — palette overrides.
- `style: table?`, `app: App?` — standard plumbing.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.knob("master-gain", state.gain, 0, 1, {
defaultValue = 0.75,
onChange = "audio:gain",
tooltip = "Master gain",
})
```
## Notes
- Vertical drag updates the value: `1px = 0.5% of span`, or `0.05%` when
Shift is held (10× finer for fine adjustment). Drag *up* increases.
- Double-click restores `defaultValue` if set; otherwise no-op.
- Live state lives in `ui.widgetState(id, "value")` — first render seeds
from the supplied `value`, thereafter the drag handler owns it. This
mirrors the `Z.collapsible` auto-stash pattern.
- Handlers are registered once per id via `app._knobHandlers`; subsequent
renders are no-ops on the handler side.
- The fill polyline is omitted when only one segment is filled so the
renderer's min-2-points guard isn't tripped.
- The tooltip wrap is only added when `opts.tooltip` is set so non-tooltip
knobs stay flat.
# minimap
Luau builder over `canvas` for a square minimap. Renders a 150×150
dark-green square with a single light-green centre marker by default,
and accepts an optional `markers` array so callers can paint arbitrary
points (entities, waypoints, POIs) without needing a new Rust widget
kind. Marker coordinates are normalised — `[0..1]` × `[0..1]` —
clamped so out-of-bounds entries paint at the edge.
## Exports
- `minimap(opts: Opts?) -> any` — build the canvas node. Returned directly by the module.
Types:
- `Marker = { x?: number, y?: number, color?: string, radius?: number }`
- `Style = { width?: number, height?: number, background?: string }`
- `Opts = { id?, classes?, label?, size?, background?, borderColor?, borderWidth?, borderRadius?, markers?: { Marker }, style?: Style }`
## Usage
```luau
local minimap = require("@builtin::modules.zui.widget.minimap")
local widget = minimap({
size = 200,
markers = {
{ x = 0.5, y = 0.5, color = "#FFC832", radius = 5 }, -- player
{ x = 0.2, y = 0.8, color = "#64C8FF" }, -- ally
{ x = 0.7, y = 0.3, color = "#C84040" }, -- enemy
},
})
```
## Notes
- DOM mirror exposes the widget as `<canvas role="img"
aria-label="Minimap">` via the generic `role` / `aria*` prop
pass-through.
- Legacy single-dot fallback ensures existing demos keep rendering
even when no markers are supplied.
- The marker `radius` and `color` are per-marker — use them to encode
per-entity context (size for distance, colour for faction, etc.).
# datePicker
Calendar-popup date picker. Wraps `egui_extras::DatePickerButton`
(egui_extras 0.34 with the `datepicker` feature, enabled in
`crates/zero_ui/Cargo.toml`). Value flows as an ISO-style date string;
format defaults to `"%Y-%m-%d"` and can be overridden with a jiff
strftime spec.
## Exports
- `datePicker(id: string?, value: any?, opts: DatePickerOpts?) -> WidgetNode` — build a date-picker widget node.
Types:
- `DatePickerOpts = { id?, props?, style?, format?, onChange?, enabled?, focusable?, tooltip? }`
## Usage
```luau
local datePicker = require("@builtin::modules.zui.widget.datePicker")
datePicker("startDate", "2026-05-03", {
onChange = "startDateChanged",
})
datePicker("birthday", "1990/01/15", {
format = "%Y/%m/%d",
onChange = "birthdayChanged",
focusable = true, -- Wave 5 (#2416) prep; informational today
})
```
## Notes
- `onChange` receives the newly-formatted date string as `value`.
- The widget id falls back to `opts.id` when the `id` positional is nil.
- Date format follows the jiff strftime specifier syntax (e.g. `"%Y-%m-%d"`, `"%Y/%m/%d"`).
# statRow
Label-value row — `[label] [value]` — with consistent label width,
theme-token defaults, and an optional value class/color. Used wherever
a stats panel hand-rolls a tiny `Z.hbox(label, value)` (runtime /
profiler / physics tabs).
`value` may be a string/number (rendered as a label) or a widget table
(rendered as-is — e.g. `Z.statusLabel`).
## Exports
- `statRow(labelText: any, value: any, opts: StatRowOpts?) -> any` — build a label-value row widget. The module returns this function directly.
Types:
- `StatRowOpts = { id: string?, class: (string | { string })?, fontSize: number?, labelWidth: number?, labelMinWidth: number?, labelColor: string?, labelClass: (string | { string })?, valueColor: string?, valueClass: (string | { string })?, bold: boolean?, gap: number?, align: string?, padding: any? }`
## Usage
```luau
local statRow = require("@builtin::modules.zui.widget.statRow")
statRow("Heap allocated", "12.4 MB", {
labelWidth = 140, valueClass = "debug-good", fontSize = 11,
})
statRow("Frame", Z.statusLabel(dtMs, { good = 16, warn = 33 }))
```
## Notes
- Label width defaults to 140 px, font size to 11.
- Label / value colors fall back to the active theme's `text_dim` /
`text` tokens respectively.
- The value slot accepts a widget table directly, which is the
idiomatic way to inline status-coloured values.
# popup
Anchored floating popup. Wraps `egui::Popup` (added 0.32). Pins
itself to a referenced widget's rect — useful for button-anchored
dropdowns, autocompletes, callouts. The anchor MUST render before the
popup in the tree, otherwise the rect lookup misses and the popup
silently does not paint.
## Exports
- `popup(opts: Opts?) -> any` — build the popup widget node. Returned directly by the module.
Types:
- `Opts = { id?, anchor: string, pivot?, open?, onDismiss?, focusable?, children?, props?, style? }`
## Usage
```luau
local popup = require("@builtin::modules.zui.widget.popup")
Z.btn("Save", "saveBtn"),
popup({
anchor = "saveBtn",
open = state.popupOpen,
onDismiss = "save:popup-dismiss",
children = {
Z.btn("PNG", "save:png"),
Z.btn("JPG", "save:jpg"),
},
})
```
## Notes
- The `anchor` is required — the wrapper asserts and errors loudly on
nil rather than silently failing.
- Pivot maps to egui's `RectAlign`: `"belowLeft"` → BOTTOM_START
(default), `"belowRight"` → BOTTOM_END, `"aboveLeft"` → TOP_START,
`"aboveRight"` → TOP_END.
- Auto-dismiss fires `onDismiss` on click-outside / Escape so the
caller can mirror the new state. Falls back to `<id>-dismiss` if
`onDismiss` is omitted.
- The Lua-facing key is `anchor`; the underlying Rust prop is
`anchorTo` (egui already claims `anchor` for an enum). The wrapper
bridges the two names automatically.
# slider
Horizontal slider for a numeric value in `[min, max]`. Emits the given
`onChange` callback id with `data.value` set to the new value. Pass
`step` to constrain to discrete increments.
## Exports
- `slider(id: string, value: number?, lo: number?, hi: number?, opts: SliderOpts?) -> any` — build a horizontal slider widget. The module returns this function directly.
Types:
- `SliderOpts = { props: { [string]: any }?, style: { [string]: any }?, onChange: string?, step: number? }`
## Usage
```luau
local slider = require("@builtin::modules.zui.widget.slider")
slider("volume", 0.5, 0, 1, { onChange = "ui:volume:change", step = 0.05 })
```
## Notes
- `value`, `lo`, `hi` all default to `0`, `0`, `1` respectively when nil.
- `onChange` callbacks fire with `data.value` set to the new numeric
value; route them via `component.onCallback` or a `Z.app` router.
# grid
Fixed-column grid container. Children flow left-to-right then wrap.
`columns` is required.
## Exports
- `grid(children: { WidgetNode }?, columns: number, opts: GridOpts?) -> WidgetNode` — build a fixed-column grid container.
Types:
- `GridOpts = { id: string?, classes: (string | { string })?, props: { [string]: any }?, style: { [string]: any }? }`
- `WidgetNode = { [string]: any }` — widget table (interoperable with hand-written widget trees).
## Usage
```luau
local Z = require("@builtin::modules.zui")
local g = Z.grid({ a, b, c, d }, 2, { style = { gap = 4 } })
```
## Notes
- Pure builder — no state, no engine calls. Safe at module load.
- `children` defaults to `{}` when nil; `columns` has no default and
must be provided.
- The `columns` value is forwarded to the engine as `props.columns`;
any other props the caller passes survive untouched.
# selectableList
Luau builder for a single-select listbox composed of focusable per-row
canvases inside a panel. Same shape as `Z.radioGroup`, but rows draw
no glyph — they're highlight-on-selection labels — and the wrapping
panel declares `role="listbox"` / row `role="option"` so screen
readers read it as a single-select list.
## Exports
- `selectableList(id: string, options: { any }?, selected: number?, opts: SelectableListOpts?) -> any` — build a listbox widget. The module returns this function directly.
Types:
- `SelectableListOpts = { style: { [string]: any }?, classes: (string | { string })?, rowWidth: number?, rowHeight: number?, onChange: string?, app: any?, ariaLabel: string?, label: string? }`
## Usage
```luau
local selectableList = require("@builtin::modules.zui.widget.selectableList")
selectableList("category", CATEGORIES, state.cat, {
onChange = "ui:cat:change",
})
```
## Notes
- Single-select; `selected` is a 1-based index. The first call seeds
`ui.widgetState(id, "selected")` from the caller's arg; subsequent
ticks read state back from there.
- Keyboard nav (per-row canvas `onKey`): `ArrowDown` / `ArrowRight`
next, `ArrowUp` / `ArrowLeft` previous, `Home` -> 1, `End` ->
`#options`. Tab walks the per-row canvases via egui's focus chain.
- For sidebar / asset-browser / category-picker patterns where every
option is visible at once. For long lists where only a few items fit,
wrap the result in `Z.scroll(...)`.
- `opts.onChange` is dispatched as a callback id on the surrounding
`Z.app` router with `data.value` set to the new 1-based index.
Without an app in scope, only `ui.widgetState` updates.
- Handlers are registered once per group id (deduped via
`app._selectableListHandlers`); option-count and `onChange` swaps
land without re-registering.
# chip
Compact tag/badge. Variants (`"info"`, `"success"`, `"warning"`,
`"error"`) pick a foreground/background colour pair from the active
theme. Built as a `panel + label` composition so the engine has no
dedicated arm for what is fundamentally a coloured rounded text-box.
## Exports
- `chip(text: string?, opts: ChipOpts?) -> WidgetNode` — build a chip widget node.
Types:
- `ChipVariant = "info" | "success" | "warning" | "warn" | "error" | "danger"`
- `ChipOpts = { id?, variant?, bg?, color?, fontSize?, padding?, borderRadius? }`
## Usage
```luau
local chip = require("@builtin::modules.zui.widget.chip")
chip("New", { variant = "success" })
chip("Beta", { bg = "#222", color = "#fff" })
```
## Notes
- `bg` and `color` per-call override the variant-selected colours.
- Defaults: `fontSize = 11`, `padding = { 1, 6, 1, 6 }`, `borderRadius = 4`.
- Colours pull from `Theme.default` (`success`, `warn`, `danger`, `info`, `bg_deep`).
# lsp
LSP composite widgets — pure builders that turn the engine's `lsp.*`
data shapes into widget trees. Six pieces gathered onto a single `M`
table so callers can `require` the namespace and pick out individual
builders. These are stateless: pass data in, get a widget tree out.
## Exports
- `M.severitySummary(counts, opts?) -> WidgetNode` — hbox of count chips per severity.
- `M.diagnosticsList(diags, onSelect, opts?) -> WidgetNode` — scrollable vbox of diagnostic rows.
- `M.diagnosticDetail(diag, opts?) -> WidgetNode` — detail panel for one diagnostic.
- `M.docBrowser(state, opts?) -> WidgetNode` — namespaces / methods / describe browser.
- `M.strictModeToggle(mode, onChange, opts?) -> WidgetNode` — three-button radio.
- `M.directiveBadge(skipMode, opts?) -> WidgetNode?` — chip indicating the file's `--!skip` mode, or `nil`.
Each export is the builder module's `return function(...)`; see the
sibling `*.module/README.md` files for the per-builder signatures.
## Usage
```luau
local Z = require("@builtin::modules.zui")
local lsp = require("@builtin::modules.zui.widget.lsp")
return Z.vbox({
lsp.severitySummary(counts),
lsp.diagnosticsList(diags, "lsp:row:select"),
lsp.diagnosticDetail(diags[selectedIndex], { source = source }),
})
```
## Notes
- Layer B (`lsp_ui.module`) and Layer C (system_tools' LSP tab) compose
these with their own state + polling — but you don't have to: roll
your own.
- Each entry is loaded lazily by `require`. Hot-reloading any sibling
re-imports it on next call.
- This namespace owns no state. The composite layers above this one do.
# anchor
Corners a child to a screen edge. Root-only — must be the top-level
widget of its registered screen. `anchor` is one of `TopLeft / TopCenter
/ TopRight / CenterLeft / Center / CenterRight / BottomLeft / BottomCenter
/ BottomRight`. `margin` is `{ top, right, bottom, left }`.
## Exports
This module returns the widget factory directly. There is no returned
table.
- `anchor(anchor: AnchorPos, margin: Margin, children: { Node }?, opts: AnchorOpts?) -> Node` — build an anchor widget node.
Types:
- `AnchorPos = "TopLeft" | "TopCenter" | "TopRight" | "CenterLeft" |
"Center" | "CenterRight" | "BottomLeft" | "BottomCenter" |
"BottomRight"`
- `Margin = { number }` — `{ top, right, bottom, left }`.
- `Node = { [string]: any }` — opaque widget node.
- `AnchorOpts = { id: string?, props: { [string]: any }?, style: { [string]: any }? }`
## Usage
```luau
local anchor = require("@builtin::modules.zui.widget.anchor")
local closeBtn = anchor("BottomRight", { 0, 16, 16, 0 }, {
Z.btn("Close", { onClick = "close" }),
})
ui.registerScreen("close", closeBtn)
```
## Notes
- The widget MUST be the top-level node of its registered screen — the
engine's `anchor` decoder only resolves at the screen root.
- `margin` is injected into `props.margin`; `anchor` into `props.anchor`.
`opts.props` is mutated in place — pass a fresh table if you reuse it
across calls.
- Single-child `children` (a single widget node) is normalised by `node`,
so callers don't need to wrap a single child in `{ ... }`.
# dockPanel
A single dockable panel inside a `dockArea`. Its widget `id` is the stable tab id the dockArea uses for reconciliation and for the `<id>-close` interaction fired when the tab is closed. Children form the tab's content subtree, rendered by the dockArea's TabViewer.
The module returns the widget builder function directly.
## Builder
`dockPanel(opts?) -> widget node`. `opts` fields:
- `id` (required) — the stable tab id.
- `title` — the tab label shown in the dock tab bar; defaults to the panel `id`.
- `closable` — whether the tab shows a close button (default true). On close the dockArea emits `<id>-close`.
- `float` — when a NEW panel first appears with `float = true`, the dockArea opens it as a floating, movable, resizable dock-window over the content behind instead of in the focused leaf.
- `children`, `props`, `style`.
## Usage
```luau
local dockPanel = require("modules.zui.widget.dockPanel")
local widget = dockPanel({ id = "props", title = "Properties", children = {
Z.lbl("Inspector body"),
}})
```
# bottomPanel
Bottom-docked panel. Root-only — must be the top-level widget of its
registered screen. Pairs with `centralPanel` (and optionally `topPanel` /
`leftPanel` / `rightPanel`) for a docked app shell — register each as
its own screen with appropriate layer ordering.
## Exports
This module returns the widget factory directly. There is no returned
table.
- `bottomPanel(children: { Node }?, opts: PanelOpts?) -> Node` — build a bottom-docked panel node.
Types:
- `Node = { [string]: any }` — opaque widget node.
- `PanelOpts = { id: string?, classes: ({ string } | string)?,
class: ({ string } | string)?, props: { [string]: any }?,
style: { [string]: any }? }`
## Usage
```luau
local bottomPanel = require("@builtin::modules.zui.widget.bottomPanel")
local statusBar = bottomPanel({
Z.hbox{ Z.lbl("Status: OK"), Z.flex(), Z.lbl("v0.1.0") },
}, { id = "status" })
ui.registerScreen("statusBar", statusBar)
```
## Notes
- Root-only — the widget MUST be the top-level node of its registered
screen.
- Children can be a single Node or an array; `node` normalises a single
child to `{ child }` so callers don't have to wrap manually.
- `classes` / `class` accept either an array of strings or a single
space-separated string — `node` normalises both forms.
- Pairs with the other panel widgets for a docked app shell; register
each on its own screen so layer ordering / visibility can be controlled
independently.
# iconButton
Square button rendered with the phosphor glyph font. Thin alias for
`Z.iconBtn` — re-requires the same builder so the call shapes are
identical. Pick whichever name reads better at the call site.
## Exports
- `iconButton(iconText: string, callbackId: string, opts: table?) -> WidgetNode` — module returns the `iconBtn` builder directly.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.iconButton("", "save:click", { id = "save", tooltip = "Save" })
```
## Notes
- This module is a one-line `return require("modules.zui.widget.iconBtn")`.
- See `iconBtn` for full option semantics.
- Useful when reading code: `iconButton` reads more naturally in some
call sites, `iconBtn` is shorter for inline use.
# badge
Alias for chip — kept as its own module for vocabulary clarity. Pick
whichever name reads better at the call site; both compile to the same
widget under the hood.
## Exports
This module re-exports `modules.zui.widget.chip` directly. There are no
typed functions or types declared here — the public surface is whatever
`chip` exposes.
- `badge(...) -> Node` — identical to `chip(...)`.
## Usage
```luau
local badge = require("@builtin::modules.zui.widget.badge")
local newBadge = badge("New", { variant = "info" })
```
## Notes
- This module is a thin re-export — `badge == chip` (same function
reference). Hot-reloading either reloads both.
- See `modules.zui.widget.chip` for the full argument / option surface
and type signature.
- The alias exists purely so call sites can say `badge` when that reads
better (e.g. "new feature" badge) and `chip` when that reads better
(e.g. a removable tag chip). There is no behavioural difference.
# card
Card composite — title + description + optional image placeholder +
arbitrary children inside a styled panel, built from `panel` + `label`
primitives. Pure Luau composition; the engine has no dedicated `card`
arm.
## Exports
- `card(opts: CardOpts?) -> WidgetNode` — build a card widget node.
Types:
- `CardOpts = { id?, classes?, class?, title?, titleColor?, description?, descriptionColor?, image?, children?, bg?, border?, borderWidth?, padding?, gap? }`
## Usage
```luau
local card = require("@builtin::modules.zui.widget.card")
card({
title = "Card title",
description = "muted secondary text",
image = "asset/path", -- optional 100px tall placeholder bar
children = { ... }, -- arbitrary widgets below
id = "myCard",
padding = 8,
})
```
## Notes
- Click handling is not exposed directly — wrap in a clickable wrapper (e.g. an outer `Z.btn` with empty text) if needed.
- The image slot currently renders a `panel_alt`-coloured placeholder rect; once an image-loader pipeline lands, callers can pass a real `Z.image` instead.
- Theme defaults (`panel`, `border`, `text`, `text_dim`) come from `modules.zui.theme`'s `default` table.
# panel
Bordered surface for grouping widgets. Accepts top-level shortcut
keys (`bg`, `border`, `borderWidth`, `padding`, `gap`, `minWidth`,
`minHeight`, `maxWidth`, `maxHeight`) so the common case doesn't
require nesting under `style = { ... }`.
## Exports
- `panel(children: { any }?, opts: Opts?) -> any` — build the panel widget node. Returned directly by the module.
Types:
- `Opts = { id?, classes?, props?, style?, bg?, border?, borderWidth?, padding?, gap?, minWidth?, minHeight?, maxWidth?, maxHeight?, scroll?, scrollMaxHeight?, scrollId? }`
## Usage
```luau
local panel = require("@builtin::modules.zui.widget.panel")
local widget = panel({
Z.lbl("Header"),
Z.btn("Action", "click"),
}, {
bg = "#101010",
border = "#3a3a3a",
borderWidth = 1,
padding = 8,
})
```
## Tall panels — scrolling overflow (closes #3270)
`Z.anchor("Center", ...) { Z.panel(rows) }` with more rows than the
viewport will silently clip top + bottom of the list — content scrolls
off-screen with no affordance. Opt into in-place scrolling by passing
`scroll = true` alongside a `maxHeight`:
```luau
local rows = {}
for i = 1, 100 do rows[#rows + 1] = Z.lbl("row " .. i) end
-- Header + footer pin to the panel; the rows scroll between them.
return Z.anchor("Center", {}, {
Z.panel({
Z.lbl("Codex", { bold = true }),
Z.sep(),
Z.panel(rows, { scroll = true, maxHeight = 480 }),
Z.sep(),
Z.btn("Close", "codex:close"),
}, { bg = "#101010", padding = 12 }),
})
```
Without `scroll = true`, `maxHeight` only clips — `scroll = true` wraps
the children in a `scrollArea` so overflow rows are reachable via the
scrollbar. `scrollMaxHeight` (optional) bounds only the scroll region
when you want the panel itself to size to the header + footer and let
the scroll region claim what's left.
## Notes
- Top-level shortcuts merge into `opts.style` (caller-supplied keys
take precedence — explicit `style.background` wins over `opts.bg`
only because the shortcut writes to `style.background` after `style`
is captured).
- Defers to `node("panel", ...)` so any panel-specific renderer arm
changes flow through automatically.
- Children default to `{}` — an empty panel renders as a bordered
spacer.
- `scroll = true` inserts a single `scrollArea` child wrapping the
caller's children. Pair with a `maxHeight` (or `scrollMaxHeight` to
bound only the scroll region) so the scrollArea has something to
scroll against — without a bound the scrollArea fills its parent and
no scrolling is observed.
# collapsible
Collapsible section with a header bar. Click the header to expand or
collapse the children. Controlled by Luau — the wrapper auto-stashes
open/closed state via `ui.widgetState(id, "open")` and registers a
one-time click handler with the surrounding `Z.app` so existing demos
that pass `defaultOpen = true` keep working unchanged.
## Exports
- `collapsible(header: any?, children: { any }?, opts: CollapsibleOpts?) -> WidgetNode` — build a collapsible widget node with the supplied header and children.
Types:
- `CollapsibleOpts = { id?, props?, style?, defaultOpen?, app? }`
## Usage
```luau
local collapsible = require("@builtin::modules.zui.widget.collapsible")
collapsible(label("Advanced"), {
-- collapsible children here
}, { id = "advanced-section", defaultOpen = false })
```
## Notes
- Inside `Z.app`: state is auto-managed via `ui.widgetState` + a one-time `app:on` handler keyed off `id`.
- Outside `Z.app`: falls back to a static render keyed off `defaultOpen`; the caller must drive `props.open` and react to `ValueChanged(new_open)` themselves.
- State APIs are flat top-level FFI functions — `ui.widgetState(id, key)` and `ui.widgetStateSet(id, key, value)`, **not** `ui.widgetState.set`.
- `id` is required for cross-frame state to persist. Without an id, the widget renders but the toggle won't reflect — the wrapper logs no warning, just degrades gracefully.
# dndList
Drag-and-drop reorderable list. Each child renders inside a vbox of
two stacked nodes — the original child plus an overlay canvas sized to
the row that captures drag and pointer-move events. Built on `onDrag`
+ `onPointerMove` canvas interactions; the engine has no dedicated
dnd-list arm.
## Exports
- `dndList(id: string?, children: { any }?, opts: DndListOpts?) -> WidgetNode` — build a drag-reorderable list widget node.
Types:
- `DndListOpts = { rowHeight?, rowWidth?, style?, app?, onReorder?, classes?, barColor?, dragStroke?, label? }`
## Usage
```luau
local dndList = require("@builtin::modules.zui.widget.dndList")
dndList("playlist", {
Z.lbl("Track 1"),
Z.lbl("Track 2"),
Z.lbl("Track 3"),
}, { onReorder = "playlist:reorder" })
-- Then in `onCallback`:
-- data.value = { from = 1, to = 3, value = "1,3" }
-- Caller re-renders with the children rearranged.
```
## Notes
- Reorder events fire only on drag stop (`dragStopped = true`) and only when `from ≠ to`. The payload includes both numeric `from` / `to` and a legacy `"from,to"` string that the engine test suite asserts on.
- Defaults: `rowHeight = 28`, `rowWidth = 240`, `barColor = "#78b4ff"`, `dragStroke = "#78b4ff78"`.
- Auto-registers `<id>:drag:<index>` and `<id>:hover:<index>` handlers on the surrounding `Z.app`, deduped by id. Drag state lives in `app._dndListState[id]` and persists across renders.
- Outside `Z.app` (no app in scope), the widget renders but the overlay never draws the insertion bar — drag interaction is inert.
- The outer Panel exposes `role = "list"` and `ariaLabel = opts.label or "Reorderable list"` for accessibility.
# viewport
Embedded 3D viewport rendered into the UI. `target` is a render-target
handle id (paired with the Camera component's `renderTarget` setting).
Use for in-UI minimaps, picture-in-picture, scene previewers.
## Exports
- `viewport(target, opts?) -> Node` — returns the viewport widget node. Module returns the builder function directly.
Types:
- `ViewportOpts = { id?, props?, style? }`
## Usage
```luau
local viewport = require("@builtin::modules.zui.widget.viewport")
viewport(minimapTarget, { style = { width = 200, height = 200 } })
```
## Notes
- Pair with a Camera component whose `renderTarget` matches `target` —
the widget itself doesn't choose what to render, it just displays
the target's contents.
- Sizing comes from `style.width` / `style.height` (or layout); the
target's pixel resolution is set when the render target is created.
# fader
DAW-style vertical fader built on `canvas` interaction props. Draws
track + fill + cap as `rect` commands plus a centred grip `line`, and
wires drag / click / double-click handlers via `widgetState`. Drag and
click both project the pointer Y onto the track — grabbing or
clicking anywhere snaps the cap to that Y. Double-click resets to
`defaultValue` when set.
## Exports
- `fader(id: string, value: number?, lo: number?, hi: number?, opts: FaderOpts?) -> WidgetNode` — build the fader canvas. Auto-registers `<id>:drag`, `<id>:click`, `<id>:reset` handlers (deduped). When `opts.tooltip` is set, the canvas is wrapped in a transparent panel that hosts the tooltip.
Types:
- `FaderOpts = { defaultValue: number?, onChange: string?, tooltip: string?, style: { [string]: any }?, width: number?, height: number?, trackColor: string?, capColor: string?, fillColor: string?, app: any? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.fader("master-volume", state.volume, 0, 1, {
defaultValue = 0.8,
onChange = "audio:volume",
})
app:on("audio:volume", function(v)
state.volume = v -- the new numeric value
end)
```
## Notes
- Track top = `hi`, track bottom = `lo` (egui +y is down). Pointer Y
is projected onto the track range to produce the new value, then
clamped to `[min(lo,hi), max(lo,hi)]`.
- Per-id handlers are deduped — re-renders don't accumulate listeners.
Mutating width/height between renders means the *first* render's
geometry wins (matches every other auto-stash widget).
- Double-click is a no-op when `opts.defaultValue` is nil.
# dialogueBox
Narrative dialogue panel — speaker name + dialogue text + per-option
response buttons inside a styled panel. Built as a primitive
composition (`panel + label + btn`), so the engine has no dedicated
arm.
## Exports
- `dialogueBox(opts: DialogueBoxOpts?) -> WidgetNode` — build a dialogue widget node.
Types:
- `DialogueOption = { text?, enabled?, tooltip? }`
- `DialogueBoxOpts = { id?, speaker?, dialogue?, options?, bg?, border?, borderWidth?, padding?, gap? }`
## Usage
```luau
local dialogueBox = require("@builtin::modules.zui.widget.dialogueBox")
dialogueBox({
id = "dlg1",
speaker = "Captain",
dialogue = "We've got incoming. Pick your move.",
options = {
{ text = "Engage" },
{ text = "Retreat", enabled = false },
{ text = "Hail", enabled = true },
},
})
```
## Notes
- Each option emits a click event keyed `<id>-<index>` (1-based), so a single pattern handler captures every response:
```luau
app:on("^dlg1%-(%d+)$", function(_, _, idx)
handleResponse(tonumber(idx))
end)
```
- When `opts.id` is omitted, the callback prefix falls back to `"dialogueBox"`.
- `option.enabled` defaults to `true` (the wrapper checks `option.enabled ~= false`).
- Styling defaults pull from `Theme.default` (`panel`, `border`, `text`, `text_bright`).
# richText
Read-only multi-colored text widget. Pass a flat list of
`{ text, color, italic?, monospace? }` segments and the renderer builds
a single-flow `LayoutJob` from them — no edit buffer, no cursor, no
input handling. The same segment shape is consumed by `TextInput`
when `props.segments` is set; `Z.codeEditor` builds segments via the
highlighter modules under `zui.highlight.*` and passes them to
`TextInput`.
## Exports
- `richText(segments: { RichTextSegment }?, opts: RichTextOpts?) -> any` — build a styled-text widget. The module returns this function directly.
Types:
- `RichTextSegment = { text: string, color: string?, italic: boolean?, monospace: boolean? }`
- `RichTextOpts = { id: string?, classes: (string | { string })?, style: { [string]: any }? }`
## Usage
```luau
local richText = require("@builtin::modules.zui.widget.richText")
richText({
{ text = "hello ", color = "#fff" },
{ text = "world", color = "#0E639C" },
})
```
## Notes
- Read-only: no edit buffer, no cursor, no input handling. Use
`Z.codeEditor` or `TextInput` directly when text needs to be editable.
- The renderer produces a single-flow `LayoutJob` from the segments,
so a mid-sentence color change does not break line wrapping.
# kbd
Keybind widget — a Luau builder over `hbox` + `button` (or a
focusable `canvas` while capturing). Uses canvas `onKey` and
`ui.focus(id)` to capture the next pressed key from primitives. Has
two call shapes that produce the same widget kind: a display-only
form for showing a key combo, and an interactive capture form for
rebinding.
## Exports
- `kbd(keysOrOpts: string | KbdOpts, opts: table?) -> WidgetNode` — module returns the builder function directly. The first arg is polymorphic.
Display-only call: `kbd("Ctrl+S")` or `kbd("Ctrl+S", { style = {...} })`.
Interactive call (table form):
- `id: string` — required; identifies the widget for state + handlers.
- `label: string?` — left-side label. Default `"Key"`.
- `key: string?` — current binding text. Default `"None"`.
- `onChange: string?` — callback id; fires on commit with `{ value = "F1" }`.
- `app: App?` — explicit app; falls back to `App.current()`.
- `buttonWidth`, `buttonHeight`, `gap`, `labelStyle`, `buttonStyle`, `canvasStyle`, `style`, `classes` — layout/style overrides.
## Usage
```luau
local Z = require("@builtin::modules.zui")
-- Display only:
Z.kbd("Ctrl+S")
-- Interactive:
Z.kbd({
id = "settings:bind:jump",
label = "Jump",
key = state.jumpKey,
onChange = "ui:bind:jump", -- fires with { value = "F1" }
})
```
## Notes
- Behaviour while capturing: clicking the `[<key>]` button transitions
to capturing and focuses the per-instance hidden canvas. Any key
press commits and leaves capturing. `Escape` cancels (capturing flips
off, key unchanged).
- Captured state lives in `ui.widgetState(id, "capturing"|"key")` so
the wrapper survives screen rerenders without the caller threading
it through.
- `onChange` fires only on commit, not on cancel.
- Per-id handler registration is deduped via `app._keybindHandlers`
(mirrors the `radioGroup` pattern); `onChange` swaps land on each
render without re-registering.
- Calling the table form without an `id` falls back to display-only.
# topPanel
Top-docked panel. Root-only. Pairs with `centralPanel` (and
optionally `bottomPanel`/`leftPanel`/`rightPanel`) for a docked app
shell — register each as its own screen with appropriate layer
ordering.
## Exports
- `topPanel(children, opts?) -> Node` — returns the top-panel widget node. Module returns the builder function directly.
Types:
- `TopPanelOpts = { id?, classes?, props?, style? }`
## Usage
```luau
local topPanel = require("@builtin::modules.zui.widget.topPanel")
topPanel({ menubar, breadcrumb })
```
## Notes
- Must be the top-level widget of the screen it's registered to —
nested topPanels render incorrectly.
- Pair with `centralPanel`/`bottomPanel`/`leftPanel`/`rightPanel` for
full docked-shell layouts; each lives on its own screen so their
layer ordering controls the dock.
# dataView
Filterable, multi-selectable data list bound to a named
`modules.api.editor.selection` scope. `props.mode` ("list" | "table" |
"grid", default "list") selects the rendering strategy:
- **list** — one focusable canvas row per visible item (icon + label +
badge) inside a virtualized `scrollArea`.
- **table** — the same focusable-row shape extended to multiple
columns, with a sortable header.
- **grid** — a wrapping `Z.grid` of selectable thumbnail cells, for
asset browsers.
## Exports
- `dataView(props: Props?) -> any` — build a DataView widget. The
module returns this function directly.
Types:
- `Props = { id: string?, items: { any }?, key: ((any) -> string)?, row: ((any) -> RowSpec)?, selection: Scope?, onActivate: string?, filter: string?, rowHeight: number?, rowWidth: number?, maxHeight: number?, mode: string?, app: any?, columns: { ColumnSpec }?, gridColumns: number?, commands: { string }? }`
- `RowSpec = { label: string, icon: string?, badge: string?, columns: { [string]: string }?, thumb: string? }`
- `ColumnSpec = { id: string, label: string, width: number?, align: ("left" | "right" | "center")?, sort: boolean? }`
- `Scope = { name: string }` — matches `editorSelection.Scope`.
`id`, `key`, `row`, and `selection` are required at runtime (a missing
one raises an `error`); `columns` is additionally required for table
mode. Every `Props` field is typed optional so `props or {}` at the
registration boundary stays well-typed.
## Usage
```luau
local dataView = require("@builtin::modules.zui.widget.dataView")
local Selection = require("@builtin::modules.api.editor.selection")
local scope = Selection.scope("asset")
-- list mode
dataView{
id = "assets", items = assets, selection = scope,
key = function(a) return a.guid end,
row = function(a) return { label = a.name, icon = a.icon } end,
onActivate = "assets:open",
filter = state.filterText,
}
-- table mode
dataView{
id = "assetsTable", mode = "table", items = assets, selection = scope,
key = function(a) return a.guid end,
row = function(a) return { columns = { name = a.name, type = a.assetType, size = tostring(a.size) } } end,
columns = {
{ id = "name", label = "Name", width = 160, sort = true },
{ id = "type", label = "Type", width = 100, sort = true },
{ id = "size", label = "Size", width = 80, align = "right", sort = true },
},
}
-- grid mode
dataView{
id = "assetsGrid", mode = "grid", items = assets, selection = scope,
key = function(a) return a.guid end,
row = function(a) return { label = a.name, thumb = a.path .. "/preview.png" } end,
gridColumns = 4,
}
-- right-click command menu (list/table mode)
local Commands = require("@builtin::modules.api.editor.commands")
Commands.declare({ id = "asset.rename", title = "Rename", category = "Assets", run = function(ctx) ... end })
dataView{
id = "assets", items = assets, selection = scope,
key = function(a) return a.guid end,
row = function(a) return { label = a.name } end,
commands = { "asset.rename", "-", "asset.delete" },
}
```
## Notes
- Selection is read from and written to `props.selection` in every
mode — the widget holds no selected-ids of its own, so every other
view sharing the scope always agrees with what's drawn. Only the
click anchor, keyboard focus position, and (table mode) sort
key/direction live in `ui.widgetState`, because none of them has a
home in the selection model.
- Row click resolves through `selectionModel.resolveClick` (plain /
ctrl / shift semantics) using positions in the CURRENT filtered/
sorted view, not indices into `props.items` — a shift-range always
spans what's visually between the anchor and the click.
- The canvas `onClick` payload carries only the cursor position and
button, never modifier keys, so ctrl/shift state is read from
`input.isDown("ControlLeft" | "ControlRight" | "ShiftLeft" | "ShiftRight")`
at the moment of the click — the same substrate `modules.zinput.rebind`
polls for modifier-aware interactions.
- Keyboard nav (list/table row canvas `onKey`): `ArrowDown` / `ArrowUp`
move focus and single-select the new row, `Home` / `End` jump to the
first/last visible row, `Enter` dispatches `props.onActivate` for the
focused row. Double-clicking a row also dispatches `props.onActivate`.
Grid cells are panels (generic `onClick` only) — they select but
don't carry keyboard nav or double-click.
- Table mode's column header cells dispatch a `<id>:sort:<colId>`
click for any column with `sort = true`, cycling
none/other-column -> ascending -> descending -> none. Sort compares
`row(item).columns[col.id]` (the same text shown in the cell), so a
numeric-looking column sorts lexicographically unless the cell text
is itself a plain unpadded number.
- Every scrollable body (`virtual = true` list/table rows, the grid's
wrapping `Z.grid`) is wrapped with BOTH `maxHeight` and `minHeight`
set to the same value. Without `minHeight`, nesting the scrollArea
under a header (table mode) or wrapping `Z.grid` directly (grid mode)
makes its column an indefinite-height ancestor, and the viewport
collapses to a sliver well under the intended bound.
- Grid cells set `width` / `height` (not `minWidth` / `minHeight`) on
the panel — `Z.grid`'s content-driven column sizing reads each
child's `width` style to pick a cell size, and a `minWidth`-only cell
measures as 0 there.
- An empty filtered view renders a single muted "No items" label
instead of an empty scroll area (table mode keeps the header above
it).
- Handlers (click, double-click, keyboard nav, column sort) are
registered once per widget id (deduped via `app._dataViewHandlers`);
item list, filter, column and callback swaps land without
re-registering, mirroring `selectableList`'s handler dedup.
- `props.commands` (list/table mode only — see below) adds a right-
click command menu, driven by `modules.api.editor.commands`. Each
entry is a command id (`Commands.get(id).title` labels it,
`Commands.isEnabled(id, ctx)` gates it); a `"-"` entry is a
separator. Right-clicking a row selects it alone first if it wasn't
already part of the selection, then opens the menu anchored to that
row. `ctx` passed to `enabledWhen`/`run` is `Selection.context()`
(this DataView's selection scope, since selecting the row focuses
it) plus `view` (this widget's id) and `item` (the row's underlying
item). Clicking an entry runs the command and closes the menu;
clicking elsewhere or Escape also closes it (`Z.popup` auto-dismiss).
- Grid mode does not support `props.commands` — its cells are `Z.panel`s
(generic `onClick` only), and `onPointerDown` (right-click sensing)
is decoded by the Canvas widget type alone.
# sides
Two-slot horizontal row that anchors the first child left, the second
child right, and stretches a flexible gap between them. Designed for
window title bars, toolbars, status footers — any "title left,
controls right" pattern.
## Exports
- `sides(opts: SidesOpts?) -> any` — build a two-slot left/right row. The module returns this function directly.
Types:
- `SidesOpts = { left: any?, right: any?, [number]: any, id: string?, style: { [string]: any }? }`
## Usage
```luau
local sides = require("@builtin::modules.zui.widget.sides")
-- Positional shape
sides({ leftWidget, rightWidget })
-- Named-slot shape
sides({
left = Z.hbox({ Z.lbl("◆"), Z.lbl("zero") }),
right = Z.hbox({ Z.iconBtn("─"), Z.iconBtn("□"), Z.iconBtn("✕") }),
})
```
## Notes
- When more than two widgets are needed on either side, wrap them in a
layout container first (`Z.hbox` / `Z.vbox`).
- Implemented as an `hbox` containing `[left, flex(), right]`; the
flex-grow spacer makes the right slot hug the row's right edge.
- Children render in source order on both sides.
# iconBtn
Button rendered with the phosphor glyph font. Convenience wrapper over
`Z.btn` for the common case of an icon-only button — defaults the
`fontFamily` to `phosphor` so callers don't repeat it on every click
target.
## Exports
- `iconBtn(iconText: string, callbackId: string, opts: table?) -> WidgetNode` — module returns the builder function directly.
`opts` is forwarded to `Z.btn`; this wrapper only injects
`opts.style.fontFamily = "phosphor"` when none is set.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.iconBtn("", "save:click", { id = "save", tooltip = "Save" })
```
## Notes
- Equivalent to `Z.iconButton` (which simply re-requires this module).
- The wrapper mutates the supplied `opts` table in place. If you reuse
the same `opts` across calls, expect `style.fontFamily` to persist.
- Any explicit `opts.style.fontFamily` wins; the default only fires
when the field is missing.
# hotbar
Game-HUD hotbar. Composes an hbox of per-slot buttons with overlaid
count + slot-index labels, highlighting the slot indicated by
`selectedSlot`. Clicking a slot fires the callback
`<opts.id or "hotbar">-<index>`. The caller is the source of truth
for the selection (controlled-component pattern).
## Exports
- `hotbar(slots: {Slot}, selectedSlot: number?, opts: HotbarOpts?) -> WidgetNode` — module returns the builder function directly.
Per-slot shape:
- `Slot = { icon: string?, count: number?, tooltip: string? }`
Options:
- `id: string?` — callback prefix. Default `"hotbar"`.
- `style: table?` — hbox style. Default `{ gap = 4 }`.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.hotbar(state.slots, state.selectedSlot, { id = "hotbar" })
-- Pattern-match the per-slot callbacks:
app:on("^hotbar%-(%d+)$", function(_, _, idx)
state.selectedSlot = tonumber(idx)
end)
```
## Notes
- Slot indices start at 1.
- Empty `slots` (or `nil`) renders an empty hbox.
- The widget is stateless — the parent app owns selection state.
- Each button's `id` is `<prefix>-slot-<i>`; the click callback is
`<prefix>-<i>` (note: different separator pattern by design).
# sep
Horizontal separator — a Luau builder over `canvas` with `fillWidth`
+ `"N%"` coords. Emits a single `kind = "line"` command spanning
`[0, mid]` -> `["100%", mid]`. `fillWidth = true` makes the canvas
claim the parent's full available width. 4 px of vertical breathing
room is baked in (thickness + 4) so the line has a clear top/bottom
gap.
## Exports
- `sep(opts: SepOpts?) -> any` — build a horizontal-line separator widget. The module returns this function directly.
Types:
- `SepOpts = { id: string?, classes: (string | { string })?, style: { [string]: any }? }`
## Usage
```luau
local sep = require("@builtin::modules.zui.widget.sep")
sep()
sep({ style = { color = "#0E639C", height = 2 } })
```
## Notes
- Style overrides: `style.height` (default 1.0) sets line thickness in
pixels, `style.color` (default `#3C3C3C`) sets line color.
- Pure builder — no side effects, no state.
# scene
Pan/zoom 2D viewport. Wraps `egui::containers::Scene` (added 0.34).
Children render inside a transformed coordinate space — mouse-wheel
zooms, primary-drag pans (right-click stays free for context menus).
Transform state persists in egui's per-id temp data, so pan/zoom
carries across frames without caller plumbing.
## Exports
- `scene(opts: SceneOpts?) -> any` — build a pan/zoom 2D viewport widget. The module returns this function directly.
Types:
- `SceneOpts = { id: string?, props: { [string]: any }?, style: { [string]: any }?, children: { any }?, zoomRange: { number }?, initialPan: { x: number, y: number }?, initialZoom: number?, onTransformChange: string?, focusable: boolean? }`
## Usage
```luau
local scene = require("@builtin::modules.zui.widget.scene")
scene({
zoomRange = { 0.25, 4.0 },
onTransformChange = "graph:moved",
children = {
Z.canvas({ commands = drawNodes(state) }),
Z.area({ id = "node-1", pos = nodePos[1] }, { ... }),
},
})
```
## Notes
- `zoomRange` defaults to `[0.25, 4.0]` (overriding egui's default of
`0.0..=1.0` so wheel-zoom works in both directions).
- `initialPan` and `initialZoom` only apply on the first frame; once
the user pans or zooms the persisted rect takes over.
- `onTransformChange` payload is a comma-separated string
`"panX,panY,zoom"` — UiValue is scalar-only. Parse with
`string.split(value, ",")`. Falls back to `<id>-transform` when not
set.
- Pan is primary-button only — right-click stays free for
`Z.contextMenu` handlers on inner widgets.
# dragValue
DragValue is a Luau builder over `canvas`. Emits a drag-to-change
numeric scrubber: background rect + centered numeric readout + an
`onDrag` handler that accumulates the per-frame delta scaled by
`opts.speed`. Range clamping + Shift-fine-drag (10× finer) ride on top.
Auto-registers `<id>:drag` per id (deduped) and dispatches
`opts.onChange` (or the widget id) with `{ value = newNumber }` on
every drag tick.
## Exports
- `dragValue(id: string, value: number?, lo: number?, hi: number?, opts: DragValueOpts?) -> WidgetNode` — build the scrubber canvas. Default range is `[0, 1]`; default speed is `0.01`; default format is `%.2f`.
Types:
- `DragValueOpts = { style: DragValueStyle?, width: number?, height: number?, speed: number?, format: string?, onChange: string?, label: string?, classes: (string | { string })?, app: any? }`
- `DragValueStyle = { width: number?, height: number?, background: string?, color: string?, borderColor: string?, borderRadius: number?, fontSize: number?, [string]: any }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.dragValue("plug:gain", state.gain, -24, 24, {
speed = 0.1,
format = "%.1f",
onChange = "plug:gain", -- defaults to widget id
})
```
## Notes
- Live value lives in `ui.widgetState(id, "value")`; the first render
seeds it from the caller's `value` arg.
- Inside a `Z.app` context the drag handler is auto-registered and
deduped per id. Outside, the canvas's `onDrag` is routed directly to
the user's callback id (legacy raw-onCallback mode) — the handler
receives the structured drag payload rather than a scalar.
- Inline text-edit on double-click is deferred. If a use case needs
typing, swap to `Z.input` via widgetState.
# input
Text input widget. Single-line by default; pass `multiline = true`
for a textarea. The code-editor variant (syntax-highlight, line
numbers, folding) lives in `zui.widget.codeEditor` — same underlying
widget kind, more defaults, plus the Luau highlighter producing
`segments`.
## Exports
- `input(id: string, value: string?, opts: InputOpts?) -> WidgetNode` — module returns the builder function directly.
Options forwarded to the node's `props`:
- `placeholder: string?`
- `onChange: string?` — callback id
- `submitOnEnter: boolean?`
- `multiline: boolean?`
- `password: boolean?`
- `codeEditor: boolean?`
- `segments: { Segment }?` — pre-highlighted layout segments
- `foldLanguage: string?` — opt into the Rust fold detector (`"lua"` etc.)
- `lineNumbers: boolean?`, `folding: boolean?`
- `props`, `classes`, `style` — standard widget plumbing.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.input("name", state.name, {
placeholder = "Your name",
onChange = "name:edit",
})
-- Multiline:
Z.input("notes", state.notes, { multiline = true })
```
## Notes
- When `props.segments` is supplied, the renderer builds the LayoutJob
from segments instead of the plain `text` string — but `text` is
still set, so the underlying value continues to work for callbacks.
- For syntax-highlighted code editing, prefer `Z.codeEditor`, which
bundles `foldLanguage`, `lineNumbers`, `folding`, and a default
highlighter call.
- Stateless — the parent owns the buffer; this widget only renders.
# area
Free-positioned area. Root-only — must be the top-level widget of its
registered screen. The `pos` field is at the widget root level (not
inside `props`) because that's what the engine's `area` decoder expects.
Pass `movable = true` to let the user drag the area; the dragged
position is held in egui's persistent memory.
## Exports
This module returns the widget factory directly. There is no returned
table.
- `area(children: { Node }?, opts: AreaOpts?) -> Node` — build a free-positioned area node.
Types:
- `Pos = { number }` — `{ x, y }` in screen pixels.
- `Pivot = string` — engine-defined anchor name (`"center"`, `"topleft"`, ...).
- `Node = { [string]: any }` — opaque widget node.
- `AreaOpts = { id: string?, pos: Pos?, pivot: Pivot?, movable: boolean?,
interactable: boolean?, style: { [string]: any }? }`
## Usage
```luau
local area = require("@builtin::modules.zui.widget.area")
local hud = area({
Z.btn("Drag me"),
}, {
pos = { 100, 100 },
movable = true,
interactable = true,
})
ui.registerScreen("draggable", hud)
```
## Notes
- The widget MUST be the top-level node of its registered screen.
- `pos`, `pivot`, `movable`, `interactable`, `style` are written at the
widget root level (NOT inside `props`) — the engine's `area` decoder
reads them from there.
- Lua-side read-back of dragged position for movable areas is a planned
Rust extension; today the dragged position is held in egui's persistent
memory and is not exposed back to Luau.
- Each `nil` field is omitted from the resulting node so the engine sees
only the fields the caller explicitly set.
# colorSwatch
Click-only coloured swatch — a small (default 24×24) coloured rect
with a white border. Used as a click target for material chips,
palette swatches, or any visual indicator that doubles as a click
target. Implemented as an empty-text `button` so the engine has no
dedicated arm.
## Exports
- `colorSwatch(color: any?, callbackId: any?, opts: ColorSwatchOpts?) -> WidgetNode` — build a coloured-swatch widget node.
Types:
- `ColorSwatchOpts = { id?, size?, style?, border?, borderWidth?, tooltip? }`
## Usage
```luau
local colorSwatch = require("@builtin::modules.zui.widget.colorSwatch")
colorSwatch("#ff8855", "swatch:click", { id = "row5" })
colorSwatch("#5588ff", "pick", { size = 32, tooltip = "Pick blue" })
```
## Notes
- Defaults: `size = 24`, `border = "#ffffff"`, `borderWidth = 1`, `borderRadius = 4`, `padding = 0`.
- Click handling reuses the button widget — same callback-id semantics, same closure auto-wiring inside `Z.app`.
- Per-swatch routing pattern: encode the swatch identity into the callback id (e.g. `"swatch-" .. hex`) and match with `app:on("^swatch%-(.+)$", ...)`.
# codeEditor
Code-editing variant of `input`. Defaults `multiline = true`,
`codeEditor = true`, `language = "lua"`, `lineNumbers = true`. The
`language` opt dispatches to a Luau highlighter from `zui.highlight.*`;
the resulting segments are passed to TextInput as `props.segments` so
the renderer builds a coloured LayoutJob without a Rust-side
highlighter call.
## Exports
- `codeEditor(id: string?, value: any?, opts: CodeEditorOpts?) -> WidgetNode` — build a code-editor widget node.
Types:
- `CodeEditorOpts = { multiline?, codeEditor?, lineNumbers?, folding?, language?, foldLanguage?, segments?, ... }` — accepts any opts the underlying `input` widget supports.
## Usage
```luau
local codeEditor = require("@builtin::modules.zui.widget.codeEditor")
codeEditor("editor1", "local x = 1\n", { language = "lua" })
codeEditor("editor1", code, { language = "lua", folding = true })
```
## Notes
- Folding is opt-in via `folding = true`. When combined with `language = "lua"`, the wrapper sets `foldLanguage = "lua"` so the gutter UI runs the Rust-side fold detector on the editor's live text. Other languages can't fold (no detector implemented yet).
- `opts.language` is consumed by the wrapper and never reaches `input.module`; the highlighter output lands in `opts.segments` instead.
- The engine's built-in highlighters have been deleted — language→segments mapping lives entirely in Luau.
# tabs
Tab strip — a horizontal row of buttons with one highlighted as
active. Each tab emits the callback id `<onChange>-<key>` so a single
pattern handler can capture every click and update the active tab in
state. Under a `Z.app` context the strip also intercepts arrow-key
navigation (with wrap-around, skipping disabled tabs) and dispatches
the same event a click would.
## Exports
- `tabs(items, activeKey, onChange, opts?) -> Node` — returns the tab-strip widget node. Module returns the builder function directly.
Types:
- `TabItem = { key: string, label?: string, icon?: string, enabled?: boolean }`
- `TabsOpts = { id?, app?, padding?, gap?, minTabWidth?, containerPadding?, style? }`
## Usage
```luau
local tabs = require("@builtin::modules.zui.widget.tabs")
local items = {
{ key = "entities", label = "Entities" },
{ key = "logs", label = "Logs" },
}
tabs(items, state.activeTab, "main-tabs")
app:on("^main%-tabs%-(.+)$", function(_v, _id, key)
state.activeTab = key
end)
```
## Notes
- The wrapper emits `{ type = "tabs", ... }`; the
`Z.defineWidget("tabs", ...)` registration in `zui.module/init.luau`
decodes that into the primitive `hbox` + `btn` tree the Rust
renderer sees.
- `activeKey` and `items` are mirrored into `widgetState` so the
per-strip key handler reads the live values at event time — closures
never go stale across re-renders.
- Tab itself stays the focus-traversal key (egui's built-in cycle);
only arrow keys are intercepted by the strip.
# label
Label widget — read-only text. Accepts top-level shortcuts for the
most commonly styled fields (`color`, `fontSize`, `bold`, `bg`,
`padding`, `minWidth`, `font`) so callers don't have to nest
everything under `style = { ... }`.
## Exports
- `label(text: any, opts: LabelOpts?) -> WidgetNode` — module returns the builder function directly. Also exposed as `Z.lbl`.
Options (top-level shortcuts mapped onto `style`):
- `color: string?`
- `fontSize: number?`
- `fontWeight: string?`
- `bold: boolean?` — sets `fontWeight = "bold"` when true.
- `bg: string?` — sets `style.background`.
- `padding: any?`
- `minWidth: number?`
- `font: string?` — sets `style.fontFamily`.
- `id`, `classes`, `class`, `style` — standard widget plumbing.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.lbl("Hello")
Z.lbl("dim text", { color = "#888", bold = true })
Z.lbl(42, { fontSize = 18, font = "monospace" })
```
## Notes
- `text` is coerced to a string via `tostring(text or "")`. Passing `nil`
yields the empty string.
- The shortcut props overwrite any conflicting field on `style`.
- Stateless — no module state, no engine calls.
# tree
Recursive expandable tree. Each node renders as a clickable row
(chevron + optional icon + label, indented by `depth * indentPx`, with
optional right-side action buttons). Clicking the chevron emits
`<onToggle>-<key>`; clicking the label emits `<onSelect>-<key>`. The
tree itself is stateless — toggle/select state lives in the caller's
state.
## Exports
- `tree(nodes, opts?) -> Node` — render a list of root nodes into a vbox of rows. Reached via the module's `__call` metamethod.
- `tree.fromFlat: (items, opts?) -> (roots, nodeById)` — re-exports the `fromFlat` sub-module that builds recursive nodes from a flat parent-pointer list.
Node shape:
- `{ key, label, children?, expandable?, expanded?, icon?, actions?, payload? }`
## Usage
```luau
local Z = { tree = require("@builtin::modules.zui.widget.tree") }
local function buildTree()
return Z.tree(state.nodes, {
onSelect = "ent-select",
onToggle = "ent-toggle",
selectedKey = state.selectedId,
})
end
app:on("^ent%-select%-(.+)$", function(_v, _id, key)
state.selectedId = key
end)
app:on("^ent%-toggle%-(.+)$", function(_v, _id, key)
state.expanded[key] = not state.expanded[key]
end)
```
## Notes
- The module returns a callable table — `tree(...)` works as a
function, and `tree.fromFlat(...)` reaches the sub-module. This is a
dynamic-dispatch shape; the `typed function` directive is skipped
here per the module-conversion spec rule 7.
- A row gets a chevron when it has children OR when `expandable ==
true` (lazy-load: the caller fetches children on the toggle event).
- `actions` callback ids are emitted verbatim — no key suffixing — so
a row-level action ("× delete") reads as the same callback whether
invoked from the tree, a context menu, or a toolbar.
- Hard depth cap (32) protects against accidentally cyclic node data.
# graph
Sparkline-class chart built on `canvas`. Bar mode emits N rect
commands; line mode emits a single polyline. Y axis auto-ranges from
the data unless the caller passes `minValue` / `maxValue`. Optional
gridlines + axis labels via the shared `_axes` helper; optional
on-hover tooltip pinned to the nearest data point. Plot has the more
featureful chart — graph is the cheap visual.
## Exports
- `graph(data: { number }?, opts: GraphOpts?) -> WidgetNode` — build the chart canvas. Empty data arrays render just the background; 1-element arrays render the background (the polyline path needs 2+ points).
Types:
- `GraphOpts = { id: string?, classes: (string | { string })?, style: { [string]: any }?, width: number?, height: number?, bg: string?, background: string?, color: string?, graphType: string?, minValue: number?, maxValue: number?, showAxes: boolean?, gridlines: boolean?, tooltip: boolean?, gridColor: string?, axisColor: string?, labelColor: string?, labelSize: number?, xLabels: boolean?, yLabels: boolean?, xTickFormat: any?, yTickFormat: any?, label: string?, app: any? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.graph({ 1, 4, 9, 16, 25, 36 }) -- line, auto-range
Z.graph(samples, { graphType = "bar", color = "#7AA8FF" })
Z.graph(samples, { minValue = 0, maxValue = 1 })
Z.graph(samples, { id = "fps", tooltip = true }) -- hover tooltip
Z.graph(samples, { showAxes = true, gridlines = true })
```
## Notes
- `tooltip` requires `opts.id` — the hover handler stashes the active
index in `ui.widgetState(id, "hover")`. The tooltip persists until
the next hover; the engine doesn't emit pointer-leave for canvases.
- When `showAxes` is set, the plot body shrinks to reserve room for
tick labels. With axes off, the polyline spans the full canvas
height (preserved by the `respects explicit minValue/maxValue range`
test).
- DOM mirror exposes the resulting canvas as
`<canvas role="img" aria-label="Chart">` via the generic prop
pass-through.
# window
Floating window. Root-only — must be the top-level widget of its
registered screen. Pass `movable`, `resizable`, `closable`, `pos`
(default position, only honored on first frame), and `onClose` to wire
the close button.
## Exports
- `window(title, children, opts?) -> Node` — returns the window widget node. Module returns the builder function directly.
Types:
- `WindowOpts = { id?, movable?, resizable?, closable?, pos?, onClose?, props?, style? }`
## Usage
```luau
local window = require("@builtin::modules.zui.widget.window")
window("Inspector", { body }, {
movable = true,
closable = true,
onClose = "inspector:close",
})
```
## Notes
- Signature is `window(title, children, opts)`; the older
`window(children, opts)` shape now warns loudly via `log.warn`
instead of falling back silently to the default `"Window"` header
(closes #2342).
- The decoder also accepts the raw form
`{ type = "window", title = "...", children = {...} }`; `title` is
promoted into `props.title` automatically.
- `pos` is only honored on first frame — subsequent renders preserve
the user's drag position so callers never fight egui for window
placement.
# spacer
Fixed-size gap widget. Default 4 px. Use `Z.flex()` for a flex-grow
spacer that pushes siblings apart.
## Exports
- `spacer(n: number?) -> any` — build a fixed-size spacer widget. The module returns this function directly.
## Usage
```luau
local spacer = require("@builtin::modules.zui.widget.spacer")
spacer() -- 4 px gap
spacer(12) -- 12 px gap
```
## Notes
- Pure builder — no side effects, no state. Returns a widget table the
zui renderer understands directly.
- Use `Z.flex()` when you need a spacer that grows to fill remaining
space rather than a fixed size.
# hbox
Horizontal layout container. Children laid out left-to-right; spacing
via `style.gap`; alignment via `style.align` (`"start"` | `"center"` |
`"end"`).
## Exports
- `hbox(children: { WidgetNode }?, opts: HboxOpts?) -> WidgetNode` — build a horizontal-layout container.
Types:
- `HboxOpts = { id: string?, classes: (string | { string })?, props: { [string]: any }?, style: { [string]: any }? }`
- `WidgetNode = { [string]: any }` — widget table (interoperable with hand-written widget trees).
## Usage
```luau
local Z = require("@builtin::modules.zui")
local row = Z.hbox({ Z.lbl("a"), Z.lbl("b") }, {
style = { gap = 6, align = "center" },
})
```
## Notes
- Pure builder over `modules.zui.widget.node` — no state, no engine
calls. Safe at module load.
- `children` defaults to `{}` when nil so an empty hbox is a no-op.
- `style.gap` is the inter-child spacing in pixels; `style.align`
controls cross-axis alignment.
# icon
Icon glyph rendered via a glyph font (default `phosphor`). Effectively
a `label` with the `fontFamily` fixed to the icon font, kept as its
own widget so callers don't have to remember which fontFamily the
icons live under.
## Exports
- `icon(text: string, opts: IconOpts?) -> WidgetNode` — module returns the builder function directly.
Options:
- `font: string?` — override the font family. Default `"phosphor"`.
- `size: number?` — sets `style.fontSize`.
- `color: string?` — sets `style.color`.
- `padding: any?` — sets `style.padding`.
- `id: string?`, `style: table?` — standard widget plumbing.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.icon("", { size = 16, color = "#dcdcdc" })
Z.icon("", { font = "fontawesome" })
```
## Notes
- Stateless — returns a fresh widget table each call.
- `text` is whatever the icon font renders for the codepoint you pass.
- Top-level shortcuts (`size`, `color`, `padding`, `font`) overwrite any
conflicting field on `style`.
# plot
Plot is a Luau builder over `canvas` interaction props (`onDrag`,
`onScroll`, `onPointerMove`). Rebuilds the pan/zoom/legend/crosshair
UX from canvas primitives so there is no `egui_plot` dependency. The
module returns a callable table — `Z.plot(id, spec, opts)` builds the
widget, while `.line` / `.points` / `.heatmap` / `.boxPlot` /
`.bezierLine` are element helpers that tag specs with the right `type`.
## Exports
- `Plot.line(spec: LineSpec) -> LineSpec` — tag a spec with `type = "line"`.
- `Plot.points(spec: PointsSpec) -> PointsSpec` — tag a spec with `type = "points"`.
- `Plot.heatmap(spec: HeatmapSpec) -> HeatmapSpec` — tag a spec with `type = "heatmap"`.
- `Plot.boxPlot(spec: BoxPlotSpec) -> BoxPlotSpec` — tag a spec with `type = "boxPlot"`.
- `Plot.bezierLine(p0, p1, p2, p3, samples?, opts?) -> LineSpec` — sample a cubic bezier into a `LineSpec`.
- `__call(id, plotSpec, opts) -> any` — calling the table builds the plot widget itself.
Types:
- `Point = { number }` (2-element)
- `LineSpec = { type?, id?, name?, color?, points?, y?, width?, gradient?, fillY?, fillColor?, lineStyle? }`
- `PointsSpec = { type?, id?, name?, color?, points?, shape?, radius?, filled? }`
- `HeatmapSpec = { type?, id?, name?, values?, cols?, palette?, showLabels? }`
- `BoxPlotSpec = { type?, id?, name?, boxes?, horizontal? }`
- `PlotOptions = { showAxes?, showGrid?, showCrosshair?, invertX?, invertY?, legend?, showCoordinates?, coordinatesCorner?, allowZoom?, allowDrag?, allowScroll?, linkGroup?, linkAxis?, linkCursor?, xTickFormat?, yTickFormat? }`
- `PlotSpec = { options?: PlotOptions, elements?: { any } }`
- `PlotOpts = { width?, height?, style?, background?, classes?, label?, app? }`
## Usage
```luau
local Plot = require("@builtin::modules.zui.widget.plot")
local widget = Plot("chart-1", {
options = { showGrid = true, legend = { show = true } },
elements = {
Plot.line({ points = { { 0, 0 }, { 1, 0.5 }, { 2, 0.2 } }, name = "A" }),
Plot.points({ points = { { 1, 0.5 } }, radius = 6 }),
},
}, { width = 480, height = 240 })
```
## Notes
- Pan/zoom state lives in `ui.widgetState(id, ...)`; the same `id`
across renders preserves the viewport.
- `linkGroup` syncs pan/zoom across multiple plots — set `linkAxis`
/ `linkCursor` per axis to opt each plot in.
- Shift+Drag triggers boxed-zoom marquee selection; non-shift drag pans.
- `xTickFormat` / `yTickFormat` accept `function(v: number) -> string`
to override the default D3 nice-tick labelling.
- Heatmap `cols` defines column count — rows are inferred from
`#values / cols`.
# rightPanel
Right-docked panel. Root-only — must be the top-level widget of its
registered screen.
## Exports
- `rightPanel(children: { any }?, opts: RightPanelOpts?) -> any` — build a right-docked panel widget. The module returns this function directly.
Types:
- `RightPanelOpts = { id: string?, classes: (string | { string })?, class: (string | { string })?, props: { [string]: any }?, style: { [string]: any }? }`
## Usage
```luau
local rightPanel = require("@builtin::modules.zui.widget.rightPanel")
rightPanel({ child1, child2 })
```
## Notes
- Root-only: must be the top-level widget of its registered screen.
Nesting a `rightPanel` inside another container is unsupported.
# progressBar
Horizontal progress bar — a Luau builder over `canvas` with
`fillWidth` + `"N%"` coords. Emits two stacked `kind = "rect"`
commands: a full-width track plus a percent-of-width fill clipped to
the value. `value` is clamped to `[0, 1]` so out-of-range inputs don't
paint outside the canvas.
## Exports
- `progressBar(value: number?, opts: Opts?) -> any` — build the canvas widget node. Returned directly by the module.
Types:
- `Style = { height?: number, color?: string, borderRadius?: number }`
- `Opts = { id?: string, classes?: any, color?: string, trackColor?: string, style?: Style }`
## Usage
```luau
local progressBar = require("@builtin::modules.zui.widget.progressBar")
local widget1 = progressBar(0.42)
local widget2 = progressBar(value, { color = "#7BC97B", trackColor = "#222" })
```
## Notes
- A11y: the canvas declares `role = "progressbar"` and ARIA value
attrs (`ariaValueNow`, `ariaValueMin = 0`, `ariaValueMax = 1`) so
the DOM mirror exposes the same shape as a native `<progress>`.
- `style.borderRadius` defaults to `height / 2` for a pill silhouette.
Override to 0 for a sharp-cornered look.
- The fill rect's `max.x` is a `"N%"` string — width resolves at
paint time via `fillWidth = true`, so the builder doesn't need to
know the canvas's pixel width at construction time.
# dropdown
Combobox-style dropdown — Luau builder over `Z.btn` + `Z.popup` +
`Z.selectableList`. Click-to-open, click-to-select, click-outside /
Escape closes. Full keyboard navigation: the inner SelectableList
handles ArrowUp/Down/Home/End + Enter/Esc, and the trigger button
accepts ArrowDown / Enter / Space to open via generalised `onKey`.
## Exports
- `dropdown(id: string, options: { string }?, selected: number?, opts: DropdownOpts?) -> WidgetNode` — build the trigger + popup. `selected` is the 1-based index of the active option; first call seeds `ui.widgetState(id, "selected")`.
Types:
- `DropdownOpts = { style: { [string]: any }?, classes: (string | { string })?, onChange: string?, pivot: string?, rowWidth: number?, rowHeight: number?, ariaLabel: string?, app: any? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.dropdown("env", { "Dev", "Stage", "Prod" }, state.env, {
onChange = "env:change",
})
app:on("env:change", function(v)
state.env = v -- v is the new 1-based index
end)
```
## Notes
- ARIA roles: trigger is `role="combobox"` / `aria-expanded`; popup
hosts a listbox. Focus moves to the row matching the current
selection on open, and returns to the trigger on close.
- Per-id handlers (`<id>-trigger`, `<id>:triggerKey`, `<id>:commit`,
`<id>:dismiss`) are registered once and deduped — re-renders don't
accumulate handlers.
- `selected` is clamped to `[1, #options]` so out-of-range inputs
don't crash the popup.
# toggle
iOS-style on/off switch — a Luau builder over `canvas`. Draws a
rounded pill background, a white circle knob sliding along its axis,
and an optional text label next to it. Operable by mouse (click) and
keyboard (Tab to focus, Enter/Space to flip).
## Exports
- `toggle(label, id, checked, opts?) -> Node` — returns the toggle widget node. Module returns the builder function directly.
Types:
- `ToggleOpts = { style?, width?, height?, onColor?, offColor?, knobColor?, onChange?, classes?, ariaLabel?, app? }`
## Usage
```luau
local toggle = require("@builtin::modules.zui.widget.toggle")
toggle("Lights on", "lights", state.lights, { onChange = "lights:toggle" })
-- Without onChange, the callback id defaults to the widget id:
toggle("Bypass", "fx:bypass", state.bypass)
```
## Notes
- Under `Z.app` the internal `<id>:click` handler reads
`widgetState(id, "checked")`, flips it, and dispatches `onChange` (or
the widget id) with `{ value = newBool }`. The Router unpacks
`data.value` so `function(v) end` receives the new bool.
- Without `Z.app` the canvas's `onClick` routes directly to the
caller's callback id and the legacy `onCallback(id, _)` pattern
(caller flips its own local state) keeps working.
- DOM mirror surfaces as `<canvas role="switch" aria-checked="…">` for
screen-reader support.
# button
Clickable button widget. Emits a callback id (string) routed by the
engine to `component.onCallback`, or wires up a closure when called
inside a `Z.app` builder. Accepts top-level style shortcuts (bg,
color, fontSize, bold, padding, border, borderWidth, minWidth,
minHeight, tooltip, enabled).
## Exports
- `button(text: string?, callbackId: any?, opts: ButtonOpts?) -> WidgetNode` — build a button widget node. The 2nd arg may be a string callback id, a closure (auto-wired in `Z.app`), or an options table when no 3rd argument is provided.
Types:
- `ButtonOpts = { id?, classes?, class?, style?, onClick?, bg?, color?, fontSize?, bold?, padding?, border?, borderWidth?, minWidth?, minHeight?, tooltip?, enabled?, paint? }`
## Usage
```luau
local btn = require("@builtin::modules.zui.widget.button")
btn("Save", "save:click") -- string callback id
btn("Save", function() print("clicked") end) -- closure (inside Z.app)
btn("Save", { onClick = "save:click", bg = "#222" }) -- JS-style opts shape
```
## Notes
- Passing a closure outside a `Z.app` builder errors at builder time — closures can't cross the Luau↔Rust FFI boundary, so they only auto-wire inside an app router.
- The 2-arg `Z.btn(text, opts)` shape is supported: if slot 2 is a table and slot 3 is missing, the callback is pulled from `opts.onClick`. Guards against the silent-dead-button bug tracked by #3059.
- See `docs/guides/zui-cheatsheet.md` for the three callback patterns.
# modal
Top-layer modal dialog with backdrop dim, click-outside + Escape
dismissal. Wraps `egui::Modal` (added 0.30, refined through 0.34).
Root-only — the render arm dispatches at the screen-root path, like
Window. Pass `dismissible = false` to force a button-only answer.
## Exports
- `modal(opts: Opts?) -> any` — build the modal widget node. Returned directly by the module.
Types:
- `Opts = { id?, title?, open?, onClose?, dismissible?, focusable?, tooltip?, children?, props?, style? }`
## Usage
```luau
local modal = require("@builtin::modules.zui.widget.modal")
local widget = modal({
title = "Confirm",
open = state.dialogOpen,
onClose = "dialog:close",
children = {
Z.lbl("Are you sure?"),
Z.hbox({
Z.btn("Yes", "dialog:yes"),
Z.btn("No", "dialog:no"),
}),
},
})
```
## Notes
- Known limitation (pending Wave 5 / #2416): Tab navigation isn't
trapped inside the modal — keyboard focus can still reach widgets
behind the backdrop. The visible "this is modal" contract is
otherwise complete.
- Must render at the screen root for the backdrop to cover the whole
surface; nesting inside a `panel` will clip the dim layer.
- `dismissible = false` is the right choice for confirm dialogs whose
answer affects irreversible state.
# dockArea
A docking container backed by egui_dock. Holds a persistent `DockState` keyed by the widget id, so split/tab arrangements and user drags survive across frames. Children are `dockPanel`s, each supplying a tab id, a title, and a content subtree.
The dockArea must be the root of a registered screen tree. Each frame it reconciles: it adds tabs for newly-present dockPanel ids and removes tabs whose dockPanel disappeared. Closing a tab emits a `<panelId>-close` interaction so the app can drop the panel from its children.
The module returns the widget builder function directly.
## Builder
`dockArea(opts?) -> widget node`. `opts` fields:
- `id` — the widget id the DockState is keyed by.
- `layout` — a serialized `DockState` JSON string that seeds the split/tab arrangement on first render. Read the live arrangement back with `ui.getDockLayout(id)` and pass it here to restore.
- `restoreLayout` / `restoreEpoch` — re-apply a saved layout to an already-live dock: pass the JSON in `restoreLayout` and bump `restoreEpoch` to a new value; the dock re-deserializes once per new epoch, then stays freely draggable.
- `overlayType` — `"widgets"` (icon drop buttons) or `"highlightedAreas"` (quadrant highlights — edges split, middle ring appends as a tab, dead-center floats the tab into its own window).
- `leafCollapseButtons` / `leafCloseAllButtons` — the collapse arrow and close-all button on each leaf's tab bar.
- `allowedSplits` — how a dragged tab resolves over a drop area: `"all"`, `"none"`, `"leftRight"`, `"topBottom"`.
- `children` — the dockPanels.
- `props`, `style` — the `style` block carries the standard widget keys (`borderWidth`, `borderRadius`, `padding`) plus the dock-specific chrome keys typed as `ZuiDockStyle`.
## Usage
```luau
local dockArea = require("modules.zui.widget.dockArea")
local widget = dockArea({ id = "editor", children = {
Z.dockPanel({ id = "scene", title = "Scene",
children = { Z.lbl("Scene body") } }),
Z.dockPanel({ id = "props", title = "Properties",
children = { Z.lbl("Inspector body") } }),
}})
```
# checkbox
Check-mark style boolean input with a trailing label.
## Exports
- `checkbox(label: string?, id: string?, checked: any?, opts: CheckboxOpts?) -> WidgetNode` — build a checkbox widget node.
Types:
- `CheckboxOpts = { props?, onChange?, style? }`
## Usage
```luau
local checkbox = require("@builtin::modules.zui.widget.checkbox")
checkbox("Enabled", "cb1", true, { onChange = "cb1:change" })
```
## Notes
- Initial `checked` is coerced via `value == true` — anything that isn't strictly `true` becomes `false`.
- `onChange` receives a `ValueChanged(boolean)` event when the user toggles the box.
# contextMenu
Convenience wrapper over `Z.popup` for the context-menu pattern — a
popup pinned to a widget you typically open on right-click. Same
ordering constraint as `Z.popup`: the anchor must render before the
menu in tree order.
## Exports
- `contextMenu(anchorId: string?, items: { any }?, opts: ContextMenuOpts?) -> WidgetNode` — build a context-menu popup attached to the anchor widget.
Types:
- `ContextMenuOpts = { id?, open?, onDismiss?, pivot?, focusable?, style? }`
## Usage
```luau
local contextMenu = require("@builtin::modules.zui.widget.contextMenu")
-- in your Z.app builder:
Z.btn("Item", "row1"), -- anchor
Z.contextMenu("row1", {
Z.btn("Cut", "ctx:cut"),
Z.btn("Copy", "ctx:copy"),
Z.btn("Paste", "ctx:paste"),
}, { open = menuOpen, onDismiss = "ctx:dismiss" })
```
## Notes
- Wave 3 ships caller-managed `open` state. Right-click sensing on interactive widgets (and self-toggling menus) is future work — for now, drive `open` yourself in `onClick` / `onCallback` handlers.
- Default pivot is `"belowLeft"`.
- The wrapper is a thin pass-through; any popup-supported field can be reached by adding it to the surrounding popup directly if needed.
# centralPanel
Central panel that fills the area not consumed by the docked edge
panels. Root-only — must be the body of a docked-shell layout, with
edge panels (e.g. `topPanel`, `bottomPanel`, `leftPanel`, `rightPanel`)
docked around it.
## Exports
- `centralPanel(children: { any }?, opts: { [string]: any }?) -> WidgetNode` — build a centralPanel widget node wrapping the given children.
## Usage
```luau
local centralPanel = require("@builtin::modules.zui.widget.centralPanel")
centralPanel({
heading("Body"),
-- ...
})
```
## Notes
- Root-only — the renderer expects it at the top level of a screen, alongside any docked edge panels.
- Passes `opts` straight through to the underlying `node` constructor; no widget-specific shortcuts.
# statusLabel
Numeric label that auto-picks a class by threshold ranges. The caller
passes `{ good = X, warn = Y }` (sorted ascending). Values below `good`
get the `good` class; below `warn` get `warn`; the rest get `bad`.
Non-numeric values get the `bad` class so a missing/erroring data
source is visually loud. Defaults to the engine theme's
`debug-timing-good/warn/bad` classes shipped in `themes/debug.yaml`.
## Exports
- `statusLabel(value, thresholds, opts?) -> Node` — returns a coloured label widget. Module returns the builder function directly.
Types:
- `StatusClasses = { good: string, warn: string, bad: string }`
- `StatusThresholds = { good: number?, warn: number? }`
- `StatusLabelOpts = { id?, classes?, ratioOf?, fmt?, bold?, color?, fontSize? }`
## Usage
```luau
local statusLabel = require("@builtin::modules.zui.widget.statusLabel")
statusLabel(dtMs, { good = 16, warn = 33 })
statusLabel(used, { good = 0.8, warn = 0.95 }, {
ratioOf = max,
fmt = function(v) return string.format("%.1f%%", v * 100) end,
})
```
## Notes
- Pass `ratioOf` to compare `value / ratioOf` against the thresholds
while still feeding the *raw* value to `fmt`. Non-positive `ratioOf`
values are ignored.
- Pass custom `classes = { good, warn, bad }` to retarget styling. The
default classes live in `themes/debug.yaml`.
- Non-numeric `value` always renders in the `bad` class so a broken
data source is visually loud.
# leftPanel
Left-docked panel. Root-only — must be a top-level child of the
screen builder, not nested inside another container. Wraps the
supplied children in a `leftPanel` node that the layout engine docks
against the left edge of its parent screen.
## Exports
- `leftPanel(children: { WidgetNode }, opts: table?) -> WidgetNode` — module returns the builder function directly.
`opts` is forwarded verbatim to the underlying node.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.app(function()
return {
Z.leftPanel({
Z.lbl("Sidebar"),
-- ...
}),
Z.centralPanel({
-- main view
}),
}
end)
```
## Notes
- Must be a root-level child of the screen builder; nesting it inside
another container is unsupported.
- `nil` children are treated as an empty array.
- Stateless.
# flex
Flex-grow spacer. Pushes siblings apart inside an hbox/vbox — use to
right-align items in a horizontal row, etc. The engine treats a spacer
with no `space` set as flex-grow.
## Exports
- `flex() -> Spacer` — build a flex-grow spacer widget table.
Types:
- `Spacer = { type: string }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
local row = Z.hbox({ Z.lbl("left"), Z.flex(), Z.lbl("right") })
```
## Notes
- Pure function — no engine calls, no state. Safe at module load time.
- The returned table is interoperable with hand-written widget trees;
any container that accepts a `{ type = "spacer" }` child will treat
it as flex-grow.
# ZuiTerminal
A terminal pane widget. Renders the live vt100 grid of a terminal from the
engine's terminal registry (created with `terminal.create`, run with
`terminal.spawn`) inside the UI tree. Captures the keyboard while focused; the
scroll wheel and Shift+PageUp/PageDown page the scrollback. Backed by a host PTY,
so terminals run on native targets.
# split
Two-pane resizable split. `direction` is `"horizontal"` or
`"vertical"`; `firstSize` is the first pane's size in pixels (or
`0..1` for proportional). Children must be exactly two widgets.
## Exports
- `split(children: { any }?, opts: SplitOpts?) -> any` — build a two-pane resizable split widget. The module returns this function directly.
Types:
- `SplitOpts = { id: string?, direction: string?, firstSize: number?, props: { [string]: any }?, style: { [string]: any }? }`
## Usage
```luau
local split = require("@builtin::modules.zui.widget.split")
split({ leftWidget, rightWidget }, {
direction = "horizontal",
firstSize = 200,
})
```
## Notes
- `direction` defaults to `"horizontal"` when omitted.
- `firstSize` accepts pixels (>= 1) or a proportion in `0..1`.
- Children must be exactly two widgets — wrap multiple widgets in a
layout container if more pane content is needed.
# vbox
Vertical layout container. Children stacked top-to-bottom; spacing via
`style.gap`; horizontal alignment via `style.align` (`"start"` |
`"center"` | `"end"`).
## Exports
- `vbox(children, opts?) -> Node` — returns the vertical-layout widget node. Module returns the builder function directly.
Types:
- `VboxOpts = { id?, classes?, props?, style? }`
## Usage
```luau
local vbox = require("@builtin::modules.zui.widget.vbox")
vbox({ header, body, footer }, { style = { gap = 8, align = "center" } })
```
## Notes
- Pairs with `hbox` for horizontal layouts and `wrap`/`grid` widgets
for more complex flows.
- `style.gap` is the spacing between children, not padding around the
container — use `style.padding` for the latter.
# fs
Filesystem composite widgets — pure builders that turn VFS listing
data into widget trees. Four stateless pieces every caller composes
with their own state + polling.
## Exports
- `M.tree(roots, opts) -> WidgetNode` — folder tree (recursive) built on `Z.tree` with VFS-friendly defaults.
- `M.fileList(entries, opts) -> WidgetNode` — flat list of files; folders first; selected file highlighted.
- `M.preview(data, opts) -> WidgetNode` — file preview pane (text or note).
- `M.breadcrumb(path, opts) -> WidgetNode` — clickable path crumbs.
## Usage
```luau
local Fs = require("@builtin::modules.zui.widget.fs")
return Z.hbox({
Fs.tree(state.roots, { onSelect = "files-tree-select" }),
Fs.fileList(state.entries, { onSelect = "files-row", selectedKey = state.name }),
Fs.preview({ name = state.name, text = state.text }, { id = "files-preview" }),
})
```
## Notes
- All four builders are stateless: pass data in, get a widget tree
out. They never touch the VFS — the caller owns reads, listing, and
cache eviction.
- `Z.fs.*` is what Layer B (`engine/ui/file_manager`) and Layer C
(system_tools' "Files" tab) compose with their own polling. You can
build your own file-browser by composing these directly.
# meter
Peak-style level meter built on `canvas`. Renders either a continuous
fill or N segmented LED rects, plus an optional peak indicator line.
Read-only — no interaction props. Peak-hold state lives in
`ui.widgetState(id, ...)` when an `id` is supplied; without one, the
meter still renders but the peak indicator is skipped.
## Exports
- `meter(value: number?, opts: Opts?) -> any` — build the meter canvas node. Returned directly by the module.
Types:
- `Style = { width?, height?, background?, fillColor?, warnColor?, clipColor?, offColor? }`
- `Opts = { id?, orientation?, segments?, warnThreshold?, clipThreshold?, peakHoldMs?, peak?, width?, height?, background?, fillColor?, warnColor?, clipColor?, offColor?, style? }`
## Usage
```luau
local meter = require("@builtin::modules.zui.widget.meter")
local widget = meter(state.master_l, {
id = "master-meter-l",
orientation = "horizontal",
style = { width = 220, height = 8,
fillColor = "#3cc864", warnColor = "#dcc83c",
clipColor = "#dc463c", background = "#101214" },
})
```
## Notes
- `value` is clamped to `[0, 1.5]` so out-of-range inputs paint into
the clip-coloured band without spilling outside the canvas.
- Peak hold defaults to 1500ms then decays at 1.5/sec — matches the
deleted Rust `render_meter` algorithm.
- Pass `opts.peak` to override the computed peak — useful for
ganged-channel meters where a single source value drives multiple
visuals.
- Vertical default is 12×160; horizontal flips to 160×12.
# radioGroup
Luau builder for a single-select radio group composed of focusable
per-row canvases inside a panel. Each row draws its own background
highlight + circle glyph + label; click commits the selection,
arrow-keys cycle it. Wraps the rows in a panel with
`role="radiogroup"` so assistive tech reads it as a group.
## Exports
- `radioGroup(id: string, options: { any }?, selected: number?, opts: RadioGroupOpts?) -> any` — build a radio-group widget. The module returns this function directly.
Types:
- `RadioGroupOpts = { style: { [string]: any }?, classes: (string | { string })?, rowWidth: number?, rowHeight: number?, onChange: string?, app: any?, ariaLabel: string?, label: string? }`
## Usage
```luau
local radioGroup = require("@builtin::modules.zui.widget.radioGroup")
radioGroup("difficulty", { "Easy", "Normal", "Hard" }, state.diff, {
onChange = "ui:diff:change",
})
```
## Notes
- Single-select; `selected` is a 1-based index. The first call seeds
`ui.widgetState(id, "selected")` from the caller's arg; subsequent
ticks read state back from there.
- Keyboard nav (per-row canvas `onKey`): `ArrowDown` / `ArrowRight`
next, `ArrowUp` / `ArrowLeft` previous, `Home` -> 1, `End` ->
`#options`. Tab / Shift+Tab walk the per-row canvases via egui's
focus chain. Click also commits selection.
- `opts.onChange` is dispatched as a callback id on the surrounding
`Z.app` router with `data.value` set to the new 1-based index.
Without an app in scope, only `ui.widgetState` updates.
- Handlers are registered once per group id (deduped via
`app._radioGroupHandlers`); option-count and `onChange` swaps land
without re-registering.
# svg
Inline vector graphics widget. Wraps a single `svg` node that
rasterizes an SVG document at the element's display resolution, so it
stays crisp from a 16px line icon to a 600px chart. Sizing comes from
`style.width` / `style.height`; `opts.fit` chooses the viewBox mapping.
## Exports
- `svg(source: string?, opts: SvgOpts?) -> WidgetNode` — module returns the builder function directly.
Options:
- `id: string?` — widget id.
- `style: table?` — passes through to the underlying node; supply `width`/`height` here, and `color` to ink `fill="currentColor"`.
- `fit: string?` — viewBox mapping: `"meet"` (default, uniform + letterbox), `"none"`/`"stretch"` (fill both axes), `"slice"` (uniform + crop). `preserveAspectRatio` keywords (e.g. `"xMidYMid meet"`) are accepted too.
- `src: string?` — render a `.svg` texture-asset reference instead of an inline `source` string.
## Usage
```luau
local Z = require("@builtin::modules.zui")
-- Inline document; currentColor inks the element's CSS color.
Z.svg("<svg viewBox='0 0 24 24'><path d='M4 12h16' stroke='currentColor' stroke-width='2'/></svg>", {
style = { width = 24, height = 24, color = "#3b82f6" },
})
-- From a .svg asset file.
Z.svg(nil, { src = "@builtin::icons.logo", style = { width = 64, height = 64 } })
```
## Notes
- Stateless — returns a fresh widget table each call.
- `fill="currentColor"` resolves to the element's CSS `color`; `fill`/`stroke`
gradients, the full path grammar, basic shapes, `viewBox`,
`preserveAspectRatio`, and `<g>` grouping all render.
- Pass either an inline `source` string or `opts.src` — `source` is the
document text, `src` points at a `.svg` asset file.
- Rasterization happens at the resolved display size, so scaling the widget
re-renders sharp rather than sampling a fixed bitmap.
# section
Panel with a bold header label on top. Convenience composition — every
demo had its own version before this widget existed. The header style
defaults to a small accent label; override via `opts.headerStyle`.
## Exports
- `section(title: any, children: { any }?, opts: SectionOpts?) -> any` — build a panel with a bold header label. The module returns this function directly.
Types:
- `SectionOpts = { id: string?, classes: (string | { string })?, props: { [string]: any }?, style: { [string]: any }?, headerStyle: { [string]: any }?, titleColor: string?, bg: string?, border: string?, borderWidth: number?, padding: any?, gap: number?, minWidth: number?, minHeight: number?, maxWidth: number?, maxHeight: number? }`
## Usage
```luau
local section = require("@builtin::modules.zui.widget.section")
section("Settings", { Z.label("hello") })
```
## Notes
- The `title` is coerced via `tostring`, so non-string values are
accepted as headers.
- `opts.headerStyle` fully replaces the default header style — pass a
table containing every field you want set, not a partial override.
- All standard panel options (`bg`, `border`, `padding`, ...) flow
through to the wrapping panel.
# healthBar
Game-HUD horizontal health bar. Luau builder over `canvas` — emits a
background rect plus a foreground fill rect scaled by `current / max`,
with an optional centered numeric label. The wrapping canvas exposes
ARIA `progressbar` semantics for the DOM mirror so screen readers
report the current value.
## Exports
- `healthBar(current: number, max: number, opts: HealthBarOpts?) -> WidgetNode` — module returns the builder function directly. Call `Z.healthBar(...)` via the zui re-export.
Options:
- `showText: boolean?` — overlay `"current/max"` centered. Default `true`.
- `background: string?` — empty-bar tint. Default `"#3c1e1e"`.
- `color: string?` — fill color when `gradient` is not set. Default `"#c83c3c"`.
- `width: number?`, `height: number?` — bar size. Defaults `200 x 24`.
- `label: string?` — `aria-label` override. Default `"Health"`.
- `gradient: boolean | {{number, string}}?` — `true` for the default 3-stop red→yellow→green, or a sorted `{{ratio, "#rrggbb"}}` list.
- `id`, `classes`, `style` — standard widget plumbing.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.healthBar(state.hp, state.hp_max, {
showText = true,
gradient = true,
label = "Player health",
})
-- Custom gradient stops:
Z.healthBar(hp, max, {
gradient = {
{ 0, "#000000" },
{ 0.5, "#777777" },
{ 1, "#ffffff" },
},
})
```
## Notes
- Stops must be sorted ascending by ratio. Ratios outside `[0, 1]` are
clamped. Fewer than 2 stops falls back to the default 3-stop palette.
- The fill rect is omitted when `ratio == 0` so an empty bar's edge
doesn't render a 0-width sliver.
- `gradient` (when truthy) overrides `color`.
- Stateless — no module state, safe to call every frame.
# filterRow
Composite filter row — search input + toggle checkboxes + sort
dropdown + counter + action buttons. Captures the pattern every list
view re-implements (entities, logs, assets, scripts,
animation_browser). Caller owns all bound values; the widget is
stateless. Each piece is optional; an empty `opts` produces an empty
hbox.
## Exports
- `filterRow(opts: FilterRowOpts?) -> WidgetNode` — assemble the row from the optional pieces in `opts`. Callback ids fire as `<id>-search`, `<id>-toggle-<key>`, `<id>-sort`, plus each action's `onClick` verbatim.
Types:
- `FilterRowOpts = { id: string?, search: SearchOpts?, toggles: { ToggleOpts }?, sort: SortOpts?, counter: CounterOpts?, actions: { ActionOpts }?, searchClass: string?, toggleClass: string?, sortClass: string?, class: string?, gap: number?, marginBottom: number?, padding: any?, align: string? }`
- `SearchOpts = { value: any?, placeholder: string?, class: string?, minWidth: number? }`
- `ToggleOpts = { key: any, label: string?, value: boolean?, class: string? }`
- `SortOpts = { options: { string }?, selected: number?, class: string?, minWidth: number? }`
- `CounterOpts = { visible: number?, total: number?, class: string?, fontSize: number? }`
- `ActionOpts = { label: string?, onClick: string?, variant: string?, padding: { number }?, minWidth: number?, tooltip: string?, class: string? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
return Z.filterRow({
id = "logs-filter",
search = { value = state.q, placeholder = "Search...", minWidth = 160 },
toggles = {
{ key = "info", label = "Info", value = state.showInfo },
{ key = "warn", label = "Warn", value = state.showWarn },
},
sort = { options = LOG_TYPES, selected = state.typeIdx },
counter = { visible = #filtered, total = #all },
actions = { { label = "Clear", onClick = "logs-clear", variant = "danger" } },
})
```
## Notes
- Stateless. Caller owns every bound value; the widget only routes
events through the `<id>-*` callback ids.
- When `counter` or `actions` are present, a flex spacer is inserted
first so they right-align to the trailing edge of the row.
- Action `variant` of `"primary"` / `"danger"` selects a coloured
background from the active theme; anything else falls back to
`theme.panel_alt`.
# image
Static image widget. Wraps a single `image` node whose `src` is fed
to the engine's image loader. Sizing comes from `style.width` and
`style.height`.
## Exports
- `image(src: string, opts: ImageOpts?) -> WidgetNode` — module returns the builder function directly.
Options:
- `id: string?` — widget id.
- `style: table?` — passes through to the underlying node; supply `width`/`height` here.
## Usage
```luau
local Z = require("@builtin::modules.zui")
-- By VFS path.
Z.image("/source/libs/@builtin/textures/uv_checker_bw.png", { style = { width = 64, height = 64 } })
-- By asset identity.
Z.image("@builtin::textures.uv_checker_bw", { style = { width = 64, height = 64 } })
```
## Notes
- Stateless — no side effects beyond producing a widget table.
- `src` reaches the engine's image loader, which resolves these spellings:
a VFS path (`/source/...` or `/zero/source/...`), an asset identity
(`@builtin::textures.uv_checker_bw`), an asset guid, and an http URL.
A path anchors at the VFS root, so name it from there.
- A `src` the loader cannot reach paints a `[failed <src>]` placeholder in
place of the image.
- Aspect ratio is whatever the engine's image renderer applies — this
widget does not impose one.
# scroll
Scrollable container. Pass `maxHeight` (or `maxWidth`) to bound the
visible region; children beyond that get scrolled. Top-level shortcuts
`maxHeight` / `maxWidth` / `minHeight` / `minWidth` / `stickToBottom`
merge into `style` / `props` so the common case doesn't require nesting
under `style = { ... }`.
## Exports
- `scroll(children: any?, opts: ScrollOpts?) -> any` — build a scrollable container. The module returns this function directly.
Types:
- `ScrollOpts = { id?, classes?, props?, style?, maxHeight?, maxWidth?, minHeight?, minWidth?, stickToBottom? }`
## Usage
```luau
local scroll = require("@builtin::modules.zui.widget.scroll")
-- Shortcut form (preferred):
scroll(rows, { maxHeight = 200 })
-- Equivalent verbose form:
scroll(rows, { style = { maxHeight = 200 } })
```
For tall content inside `Z.panel`, prefer `Z.panel(rows, { scroll =
true, maxHeight = N })` — see the [panel README](../panel.module/README.md)
for the pattern documented under issue #3270.
## Notes
- Either a single widget table or an array of widgets is accepted as
`children` — single widgets do not need to be wrapped in `{ ... }`
(closes #2331).
- Without a `maxHeight` / `maxWidth` (top-level or `style.*`), the
scroll area fills the parent's available rect and scrolls on overflow
(renderer applies `auto_shrink([false, false])` in that case). With a
bound, the scrollArea sizes to it and scrolls past that bound.
# component_events
Per-instance runtime for declared component events. A component's `events`
block (built with `Event(...)`, see `event.module`) is a schema: a map of
event name to descriptor. This module turns that schema into the live
objects an instance actually fires and listens on.
## Three faces, one Signal
Each declared event resolves to one `signal.module` `Signal`, reachable
through three tables that share it:
- `buildSignals(schema)` returns the private, fire-capable table (`:Fire`,
the raw Signal method). Engine-internal — never handed to user code
directly.
- `buildEmitter(signals, schema)` returns the owner-side emitter, bound
into the declaring component's own env as `events`. Each entry has
`:fire(payload)` only — lowercase, symmetric with the facade — and
validates the payload against the event's declared members before
dispatch. The component's own code fires with `events.onHit:fire({ dmg = 10 })`.
- `buildFacade(signals)` returns a subscribe-only view over the same
table, exposed to outside callers through the ComponentProxy's `.events`
key. Each entry has `:connect(fn)` / `:once(fn)` / `:wait()` — no
`:fire`.
`setup(schema)` builds all three in one call and returns
`{ signals, emitter, facade }`; the engine calls it once per instance.
```lua
local schema = { onHit = { payload = { dmg = Field.number(0, NoSync) }, sync = false } }
local runtime = component_events.setup(schema)
-- inside the component's own code (env global `events` is runtime.emitter):
events.onHit:fire({ dmg = 10 })
-- outside code, through the proxy (proxy.events is runtime.facade):
proxy.events.onHit:connect(function(p) print("hit for", p.dmg) end)
```
## Fire authority by construction
`buildFacade` never returns the underlying `Signal` object, only a fresh
table exposing `connect` / `once` / `wait`. There is no `fire` method to
find on the facade at any key, so outside code cannot fire a component's
events no matter what it holds a reference to. Firing authority lives only
with the owner, through the emitter's env `events` global.
Both tables reject access to an undeclared event name: the private table's
metatable raises on a bad key, and the facade raises on a bad key too, so
a typo surfaces immediately instead of silently returning nil.
## Cleanup
Connections made through the facade are ordinary `signal.module`
connections, tracked and torn down the same way as any other signal
connection: `entity_signals.module` disconnects everything sourced from an
entity when that entity is destroyed. Component events carry no separate
cleanup path.
# assetType (asset type)
The `assetType` is the **type of types** — the meta-type that every
`<typename>.assetType/` folder is an instance of, including itself. A
`<typename>.assetType/` folder declares a new asset type called
`<typename>`: its on-disk shape, its identity rule, and the structural
contract instances of the type must satisfy.
This type is self-hosting. `assetType.assetType/` conforms to its own
schema — a `type.yaml` plus a `README.md` — so the type system is
defined in terms of itself rather than a hidden engine special-case
(the same way a C compiler is written in C). Every asset in the engine,
including each `.assetType` definition, now resolves to a registered
assetType: a `.material` resolves to `material.assetType`, and
`material.assetType` resolves to `assetType.assetType`, which resolves
to itself.
## Why it exists
Assets used to be mapped to their type *implicitly*, by matching the
folder suffix against a name-keyed registry. That match carried no
stored link: nothing on a `Foo.material` recorded *which*
`material.assetType` it was written against, so two worlds shipping a
same-named type could silently disagree. Making `assetType` a real,
resolvable asset closes that gap — the link from an asset to its type is
now an explicit reference pinned in the asset's `.refs` sidecar
(`via: "asset_type"`), exactly like every other dependency.
## Where it lives
- Source: `/zero/source/.../<typename>.assetType/`
- Identity: `<typename>` (the `.assetType` suffix strips from the
identity; the folder retains the suffix on disk).
- Folder shape:
- `type.yaml` — the structural spec the validator reads. **Required.**
- `README.md` — type-level documentation. **Required.**
- `behavior.luau` — optional behavior/scaffolding module.
- `template/` — optional canonical placeholder body the templater
copies into a new asset of the type.
- `<name>.module/` — optional shared code the type ships to **every
instance** of it, conventionally `shared.module/` (see below).
## Shared code its instances reach (`asset.containing` + `.modules`)
A type ships code every instance of it uses by declaring the modules in
its own `behavior.luau`, as a map of tracked `require`s:
```luau
-- inside <typename>.assetType/behavior.luau
M.modules = { shared = require(".shared") } -- the sibling shared.module/
```
An instance reaches it with one location-independent line:
```luau
-- inside any foo.<typename>/init.luau
local api = asset.containing(__FILE__).modules.shared
```
`__FILE__` is the chunk's own VFS path; `asset.containing` resolves the
calling asset, and `.modules.<name>` follows the instance's pinned
`typeRef` (the type's **guid**, recorded in the instance's `.refs` as
`via = "asset_type"`) to the module its type declares. Because
resolution follows that link rather than a category name, the same line
in an instance of a different type resolves to that type's module, and
identically-named modules in two types never collide. `asset.typeRef(target)`
reads that guid for any target; `asset.resolve(asset.typeRef(target))`
gives the full `AssetRef<assetType>`. See `assetTypes/README.md`
§ "Shared code: type-owned modules" for the full mechanism.
## How to create one
Authoring a new asset type is just creating a `<typename>.assetType/`
folder under `/zero/source/`. The engine registers `<typename>` as a
category **the moment the folder is written** — no engine restart, no
Rust change, and it works for built-in (`@builtin/assetTypes/`) and
user-defined types identically. Once `<typename>` is registered,
`<name>.<typename>/` folders are recognised as assets of that type and
resolve their `asset_type` link back to this definition.
Creating a `<name>.<typename>/` folder *before* its
`<typename>.assetType/` exists fails fast: the suffix has no registered
type, so the folder can't be a valid asset. Author the type first.
## The `behavior.luau` contract
`behavior.luau` is the type's code. It returns ONE table, and the keys below
are the ones the framework reads off it for EVERY type — the surface you get
by declaring them. Anything else on the table is your own: reachable by name
through `require("@builtin::assetTypes.assetType.shared.ref").loadTypeModule("<typename>")` for a
system that knows your type (`computeShader` publishes `dispatchKey` that
way), and never called by the framework.
```luau
local M = {}
M.ref = { ... } -- methods on every AssetRef of this type
M.modules = { ... } -- shared code the instances reach
M.global = {} -- reserved key; ship the empty table
function M.onCreate(name, opts) ... end
return M
```
### The tables
| Key | Shape | What the engine does with it |
|-----|-------|------------------------------|
| `M.ref` | `{ [name] = function(self, ...) }` | Every function becomes a method on every `AssetRef` of this type: `ref:name(...)`, with `self` the ref. This is how a type gives its instances an API. Two names in here are contracted — see below. |
| `M.modules` | `{ [name] = require(".sibling") }` | Shared code the type's instances reach through `asset.containing(__FILE__).modules.<name>`, resolved by the instance's pinned `typeRef` guid. See the section above. |
| `M.refShapes` | `{ [method] = "TypeName" }` | Declares the result type of a `M.ref` method so the LSP can check what a call site does with it. See the `M.refShapes` section below. |
| `M.events` | an events schema | The events an ASSET of this type fires, declared the way a component declares its own. Readers subscribe through `ref.events.<name>:connect(...)`; firing authority stays with the type. Keyed by the asset's guid, so every resolver of the same asset shares the signals and a re-resolved ref re-attaches to subscriptions already there. |
| `M.namePattern` | a Lua pattern string | The name shape `asset.create` enforces for instances of this type, replacing the default `^[A-Za-z][A-Za-z0-9_]*$`. |
| `M.global` | `{}` | Reserved. Ship the empty table. |
### The lifecycle hooks
Each is optional; a type that omits one costs nothing. The engine calls them:
| Hook | Signature | When it fires |
|------|-----------|---------------|
| `M.onCreate` | `(name, opts) -> { [filename] = contents }` | On `asset.create("<type>", name, opts)`. Returns the files that scaffold the new instance, as a map of relative path to contents; the framework writes them. The `opts` type annotation is the schema `asset.create` validates the caller's arguments against, so annotate it. |
| `M.onRegister` | `(self)` | Once per instance, the first time it registers: on `engine.onWorldLoaded` for instances already in the world, immediately on a live `asset.create`, and when an instance arrives after that sweep (installed, pulled, or synced from a peer). Guarded by the instance's path, so a double trigger never double-registers. An instance that leaves the runtime releases the guard, so one arriving at the same path again registers. This is what makes "write an asset into the world and it takes effect live" work for content that registers into a runtime registry. The `@builtin` library is excluded from the sweep. |
| `M.onChange` | `(ref, change)` | On every VFS write to a file INSIDE one of this type's instances. `change` is `{ path, asset, type, kind, origin }`: `kind` is `"edited"` or `"seeded"`, `origin` is `"local"` or `"remote"` (an importer runs on the originator only). **Filter on `change.path`**: the hook fires for any file under the folder, so act on the one you care about. It runs as its own task and may wait on what it reads: a `vfs.read` of bytes only the blob store holds (a binary file on the web build) fetches them. The writes to one asset reach the hook in the order they were made, each once the hook before it has finished. A re-entrancy guard suppresses writes back into the same asset routed inline from the hook, and a write the hook makes is routed on a later frame like any other, so the hook must be convergent on its own: diff the meaningful state and short-circuit while your own regeneration is in flight. |
| `M.onDelete` | `(ref)` | When an instance of this type leaves the runtime: it is deleted, or the engine swaps away from the world binding that held it. Its files are gone from `/zero/source` by then. A removal routed while the instance's `onChange` is waiting reaches `onDelete` once that hook, and every write queued behind it, has finished. On a swap the instance still exists in the world it belongs to, and a VFS write the hook makes reaches the world now bound. |
| `M.validate` | `(assetRef) -> { { code, message, severity? } }` | On `asset.validate`, after the structural `type.yaml` check, for the type's own SEMANTIC validation. `severity` defaults to `"error"`; error-severity problems flip `ok` to false, warnings do not. A hook that raises or returns a non-table is itself reported as a `validate.hook_failed` error. `world.push` runs this per user asset, so declaring it enforces your type's rules at publish time with no further wiring. |
### The two contracted `M.ref` names
Most `M.ref` methods are your type's own surface: name them what you like,
return what you like. Two are read by the engine and mean the same thing for
every type, so their shape is fixed:
- **`instantiate(self, target?, opts?) -> (root, idMap)`** — makes an instance
part of the scene. Defining it is the whole opt-in: `ref:canInstantiate()`
is true exactly when it exists. Its **return is a contract** — see the next
section.
- **`inspect(self) -> detail`** — the type-specific half of `asset.inspect`.
See its section below.
### Names a type cannot shadow
Some keys resolve on every `AssetRef` before per-type dispatch, so an `M.ref`
entry of the same name is never reached: `canInstantiate`, `getSource` /
`getBytes` / `getText`, `exists`, `deps`, `meta`, `runtime`, `events`,
`modules`, `typeRef`, and the residency flags `has_backing_asset`,
`has_runtime_changes`, `cpu_resident`, `gpu_resident`. These are the
behaviours every asset must expose identically, which is why they win.
### Reloading
A `behavior.luau` edit ripples to existing refs with no restart: dispatch
reads `mod.ref` fresh each time and `require`'s cache is re-run in place. A
type whose folder appears at runtime is picked up on the next dispatch —
negative results are not cached either.
## What search does with your type (`indexing:`)
A type declares what search embeds for its instances. **This is not optional
and there is no useful default**: ZeroMind runs your declaration and adds
nothing of its own, so anything you do not point at is absent from the index —
silently, and for every instance of the type forever. Twenty types once shipped
with no `indexing:` block and every instance of them was unfindable.
Two words carry the whole thing, and they are the same two the search tool
exposes:
- **`identity`** — what an instance IS. For anything you can look at, that is
the picture (`content:` + `modality: image`, matched directly by a text
query). For everything else it is the authored text that says what it is.
- **`capability`** — what it DOES and how it is made. Code, settings, the
model-written summary of them.
A type with no behaviour has no `capability` entry. A type with nothing to look
at has no image. Leaving a slot empty is a statement; filling it with whatever
happens to be lying around is not.
The one you have to decide when authoring a type is: *for an instance of this,
what is the thing a person would recognise it by, and what is the thing it
does?* A texture answers "the image" and "its compression settings". A module
answers "its README" and "its code". Write those two answers into `indexing:`
and the rest follows.
Three specifics worth knowing before you write one:
- **`derive` is instructed by this type's own `README.md`.** A model reads an
instance's source and writes a description; what it embeds is the
description, not the source. It follows your README to know what it is
looking at, so a type whose README says what its instances are gets good
derivations for free. There is no prompt to name.
- **`derive` loses detail.** It keeps the main technique and drops secondary
ones. If the source is code you want searchable by its own vocabulary,
declare the same files a second time as `capability` with
`extractor: verbatim`. Two entries, same role, different failure modes.
- **Never `source: { field: name }`.** A filename is one or two words with no
usable embedding, and a corpus indexed that way answers "language runtime"
with `say_runtime_2.soundClip`.
`facets:` is the second stage: a `file:` embedded whole (`.metadata`, so keys
nobody declared still get indexed) plus computed keys that state a value for
every instance including `false` — the raw file can imply "not rigged" and can
never say it.
The full schema, every extractor and the facet sources are in
[`assetTypes/README.md`](../README.md); the scaffolded block with inline
guidance is in [`template/type.yaml`](template/type.yaml).
## What your type looks like (`preview:`)
A type declares how its instances are pictured, and the engine makes the
picture when something needs it: staging an instance whose `preview.png` is
missing or pictures content it no longer holds queues a render, a view that
shows the instance asks for one, and `ref:preview()` renders one on demand. An
edit renders nothing on its own. Each `preview.png` records the content it
pictured, which is how a stale one is told from a current one. The preview studio owns the light, the
backdrop, the framing and the camera, and nothing in the world reaches it, so
an instance pictures the same in every world. Declare one of four recipes:
| `kind` | The picture is | Your type provides |
|---|---|---|
| `instantiate` | the asset stood in the studio through its own `instantiate` | `ref.instantiate` |
| `image` | a file the instance holds, fitted to the frame, nothing rendered | `source.files`, the candidate files in order |
| `surface` | the asset painted on a builtin mesh | `mesh`, and `ref.previewMaterial(self) -> { material }` |
| `custom` | whatever `ref.preview(self, opts)` returns | `ref.preview` |
```yaml
preview:
kind: surface
mesh: sphere
```
A scene-instantiable type that declares nothing still answers `ref:preview()`
through its `instantiate`, but persists no picture: the declaration is what
makes the engine keep one. List `preview.png` among the type's files so an
instance may hold it.
The picture is findable only when `indexing.content` names it. The two are
separate statements, and neither is derived from the other:
```yaml
indexing:
content:
- role: identity
modality: image
source: { file: "preview.png" }
```
`@builtin::assetTypes.assetType.shared.previewRecipe` reads the declaration;
the scaffolded block with inline guidance is in
[`template/type.yaml`](template/type.yaml).
## Typing a result from the instance (`M.refShapes`)
Every method on `M.ref` is checked against the type its `--!return` names,
and every asset of the type gets the same one. That is right for most
methods and wrong for the ones whose result is shaped by the ASSET: a
method answering one entry per child folder, per row of a config, per
binding an input map declares. The widest true annotation for those is
`{ [string]: Thing }`, and an indexer accepts every key — so a caller's
typo reads as valid and nothing reports.
`M.refShapes` lets the type state the result per instance. Each entry is
`function(self) -> (typeExpression, source?)`: a Luau type expression for
THIS asset, and the module whose type vocabulary the expression uses.
Read the asset; never run it — stating a type must not take effect.
```lua
-- inputMap.assetType/behavior.luau — the shipped example.
-- `activate()` hands back one handle per control the map declares.
M.refShapes = {
activate = function(self): (string, string)
local names = {}
for _, control in ipairs(M.ref.controls(self)) do
table.insert(names, control.name .. ": Handle")
end
if #names == 0 then return "", "" end
return "{ " .. table.concat(names, ", ") .. " }",
"@builtin::modules.zinput.scheme"
end,
}
```
With it, `map.jump:onPressed(fn)` resolves and `map.noexisting` is
reported with the map's real controls. Without it, both pass silently.
### What the call site has to say
A per-instance type is matched by the asset's IDENTITY, so it applies only
where the reference the method is called on says WHICH asset. Two spellings
do:
```lua
-- a component field, whose declared default names the asset
public = { map = Field.assetRef("inputMap", "@builtin::inputMaps.default", Sync) }
local controls = public.map:activate() -- typed for THAT map
-- an annotation, naming category and identity
local m: AssetRef<"inputMap", "@builtin::inputMaps.default">
```
`asset.resolve("<identity>", "<category>")` carries the CATEGORY, so the
methods the category defines are checked on the result — but not which
asset it found, so a per-instance result keeps the category's declared
return. A reference that names no asset (`asset.resolve(someVariable)`, a
value passed in as a parameter) carries neither, and calls on it are
unchecked. That is the same rule everywhere: the checker states what the
code states, and a name computed at runtime states nothing.
### The result is a value, not a binding
The type belongs to the expression, so it travels the way any inferred type
travels. Indexing the call's result is checked; stashing it in a module
local and indexing it from another function is not, because the local's
declared type is what carries across — and `any` (or no annotation) carries
nothing:
```lua
-- checked: `d.greting` is reported
function awake()
local d = public.dialogue:lines()
d.greeting:say()
end
-- NOT checked: `lines` is a module local typed `any`
local lines: any = nil
function awake() lines = public.dialogue:lines() end
function start() lines.greting:say() end
```
Bind what you need at the call site and the names stay checked in both
places — the asset-derived type at the boundary, ordinary scoping after it:
```lua
local lineGreeting = nil
function awake()
local d = public.dialogue:lines()
lineGreeting = d.greeting -- `d.greting` is reported here
end
function start() lineGreeting:say() end
```
### When nothing is reported and you expected something
From a call site, a type that is correct and a type that was never published
look the same: no diagnostic either way.
`require("@builtin::assetTypes.assetType.shared.refShapes").published()` answers which it
is. It returns two maps — the category surfaces, and the per-asset results
keyed by identity and then by method:
```lua
local shapes = require("@builtin::assetTypes.assetType.shared.refShapes").published()
shapes.categories.dialogue
--> "{ lines: () -> any, lineNames: () -> { string } }"
shapes.returns.Shopkeeper.lines
--> "{ browsing: Line, farewell: Line, greeting: Line, wares: Line }"
```
An identity absent from `returns` was never published for, which is a
different thing from published-and-correct — and the reason to look here
rather than at the call site.
The answers are recomputed when an instance is written, so a control added
to a map reaches the checker with no restart. Only types that declare
`refShapes` are read per instance, and at most 64 assets of one type are —
past that the type keeps its declared return and the engine log names what
was dropped.
## The instantiate hook (`M.ref.instantiate`) — a contracted return
A type opts into becoming part of the scene by defining `instantiate` on its
`ref` table. That is the whole opt-in: `ref:canInstantiate()` is true exactly
when the hook exists, so consumers offer a scene path — an `Asset.source`
field, a viewport drop, a tool argument — by CAPABILITY rather than by a list
of type names.
Unlike `inspect`, this hook's **return is a contract**. A caller writes one
piece of code against every instantiable type, so what comes back cannot vary
by type:
```lua
-- behavior.luau
local Instantiable = require("@builtin::assetTypes.assetType.shared.instantiable")
local M = {}
M.ref = {
instantiate = function(self, target, opts)
local root = Instantiable.root(self, target, opts) -- IN: the base opts
-- ... compose whatever this type is, under `root`, NOW ...
return Instantiable.result(self, root, idMap) -- OUT: the contract
end,
}
return M
```
**IN.** `target` is an owning entity ref: the instance lands under (or, for a
hierarchy type, onto) that owner. With no target the type spawns a fresh root.
`position`, `rotation`, `scale`, `name` and `temporary` mean the same for every
type — `Instantiable.root` applies them to a root you mint, `Instantiable.place`
to one you adopt, so you never re-read the spec. Honour more opts of your own if
your type needs them, and document them in the hook's `--!arg` docs.
**OUT.** Return `(root, idMap)` through `Instantiable.result`:
- **`root`** — the composed root, as an `EntityRef`. Composition is
**synchronous**: everything your type builds is live when you return, so the
caller can parent to it and read its components in the same statement. Do not
defer the work to a component's `awake` and return an empty shell — a root
that is not live is refused.
- **`idMap`** — the `originalId -> runtimeId` map naming what you spawned, or
nil for a type with no addressable children (`result` normalises it to `{}`).
**A caller never receives nil.** A component that re-composes your asset on
every load keeps this map and passes it back in, which is how a cross-entity
reference into the composition survives a reload.
`AssetRef` runs every `instantiate` through `result` on the way out whether or
not your type called it, so returning something else fails at your own call
rather than handing a caller a nil root. Calling it yourself is still how you
say what you return, and running it twice changes nothing.
`@builtin::assetTypes.assetType.shared.instantiable`'s README is the full reference for both halves.
## The inspect hook (`M.ref.inspect`)
`asset.inspect(ref)` resolves `ref`, builds a common envelope shared by
every asset (`identity`, `name`, `guid`, `source`, `typeName`,
`typeDefinitionPath`, `scope`, `origin`, `description`, `tags`), then
calls the resolved type's own inspect hook — `M.ref.inspect(self)` on
`behavior.luau` — for the type-specific `detail`. The hook returns
*only* `detail`; the framework fills in everything else:
```lua
-- behavior.luau
local M = {}
M.ref = {
inspect = function(self)
-- self.path is the asset's own folder. Parse the asset's OWN
-- files — source text, a native payload header, its `.metadata`
-- — never runtime state (a live component instance, a GPU
-- upload, a compiled schema): inspect must work on an asset that
-- hasn't been compiled, uploaded, or registered yet.
return {
-- type-specific fields, whatever this type wants to surface
}
end,
}
return M
```
A type that omits `M.ref.inspect` yields the generic record: the
envelope alone, `detail = nil`. That's a valid, working state — not an
error — but it means agents calling `asset.inspect` /
`tools.use("assets","describe", ...)` on an instance of the type see
only identity and scope, none of what makes an instance of it
meaningful. Declaring the hook is what makes a type's instances
inspectable for what they actually are.
A hook that raises leaves `detail = nil` and sets `record.warning` to
the error text — `asset.inspect` itself never raises for a resolved
ref.
## Discovery
- `asset.list("assetType")` — every registered asset type.
- `asset.inspect("<typename>")` — the type's identity, source path, and
this README.
- `asset.resolve("<typename>", "assetType")` — the `<typename>.assetType`
asset itself (used by the validator to find the type's `type.yaml`).
## Related
- `assetTypes/README.md` — the full `type.yaml` schema and validator
rules.
- `@builtin::assetTypes.assetType.shared.instantiable` — the instantiation contract's shared
implementation, both halves.
- Every other `*.assetType/` here — the concrete types built on this
meta-type.
## The Inspector a type ships
Every type names the file its Inspector sections come from with `inspector: { type: "inspector.luau" }` in its type.yaml. `asset.create("assetType", name)` scaffolds the block and an `inspector.luau` whose Contents section lists an asset's files, built from `ctx.parts`. `asset.validate` on a type that names none reports `assetType.inspector_required` (an error for a `@builtin` type the engine's library authors; a warning for a world's own, and for one the library carries by reference, which the world that published it answers for), and one whose file does not load `assetType.inspector_invalid`.
## In the Inspector
Type: what the type.yaml declares (suffix, container, primary files, the settings and inspector blocks), whether the type ships a behavior, how many instances it has, and **Open** for its files. Schema: the settings every instance carries, as read-only typed controls at their defaults (a type whose schema is per instance says where it is declared). Instances: a link to each of the first 20 assets of the type, and how many there are when there are more.
# signal
Event primitive. A producer holds a `Signal`; consumers attach handlers
with `:Connect` and the producer invokes them with `:Fire(...)`.
```lua
local Signal = require("@builtin::modules.signal")
local hit = Signal.new()
local conn = hit:Connect(function(dmg) print("hit for", dmg) end)
hit:Fire(10) -- runs every handler with (10)
conn:Disconnect() -- detach this handler
```
## API
- `Signal.new() -> Signal`
- `signal:Connect(fn) -> Connection` — attach a handler; runs on each `:Fire` in attachment order.
- `signal:Once(fn) -> Connection` — fire at most once, then self-disconnect.
- `signal:Wait() -> ...` — yield the calling coroutine until the next `:Fire`, returning its arguments. Call from inside a coroutine (e.g. a `task.spawn` body).
- `signal:Fire(...)` — invoke every handler with the arguments. A handler that raises is caught and logged; the rest still run.
- `signal:DisconnectAll()` — detach every handler.
- `connection:Disconnect()` — detach one handler (idempotent).
- `connection.Connected` — boolean, false once disconnected.
Handlers run synchronously and must not yield. The connection list is
snapshotted before a fire, so a handler may disconnect itself or others
mid-fire without skipping a handler.
## Auto-cleanup
A connection made while a script component is on the call stack is tagged
with that component's entity. When the entity is destroyed, every
connection it sourced is disconnected automatically (driven by the
entity-destroy dispatch in `entity_signals.module`) — a component never
has to track and tear down its own connections.
# diagnosticDetail
Detail pane for a single LSP diagnostic. Renders a severity / code
header, an optional `file:line:col` row (with optional copy-path
button), the message and suggestion, and an optional source snippet
around the offending line. Stateless builder — pass a diagnostic in,
get a widget tree out.
## Exports
- `diagnosticDetail(d: Diagnostic?, opts: DiagnosticDetailOpts?) -> WidgetNode` — module returns the builder function directly. Also reachable via `Z.lsp.diagnosticDetail`.
Options:
- `id: string?` — id forwarded to the outer panel.
- `source: string?` — full file contents. Enables the snippet panel.
- `snippetContext: number?` — lines of context around the focused line. Default `2`.
- `onCopyPath: string?` — callback prefix for the copy-path button. Emits `<prefix>-copy` when clicked. Omit to hide the button.
`d` shape (from `lsp.check*`): `{ severity, code?, path?, line?, col?, message?, suggestion? }`.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.lsp.diagnosticDetail(diags[selected], {
source = source,
onCopyPath = "lsp:path",
})
```
## Notes
- When `d` is `nil`, renders a placeholder panel ("Select a diagnostic
to see details.") rather than crashing.
- The widget never copies to the clipboard itself — it only emits a
callback. The parent app decides what "copy" means in its host
(some have `ui.copy(text)`, others log to stdout).
- The snippet renders only when both `opts.source` and `d.line` are
provided.
- Severity colours come from `Theme.default` (`danger`, `warn`, `info`,
`text_dim`); unknown severities fall back to `theme.text`.
# diagnosticsList
Scrollable list of LSP diagnostics. Input shape matches the array
returned by `lsp.check()` / `lsp.checkAll().diagnostics` /
`lsp.checkDirty()`. Each row shows a severity dot, location
(`path:line:col`), code chip, and message. Clicking a row emits the
callback id `<onSelect>-<idx>` (1-based) so the owning app can route
it back into its selection state.
## Exports
- `diagnosticsList(diags: { Diagnostic }?, onSelect: string?, opts: Opts?) -> any` — build the scrollable widget. Returned directly by the module.
Types:
- `Diagnostic = { severity?: string, line?: number, col?: number, code?: string, message?: string, suggestion?: string, path?: string }`
- `Filter = { severity?: string, code?: string, pathSubstr?: string }`
- `Opts = { id?: string, filter?: Filter, maxRows?: number, messageMax?: number, maxHeight?: number, emptyMessage?: string }`
## Usage
```luau
local diagList = require("@builtin::modules.zui.widget.lsp.diagnosticsList")
local widget = diagList(lsp.check(), "lsp-row", {
filter = { severity = "error" },
maxRows = 50,
maxHeight = 320,
})
app:on("^lsp%-row%-(%d+)$", function(_v, _id, idx)
state.selected = tonumber(idx)
end)
```
## Notes
- `opts.maxRows` defaults to 200 — overflow paints a "+N more" footer
chip rather than rendering thousands of rows.
- Messages are truncated to `opts.messageMax` characters (default 200)
with an ellipsis so a single huge diagnostic doesn't push the layout.
- Severity colors flow from the active theme — `danger` / `warn` /
`info` / `text_dim` for `error` / `warning` / `info` / `hint`.
- The list is presentational only — it does NOT call `lsp.*`. The
caller supplies the diagnostics array (and re-renders with a new
array when the underlying check refreshes).
# directiveBadge
Colored chip showing a file's `--!nocheck` / `--!nolint` /
`--!nocheck:<codes>` directive state. Input is exactly what
`lsp.readDirectives(source)` returns: `{ mode = "none" | "all" | "lint" | "codes", codes? = {string} }`.
Returns `nil` when `mode == "none"` (no directive present) so the
caller can drop the result into a layout without conditionals
cluttering the call site. Pass `opts.showNone = true` to render a
neutral "no directive" chip for layouts that need the slot occupied.
## Exports
- `directiveBadge(skipMode: SkipMode?, opts: Opts?) -> any?` — build the chip. Returns `nil` for `mode == "none"` unless `opts.showNone` is set. Returned directly by the module.
Types:
- `SkipMode = { mode?: string, codes?: { string } }`
- `Opts = { id?: string, padding?: { number }, showNone?: boolean }`
## Usage
```luau
local directiveBadge = require("@builtin::modules.zui.widget.lsp.directiveBadge")
children[#children + 1] = directiveBadge(lsp.readDirectives(source))
-- or, always-occupy-slot variant:
children[#children + 1] = directiveBadge(d, { showNone = true })
```
## Notes
- The chip color tracks severity: `danger` for `--!nocheck` (all passes
skipped), `warn` for `--!nolint` and `--!nocheck:<codes>`.
- Tooltip text spells out exactly which passes / codes the directive
silences so an inspector view doesn't need a separate explanation
panel.
- Pure presentational — no side effects, no LSP calls. The caller is
responsible for refreshing the directive state when the source
changes.
# docBrowser
Three-pane API doc browser: namespace tree (left), method list
(middle), describe pane (right). State carries the current selection
plus a search query; the widget itself is stateless — the owning app
prefetches `lsp.namespaces()` / `lsp.methods(ns)` / `lsp.describe(path)`
and threads them through. Drives the LSP UI's "Docs" tab.
## Exports
- `docBrowser(state: State?, opts: Opts?) -> any` — build the three-pane split widget. Returned directly by the module.
Types:
- `State = { namespaces?, selectedNamespace?, methods?, selectedMethod?, describe?, searchQuery?, searchResults?, kindFilter? }`
- `Opts = { id?: string, onSelect?: string, sizes?: { number } }`
## Usage
```luau
local docBrowser = require("@builtin::modules.zui.widget.lsp.docBrowser")
local widget = docBrowser(state, { onSelect = "lsp-docs" })
app:on("^lsp%-docs%-ns%-(.+)$", function(_v, _id, ns) state.selectedNamespace = ns end)
app:on("^lsp%-docs%-method%-(.+)$", function(_v, _id, path) state.selectedMethod = path:gsub("%.", "/") end)
app:on("lsp-docs-search", function(v) state.searchQuery = v end)
app:on("^lsp%-docs%-kind%-(.+)$", function(_v, _id, k) state.kindFilter = k end)
```
## Notes
- Callback ids can't contain `/`, so the widget substitutes `.` for
`/` in method paths on emit. The receiving handler must undo it.
- The fetch logic (calling `lsp.*`) lives in the parent app, not in
this widget — that keeps it cheap to test without an active LSP.
- The split sizes default to 20% / 30% / 50%. Override via `opts.sizes`
for inspector layouts that need different widths.
# severitySummary
Colored badge row of LSP diagnostic severity counts. Input shape
matches `lsp.summary()` / `lsp.checkAll()` — `{ errors, warnings,
info, hints }`. Severities with zero count are hidden by default; pass
`opts.showZero = true` to keep them in the row. When all counts are
zero the widget falls back to a neutral "0 diagnostics" chip so the
header doesn't collapse.
## Exports
- `severitySummary(counts: Counts?, opts: Opts?) -> any` — build the hbox of severity chips. Returned directly by the module.
Types:
- `Counts = { errors?: number, warnings?: number, info?: number, hints?: number }`
- `Opts = { id?: string, showZero?: boolean, tooltips?: boolean, gap?: number, padding?: { number } }`
## Usage
```luau
local severitySummary = require("@builtin::modules.zui.widget.lsp.severitySummary")
local widget = severitySummary(lsp.summary(), { tooltips = true })
```
## Notes
- Chip colors flow from `Z.theme.danger / warn / info / success` so the
palette automatically tracks theme overrides.
- The "0 diagnostics" fallback prevents the parent layout from jumping
when diagnostics first appear — important for fixed-height headers.
- Pure presentational — no LSP calls, no side effects.
# strictModeToggle
Three-button radio for the LSP strict gate. Pass the current mode
(`"off" | "soft" | "strict"` — exactly what `lsp.getStrictMode()`
returns) and a callback prefix; emits `<onChange>-<mode>` when a
button is clicked. The widget is presentational only — the owning app
calls `lsp.setStrictMode()` from its handler and mirrors the change
back into its render state.
## Exports
- `strictModeToggle(currentMode: string?, onChange: string?, opts: Opts?) -> any` — build the hbox of mode buttons. Returned directly by the module.
Types:
- `Opts = { id?: string, label?: any, padding?: { number }, containerPadding?: { number } }`
## Usage
```luau
local strictToggle = require("@builtin::modules.zui.widget.lsp.strictModeToggle")
local widget = strictToggle(lsp.getStrictMode(), "lsp-strict")
app:on("^lsp%-strict%-(.+)$", function(_v, _id, mode)
lsp.setStrictMode(mode)
state.strictMode = mode
end)
```
## Notes
- Decouples the visual from the side effect — matches the rest of the
zui composite-widget convention so screens stay testable without an
active LSP.
- Pass `opts.label = false` to drop the leading label entirely (e.g.
for compact inspectors that already provide context).
- Tooltips on each button explain exactly which classes of error are
gated at that level.
# dataView.selectionModel
Pure selection-interaction algebra for `Z.dataView`. Maps a row click plus
modifier state to a new selected-index set and anchor, following the standard
desktop model: plain click replaces, ctrl toggles, shift selects the contiguous
range from the anchor.
## Exports
- `M.resolveClick(index, mods, current, anchor) -> { selected: { number }, anchor: number }`
— 1-based indices; `mods` is `{ ctrl?, shift? }`; `current` is the current
selected-index array; `anchor` is the current anchor or nil.
## Notes
- Pure functions, no engine calls — unit-tested in the `editor_dataview` suite.
- Callers translate the returned indices into selection-service refs.
# dataView.view
Pure view computation for `Z.dataView` — filter then sort an item array into an
ordered list of 1-based indices.
## Exports
- `M.computeView(items, spec) -> { number }` — `spec` is
`{ filter?, sortKey?, sortDir?, textOf, valueOf }`. Filter is a case-insensitive
substring over `textOf(item)`; sort compares `valueOf(item, sortKey)`
numerically or case-insensitively, ascending or descending, with a stable
tiebreak on the original index.
## Notes
- Pure functions, no engine calls — unit-tested in the `editor_dataview` suite.
# dataView.virtual
Pure virtualization window math for `Z.dataView` — given a scroll position and
geometry, compute the inclusive range of row indices that are visible.
## Exports
- `M.windowFor(scrollTop, viewportH, rowH, count, overscan) -> { first, last }`
— 1-based inclusive; `last < first` when the dataset is empty. `overscan`
extends the window above and below the visible band.
## Notes
- Pure functions, no engine calls — unit-tested in the `editor_dataview` suite.
- Backs the row-window rendering; the same math would drive a future
construction-virtualization provider.
# fromFlat
Build the recursive node table `Z.tree` expects from a flat array of
items where each item carries a parent link. Useful for ECS entity
snapshots (each entity has `parentId`) and any other parent-pointer
dataset.
## Exports
- `fromFlat(items, opts?) -> (roots, nodeById)` — returns the array of root nodes and a stringified-id → node map. Module returns the builder function directly.
Types:
- `FromFlatOpts = { idKey?, parentKey?, labelFn?, iconFn?, actionsFn?, expanded?, defaultExpanded? }`
- `TreeNode = { key, label, children?, payload, icon?, actions?, expanded? }`
## Usage
```luau
local fromFlat = require("@builtin::modules.zui.widget.tree.fromFlat")
local roots, byId = fromFlat(entities, {
idKey = "id",
parentKey = "parentId",
labelFn = function(e) return e.name or "#" .. e.id end,
iconFn = function(e) return e.children and "▣" or "·" end,
expanded = state.expanded,
defaultExpanded = false,
})
```
## Notes
- Items missing the id field are silently skipped — we cannot place
them in the tree without a key, and crashing on bad data would kill
the inspector.
- Items whose `parentKey` value isn't found in the input array are
treated as roots, matching typical ECS behaviour where a filtered
list may contain children whose parents were filtered out.
- The `expanded` map is consulted by stringified key first, then by
the raw id; absent keys fall back to `defaultExpanded`.
# breadcrumb
Breadcrumb path widget — renders `/zero/runtime/scenes/...` as a row
of clickable segments. Each segment emits a callback id carrying the
absolute path up to and including that segment, so a single router
rule reaches every navigation target. Stateless — caller passes the
current absolute path and a callback prefix; receives a hbox.
## Exports
- `breadcrumb(path: string, opts: BreadcrumbOpts?) -> WidgetNode` — build a hbox of breadcrumb segments. Home (`⌂`) segment always renders first; each `/`-delimited segment renders as a button whose callback id is `<onNavigate>-<absolute-path>`.
Types:
- `BreadcrumbOpts = { id: string?, onNavigate: string? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
local crumb = Z.fs.breadcrumb("/zero/runtime/scenes/", {
onNavigate = "files-nav",
})
app:on("^files%-nav%-(.+)$", function(_v, _id, path)
state.currentPath = path
end)
```
## Notes
- Stateless. No state ownership, no engine calls beyond the widget
primitives.
- A trailing slash on the input path is stripped before splitting
unless the whole input is `/`.
- The home crumb's callback id is `<onNavigate>-/` so a single router
rule (`^files%-nav%-(.+)$`) covers root too.
# controller
The state half of the generic Explorer. The `Z.fs.*` builders are stateless (data in, widgets out); this controller owns the VFS-backed navigation state and the callback routing that drives them, so both the Files panel and the save/load dialog reuse one model and one router.
It operates on a caller-owned `slot` table (so two Explorers — e.g. the panel and a dialog — keep independent state) and a caller-chosen callback `prefix` (so their widgets' callbacks don't collide). The controller caches directory listings, tracks the current directory, the expanded folder set, the selected path, and a file preview.
## API
- `M.init(slot, opts?) -> slot` — idempotently seed a controller state slot. `opts.root` defaults to `/zero/`.
- `M.model(slot)` — build the render model the `Z.fs.explorer` / `Z.fs.dialog` builders consume: `{ root, currentDir, breadcrumbPath, treeRoots, listEntries, selectedPath, expanded, preview }`.
- `M.handle(slot, prefix, callbackId, data?) -> boolean` — route an Explorer callback against this slot; returns true when handled.
- `M.invalidate(slot, pathPrefix?)` — drop cached listings under `pathPrefix` (or all when nil) so fresh writes show.
- `M.readPreview(path) -> (text|nil, note, size)` — read a file preview; refuses binary, truncates large text.
- `M.confirmTarget(slot, mode, filename) -> path|nil` — resolve the absolute target a dialog confirm should act on. In `"save"` mode it's `currentDir .. filename` (nil if filename is empty); in `"open"` mode it's the selected file (nil if nothing or a folder is selected).
## Callback grammar
All callbacks carry the absolute path verbatim:
- `<prefix>-nav-<path>` — breadcrumb segment
- `<prefix>-tree-select-<path>` — tree row
- `<prefix>-tree-toggle-<path>` — tree chevron
- `<prefix>-list-<d|f>-<path>` — details-list row (`d` = dir, `f` = file)
- `<prefix>-up` — parent directory
- `<prefix>-refresh` — drop cache for the visible subtree
## Usage
```luau
local fsc = require("modules.zui.widget.fs.controller")
fsc.init(state.files, { root = "/zero/" })
local model = fsc.model(state.files)
local widget = Z.fs.explorer(model, { prefix = "files" })
-- in onCallback:
if fsc.handle(state.files, "files", callbackId, data) then end
```
# detailsList
The right pane of the two-pane Explorer: a details list of one directory's entries with an icon, name, and kind column. Folders sort first (warm accent), then files. Stateless — the caller passes the entries (each already carrying its absolute `path`) and a callback prefix, and receives a scrollable column of clickable rows.
A row click emits `<onActivate>-<d|f>-<path>` — the `d`/`f` marker tells the handler folder-vs-file without a second lookup, and the absolute path is carried verbatim. Each row shows a phosphor icon, a clickable name button, and a kind column derived from the file extension.
The module returns the builder function directly.
## Builder
`build(entries?, opts?) -> widget node`.
- `entries` — array of `{ name, isDirectory?, path }` (path = the entry's absolute path).
- `opts` (`DetailsListOpts`) — `id`, `onActivate` (callback prefix; rows emit `<onActivate>-<d|f>-<path>`), `selectedPath` (the highlighted row), `maxHeight` (caps the scroll height; otherwise the pane fills available height via flex), `showHeader` (the Name · Kind header row, default on).
Returns a scroll-wrapped column with an optional header row and one row per entry; an empty list renders `(empty)`.
## Usage
```luau
Z.fs.detailsList(entries, {
onActivate = "files-list",
selectedPath = state.selectedPath,
})
```
# dialog
A modal save / load file picker: the same two-pane Explorer (`Z.fs.explorer`) inside a `Z.modal`, plus a filename field (save mode) and Open/Save + Cancel buttons. The panel and the dialog share one Explorer and one controller, so navigating feels identical in both.
Stateless builder: pass the controller model and the dialog options; the host owns `open`, the filename string, and the confirm/cancel wiring. The module returns the builder function directly.
## Builder
`build(model, opts?) -> widget node` (a `Z.modal` tree; renders nothing when `open` is false).
- `model` — the `Z.fs.controller.model(slot)` result for the dialog's own controller slot.
- `opts` (`DialogOpts`) — `prefix` (callback prefix, default `"fsdlg"`), `open` (modal visibility, host-owned), `title`, `mode` (`"save"` for a filename field, `"open"` default), `filename` (save-mode field value), `confirmLabel` (defaults to "Save"/"Open" by mode), `width` (default 640), `height` (default 460), `treeFrac`, `id`.
## Callbacks
Beyond the Explorer's `<prefix>-*` navigation:
- `<prefix>-filename` — filename field changed (value in the event payload)
- `<prefix>-confirm` — Open/Save pressed
- `<prefix>-cancel` — Cancel pressed or the modal dismissed
## Usage
```luau
-- host state: state.dlg = {} (controller slot), open, filename
Z.fs.dialog(Z.fs.controller.model(state.dlg), {
prefix = "savedlg", open = state.open, mode = "save",
title = "Save Layout", filename = state.filename,
confirmLabel = "Save",
})
-- host onCallback:
-- Z.fs.controller.handle(state.dlg, "savedlg", cb, data) -- navigation
-- "savedlg-filename" → state.filename = Z.eventValue(data)
-- "savedlg-confirm" → path = Z.fs.controller.confirmTarget(state.dlg, "save", state.filename)
-- "savedlg-cancel" → state.open = false
```
# explorer
A two-pane filesystem Explorer composite: a breadcrumb plus up/refresh actions on top, a folder tree on the left, and a details list (icon · name · kind) of the current directory on the right. Stateless — pass the model from `Z.fs.controller.model(slot)` and a callback `prefix`; the controller's `handle(slot, prefix, ...)` routes the callbacks this emits.
The same composite is the body of `Z.fs.dialog` (the modal save/load picker), so the panel and the dialog look and behave identically. The explorer fills its parent's height via flex, so the host panel just needs to give it room.
The module returns the builder function directly.
## Builder
`build(model, opts?) -> widget node`.
- `model` — the `Z.fs.controller.model(slot)` result: `{ root, currentDir, breadcrumbPath, treeRoots, listEntries, selectedPath, expanded }`.
- `opts` (`ExplorerOpts`) — `prefix` (callback prefix, default `"fs"`), `treeFrac` (tree pane fraction of width, 0..1, default 0.4), `id`.
Returns a vbox of the action row (up + refresh + breadcrumb) and a resizable horizontal split of the tree and details-list panes.
## Usage
```luau
local model = Z.fs.controller.model(state.files)
Z.fs.explorer(model, { prefix = "files", treeFrac = 0.4 })
```
# fileList
Flat row list of files in a directory. Folders first (orange), then
files (selected file highlighted). Stateless — caller passes the
entries array `[{name, isDirectory}]` and a callback prefix; receives
a scrollable vbox of clickable rows. Each click emits
`<onSelect>-<index>` where index is 1-based into the *original*
entries array, so the caller doesn't need a sorted-vs-original map.
## Exports
- `fileList(entries: { FileEntry }?, opts: FileListOpts?) -> WidgetNode` — build a scrollable vbox of rows. Folders style differently from files; the selected file row is highlighted.
Types:
- `FileEntry = { name: string, isDirectory: boolean? }`
- `FileListOpts = { id: string?, onSelect: string?, selectedKey: string?, sort: string?, maxHeight: number? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
return Z.fs.fileList(state.entries, {
onSelect = "files-row",
selectedKey = state.selectedName,
sort = "dirs_first", -- or "alpha"
})
app:on("^files%-row%-(%d+)$", function(_v, _id, idx)
state.selectedName = state.entries[tonumber(idx)].name
end)
```
## Notes
- Stateless. The caller owns the entries array and the selection key.
- The callback id carries an index (1-based) rather than the file
name, so unusual characters in paths can't break the router.
- `sort` defaults to `"dirs_first"` (folders alphabetically, then files
alphabetically). `"alpha"` ignores type. Any other value preserves
the caller's order.
- An empty entries array produces a single `(empty directory)` label.
# preview
File preview pane — header, optional note line ("Size: 12345 bytes" /
"Truncated: ..."), separator, and the body. Body is monospace text
when `text` is non-nil, or a "(no preview)" line when nil. Stateless:
caller decides what `text`/`note` should be by reading the file (e.g.
via `vfs.read`).
## Exports
- `preview(data: PreviewData?, opts: PreviewOpts?) -> WidgetNode` — build the preview pane. When all three `data` fields are nil, renders a "Select a file to preview." placeholder.
Types:
- `PreviewData = { name: string?, text: string?, note: string? }`
- `PreviewOpts = { id: string?, scrollHeight: number?, onCopyPath: string? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
return Z.fs.preview({
name = state.name,
text = state.text,
note = "Size: " .. tostring(state.size) .. " bytes",
}, {
id = "files-preview",
scrollHeight = 480,
onCopyPath = "files-copy",
})
```
## Notes
- Stateless. Caller owns the file read and decides what `text` and
`note` should be (truncation messaging, byte counts, hexdumps, etc.
are all the caller's job).
- `scrollHeight` defaults to 480; only applies when `data.text` is
non-nil (the body is wrapped in a `scroll` container).
- The copy-path button is only rendered when `opts.onCopyPath` is set;
the click emits that callback id verbatim so the caller routes it.
# tree
Folder tree composite. Wraps `Z.tree` with folder-friendly defaults:
folder icon for directories, file icon for leaves, callback ids that
carry the absolute path. Stateless — caller owns the recursive node
table. Use this when you want a single deep tree of folders + files;
for the classic two-pane ("folder tree | file list") layout, use this
for the left pane and `Z.fs.fileList` for the right.
## Exports
- `tree(roots: { FsNode }?, opts: FsTreeOpts?) -> WidgetNode` — build a recursive folder tree. Callback ids: `<onSelect>-<path>` (label click), `<onToggle>-<path>` (chevron click).
Types:
- `FsNode = { name: string, path: string?, isDirectory: boolean?, children: { FsNode }?, expanded: boolean? }`
- `FsTreeOpts = { id: string?, onSelect: string?, onToggle: string?, selectedKey: string?, expanded: { [string]: boolean }?, basePath: string?, indentPx: number?, gap: number? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
return Z.fs.tree(state.roots, {
onSelect = "files-tree-select",
onToggle = "files-tree-toggle",
selectedKey = state.selectedPath,
expanded = state.expandedSet,
})
app:on("^files%-tree%-select%-(.+)$", function(_v, _id, path)
state.selectedPath = path
end)
app:on("^files%-tree%-toggle%-(.+)$", function(_v, _id, path)
state.expandedSet[path] = not state.expandedSet[path]
end)
```
## Notes
- Stateless. Caller owns selection, expansion, and on-demand listing
(the `onToggle` handler is where you'd `vfs.list` and refill
`children` for the next render).
- Folders without listed children (`children == nil`) still get a
chevron via `expandable = true`. This is the lazy-load shape.
- `basePath` defaults to `/`. Used when an `FsNode` has no `path` —
the recursive walk builds the absolute path on the fly.
# shell
Pre-built shells for common app shapes. Each shell is a function taking
a single props table; missing fields fall back to sensible defaults.
See `docs/specs/ui-v3-architecture.md` Layer 5 for the canonical
signatures.
## Exports
- `Shell.docked` — toolbar + body + status layout. See `docked.module/`.
- `Shell.canvas` — node-graph editor with bezier connections. See `canvas.module/`.
- `Shell.inspector` — form-with-fields-and-actions pane. See `inspector.module/`.
- `Shell.tabApp` — visibility-driven tabbed app with per-tab refresh scheduler. See `tabApp.module/`.
## Usage
```luau
local Shell = require("@builtin::modules.zui.shell")
Shell.docked { top = ..., center = ..., bottom = ... }
Shell.tabApp { name = "system_tools", tabs = ..., tabOrder = ... }
```
## Notes
- This module is a pure aggregator: it does nothing but re-export the
four shell submodules. Each shell owns its own behaviour, defaults,
and contract; consult the respective submodule for the prop shapes.
- Missing slots in any shell are skipped cleanly — there are no empty
containers, no wasted space.
# dynamic
`Z.dynamic(list, fn)` — one screen per list item. The `Z.app` mount/tick
loop recognises the wrapper and registers `<base>-<item.id>` screens;
on every tick it diffs against the previously-registered ids and
unregisters screens whose items disappeared.
## Exports
- `Dynamic.create(list: { any }, fn: (any, number) -> any) -> Wrapper` — build a dynamic-list bucket consumed by `Z.app`.
- `Dynamic.is(value: any) -> boolean` — identify a `Z.dynamic(...)` wrapper.
- `Dynamic.itemId(item: any, index: number) -> string` — derive a stable per-item id (falls back to the array index).
Types:
- `Wrapper = { list, fn, ... }` — opaque wrapper carrying the `__zuiDynamic = true` tag.
## Usage
```luau
local Dynamic = require("@builtin::modules.zui.dynamic")
local Z = require("@builtin::modules.zui")
return Z.app("inventory", function(state)
return {
items = Z.dynamic(state.items, function(item, index)
return Z.lbl(item.label)
end),
}
end)
```
## Notes
- The wrapper carries an internal `__zuiDynamic = true` tag — call
`Dynamic.is(v)` instead of inspecting keys directly.
- Each item should expose a stable `.id` field; without one, the screen
name uses the array index, which makes ordering changes feel like
add/remove events.
- The list is read by reference on every tick — mutating it between
ticks is the normal way to drive add/remove/reorder.
- Unregistration is automatic: when an item disappears from the list,
its per-item screen is unregistered on the next tick.
# utils
Formatting and dispatch helpers shared across zui layers. Anything
domain-agnostic and useful both inside zui and to library consumers
goes here — numeric formatting (WASM-safe, no `string.format` dependency),
stable hierarchical widget id construction, callback payload unwrap, and
small list comprehensions.
## Exports
- `M.round(v: number?, decimals: number?) -> number` — round to `decimals` places (default 2). `nil` → 0.
- `M.fmt(v: number?, decimals: number?) -> string` — `tostring(round(v, decimals))`.
- `M.fmtVec3(vec: Vec3Array?, decimals: number?) -> string` — format `{x, y, z}` as `"(x, y, z)"`.
- `M.id(parent: any, child: any) -> string` — stable widget id (`"parent/child"`); empty parent drops the prefix.
- `M.eventValue(data: EventEnvelope?) -> any` — unwrap `data.value` from v2 event envelopes; pass through raw payloads (`nil` returns `nil`).
- `M.map(items: { any }?, fn: (any, number) -> any?) -> { any }` — map and drop `nil`s.
- `M.when(cond: any, widget: any) -> any` — `widget` if `cond`, else `nil`. Pairs with `compact`.
- `M.compact(widgets: { any }?) -> { any }` — drop `nil` entries.
Types:
- `Vec3Array = { number }` — array form, `{ x, y, z }`.
- `EventEnvelope = { value: any? } | any`
## Usage
```luau
local Utils = require("@builtin::modules.zui.utils")
Utils.round(1.2345) -- 1.23
Utils.fmt(1.2345, 1) -- "1.2"
Utils.fmtVec3({1, 2, 3}) -- "(1.00, 2.00, 3.00)"
Utils.id("toolbar", "save") -- "toolbar/save"
local rows = Utils.map(items, function(r) return Z.lbl(r.name) end)
local kids = Utils.compact{ Z.lbl("a"), Utils.when(showFoo, Z.lbl("foo")) }
```
## Notes
- Pure module — no engine calls, no module state. Safe to call from any
context.
- `round` / `fmt` / `fmtVec3` are WASM-safe; they avoid `string.format`
for the rounding step and use repeated multiplication for `10^n`.
- `eventValue` is the canonical onCallback unwrap — preferred over manual
`data.value` access because it transparently handles the older raw-payload
flow too.
- `map` / `compact` / `when` are the canonical list comprehensions; demos
rely on the `nil` filtering for conditional widgets.
# app
Layer 3 lifecycle wrapper for `Z.app`. Collapses the
register-or-update dance, owns multiple named screens, ticks reactive
state, and routes callbacks via an embedded `Router`. Per
`docs/specs/ui-v3-architecture.md` Layer 3. The instance is a callable
`{ buildFn }` plus a `State` reactive store and a `Router` callback
dispatcher; mount → tick → unmount drives `ui.registerScreen` /
`ui.updateScreen` / `ui.unregisterScreen` under the hood.
## Exports
- `App.create(name: string, builderFn: BuilderFn) -> AppInstance` — construct an App.
- `App.current() -> AppInstance?` — the App whose builder is currently rendering (used by widget builders that auto-stash state).
Per-instance methods (on `AppInstance`):
- `:mount(ctx: any) -> AppInstance` — initial register; idempotent across re-mounts.
- `:tick(dt: number?)` — re-render dirty screens from the host's `update(dt)`.
- `:unmount()` — unregister every owned screen and drop the token claims.
- `:markDirty()` / `:isDirty() -> boolean` — dirty bit control.
- `:on(idOrPattern, handler) -> AppInstance` — router subscription (chainable).
- `:cb(id, handler) -> id` — router callback registration; returns the id.
- `:dispatch(callbackId, data?)` — router dispatch.
- `:set(key, value) -> AppInstance` / `:update(updates) -> AppInstance` — state shortcuts.
- `:_screensForTest()` / `:_routerForTest()` — test introspection.
- Internals (`_nextAnonId`, `_runBuilder`, `_register`, `_renderOne`, `_isRegistered`, `_isFirstRegister`, `_forgetScreen`, `_showAll`) are exposed for use by `Z.app`'s helpers but should not be called externally.
Types:
- `AppInstance` — opaque App table carrying `name`, `state`, the embedded router, and registered-screen bookkeeping.
- `BuilderFn = (state: any, ctx: any) -> { [screenName] = buildFn | dynamic }`
- `RenderOpts = { layer: number?, tags: { string }? }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
local app = Z.app("inventory", function(state, ctx)
state:default("count", 0)
return {
main = function()
return Z.btn("count=" .. state:get("count"), "inc")
end,
}
end)
app:on("inc", function() app:set("count", app.state:get("count") + 1) end)
app:mount(self)
-- in update(dt):
app:tick(dt)
-- on destroy:
app:unmount()
```
## Notes
- A per-process token-based stale-screen guard keeps a second App with
the same screen name from doubling-up `ui.updateScreen` pushes. The
newest mounted App wins; older instances skip their tick silently.
- Closure-as-callback widgets (`Z.btn("Save", fn)`) get stable
per-screen ids via `_nextAnonId` so re-ticks update the existing
router entry instead of accumulating handlers.
- `Z.dynamic(list, fn)` values in the builder's return table are
exploded into per-item screens named `<base>-<item.id>`; vanished
items are unregistered automatically on the next tick.
- `{ build, layer, tags }` is the supported sugar for screens that need
`ui.registerScreen`'s optional layer or `Z.tags.set` entries. Tags
are written immediately after register so the editor toggles and
`Z.tags.findByTag` see them on the same frame.
- A build that returns `nil` means "this screen has nothing to draw this
frame": the App hides the screen and shows it again on the first tick
the build produces a tree. That is the whole of the App's claim on
visibility — a screen hidden by anyone else (`ui.hideScreen`,
`Z.screens`, the editor's F1 toggle via `Z.tags.hideByTag("editor")`)
stays hidden while the App keeps pushing tree updates on its refresh
clock, and becomes visible again when that caller shows it.
- Errors thrown by a builder propagate through `pcall` and re-raise
with `error(..., 0)` so the screen name is preserved in the
traceback.
# highlight
Syntax-highlighting dispatch + per-language modules. Each language
module exports `(text: string) -> { Segment }` where
`Segment = { text, color, monospace?, italic? }`. `Z.codeEditor` calls
into `dispatch` at update time and passes the resulting segments to
TextInput, which builds the egui LayoutJob in pure Luau. Library authors
can ship their own `@author/zui_highlight_<lang>` modules and slot them
into `Z.codeEditor` via the same `language` opt.
## Exports
- `M.dispatch(language: string, text: string) -> { Segment }` — pick a per-language highlighter (with `yml` / `md` aliases) and tokenize; unknown languages fall through to a single plain segment.
- `M.lua: Highlighter` — Lua highlighter.
- `M.json: Highlighter` — JSON highlighter.
- `M.yaml: Highlighter` — YAML highlighter.
- `M.wgsl: Highlighter` — WGSL highlighter.
- `M.markdown: Highlighter` — Markdown highlighter.
Types:
- `Segment = { text: string, color: string, monospace: boolean?, italic: boolean? }`
- `Highlighter = (string) -> { Segment }`
## Usage
```luau
local Highlight = require("@builtin::modules.zui.highlight")
local segs = Highlight.dispatch("lua", source)
-- segs = { { text = "local", color = "#...", monospace = true }, ... }
-- Or call a specific language directly:
local segs2 = Highlight.json(jsonText)
```
## Notes
- Per-language colors come from the active theme's `code.*` tokens
(`ui.getToken("code.keyword")` etc.). Switching themes
(`ui.setTheme("light")`) automatically re-colors on the next call.
- A single-slot LRU cache keyed by `(language, text, theme)` makes
same-text re-emits free (focus-loss redraws, no-op editor pushes).
Cache returns a shared array reference — callers must not mutate it.
- Aliases: `"yml"` → `yaml`, `"md"` → `markdown`. Unknown languages
return a single default-colored monospace segment so user input never
crashes the dispatcher.
## Roles the Lua highlighter reads
`code.keyword`, `code.string`, `code.number`, `code.comment`, `code.bool`
(`true` / `false` / `nil`), `code.attribute` (`--!` directives),
`code.type` (a name after `:`, `->`, `::` or `type`), `code.function` (a name
a call is made through, or one `function` declares), `code.property` (a
member reached by `.` that is not called), `code.builtin` (the standard
library's globals), and `code.default` for the rest.
# theme
Theme tokens for zui (Layer 2). Reads through `ui.getToken(name)` first
so a loaded engine theme propagates automatically; falls back to
in-module defaults that cover every named color used by the demos. Also
owns the Luau-side `$variable` cascade — `register` / `load` resolve
references end-to-end (cycle-detected) and push flat values to
`ui.registerTheme`.
## Exports
- `M.with(overrides: TokenMap?) -> ThemeView` — read-only view with overlays on top of engine/defaults.
- `M.defaults() -> TokenMap` — raw default token map (same reference each call).
- `M.tokenNames() -> { string }` — sorted list of shipped token names.
- `M.resolve(theme: any) -> (ResolvedTheme?, string?)` — flatten a theme table (`$var` → literal). Returns `(nil, errMsg)` on failure.
- `M.resolveTokens(theme) -> (TokenMap?, string?)` — re-export from cascade.
- `M.resolveStyles(theme, tokens) -> (StyleMap?, string?)` — re-export from cascade.
- `M.register(name: string, theme: any) -> (boolean, string?)` — resolve and push to `ui.registerTheme`.
- `M.load(name: string) -> (boolean, string?)` — require `@builtin::themes.<name>` and register it.
- `M.activate(name: string) -> boolean` — thin wrapper over `ui.setTheme(name)`.
- `M.default: ThemeView` — module-level token view with no overrides.
Types:
- `TokenMap = { [string]: any }`
- `StyleMap = { [string]: { [string]: any } }`
- `Theme = { name: string?, tokens: TokenMap?, styles: StyleMap? }`
- `ResolvedTheme = { name: string, tokens: TokenMap, styles: StyleMap }`
- `ThemeView` — read-only metatable proxy; writes throw.
## Usage
```luau
local Theme = require("@builtin::modules.zui.theme")
Theme.load("dark") -- require + register the built-in dark theme
Theme.activate("dark") -- ui.setTheme("dark")
local view = Theme.with({ accent = "#ff0" })
print(view.accent) -- "#ff0"
print(view.bg) -- engine token or default
```
## Notes
- `register` overrides `theme.name` with the caller-supplied name so
`listThemes()` / `setTheme(name)` find it under the requested key
(mirrors the pre-Phase-4 #942 fix).
- The cascade is cycle-detected — broken inputs surface as structured
`(false, errMsg)` returns instead of silently producing garbage colors.
- `M.default` is built at module-load time, after `M` is fully populated,
so its function-fallback `__index` resolves correctly.
- `ThemeView` writes raise — use `Z.themeWith({...})` to get an overlay
view rather than mutating the existing one.
# state
Reactive state container for `Z.app`. Backs the Layer 3 example in
`docs/specs/ui-v3-architecture.md` — `state:default(...)`,
`state.nodes`, `state:set("size", n)`, etc. Writes via the data-store
path (`state.foo = bar`) or the method path (`state:set("foo", bar)`)
both auto-mark the owning App dirty.
## Exports
- `State.create(initial: { [string]: any }?, app: App?) -> StateInstance` — make a new container.
- Instance methods (also reachable via reactive `state.foo = bar` writes):
- `state:default(initial)` — write only keys not already present; idempotent. Does NOT mark dirty.
- `state:get(key)` — typed read of a data-store key.
- `state:set(key, value)` — write one key; marks dirty on change.
- `state:update(updates)` — bulk write; single dirty-mark per call.
- `state:all()` — return the raw data-store table.
Types:
- `App = { markDirty: ((App) -> ())? }` — minimum owning-App contract.
- `StateInstance` — the metatable-bound container; supports `state.foo` field access in addition to the methods.
## Usage
```luau
local State = require("@builtin::modules.zui.state")
local s = State.create({ count = 0 }, app)
s:default({ size = 10 }) -- idempotent, no dirty mark
s.nodes = { ... } -- reactive __newindex, marks dirty
s:set("count", 5) -- method path, marks dirty
```
## Notes
- The methods table is consulted by `__index` BEFORE the data store, so
`state:default` resolves to the method even if a `default` data key
exists.
- `:default` is a setup-time merge and intentionally does NOT mark the
App dirty — it's safe to call inside an `App.create` builder.
- `:update` collapses multiple key writes into a single dirty-mark; use
it in hot loops where the per-key `__newindex` path would mark dirty
on every assignment.
# tags
Luau-owned screen-tag registry for zui. Tags are pure derived metadata
over the `ui.hideScreen` / `ui.showScreen` / `ui.listScreens`
capabilities; the source of truth lives here, not in the engine. Typical
use is the editor F1 toggle — every editor-owned UI screen is tagged
`"editor"` at register time and the editor's default scene flips them all
in one call via `hideByTag` / `showByTag`.
## Exports
- `M.set(name: string, tags: { string }?)` — replace `name`'s tag set with the array. Nil/empty clears.
- `M.add(name: string, tag: string)` — add a single tag, creating the entry if absent.
- `M.remove(name: string, tag: string)` — drop a tag; drops the registry entry when the set becomes empty.
- `M.clear(name: string)` — drop every tag for `name` (idempotent).
- `M.get(name: string) -> { string }` — sorted array of tags for `name` (empty when no entry).
- `M.findByTag(tag: string) -> { string }` — sorted screens currently registered AND visible-via-`ui.listScreens` carrying `tag`.
- `M.hideByTag(tag: string)` — hide every currently-visible screen tagged with `tag`.
- `M.showByTag(tag: string)` — show every currently-hidden screen tagged with `tag`.
- `M._registry: Registry` — internal `{ [name] = { [tag] = true } }` map. Inspectable but treat as private.
Types:
- `TagSet = { [string]: boolean }`
- `Registry = { [string]: TagSet }`
- `ScreenInfo = { name: string, visible: boolean }`
- `LiveScreens = { [string]: ScreenInfo }`
## Usage
```luau
local Tags = require("@builtin::modules.zui.tags")
Tags.set("inspector", { "editor" })
Tags.add("console", "editor")
Tags.hideByTag("editor") -- hides every visible "editor"-tagged screen
Tags.showByTag("editor") -- shows every hidden "editor"-tagged screen
local screens = Tags.findByTag("editor") -- sorted live matches
```
## Notes
- Registry is not auto-pruned. `ui.listScreens()` is one frame behind, so
same-frame register-then-tag flows must keep their entries intact;
bulk ops filter via the live screen snapshot.
- Memory is bounded by total session screen count.
- `set` with `nil` / `{}` is the only way to clear an entry through `set`;
use `clear(name)` for the explicit variant.
- All bulk ops are silent on missing FFI bindings (`ui.hideScreen`,
`ui.showScreen`, `ui.listScreens`) — safe to call before the UI layer
is wired.
# dataSource
TTL + gated cached data fetcher. Used by tools that pull live data from
the engine at a slower cadence than the UI rebuilds. A closed gate
returns the last cached value WITHOUT calling the fetch function; within
TTL, the cached value is returned directly. Errors from the fetcher are
swallowed by default (last value preserved) and surfaced through the
optional `onError` hook.
The module table itself is callable as a shorthand for `.create`.
## Exports
- `M.create(fetchFn: () -> any, opts: Opts?) -> Source` — build a new source. Also reachable as `M(fetchFn, opts)`.
- `M.invalidate(key: string)` — force a re-fetch on the next read for a registered key.
- `M.reset()` — wipe the registry; release retained values.
- `M._seedForTest(key: string, value: any) -> Source?` — test hook: install a value without invoking the fetcher.
- `M._peekForTest(key: string) -> Source?` — test hook: look up a source by key.
Per-instance methods (on `Source`):
- `source:read() -> value` — read with TTL + gate semantics.
- `source()` — callable shorthand, equivalent to `source:read()`.
- `source:invalidate()` — clear cached value and timestamp.
- `source.version: number` — monotonic counter bumped on every successful fetch.
Types:
- `Opts = { ttl: number?, gate: (() -> boolean)?, key: string?, onError: ((any) -> ())? }`
- `Source` — instance carrying `fetchFn`, `ttl`, `gate`, `onError`, `key`, `version`, and the internal `_value` / `_t` cache slots.
## Usage
```luau
local DataSource = require("@builtin::modules.zui.dataSource")
local entities = DataSource(function()
return wld.list()
end, { ttl = 0.2, key = "entities:list" })
local list = entities() -- callable shorthand
local same = entities:read() -- explicit form
DataSource.invalidate("entities:list") -- force re-fetch
```
## Notes
- TTL `0` means "always re-fetch"; the cache slot still holds the last
value so a closed gate or a fetch error returns it.
- A closed gate (`gate()` returns false) skips the fetch entirely and
returns the cached value as-is — useful for visibility gating.
- Fetcher errors are swallowed (cached value preserved) and routed
through `onError(err)` when set.
- Auto-allocated keys are namespaced under `"zui:dataSource:<n>"`.
Explicit keys are preferred so `invalidate` and the test hooks have a
stable handle.
# screens
Derived screen-lifecycle helpers on top of the `ui.*` capability
surface. Every operation is computed from `ui.listScreens()` +
`ui.hideScreen` / `ui.showScreen`; the module owns no engine state of
its own. Deliberately does NOT wrap `ui.registerScreen` /
`ui.updateScreen` / `ui.unregisterScreen` — those are capabilities
the engine needs to manage directly.
## Exports
- `M.exists(name: string) -> boolean` — does the screen exist in the current snapshot?
- `M.visible(name: string) -> boolean` — does it exist AND is it currently shown?
- `M.toggle(name: string)` — flip a visible screen hidden, or a hidden screen visible. Unknown screens are no-ops.
- `M.list() -> { ScreenEntry }` — pass-through to `ui.listScreens()`.
- `M.byName() -> { [string]: ScreenEntry }` — same data, map shape.
- `M.hideAll()` — hide every currently-visible screen.
Types:
- `ScreenEntry = { name: string, visible: boolean, layer: number?, hasRoot: boolean? }`
## Usage
```luau
local Screens = require("@builtin::modules.zui.screens")
if Screens.visible("hud") then Screens.toggle("hud") end
```
## Notes
- The screen snapshot is one frame behind on register/update commands —
a freshly registered screen returns `false` from `exists` until the
next frame.
- `toggle` is a state-driven flip, not a flag write — it reads the
current state before deciding to show or hide.
- All operations are safe when the `ui` global is unavailable; they
no-op or return empty values.
# router
Layer 4 callback router for `Z.app`. Pattern-matched dispatch with
auto-`data.value` extraction and a one-shot warning on unhandled ids so
authors find stale callbacks during iteration. Each router instance
owns its own handler map, pattern list, and warning gate.
## Exports
- `Router.create() -> RouterInstance` — make a fresh router.
- `Router:on(idOrPattern: string, handler: Handler) -> RouterInstance` — register a handler; patterns are auto-detected.
- `Router:cb(id: string, handler: Handler) -> string` — inline-register shorthand; returns the id so it can be bound to a widget in one expression.
- `Router:dispatch(callbackId: string, data: any?) -> boolean` — look up and invoke a handler; logs a one-shot warning on unhandled ids.
- `Router:_resetWarnings()` — test-only: clear the unhandled-id warning gate.
- `Router:_wasWarned(id: string) -> boolean` — test-only: query whether an id has been warned.
Types:
- `Handler = (value: any, callbackId: string, ...any) -> ()`
- `RouterInstance` — the metatable-bound router object.
## Usage
```luau
local Router = require("@builtin::modules.zui.router")
local router = Router.create()
router:on("save:click", function(value, id) end)
router:on("cell:%d+:%d+", function(value, id, row, col) end)
router:dispatch("save:click", data)
```
## Notes
- Pattern detection is heuristic: strings containing any of
`% ( ) [ ] + * ? ^ $ .` are treated as Lua patterns. `-` is excluded
because it is common in literal ids ("system-tools-tab-entities").
- Handlers receive `(value, callbackId, ...captures)`. `value` is
pre-extracted via `Utils.eventValue(data)` so handlers don't unwrap
`data` manually.
- Unhandled ids log Warn exactly once per id; subsequent dispatches of
the same id are silent so a per-frame fired callback doesn't drown
the log.
# declaration (module)
A type's `type.yaml`, decoded and held: the one reader behind every question
about a type as a whole — its files, whether it broadcasts its subassets, how
its instances are pictured.
## Exports
- `M.of(typeName) -> table?` — the decoded declaration, or nil when no type
answers to the name, it has no `type.yaml`, or the file does not decode (the
last with a warning naming the type). Shared: read it, do not write to it.
- `M.forget()` — drop every held declaration. The change dispatcher calls it
for every write inside an `.assetType` folder and for every removed type.
# asset_ref_shapes
Publishes what every `AssetRef<category>` answers to, so a member read on an
asset-typed value is checked against the category's own surface.
An asset category's per-instance surface is authored: a
`<name>.assetType/behavior.luau` declares `M.ref = { ... }`, and each category
declares its own. The members an `AssetRef<inputMap>` carries are therefore
knowable only to `inputMap` itself — no fixed set of types covers the ones a
world defines, and the checker has no way to guess them.
This module reads each registered category's `M.ref` table from its source
(never executing it), renders it as a Luau table type, and hands the set to
the engine. From there a `ref:method(...)` on an asset-typed value resolves
against the category's real surface: a name it does not carry is reported
along with the ones it does.
## Results shaped by the asset
Some results are shaped by the asset rather than by its category —
`inputMapRef:activate()` answers one handle per binding THAT map declares, a
set that is authored and differs per map. A category states those by
declaring `refShapes`, and this module asks it once per instance and
publishes the answers keyed by asset identity. `types/assetType` documents
the authoring side.
## When it publishes
Publishing is driven by use: every entry point that produces diagnostics
calls `ensure`, which is a no-op once the set is current. A world load, a
write inside a type definition, and a write inside any asset whose container
computes shapes each mark it stale, so the cost lands on the next check and
only if one comes.
The sweep is complete and replaces what was published before, so a category
whose type is removed stops being published.
## Reading what the checker believes
From a call site, a type that is correct and one that was never published are
the same absence of a diagnostic. `published()` tells them apart:
```lua
local shapes = require("@builtin::assetTypes.assetType.shared.refShapes").published()
shapes.categories.inputMap
--> "{ activate: () -> any, controls: () -> { any }, ... }"
shapes.returns["@builtin::inputMaps.default"].activate
--> "{ crouch: Handle, interact: Handle, jump: Handle, ... }"
```
# assetType.shared.typeYaml
A type's `type.yaml`, parsed. Every block a type declares in its `type.yaml`
(`settings:`, `inspector:`) is read through this module, so one file read and
one YAML decode serve all of them.
```lua
local TypeYaml = require("@builtin::assetTypes.assetType.shared.typeYaml")
local folder, identity = TypeYaml.folder("material")
-- "/zero/source/libs/@builtin/assetTypes/material.assetType", "@builtin::assetTypes.material"
local doc = TypeYaml.of("material")
print(doc.suffix) -- ".material"
print(doc.inspector.type) -- "inspector.luau"
```
- `TypeYaml.folder(typeName)` resolves the assetType and answers its folder
path and identity, or `(nil, nil)` when no such type resolves.
- `TypeYaml.of(typeName)` answers the decoded document, or nil when the type
does not resolve or its `type.yaml` does not decode to a table. The decode is
cached per type and read again whenever the file's content version moves, or
the type resolves to a different folder.
# scene_instantiable (module)
The instantiation contract's shared implementation. An asset type opts into
scene instantiation by defining `instantiate(self, target?, opts?)` on its
behaviour `ref` table; this module carries the parts that mean the same thing
for every type, so a caller writes ONE piece of code against all of them.
```lua
local root, idMap = ref:instantiate(target?, opts?)
```
## The contract
**IN — the base opts.** `target` is an owning entity ref: the instance lands
under (or, for a hierarchy type like a bundle, onto) that owner. With no
target the type spawns a fresh root. `position`, `rotation`, `scale`, `name`
and `temporary` place that root and mean the same for every type. `rotation`
takes three numbers as pitch/yaw/roll in DEGREES, or four as a quaternion. A
type may honour more opts of its own — `params` for a type built from declared
inputs, `idMap` / `diff` / `sourceTag` for the bundle override contract — and
says so in its own `instantiate` docs.
**OUT — the two returned values.** Every type returns the same pair:
- **`root`** — the composed root, as an `EntityRef`. Composition is
**synchronous**: the root and everything the type built under it are live
the moment the call returns, so a caller can parent to it, read its
components and hand it on in the same statement. There is no frame to wait
for and no callback.
- **`idMap`** — the `originalId -> runtimeId` map naming what the composition
spawned, `{}` for a type with no addressable children. **Never nil.** A
component that re-composes the asset on every load (`Asset`) keeps this map
and passes it back in, which is how a cross-entity reference into the
composition — `SkinnedModel.skeletonRoot` pointing at a bone — survives a
reload.
The return is enforced, not merely described: `AssetRef` dispatches every
`ref:instantiate(...)` through `result` on the way out, whether or not the
type called it. A type that returns something else fails at its own call
rather than handing its caller a nil root or a map that is sometimes absent.
## Exports
- `M.root(self, target?, opts?) -> root` — stand the root entity for a type
that spawns one: parented to `target`, born temporary when
`opts.temporary`, named `opts.name` else the asset's own name, placed.
- `M.place(root, opts?) -> root` — apply the placement opts to a root the type
already has (the adopt path: a bundle exploding onto its target, a
sceneModule reconciling under one).
- `M.result(self, root, idMap?) -> (root, idMap)` — return through the
contract. Checks `root` is a live entity ref, normalises a missing map to
`{}`, and errors naming the asset type when either is something else.
- `M.isOwned(opts?) -> boolean` — whether a component drives this call. The
`Asset` / `SceneModule` components tag their own calls with `sourceTag`;
they hold the reference and re-compose on every load. An untagged call came
straight from `ref:instantiate(...)` and has no such owner.
- `M.own(root, self, idMap?) -> root` — hand an already-composed root to an
`Asset` component pointing at `self`, so the reference and its map persist
and the composition is rebuilt on the next load. The component adopts the
live composition rather than building a second one.
- `M.check(value, constraint) -> ok, reason?` — the `sceneInstantiable`
field-constraint validator, also registered under that kind on load.
## The field constraint
`Field.instantiableRef` emits `constraint = { kind = "sceneInstantiable" }`,
and this module registers the validator for it on load. Given a written value:
1. `nil` passes — the field is optional (no asset assigned).
2. Reads the value's identity — a bare string, or a table's `__ref` / `guid` /
`identity` / `name`.
3. Resolves it (`asset.resolve(identity)` — any category) and asks the
resolved ref `ref:canInstantiate()`: true iff the asset's type defines an
`instantiate` method.
This is what lets `Field.instantiableRef` accept by CAPABILITY rather than a
hardcoded type list — a new scene-instantiable asset type is accepted the
moment it defines the hook, with no edit to the field or its consumers.
# asset_on_register
Drives the optional `onRegister(self)` assetType lifecycle hook — the surface that lets an asset instance run code exactly once when it first registers, operating on the per-instance ref (`self`). This is what makes "write an asset into the world → it takes effect live, no restart" work for content that registers into a runtime registry (editor panels, and anything else that opts in).
A type opts in by exporting `onRegister` from its `behavior.luau`. The hook fires exactly once per instance:
- On `engine.onWorldLoaded` — a batched sweep over every world instance of every type that implements `onRegister`, yielding every 32 instances so a large instance count never stalls a frame. The @builtin library (`/zero/source/libs/`) is excluded.
- On live `asset.create` — the create path calls `M.fireFor(ref)` on the freshly-created instance.
A per-VM once-guard keyed by the instance's VFS path ensures the two triggers can't double-register the same instance.
## Exports
- `M.fireFor(ref, onRegisterFn?) -> boolean` — fire one instance's `onRegister(self)` hook exactly once. Resolves the type's hook from `ref.type` when `onRegisterFn` isn't supplied. Idempotent: a second call on the same instance is a no-op. Returns true if the hook fired this call.
- `M.sweep()` — batched sweep: fire `onRegister` once for every world instance of every registered type that implements it. Yields, so it must run inside a task. Safe to call repeatedly.
- `M.install()` — install the world-loaded sweep. Idempotent. Runs the sweep on a system task so it ticks through pause and never blocks the caller.
# previewRecipe (module)
How a type makes its instances' pictures. A type declares it in its
`type.yaml`, under `preview:`, and every instance's `preview.png` is produced
by the recipe named there.
```yaml
preview:
kind: surface # instantiate | image | surface | custom
mesh: sphere # surface: the builtin mesh the asset is painted on
```
| `kind` | The picture is | Needs |
|---|---|---|
| `instantiate` | the asset stood in the studio through its own `instantiate` | — |
| `image` | a file the instance already holds, fitted to the frame | `source.file` or `source.files` |
| `surface` | the asset painted on a builtin mesh | `mesh` |
| `custom` | whatever the type's `behavior.luau` `ref.preview` returns | — |
`angle: [yaw, pitch]` (degrees) states the bearing the camera looks from, for
any kind that renders.
The `preview:` block says how the picture is PRODUCED. Whether search embeds
that picture is a separate declaration — an `image` chunk under
`indexing.content` naming `preview.png` — so a type states both, and a type
that states one without the other gets exactly what it stated.
## Exports
- `M.of(typeName) -> PreviewRecipe?` — the recipe `typeName` declares, or nil
when it declares none or no type answers to that name. Read once and held;
a block naming an unknown `kind`, or missing what its kind needs, is refused
with a warning naming the type.
- `M.forget()` — drop every held recipe. The change dispatcher calls it, with
`declaration.forget()`, for every write inside an `.assetType` folder and for
every removed type, so an edited `type.yaml` is read again.
# asset_create
`asset.create` — the single, generic "instance a new asset of an existing type"
API. The same entry point scripts and agents use; there is deliberately no tool
wrapper, because agents author in Luau and call `asset.create(...)` directly.
```lua
local inst = asset.create("material", "my_metal", { base_color = { 0.8, 0.7, 0.2 } })
-- → AssetRef: inst.path == "/zero/source/my_metal.material", inst.guid, plus the
-- material type's ref methods.
```
`asset.create` makes a new *instance of an already-registered type* by running
that type's `onCreate(name, opts)` behaviour hook (declared in its
`behavior.luau`) and writing the produced files to
`/zero/source/<name>.<typeName>/` — the one authored location, in every mode.
While play runs, the play-mode write lock takes that write onto the **play
shadow**: live in the session, listed by
`vfs.playShadowPaths()`, and promoted or discarded on a guarded play-exit. The
asset created in play therefore carries the same identity, path and `require`
spelling it has in edit, and the session decides whether it stays.
A create whose output the world **reproduces** on every load — one made from a
component or a scene entrypoint — writes to the copy-on-write runtime store at
`/zero/runtime/assets/<identity>.<typeName>/` instead, so the code that rebuilds
it each load is its only source and the saved manifest never carries a second
copy.
## Placement
Four `opts` keys are consumed by the framework before the type's `onCreate`
hook runs, and steer where the instance lands:
| Key | Effect |
|-----|--------|
| `folder` | A relative subfolder under the source root: `/zero/source/<folder>/<name>.<typeName>/`. Groups a generator's output instead of accumulating it at the source root. |
| `into` | A resolved container ref (`.toolbox` / `.package`) to author INSIDE — lands at `<container>/<name>.<typeName>/` and registers as a member. Edit-mode only. |
| `dest` | An absolute destination path, owned by the caller (an importer building a `<name>.bundle/`). |
| `overwrite` | Re-author an existing destination in place, keeping its `.meta` guid so every reference stays valid. |
The `name` is always the asset's bare identity — the path goes in `folder`:
```lua
local rock = asset.create("mesh", "rock", { positions = p, indices = i, folder = "terrain/props" })
-- → rock.path == "/zero/source/terrain/props/rock.mesh"
```
`dest` and `into` both take precedence over `folder`. `folder` decides the
asset's identity, so it applies in every context: the runtime store is flat and
spells that identity in one folder name.
```lua
-- the same call from a component's awake()
-- → rock.path == "/zero/runtime/assets/terrain.props.rock.mesh"
-- → rock.identity == "terrain.props.rock", as it is from an execute
```
Types without an `onCreate` hook fall back to cloning their verbatim
`template/` skeleton to the same destination, so `create` works for every type.
Returns the created asset's `AssetRef` (`.path` / `.guid` plus the type's ref
methods — the same interned instance `asset.resolve` returns) on success and
raises (via `error`) on bad arguments or a failing `onCreate`.
Installed onto the FFI `asset` namespace by the prelude (`M.installInto(asset)`).
See `docs/specs/runtime-asset-copying.md`.
# assetType.shared.settings
Resolves four questions about an asset's typed settings, all answered from its
type's `type.yaml` — nothing here is found by convention.
```lua
local Settings = require("@builtin::assetTypes.assetType.shared.settings")
-- or, from inside the assetType runtime:
local Settings = require("@builtin::assetTypes.assetType.shared").settings
```
## Where does a type's schema live?
`type.yaml` names it in a `settings:` block:
```yaml
settings:
values: "settings.yaml" # where an instance's own values live
schema: { type: "settings.luau" } # one schema for every asset of this type
```
or, for a type whose settings differ per instance (an importer):
```yaml
settings:
values: "settings.yaml"
schema: { instance: "settings.luau" }
```
or, for a type whose schema is a table its instance's own source declares and
whose values live elsewhere (a component: its `public` fields, valued on each
entity it is on):
```yaml
settings:
schema: { instance: "init.luau", export: "public" }
```
Whether an instance may lack its schema file is what the type's `files:`
block says of that file. Listed under `files.optional`, it may be absent, and
an instance without it declares no settings — the importer type lists it
there, since most importers take none. Listed under `files.required`, or not
listed at all, it must load, and an instance missing it is a broken
declaration and is logged.
`schema.type` is required at the folder relative to the TYPE's own
`.assetType/` directory (one schema shared by every instance).
`schema.instance` is required relative to each INSTANCE's own folder (`init`
resolves the instance's own identity; anything else is a sibling module under
it). Without `schema.export`, the file is required as a module and its return
value IS the schema. With `schema.export`, the file is run in an environment
of its own and `export` is read from a returned table carrying it, or else
from the globals the file assigned — a component's `public` is such a global.
That environment reads through to `_G` and takes every write itself; its own
`_G` is itself, its `declare` and `computed` record into it, and its `task`
records spawned, deferred and delayed work instead of starting it. Every other
global is the real one, so a call the file makes through one runs as it does
anywhere. The schema is the declared table's Field descriptors. A schema that
resolves to nothing says why through `Settings.schemaProblemOf(ref)`: the file
could not be read, compiled or run, or declared no such table. An instance
schema is read again when its file, its type's `type.yaml`, or the entry file
of any module the asset `require`s changes. `Settings.blockFor(typeName)`
returns the raw block; `Settings.schemaForType(typeName)` and
`Settings.schemaOfInstance(ref)` resolve the two schema shapes respectively;
`Settings.schemaFor(ref)` tries the type-level schema first, falling back to
the instance-level one. `ref` naming an assetType asset itself — e.g.
`asset.resolve("texture", "assetType")`, rather than an instance of it —
reports that type's own declared schema, the same one every instance of it
reports.
## Where do an asset's values live?
`settings.values` names a file living beside the asset's own content —
`Settings.valuesPathOf(ref)` joins it onto the asset's path.
`Settings.valuesOf(ref)` / `Settings.writeValues(ref, values)` read and write
it; this module only names where it is. A type that declares its schema with
no `settings.values` keeps no values in the asset — a component's live on each
entity it is on — so `valuesOf` answers nil for it, `asset.create` refuses a
`settings` table for it, and `asset.setSettings` refuses a write.
`Settings.recordedWrite(path, text)` writes a file the way every values file
is written, and an editor uses it for any other file it writes. It writes the
whole contents, records the write into the open undo operation when one is
open (so undoing it restores what the file held), and raises when the write
does not land.
## In what order are a schema's fields declared?
Every `Field.*` constructor stamps its descriptor with `declaredOrder`, one
more than the last. Luau evaluates a table constructor's entries in the order
they are written, so `Settings.orderedNames(schema)` lists a schema's fields —
and a struct's subfields — in the order the source writes them.
## How is a new asset's settings resolved at creation?
`asset.create` calls `Settings.resolveForCreate(typeName, name, destPath,
creationOpts, callerSettings, importerSettings)` before running the type's
`onCreate`, checks the result against the schema, writes it to the values
file, and hands `onCreate` a COPY of it as `opts.settings` (`Settings.
cloneValues`) — never the same table the values file gets, so nothing
`onCreate` does to its own copy can reach what was stamped. `onCreate` reads
settings from `opts.settings` and never writes to it. `asset.create` also
seeds the change baseline with the stamped values
(`Settings.seedChangeBaseline`), so the values file's own first dispatch
reports nothing changed and the payload `onCreate` encoded is the only
encode a create runs.
The layers, lowest priority first, each overriding the ones before it field
by field:
1. The schema's own declared defaults (`Settings.defaultsOf`).
2. The type's own `settingsDefaults(name, opts)` hook, when its
`behavior.luau` exports one.
3. The Preset Manager's matching row (`Settings.presetLayerFor`).
4. The creating importer's settings for this type.
5. The `settings` table the caller passed to `asset.create`.
6. The type's own `settingsCorrections(values, name, opts)` hook, when its
`behavior.luau` exports one — applied last, once every other layer has
merged.
Layer 6 is `Settings.applyCorrections(typeName, values, explicit, context)`,
the one corrections path every settings write takes — this create chain, and
`Settings.writeValues` for every later write (`asset.setSettings`, a type's
first-encode seed, the legacy heal). `explicit` names the fields the writer
asked for: the caller's `settings` table here, the patch for
`asset.setSettings`. A correction lands SILENTLY on a field the writer did not
name — nobody asked for that value, so overriding it just states a fact. A
correction that contradicts a field the writer named REFUSES the write,
naming the field, the value asked for, and why it cannot hold — the same as
every other place this codebase refuses an explicit request it cannot satisfy
(a `Field.checkSchema` violation, a BC7 alignment error) rather than silently
rewriting it into something else. So `asset.setSettings(tex, { format =
"rgba16" })` on a mipped texture stores `generateMipmaps = false` beside it,
while `asset.setSettings(tex, { format = "rgba16", generateMipmaps = true })`
is refused.
`Field.checkSchema` runs once, over the result AFTER the corrections hook has
applied — a bad value nothing corrects refuses the creation.
### Two hooks, two different jobs
Both hooks are optional exports on a type's `behavior.luau`, alongside
`onCreate` / `onChange`. **Neither is ever handed a table to mutate** — each
reads what it's given and RETURNS a table of what it wants changed (or nil);
`resolveForCreate` and `applyCorrections` are the only places that write to
the values, by merging what a hook returns the same way every other layer
merges.
- **`settingsDefaults(name, opts) -> partial?`** supplies a DEFAULT the
schema itself has no syntax for — something only knowable at creation time,
like a naming convention or a fact read off a payload the caller handed in.
It runs early (layer 2 above), so any later layer — Preset Manager,
importer, caller — freely overrides it. `texture.assetType`'s returns
`{ format = format_for_name(name) }`, so a `_normal`-suffixed name defaults
to `"linear"` without the caller having to say so, or the high-precision
format a pre-encoded ZTEX payload's own header already carries, when the
caller supplies one — either way, an explicit `settings = { format = ...
}` from the caller still wins.
- **`settingsCorrections(values, name, opts) -> (partial?, reasons?)`**
enforces a CROSS-FIELD CONSTRAINT the schema itself has no syntax for — a
fact about one field that depends on another field's resolved value, true
regardless of which layer produced that value. It runs last (layer 6
above), over the fully merged `values`, and again on every later write of
the values file. Nothing is left unmet in what gets
validated and stamped — but a value the caller explicitly asked for is
never silently rewritten to satisfy it; `applyCorrections` refuses instead
(see above). The optional second return, `{ [field]: reason }`, supplies
the "why" for that refusal message; a field with no reason falls back to a
generic one. `texture.assetType`'s returns `{ generateMipmaps = false,
maxDimension = 0 }` whenever the RESOLVED `format` is high-precision (the
encoder refuses a mip chain or a downsample for one outright), paired with
a reason naming the format, and `nil` otherwise.
## What does a declarer declare?
`Field.settingsOf(identity, mode)` embeds another declarer's schema as a
struct field without restating it. The identity names one of two kinds of
declarer, and `Settings.schemaOfDeclarer(identity)` tells them apart by what
`identity` resolves to:
- **An assetType** (e.g. `"@builtin::assetTypes.texture"`) — embeds the
schema every instance of that type carries, resolved via
`schemaForType`. This is the type-level shared schema from the section
above, not a per-instance one — an assetType has no settings of its own
to declare per instance.
- **Anything else** (an importer, or any other asset that declares settings
of its own) — embeds that asset's own schema, resolved via
`schemaOfInstance`.
Every schema this module serves has already run its `Field.settingsOf`
fields through this resolution — a caller reading `schema.someField.fields`
never sees an unresolved `declarer` with no `fields`. Resolution descends
into an already-literal struct's `fields` and a list's `element` the same
way, so a declarer embedded several fields deep is still reached. A declarer
that embeds itself, directly or through another declarer, stops one level
short of the loop instead of recursing forever.
## What are the defaults?
`Settings.defaultsOf(schema)` reads every field's declared `default` into a
plain table — a field declared without one is absent from the result rather
than holding a `nil` value.
# asset_change_dispatch (module)
Installs `_G.__zero_dispatch_asset_change`, the Luau half of the
asset-type **change-callback** system. The engine calls it once per VFS
source write (via `zero_scripting::ffi_callbacks::fire_asset_change_dispatch`,
queued as `VfsMutation::AssetSourceWritten`).
## What it does
1. Receives the written VFS path.
2. Walks up the path to the enclosing `<name>.<type>/` typed-asset folder
(deepest registered-type suffix wins, so the direct owner of the write
is chosen for nested typed assets).
3. Loads that type's `<type>.assetType/behavior.luau` via
`@builtin::assetTypes.assetType.shared.ref.loadTypeModule`.
4. If the module exports an `onChange` function, calls
`onChange(ref, change)` where `ref` is the typed `AssetRef` for the
changed asset and `change = { path, asset, type }`.
The engine runs each dispatch as its own yieldable task, so a hook can wait
on what it reads: a `vfs.read` of bytes only the blob store holds (a binary
file on the web build) fetches them. A hook that reads only what is resident
finishes inside the dispatch that started it. The writes routed to one asset
react in the order they were routed: a write routed while that asset's hook
is waiting queues behind it and reacts once the hook has finished.
This is the type-level analogue of the component-centric
`onAssetReload(field)` fan-out: components react to assets they
*reference*; an asset *type* reacts to writes inside its own *instances*.
## Re-entrancy
While an asset's `onChange` runs, a dispatch for that same asset made from
the hook's own coroutine is suppressed, so a handler that routes a write
back into its own folder inline doesn't recurse. A write the hook makes is
routed on a later frame like any other and reaches `onChange` again once
the hook has finished. `onChange` handlers must therefore be **convergent**: diff the meaningful
state before acting and short-circuit when there's nothing to do (the
`dynamicAsset` example only regenerates when the prompt actually changed
and bails while a generation is in flight). Idempotency is the contract,
exactly as it is for services' `start`/`stop`.
## Related
- `@builtin::assetTypes.assetType.shared.ref` — owns `loadTypeModule` + the per-type `ref` method
dispatch.
- `assetTypes/assetType.assetType/template/behavior.luau` — the documented
template that shows how to author `ref`, `global`, and `onChange`.
- `assetTypes/dynamicAsset.assetType` — the reference type that uses
`onChange` for prompt-driven regeneration with version control.
# assetType.shared.inspector
Which inspector a type declares for its assets, and the declaration that file
returns. The editor's Inspector (`edui.inspector`) reads it to add, replace and
order the sections an asset shows.
## The block
A type names its inspectors in `type.yaml`, with either key or both:
```yaml
# the Inspector for every asset of the type, a file in the type's own folder
inspector: { type: "inspector.luau" }
# a file each asset may ship in its own folder, for what that asset declares
inspector: { instance: "inspector.luau" }
# both
inspector: { type: "inspector.luau", instance: "inspector.luau" }
```
`type:` is what selecting an asset of the type shows. `instance:` applies to
the things an asset declares, never to the asset's own view: a component
names `instance: "inspector.luau"`, and a component that ships that file
shapes its card on every entity it is on. Each value is a `.luau` file name.
A block that is not a map, names another key, names neither key or names a
file that is not `.luau` is malformed; the assetType validator reports it as
`assetType.inspector_block`, and a `type:` file missing from the type folder
as `assetType.inspector_file_missing`.
## The declaration
The file returns a table:
```luau
return {
-- sections this inspector adds (before Operations)
sections = {
{ id = "summary", title = "Summary", build = function(ctx)
return { ctx.widgets.text{ text = "mode is " .. tostring(ctx.settings.mode) } }
end },
},
-- a generic section id -> the section shown in its place
replace = { tags = { id = "tags", title = "Labels", build = function(ctx) return {} end } },
-- section ids, first to last; ids it does not name follow in default order
order = { "summary", "identity" },
}
```
A section is `{ id, title, icon?, open?, build(ctx) }`; `build` returns the
section's body as a list of edui nodes. Any member of the declaration other
than `sections`, `replace` and `order` must be a function (a helper the file
exports). `material.assetType/inspector.luau` is a full example.
The Inspector rebuilds an asset's sections when the asset's files change. A
section that shows state held outside those files names it with a
`signature(ref)` member: text that moves when that state moves, read every
time the Inspector checks for a rebuild, so it has to be cheap.
`renderFeature.assetType/inspector.luau` folds in whether the feature runs
and its live settings, so a feature enabled or reconfigured from a script
shows in the Inspector as it happens.
## API
```luau
local InspectorDecl = require("@builtin::assetTypes.assetType.shared.inspector")
InspectorDecl.blockFor("material") -- { type = "inspector.luau" }
InspectorDecl.blockProblem({ other = "x.luau" }) -- why a block is malformed, or nil
InspectorDecl.declaresTypeInspector("material") -- true: the asset view reads one
local decl, problem, file = InspectorDecl.declarationFor(matRef)
local cdecl = InspectorDecl.instanceDeclarationFor(compRef)
InspectorDecl.shapeProblem(decl) -- nil when well-formed
```
`declarationFor(ref)` (and `typeDeclaration(typeName)`) read the `type:` file:
- `(nil, nil, nil)` when the type names no `type:` inspector;
- `(nil, problem, file?)` when the block is malformed, or the file is missing,
raises while loading, or returns the wrong shape — the problem names the
file and what is wrong with it;
- `(declaration, nil, file)` otherwise.
`instanceDeclarationFor(declarerRef)` reads the `instance:` file in the
declaring asset's own folder, and answers `(nil, nil, file)` when that asset
ships none.
# assetType.shared.ref
Builds the metatable every `AssetRef` (the `{ __ref, type, name, guid, identity, path }` envelope returned by `asset.resolve` / `asset.ref`) carries. Replaces the per-call C metatable that used to live in `crates/zero_scripting/src/ffi/bindings/asset.rs::attach_asset_ref_metatable`.
## Public API
```lua
local assetRef = require("@builtin::assetTypes.assetType.shared.ref")
-- Called by the Rust factory (push_asset_ref_handle) via
-- _G.__build_asset_ref_proxy after the envelope fields are set.
assetRef.build(envelope) -- attaches AssetRefMT, returns the table
```
The module exposes no invalidation surface — discovery is registry-driven and hot-reload is transparent (see "Discovery and hot-reload" below).
## Method dispatch order
`ref.foo` is resolved in this order:
1. **Default methods** — `getSource` / `getBytes` / `getText` / `exists` / `inspect` and the lazy `meta` property. Per-type definitions cannot override these (engine contract from gh#1889).
2. **Per-type `ref` table** — discovered through `asset.resolve(<typename>, "assetType")` + `require(<identity>.behavior)`. The lookup resolves to whichever `<typename>.assetType/` folder is currently registered for the typename (built-in, user, or library-vendored) and pulls its `ref` methods.
3. **Nil** — unknown key, same shape as the original C metatable.
## Adding methods for a new asset type
Drop a `behavior.luau` into the type folder:
```
src/lua/lib/assetTypes/<typename>.assetType/
├── type.yaml -- structural spec (existing)
├── behavior.luau -- ref + global behaviour (new)
└── template/ -- placeholder body (existing)
```
`behavior.luau` returns:
```lua
return {
ref = {
--!desc Per-instance method on every `<typename>` AssetRef.
myMethod = function(self, ...) ... end,
},
global = {
-- Reserved for the asset.<typename>.* surface — not consumed
-- yet; included so the contract from gh#1889 lands cleanly.
},
}
```
Within `ref` methods, `self` is the AssetRef envelope: `self.path`, `self.identity`, `self.guid`, `self.type`, `self.name` are available. Engine-required fields (`guid`, `path`, `identity`, `type`) must not be redefined.
## Discovery and hot-reload
Type discovery uses the asset registry, so it doesn't matter where a `<typename>.assetType/` folder lives — engine built-ins under `@builtin::assetTypes.<X>`, user types dropped under `/zero/source/<X>.assetType/`, or types vendored inside an imported library all resolve identically. The dispatcher walks `asset.resolve(<typename>, "assetType")` → canonical identity → `require <identity>.behavior` on every miss; the asset registry is the cache for the resolve half and Luau's `_LOADED` is the cache for the require half.
Hot-reload is transparent. When a `<typename>.assetType/behavior.luau` is rewritten through VFS, the engine's `invalidate_require_cache_for_state` re-runs the module and mutates the cached `_LOADED[<identity>.behavior]` table in place. Dispatch reads `mod.ref` on every access (rather than caching a sub-pointer), so existing AssetRefs see the new methods on the very next access — no Rust-side hook into this module is needed, and no invalidation API is exposed for callers to call.
The same registry-driven path also handles type **registration** correctness: dropping a new `<typename>.assetType/behavior.luau` updates the asset registry, the next `asset.resolve(<typename>, "assetType")` returns the new ref, and AssetRefs of that type immediately gain the new methods.
## Why Luau, not Rust
The asset surface needed per-category behaviour (`material:getProperties()`, `bundle:instantiate(entityId)`, `tool:run(args)`) and the old shape couldn't grow without an FFI change per method. Owning the proxy in Luau means:
- Type-specific methods ship as content (the `<typename>.assetType/` folder), not engine code.
- Adding a new asset type drops in a new folder; no Rust rebuild.
- Methods can call any other Luau global (`vfs`, `asset`, `Material`, `entity`, etc.) directly, instead of going through narrow FFI primitives.
## Consumers
- `crates/zero_scripting/src/ffi/bindings/asset.rs::attach_asset_ref_metatable` — the Rust factory that pushes envelopes invokes `_G.__build_asset_ref_proxy` once per push.
- Every Luau call site of `asset.resolve` / `asset.ref` / `entity(id).component.X.material` / any binding declared `AssetRef<...>` — they all receive envelopes built through this module.
# args
Shared argument resolver for zui container builders (`panel`, `vbox`, `hbox`, `grid`, …). Container builders historically took `(children, opts)` with children first, but the intuitive call is a single options table `Z.panel({ style = …, children = {…} })`. Passed to a `(children, opts)` builder, that table landed in the children slot and its `children` key was silently dropped. `resolve` removes that pit: it accepts every unambiguous shape and routes it correctly, and raises a precise error when a call is genuinely ambiguous instead of silently dropping content.
## Exports
- `resolve(builderName: string, a: any, b: any) -> ({ any }, { [string]: any })` — returns `(children, opts)` — an array of child widget tables (possibly empty) and an options table (possibly empty).
## Accepted shapes
```luau
resolve("panel", { childA, childB }, opts?) -- canonical array + opts
resolve("panel", singleChildNode, opts?) -- a lone widget (auto-wrapped)
resolve("panel", { style = …, children = {…} }) -- single options table
resolve("panel", { style = … }) -- options only (no children)
resolve("panel", nil, opts?) -- no children
```
## Errors (never silent)
- a children array that ALSO carries a `children` key
- an options-shaped first argument passed alongside a 2nd options argument
- `opts.children` that isn't an array
## Usage
```luau
local resolve = require("@builtin::modules.zui.widget.args")
local children, opts = resolve("panel", a, b)
```
# _rowCanvas
Shared selectable-row factory used by `Z.radioGroup` and
`Z.selectableList`. Each row is a single focusable `canvas` widget that
draws its background + optional radio glyph + label in widget-local
coords. Because the whole row is one canvas (not a composition of
glyph-canvas + label + container with click handlers), Tab cycles
row-by-row naturally via egui's focus chain, click commits selection via
the canvas's `onClick` prop, and arrow keys reach the parent group's nav
handler via the canvas's `onKey` prop. Underscore prefix marks it as a
package-internal helper — not exported from `zui.widget` and not part of
the `Z.*` surface.
## Exports
This module returns the row factory function directly. There is no
returned table.
- `rowCanvas(opts: RowOpts?) -> Node` — build a selectable-row canvas node.
Types:
- `RowOpts = { groupId?: string, index?: number, label?: string,
selected?: boolean, kind?: "radio" | "option", width?: number,
height?: number, bg?: string, fg?: string, selectedBg?: string,
selectedFg?: string }`
- `Node = { [string]: any }` — opaque canvas widget node.
## Usage
```luau
local rowCanvas = require("@builtin::modules.zui.widget._rowCanvas")
return Z.vbox{
rowCanvas{ groupId = "themes", index = 1, label = "Dark", selected = true, kind = "radio" },
rowCanvas{ groupId = "themes", index = 2, label = "Light", selected = false, kind = "radio" },
}
```
## Notes
- Click commits selection via `onClick = <groupId>:select:<index>`; the
parent group's callback subscribes to that event.
- Arrow keys raise `onKey = <groupId>:nav` so the parent group handles
keyboard navigation in one place.
- The whole row is a single canvas — focus moves row-by-row through
egui's focus chain naturally (no manual focus management needed).
- DOM mirror sees `<canvas role="radio"|"option"
aria-selected="true|false">` via the generic `role` + `aria*`
pass-through on canvas.
- Defaults: `200 × 22` row, `"#00000000"` bg, `"#dcdcdc"` fg, selected
`"#3a4a64"` bg + `"#ffffff"` fg.
# editorSelection
Named selection scopes for the editor. A scope is an independently
tracked, ordered set of typed refs plus a primary (the last ref added
or clicked). Distinct scopes never clobber each other, so viewport,
outliner, inspector and asset browser share one answer to "what is
selected" per kind.
A ref is `{ kind: string, id: string }` — a stable id, never a display
string, so rename/move never invalidates a selection.
## Exports
- `M.scope(name) -> Scope` — get/create a named scope handle.
- `M.set(scope, refs)` — replace refs in order; primary becomes the last.
- `M.get(scope) -> { Ref }` — refs in click order (fresh array).
- `M.primary(scope) -> Ref?` — last-clicked ref, or nil.
- `M.clear(scope)` — empty a scope.
- `M.toggle(scope, ref)` — add if absent, remove if present.
- `M.add(scope, refs)` — add each ref not already present; primary becomes the last added.
- `M.remove(scope, refs)` — remove each of `refs`; primary becomes the last remaining or nil.
- `M.contains(scope, ref) -> boolean` — membership by `(kind, id)`.
- `M.count(scope) -> number` — the scope's selection size.
- `M.subscribe(scope, fn) -> handle` / `M.unsubscribe(scope, handle) -> boolean`.
- `M.context() -> { scope, refs, primary }` — last-focused scope, for command ctx.
## Usage
```luau
local Selection = require("@builtin::modules.api.editor.selection")
local scope = Selection.scope("entity")
Selection.set(scope, { { kind = "entity", id = "player_1" } })
Selection.subscribe(scope, function() refreshInspector() end)
```
## Notes
- State lives in a fixed `_G` slot, seeded pre-seal by the boot chain
(`prelude.luau`); it survives hot-reload and edit↔play flips. Mutations
after boot write into nested tables only.
- `set` / `toggle` / `clear` mark their scope as last-focused, which drives
`context()` and therefore which scope commands act on.
- The service holds the editor's selection state in Luau. Refs handed back by
`get` / `primary` / `context` are copies, so a caller can hold or mutate them
without touching internal state.
# editorCommands
The command registry — the single place an editor action is declared.
One declaration is rendered by four surfaces: the menu bar, DataView
context menus, the command palette, and the keymap. `EditorRegistry`'s
`addMenuItem` is a thin wrapper over `declare`, so every menu action is
a command.
A command is `{ id, title, category, menu?, order?, keys?, enabledWhen?,
run }`. `ctx` passed to `enabledWhen`/`run` is the selection service's
`context()` augmented by the caller: `{ scope, refs, primary, view?,
item? }`.
## Exports
- `M.declare(cmd) -> boolean` — register/replace by id (duplicate replaces).
- `M.get(id) -> Command?`
- `M.list() -> { Command }` — sorted by (category, title).
- `M.remove(id) -> boolean`
- `M.isEnabled(id, ctx) -> boolean` — true when no predicate, else its result.
- `M.run(id, ctx) -> boolean` — runs only when enabled; returns whether it ran.
- `M.conflicts() -> { { keys, ids } }` — keys claimed by more than one command.
- `M.commandForKey(keys) -> id?` — the last declarer wins a contested key.
## Usage
```luau
local Commands = require("@builtin::modules.api.editor.commands")
Commands.declare({
id = "asset.delete", title = "Delete Asset", category = "Assets",
keys = "Delete",
enabledWhen = function(ctx) return #ctx.refs > 0 end,
run = function(ctx) ... end,
})
```
## Notes
- State lives in a fixed `_G` slot, seeded pre-seal by the boot chain; it
survives hot-reload and edit↔play flips. Mutations after boot write into
the nested `byId` / `keyClaims` tables only.
- A duplicate id replaces the prior declaration and rebinds its key.
- Conflicts are surfaced (`conflicts()`), never silently dropped; the last
declarer wins the live binding via `commandForKey`.
# _axes
Shared tick generation + axes + gridline drawing helpers for
canvas-hosted charts. Used by `Z.plot` for axes/gridlines/ticks and by
`Z.graph` for the optional axis labels + gridlines. Single source of
truth for the nice-tick algorithm so both widgets render identical-
looking tick scales. Underscore prefix marks it as a package-internal
helper — not exported from `zui.widget` and not part of the `Z.*`
surface.
## Exports
- `M.computeTicks(minV: number, maxV: number, nTicks: number?, customFormat: TickFormatter?) -> TickResult` — D3-style nice ticks. Snaps step to `{1, 2, 5} × 10^n`. `nTicks` default 5.
- `M.axisCommands(viewport: Viewport, bounds: Bounds, opts: AxesOpts?) -> { CanvasCommand }` — axes + tick marks + tick labels.
- `M.gridCommands(viewport: Viewport, bounds: Bounds, opts: AxesOpts?) -> { CanvasCommand }` — gridlines, one per major tick.
Types:
- `Viewport = { x: number, y: number, width: number, height: number }` — canvas-local pixel rect.
- `Bounds = { xMin: number, xMax: number, yMin: number, yMax: number }` — data-space range.
- `TickResult = { ticks: { number }, labels: { string } }`
- `TickFormatter = (number) -> string` — custom label formatter.
- `AxesOpts` — visibility + styling (`showXAxis`, `axisColor`, `labelSize`,
`tickLength`, `xTickFormat`, etc.).
- `CanvasCommand = { [string]: any }` — opaque canvas-renderer command.
## Usage
```luau
local Axes = require("@builtin::modules.zui.widget._axes")
local viewport = { x = 0, y = 0, width = 200, height = 120 }
local bounds = { xMin = 0, xMax = 100, yMin = 0, yMax = 1 }
local axisCmds = Axes.axisCommands(viewport, bounds, {})
local gridCmds = Axes.gridCommands(viewport, bounds, { gridY = false })
local r = Axes.computeTicks(0, 100) -- nice ticks: {0, 20, 40, ...}
-- Custom formatter:
Axes.axisCommands(viewport, bounds, {
xTickFormat = function(v) return "$" .. v end,
})
```
## Notes
- Coordinate convention matches canvas: origin top-left, `+y` down. Y
values from `bounds` are flipped at render time so `yMax` is drawn at
the top of the viewport, matching plot conventions.
- `mergedOpts` overlays caller opts on `DEFAULTS` and passes unknown keys
through — additive opts (`xTickFormat`, `yTickFormat`, future ones)
don't need bookkeeping in `DEFAULTS`.
- `computeTicks` caps iteration at 64 ticks to avoid pathological loops
on degenerate input.
- Out-of-range ticks (after the data → pixel mapping) are filtered
silently so neither labels nor lines bleed past the viewport.
# _viewport
Pan/zoom viewport helper for canvas-hosted charts. Persists
`{ xMin, xMax, yMin, yMax }` in `ui.widgetState(plotId, "viewport")` so
the bounds survive across renders; the first render seeds from
`defaultBounds`. Subsequent renders read the cached state; pan/zoom
handlers (`viewport:pan`, `viewport:zoom`) mutate the state in place and
write it back to widgetState. Underscore prefix marks it as a
package-internal helper — not exported from `zui.widget` and not part of
the `Z.*` surface.
## Exports
- `Viewport.make(plotId: string?, rect: Rect?, defaultBounds: Bounds?) -> ViewportInstance` — construct a viewport over `rect`. `plotId` enables widgetState persistence.
- `vp:dataToScreen(x: number, y: number) -> (number, number)` — data → canvas-local pixels.
- `vp:screenToData(sx: number, sy: number) -> (number, number)` — canvas-local pixels → data.
- `vp:pan(dxScreen: number, dyScreen: number, axisAllow: AxisFilter?)` — pan and write-back.
- `vp:zoom(factor: number, anchorScreen: ScreenPoint?, axisAllow: AxisFilter?)` — zoom around anchor and write-back.
- `vp:setBounds(bounds: Bounds)` — replace bounds and write-back.
- `Viewport.bounds(vp) -> Bounds` — snapshot bounds as a fresh table.
Types:
- `Rect = { x: number, y: number, width: number, height: number }` — canvas-local plot body.
- `Bounds = { xMin: number, xMax: number, yMin: number, yMax: number }` — data-space.
- `ScreenPoint = { x: number, y: number }`
- `AxisFilter = { x: boolean?, y: boolean? }`
- `ViewportInstance` — instance shape; carries `rect`, bounds fields,
`invertX/Y`, and the bound methods.
## Usage
```luau
local Viewport = require("@builtin::modules.zui.widget._viewport")
local vp = Viewport.make("plot1",
{ x = 0, y = 0, width = 200, height = 100 },
{ xMin = 0, xMax = 100, yMin = 0, yMax = 1 })
-- Inside onDrag handler:
vp:pan(dragDx, dragDy)
-- Inside onScroll handler:
vp:zoom(1 + scrollAmount * 0.1, { x = mx, y = my })
-- Reset:
vp:setBounds({ xMin = 0, xMax = 100, yMin = 0, yMax = 1 })
```
## Notes
- Coordinate convention: origin top-left, `+y` down. yMax in data space
maps to `rect.y` (top of viewport) so the cursor sits above the data
point during pan.
- Persistence is opt-in — passing `plotId = nil` skips widgetState reads
/ writes; bounds live only on the instance for the duration of the
call.
- Caller can reset by clearing widgetState explicitly:
`ui.widgetStateSet(plotId, "viewport", nil)`.
- `Viewport.bounds(vp)` is a free function (no colon) — it's a thin
snapshot helper, useful when you want a fresh bounds table without
mutating the instance.
- `invertX` / `invertY` flip the axis mapping in both `dataToScreen` and
`screenToData`; default `false` for both.
# _markers
Marker-shape canvas command generators used by `Z.plot.points`. Mirrors
the marker shapes that the deleted `egui_plot::MarkerShape` enum
exposed: `circle`, `square`, `diamond`, `cross`, `plus`, `up`, `down`,
`left`, `right`, `asterisk`. Unknown shapes fall back to circle with a
console warning (parity with the deleted Rust path).
## Exports
- `M.commands(shape: MarkerShape, cx: number, cy: number, radius: number, opts: MarkerOpts?) -> { CanvasCommand }` — list of canvas commands rendering the marker.
Types:
- `MarkerShape = "circle" | "square" | "diamond" | "cross" | "plus" | "up" | "down" | "left" | "right" | "asterisk" | string` — `string` fallthrough warns and falls back to circle.
- `MarkerOpts = { fill: string?, stroke: string?, strokeWidth: number? }`
- `CanvasCommand = { [string]: any }` — opaque canvas-renderer command.
## Usage
```luau
local Markers = require("@builtin::modules.zui.widget._markers")
local cmds = Markers.commands("diamond", 50, 50, 4, { fill = "#fff" })
-- cmds is a list of canvas commands ready to splice into a canvas.
```
## Notes
- Each shape returns one or more canvas commands centered at `(cx, cy)`
with bounding-circle radius `radius`.
- `cross`, `plus`, `asterisk`, `up/down/left/right` are stroke-based and
fall back to `opts.fill` (or `"#FFFFFF"`) for the line color when no
`stroke` is supplied.
- Unknown shapes warn via `log.warn` (when available) and fall back to
the circle marker — parity with the deleted Rust enum's default arm.
- Pure module — no engine state, no state owned here. Safe to call from
any context.
# canvas
Pre-built node-graph canvas shell — `Z.shell.canvas { width, height,
background?, nodes, connections, onSelectNode?, onMoveNode?,
onConnect? }`. Maps a node-graph state into Z.canvas paint commands
(background, bezier connections, rounded rect nodes with labels). The
module returns the shell function directly.
## Exports
- `canvasShell(opts: CanvasOpts?) -> canvas-widget` — render a node-graph canvas from a node/connection state.
Types:
- `Node = { id: string, x: number, y: number, label: string?, color: string? }`
- `Connection = { fromId: string, toId: string, color: string?, width: number? }`
- `CanvasOpts = { id?, width?, height?, background?, nodes?, connections?, onSelectNode?, onMoveNode?, onConnect? }`
## Usage
```luau
local canvasShell = require("@builtin::modules.zui.shell.canvas")
local widget = canvasShell {
width = 800,
height = 600,
nodes = { { id = "a", x = 10, y = 20 } },
connections = {},
onSelectNode = "graph:select",
}
```
## Notes
- Pointer events come through `onClick` / `onPointerMove` with the
widget-local `"x,y"` value. The caller is responsible for hit-testing
against `opts.nodes` in the App's `onCallback` to map the click to a
node id.
- `onConnect` (drag-from-A-to-B) is not yet wired — the canvas widget
doesn't surface drag-completion events. Tracked for a future update.
- Bezier connections use horizontal-tangent control points on the
mid-x line, producing smooth left-to-right flow between nodes.
# dockApp
A visibility-driven docked-app handle. Wraps `Z.app` with a per-panel refresh scheduler over the same tab contract as `Z.shell.tabApp`, but renders the open panels as `Z.dockPanel`s inside a single `Z.dockArea` (real egui_dock tabs, splits, and drag). The app costs zero engine work while hidden; each open panel polls on its own `refresh` interval, and the open model is an ordered set of open panel keys rather than a single active tab.
Each entry in `tabs` follows the tab contract `{ label, refresh, build, onMount?, onCallback?, tick?, float? }`. A tab's own `float` overrides the app-level `float` default — e.g. a viewport tab sets `float = false` to dock into the main surface while tool panels float.
## Create
`M.create(opts) -> DockAppHandle` — the module table is callable, so `DockApp(opts)` works too. Required `opts`: `name`, `tabs`, `tabOrder` (may be empty). Optional: `initialOpen`, `tabAliases`, `statusClock`, `onStatusTick`, `extraScreens`, `appOpts`, `initialState`, `dockLayout`, `float`, and dock presentation keys (`overlayType`, `leafCollapseButtons`, `leafCloseAllButtons`, `allowedSplits`, `dockStyle`).
## Handle
The returned `DockAppHandle` carries:
- `app`, `state`, `screenName`
- `update(dt)` — runs the scheduler and ticks the app.
- `onCallback(callbackId, data) -> boolean` — routes dock close, app dispatch, and tab/extraScreen callbacks.
- `destroy()`, `setVisible(v)`
- `openPanel(key)`, `closePanel(key)`, `isOpen(key) -> boolean`, `togglePanel(key)` — drive the open set.
- `getLayout() -> string?`, `restoreLayout(json)` — read and re-apply the live dock split/tab arrangement.
## Usage
```luau
local DockApp = require("modules.zui.shell.dockApp")
local handle = DockApp {
name = "system_tools",
tabs = TABS,
tabOrder = { "entities", "logs" },
initialOpen = { "entities" },
}
```
# docked
Pre-built docked-panel shell — `Z.shell.docked { top, left, center,
right, bottom }`. Captures the wrapper shape every demo's "toolbar +
body + status" layout reinvents: outer vbox with gap=0, theme bg, the
center slot flex-grows, and missing slots are skipped cleanly. The
module returns the shell function directly.
## Exports
- `dockedShell(opts: DockedOpts?) -> vbox-widget` — render a docked layout from top/left/center/right/bottom slots.
Types:
- `DockedOpts = { id?, class?, classes?, top?, left?, center?, right?, bottom?, background?, minWidth?, minHeight? }`
## Usage
```luau
local dockedShell = require("@builtin::modules.zui.shell.docked")
dockedShell { top = toolbar, center = body, bottom = status }
```
## Notes
- The center slot is stamped with `flex = 1` so it fills the middle
row horizontally. The caller's widget table is NOT mutated — its
style is shallow-cloned before stamping.
- `nil` slots disappear from the rendered tree. The middle row is
omitted entirely when left/center/right are all `nil`.
- Defaults: `gap = 0`, theme `bg` background, `minWidth = 100`,
`minHeight = 100`.
# inspector
Pre-built inspector pane — `Z.shell.inspector { title, fields,
actions }`. Captures the form-with-fields-and-actions pattern every
editor reinvents. Field editors are auto-selected from the value type;
`field.kind` overrides (e.g. for color swatches or free-form int/float
inputs). The module returns the shell function directly.
## Exports
- `inspectorShell(opts: InspectorOpts?) -> panel-widget` — render an inspector pane from a title + fields/sections + action buttons.
Types:
- `InspectorField = { label?, value?, onChange?, readonly?, kind?, hint?, id?, min?, max?, minWidth? }`
- `InspectorAction = { label?, onClick?, variant? }` — `variant in "primary" | "secondary" | "danger"`.
- `InspectorSection = { title?, fields?, defaultOpen?, id? }`
- `InspectorOpts = { id?, class?, classes?, title?, fields?, sections?, actions?, message? }`
## Usage
```luau
local inspectorShell = require("@builtin::modules.zui.shell.inspector")
inspectorShell {
title = "Entity",
fields = { { label = "x", value = 1, onChange = "x:set" } },
actions = { { label = "Apply", onClick = "apply", variant = "primary" } },
}
```
## Notes
- Editor selection: `boolean` → checkbox, `number` → slider (min/max
default 0/1), `kind = "int"|"float"` → free-form input, `kind = "color"`
→ hex button swatch, `string` → text input. Setting `readonly = true`
renders a label regardless of type.
- `sections` takes precedence over a flat `fields` list — each section
becomes a collapsible, expanded by default unless `defaultOpen = false`.
- Actions render right-aligned in a row at the bottom. The `variant`
field maps to theme tokens: `primary` → accent, `danger` → danger,
anything else → panel_alt.
# tabApp
Layer 5 shell — visibility-driven tabbed app. Wraps `Z.app` with a
per-tab refresh scheduler that gates ALL data fetches on
`(window_visible AND tab_active AND refresh_due)`, so the panel costs
zero engine work when hidden / closed / minimized and only the active
tab's `build()` is invoked when open. Lifted from `system_tools.module`
(the original consumer) so future live-data tools compose against this
surface instead of re-implementing the scheduler.
## Exports
- `M.create(opts: TabAppOpts) -> TabAppHandle` — build and mount a tab app. Required: `name`, `tabs`, `tabOrder`.
- `M(opts)` — the module table is callable; equivalent to `M.create(opts)`.
Types:
- `TabSpec = { label?, refresh?, build?, onMount?, onCallback?, tick? }`
- `ChromeOpts = { title?, layout?, minimizable?, closable?, minWidth?, minHeight?, maxWidth? }`
- `ExtraScreenSpec = { build?, refresh?, layer?, tags?, onCallback? }`
- `AppOpts = { layer?, tags?, ctx? }`
- `TabAppOpts = { name, tabs, tabOrder, tabAliases?, statusClock?, onStatusTick?, chrome?, extraScreens?, appOpts?, initialState?, callbackPrefix?, windowId?, closedWidth?, closedHeight?, minimizedWidth?, tabGap?, tabPadding? }`
- `TabAppHandle = { app, state, update, onCallback, destroy, setVisible, screenName }`
## Usage
```luau
local TabApp = require("@builtin::modules.zui.shell.tabApp")
local handle = TabApp {
name = "system_tools",
tabs = TABS,
tabOrder = { "entities", "logs" },
statusClock = 0.1,
onStatusTick = function(state) ... end,
}
-- Per-frame
handle.update(dt)
```
## Notes
- The scheduler keeps a single shared `schedClock` plus per-timer
`lastFire` timestamps. Stepping `lastFire` by the refresh interval
(rather than to `now`) keeps timers phase-locked to their mount time
forever — timers with matching intervals fire on the same frame.
- `extraScreens` may use either a `function(state)` shorthand or a
`{ build, refresh, layer, tags, onCallback }` table. The handle's
`onCallback` dispatches into App handlers first, then per-tab
`onCallback`, then extraScreen `onCallback`.
- The default chrome ships three shells (closed / minimized / open).
Pass `chrome.layout(state, opts) -> widget` to override entirely.
- `setVisible` flips `windowVisible` AND calls `ui.showScreen` /
`ui.hideScreen` for the bound screen name.
- `destroy` unmounts the App and resets the shared DataSource cache.
# json
JSON syntax highlighter. Pure `(text) -> { Segment }`. Mirrors the
algorithm of the deleted Rust `highlight_json` (renderer.rs): emits
object keys via lookahead-for-`:`, handles `\` escapes inside strings,
recognises numbers with scientific notation, and colors structural
punctuation (`{ } [ ] , :`). Colors flow from `ui.getToken("code.<role>")`
via the active theme; hardcoded fallbacks match the deleted Rust values
when no theme is registered.
## Exports
- `highlight(text: string) -> { Segment }` — tokenize a JSON string into colored segments for the zui code renderer. The module returns this function directly.
Types:
- `Segment = { text: string, color: string, monospace: boolean }`
## Usage
```luau
local highlight = require("@builtin::modules.zui.highlight.json")
local segments = highlight('{"a": 1, "b": "two"}')
```
## Notes
- Pure function — no side effects, no state. Safe to call any time.
- The `ui.getToken` lookup runs once per highlighter call (5 FFI calls)
rather than per byte, so the inner tokenize loop is allocation-light.
- Keys are disambiguated from string values by a lookahead past
whitespace for `:` — matches the deleted Rust algorithm exactly.
# lua
Luau syntax highlighter. Pure `(text) -> { Segment }`. Mirrors the
algorithm of the deleted Rust `highlight_lua` / `highlight_lua_code_part`
pair (renderer.rs pre-Phase-3): `--`-line comments, single- and
double-quoted strings (no escape handling), digit runs with optional
dots (no exponent), and identifiers dispatched against the Luau keyword
set. Colors flow from `ui.getToken("code.<role>")` via the active theme,
with hardcoded fallbacks matching the deleted Rust constants.
## Exports
- `highlight(text: string) -> { Segment }` — tokenize a Luau string into colored segments for the zui code renderer. The module returns this function directly.
Types:
- `Segment = { text: string, color: string, monospace: boolean }`
## Usage
```luau
local highlight = require("@builtin::modules.zui.highlight.lua")
local segments = highlight("local x = 1 -- pi-ish")
```
## Notes
- Pure function — no side effects, no state. Safe to call any time.
- The `ui.getToken` lookup runs once per highlighter call (5 FFI calls),
then locals are used in the hot tokenize loop — cost is per-call, not
per-byte.
- Strings have no escape handling (`"a\"b"` would terminate at the
middle quote). This matches the deleted Rust implementation exactly.
# markdown
Markdown syntax highlighter. Pure `(text) -> { Segment }`. Mirrors the
algorithm of the deleted Rust `highlight_markdown` /
`highlight_markdown_inline` pair (renderer.rs pre-Phase-3): block-level
pass over `split_inclusive('\n')` recognises fenced code (```),
headings (#), blockquotes (>), bullet/ordered lists; an inline pass
within each non-block line recognises inline code (`...`), bold
(**...**), and links ([text](url)).
## Exports
- `highlight(text: string) -> { Segment }` — tokenize a Markdown string into colored segments for the zui code renderer. The module returns this function directly.
Types:
- `Segment = { text: string, color: string, monospace: boolean }`
## Usage
```luau
local highlight = require("@builtin::modules.zui.highlight.markdown")
local segments = highlight("# Title\n**bold**\n")
```
## Notes
- The module-level color upvalues are reassigned at every highlighter
entry so the inline helper sees the active palette without per-call
args. Multiple concurrent calls are NOT safe — this module is
designed for serial UI rendering.
- Inline code reuses the `code.string` theme role; treat the two as the
same colour band.
- Fenced code (```) toggles a multi-line fence — the highlighter tracks
fence state across the input.
# wgsl
WGSL syntax highlighter. Pure `(text) -> { Segment }`. Mirrors the
algorithm of the deleted Rust `highlight_wgsl` (renderer.rs): `//` line
comments, `/* ... */` block comments, `"..."` strings with `\` escapes,
`@attribute` tokens, numeric literals (digits + dots + alphanumerics
for suffixes like `1u`, `0xFF`, `1.0_f32`), and identifiers dispatched
against the WGSL keyword and built-in type tables. Colors flow from
`ui.getToken("code.<role>")` via the active theme; hardcoded fallbacks
match the deleted Rust values when no theme is active.
## Exports
- `highlight(text: string) -> { Segment }` — tokenize a WGSL string into colored segments for the zui code renderer. The module returns this function directly.
Types:
- `Segment = { text: string, color: string, monospace: boolean }`
## Usage
```luau
local highlight = require("@builtin::modules.zui.highlight.wgsl")
local segments = highlight("@vertex fn main() -> vec4<f32> {}")
```
## Notes
- Pure function — no side effects, no state. Safe to call any time.
- Keyword and type tables match the deleted Rust constants verbatim
(renderer.rs:6245-6263).
- Strings are escape-aware (`"a\"b"` is one segment); WGSL has no
single-quoted strings.
# yaml
YAML syntax highlighter. Pure `(text) -> { Segment }`. Mirrors the
algorithm of the deleted Rust `highlight_yaml` / `highlight_yaml_content`
/ `highlight_yaml_scalar` trio (renderer.rs): per-line comment splitting,
`key: value` recognition (split on first `:`), and scalar typing
(quoted string / true|false|null / numeric / plain). Colors flow from
`ui.getToken("code.<role>")` via the active theme; hardcoded fallbacks
match the deleted Rust values when no theme is active.
## Exports
- `highlight(text: string) -> { Segment }` — tokenize a YAML string into colored segments for the zui code renderer. The module returns this function directly.
Types:
- `Segment = { text: string, color: string, monospace: boolean }`
## Usage
```luau
local highlight = require("@builtin::modules.zui.highlight.yaml")
local segments = highlight("name: zero\n# comment\n")
```
## Notes
- The module-level color upvalues are reassigned at every highlighter
entry so the inner scalar/content helpers see the active palette
without per-call args. Multiple concurrent calls are NOT safe — this
module is designed for serial UI rendering.
- Numeric detection allows `.`, `-`, `+` alongside digits, so dotted
versions and negatives parse as numbers (matches the Rust behaviour).
# cascade
Pure-Luau `$variable` cascade resolver for theme tokens and styles. Walks
a theme table's tokens and styles, expands every `$tokenName` reference
into a literal value with cycle detection, and returns a flat result.
Used by `Z.theme.register` so the engine receives fully-resolved
`(token, value)` and `(selector, prop, value)` pairs — no runtime
variable indirection. Cycle detection surfaces broken theme inputs as
structured `register` errors instead of silently leaving `Variable(name)`
in place and producing garbage colors.
## Exports
- `M.resolveValue(value, tokens, visited, order) -> (any, string?)` — single-value resolver. Exposed for tests; not part of the typed surface.
- `M.resolveTokens(theme: any) -> (TokenMap?, string?)` — flatten `theme.tokens`. Returns `(nil, errMsg)` on first cycle / unknown reference.
- `M.resolveStyles(theme: any, resolvedTokens: any) -> (StyleMap?, string?)` — flatten `theme.styles` against pre-resolved tokens.
Types:
- `TokenMap = { [string]: any }`
- `StyleMap = { [string]: { [string]: any } }`
- `Theme = { tokens: TokenMap?, styles: StyleMap? }`
## Usage
```luau
local Cascade = require("@builtin::modules.zui.theme.cascade")
local tokens, err = Cascade.resolveTokens(theme)
if err then error(err) end
local styles, err2 = Cascade.resolveStyles(theme, tokens)
if err2 then error(err2) end
-- `tokens` and `styles` now contain no `$variable` strings.
```
## Notes
- Pure module — no engine calls, no module state. Safe to invoke during
any phase (boot, test, runtime).
- Error messages include the offending token / selector / prop so cascade
failures are diagnosable from the structured error alone.
- `resolveStyles` resolves against pre-flattened `resolvedTokens` (not raw
`theme.tokens`) so chained references (`$a → $b → c`) are already
collapsed by the time style values are walked.
- Numbers, booleans, and arrays pass through unchanged — only strings
beginning with `$` are treated as references.
# validate
Pure validator for `asset.create` opts — checks a creation `opts` table against the schema produced by `asset.createSchema(typeName)`. Schema in, validated opts (or a teaching error string) out. Consumed by `asset.create`, which calls `M.validate` before forwarding opts to the type's `onCreate`. No FFI, no VFS.
Errors are teaching errors: they state what was wrong at which path (`opts.cfg.size`), what was expected, and then render the full creation-parameter contract so the caller can fix the call without reading the type's `behavior.luau`. Unknown parameter names get an edit-distance "did you mean" suggestion.
## Types
- `ValidatorShape` — `{ kind, literals?, item?, members?, fields?, indexer? }`, the recursive shape encoding.
- `ValidatorParam` — `{ name, shape?, optional?, default?, desc? }`.
- `ValidatorSchema` — `{ kind, desc?, example?, open?, error?, params?, indexer? }`.
## Exports
- `M.validate(schema, opts?) -> (validatedOpts?, err?)` — validate and default-fill an `opts` table against an `asset.createSchema` result. Returns `(validatedOpts, nil)` on success (a new table for `schema`-kind schemas) or `(nil, err)` where `err` is a bare teaching error string. The caller owns presentation. Handles the `legacy`, `error`, `none`, and `schema` schema kinds.
- `M.shapeName(shape) -> string` — render a single parameter shape (literal unions as `"png" | "jpg"`, arrays as `{ T }`, tables as `table`, primitives as their kind, nil/malformed as `any`). Used by `asset.describe`.
- `M.renderContract(schema) -> string` — render a schema's creation-parameter contract as one aligned line per parameter, with an `[extra keys]` line for open schemas.
## Usage
```luau
local V = require("@builtin::assetTypes.assetType.shared.create.validate")
local out, err = V.validate(schema, { bytes = png })
if err then error("asset.create: " .. err) end
```
# asset Module
Public Luau surface over the `__asset` Internal FFI namespace —
asset resolver, ref envelope builder, sidecar metadata.
## Purpose
Wrap the raw `__asset.*` FFI namespace in a typed Luau table that
gets auto-injected as `_G.asset` via the prelude. Three families:
- **Lookup** — `resolve`, `ref`, `typeRef`, `inspect`, `source`,
`identity`, `guid`, `meta`, `deps`. Every op accepts any name an
asset has (handle, guid, identity, path, bare name) and returns
the requested view.
- **Discovery** — `categories`, `list`, `containing`.
- **Generic `.metadata` sidecar** — `metadata`, `set_metadata`,
field-level `get_field` / `set_field` / `remove_field` /
`has_field`, tag helpers (`tags`, `has_tag`, `add_tag`,
`remove_tag`), and `validate`.
The previously-FFI `bundle.*` namespace was dissolved into the
bundle assetType (see `src/lua/lib/assetTypes/bundle.assetType/`);
that's not exposed here. The prelude mints `bundle` separately as
a thin Luau table grafted from `bundle_update`.
## Usage
```luau
-- Lookup
local cam = asset.resolve("@builtin::components.Camera")
local ref = asset.ref("animations.idle", "animation")
print(asset.identity(cam), asset.guid(cam), asset.source(cam))
-- Discovery
for _, c in ipairs(asset.categories()) do print(c) end
for _, sceneRef in ipairs(asset.list("scene")) do print(sceneRef.identity) end
local s = asset.containing("/zero/source/scenes/main.scene/scene.json")
-- Metadata sidecar (agent-editable)
asset.set_field(ref, "category", "movement")
asset.add_tag(ref, "looping")
print(asset.has_tag(ref, "looping"))
-- Structural validation
local v = asset.validate(cam)
for _, p in ipairs(v.problems) do print(p.severity, p.message) end
```
## Exports
- Lookup: `resolve`, `ref`, `typeRef`, `inspect`, `source`,
`identity`, `guid`, `meta`, `deps`.
- Discovery: `categories`, `list`, `containing`.
- `.metadata` sidecar: `metadata`, `set_metadata`, `get_field`,
`set_field`, `remove_field`, `has_field`, `tags`, `has_tag`,
`add_tag`, `remove_tag`, `list_fields`, `list_field_values`.
- Validation: `validate`.
# yaml.module
YAML decode + encode for authored engine content. Pure Luau, no engine
dependencies — a general parsing library, not data-system plumbing.
```luau
local Yaml = require("@builtin::modules.yaml")
local doc = Yaml.decode(vfs.read(path))
vfs.write(path, Yaml.encode(doc))
```
## Supported subset
- Block mappings (`key: value`) and block sequences (`- item`), nested
to any depth.
- Compact sequence-of-mapping entries (`- path: x` / ` kind: y`).
- Flow collections — `{ a: 1, b: 2 }` and `[1, 2, 3]` — single line.
- Plain scalars with type inference: `~` / `null` → nil, `true` /
`false` → boolean, numeric literals → number, everything else →
string.
- Single- and double-quoted strings (`''` escapes a literal quote
inside a single-quoted string; `\n` `\t` `\r` `\\` `\"` inside a
double-quoted one). A quote opens a quoted scalar where a scalar
begins — after `key:`, after a `- ` entry marker, or inside a flow
collection — so a plain scalar carries an apostrophe as data
(`note: the world's rules`). Quoted keys are read in flow mappings.
- `#` comments — honored outside quotes, so a `#` inside a quoted
string is kept as data.
- Literal (`|`) and folded (`>`) block scalars — the bare indicators
only. A block scalar's content is literal text: quotes (balanced or
not), `#`, `key: value` and `---` inside it are data.
- An optional leading `---` document marker.
## Loud-error contract
Constructs outside the subset above raise instead of misparsing:
anchors/aliases (`&`, `*`), tags (`!`), directives (`%`),
multi-document streams (a second `---`), block-scalar
chomping/indentation indicators (`|-`, `|+`, `>-`, `|2`, ...), tab
indentation, and inconsistent indentation (both block structure and
block-scalar bodies). Every error carries the offending line number
(`yaml: line N: ...`), so a bad config points straight at the line to
fix.
Tab indentation is rejected everywhere — including block-scalar body
lines whose leading whitespace contains a tab. A tab after the first
non-space character of a line is content and passes through.
## API
- `Yaml.decode(text: string): any` — decode a YAML document into a
Luau value (table, scalar, or nil for an empty document). Raises on
malformed input or an unsupported construct.
- `Yaml.decodeWithComments(text: string): (any, boolean)` — decode as
`decode` does, and report whether the text carries a comment by the
decoder's own rule: a `#` at a line's start or after whitespace, outside
a quoted scalar and outside a block scalar. Re-encoding the value drops
such comments, so an editor that writes a document back reads this first.
- `Yaml.encode(value: table): string` — encode a Luau table as YAML
(block style, two-space indent, alphabetically sorted keys). Raises
on values YAML can't represent (functions, userdata, non-string
mapping keys).
A top-level empty table encodes to an empty document, which decodes
back to `nil` (the YAML empty-document ambiguity).
# icons
Phosphor icon constants. The phosphor font is registered as a named
family `phosphor` in the engine, so render icons by setting
`style.fontFamily = "phosphor"` on the label that contains them.
The font is ALSO appended to the proportional family as a fallback, but
the fallback path is unreliable for PUA codepoints in egui 0.34 (see
issue #2191) — always pass `fontFamily = "phosphor"` for icons.
Curated set covering DAW transport, media controls, file management,
desktop shell, editor chrome, and common state indicators. For icons
not listed here, see https://phosphoricons.com — pass the codepoint as
a UTF-8 string literal directly.
## Exports
- `Icon.styled(extra: LabelOpts?) -> LabelOpts` — build a `UI.label` opts table with `style.fontFamily = "phosphor"` applied. Merges with the supplied `extra`.
- `Icon.<Name>: string` — UTF-8 codepoint constants. Categories:
- Transport / media: `Play`, `Pause`, `Stop`, `Record`, `FastForward`, `Rewind`, `SkipForward`, `SkipBack`, `Repeat`, `Shuffle`.
- Audio / DAW: `Microphone`, `Headphones`, `Equalizer`, `Sliders`, `Metronome`, `MusicNote`, `Waveform`, `SpeakerHigh`, `SpeakerLow`, `SpeakerNone`, `SpeakerSimpleHigh`, `SpeakerMuted`.
- File / folder: `Folder`, `FolderOpen`, `File`, `FileText`, `FileAudio`, `FileVideo`, `FileImage`, `FileCode`, `FloppyDisk`, `Trash`, `UploadSimple`, `DownloadSimple`.
- Desktop / shell: `Desktop`, `DesktopTower`, `Monitor`, `Keyboard`, `Mouse`, `House`, `Sidebar`, `Cpu`.
- Editor: `Pencil`, `Plus`, `Minus`, `Check`, `MagnifyingGlass(Plus|Minus)?`, `Gear`, `Power`, `Eye`, `EyeSlash`, `Lock`, `LockOpen`, `Bell`, `Palette`, `Funnel`, `Clipboard`.
- Code / dev: `Code`, `Terminal`, `Bug`, `GitBranch`, `GitCommit`, `GitMerge`, `Database`, `Cloud`.
- Arrows / nav: `ArrowUp/Down/Left/Right`, `ArrowsClockwise`, `ArrowsIn`, `ArrowsOut`, `CaretUp/Down/Left/Right`.
- Layout / list: `List`, `GridFour`, `DotsThree`, `DotsThreeVertical`.
- State / status: `Info`, `Warning`, `WarningCircle`, `CheckCircle`, `XCircle`, `Question`, `Heart`, `Star`, `Tag`, `Hash`, `At`.
- Shapes: `Circle`, `Square`, `Triangle`.
- Productivity / shell apps: `Calculator`, `Notepad`, `NotePencil`, `Note`.
- Games / cards: `GameController`, `Bomb`, `Cards`, `Diamond`, `Spade`, `Club`.
Types:
- `LabelStyle = { fontFamily?: string, fontSize?: number, color?: string, [string]: any }`
- `LabelOpts = { style?: LabelStyle, [string]: any }`
## Usage
```luau
local Icon = require("@builtin::modules.icons")
UI.label(Icon.Play, { style = { fontFamily = "phosphor", fontSize = 24 } })
UI.label(Icon.Folder .. " Documents", {
style = { fontFamily = "phosphor", fontSize = 14 },
})
-- Or use the helper to enforce the phosphor family:
UI.label(Icon.Play, Icon.styled({ style = { fontSize = 24, color = "#fff" } }))
```
## Notes
- The constants are plain string values — each is a single UTF-8 Private
Use Area codepoint. Concatenate freely with surrounding text.
- `Icon.styled` mutates the supplied opts table (`extra.style` is
forced). Pass a fresh table per call site if you need to reuse opts.
- Curated subset only — for icons outside this set, paste the codepoint
from https://phosphoricons.com directly.
# modules.api.engine.engine
The `engine` global's Luau implementation. Exposes `engine.mode` (edit
vs play), `engine.onModeChange(callback)` subscription registry, and
the mode-flip orchestration that fires the canonical onModeChange
dispatch through `ENGINE_MODE_WATCHERS`.
# content_version
Per-path content-version counters — a cheap, synchronous "has this file
changed?" token. An assetType behavior memoizes its parse in the ref's
`runtime` keyed by the counter `get(path)` returned when it parsed, then serves
every later read as an integer compare instead of re-reading and re-parsing the
file.
## Why it exists
A `.data()` behavior that re-reads and re-parses its source on every call is
pure overhead when the file hasn't changed. This module gives it a version
token to gate the rebuild on:
- `get(path)` when the parse ran, stored alongside the parsed value.
- On every later call, compare `get(path)` to the stored value — equal means
the cache is still valid (no `vfs.read`, no parse); a bump means rebuild.
## Exports
- `M.get(path: string) -> number` — the path's current version counter (`0` if
never written this VM).
- `M.bump(path: string)` — bump `path`'s counter, invalidating every reader
memoized against its previous value. Content code rarely calls this directly.
## What bumps a counter
Two write surfaces feed it, so the token reflects a change no matter where it
originated:
- `vfs.write` / `vfs.move` / `vfs.remove` bump **synchronously**, in the same
call — so a script that writes a file and reads it back in the same tick sees
the new content immediately (the asset-change `onChange` dispatch fires a
frame later, too late for a same-tick read).
- the generic asset-change dispatcher bumps on every source write it routes,
including peer-synced and engine-originated writes that never pass through the
Luau `vfs.*` surface — with the engine's normalized path, the canonical form a
reader keys on.
## Per-path, not a global epoch
Counters are per **path**, not one global epoch. The per-frame dirty-entity
writer churns scene-dirty paths every frame during play; a global epoch would
let that churn invalidate every unrelated cache. Per-path isolation means only a
change to the file a reader depends on rebuilds it.
The counter map lives on `_G` (`__zero_content_versions`), installed once before
the global table is sealed at boot, so a single map is shared across every copy
of this module that a require-cache reset (`vfs.reload`) might create. A
module-upvalue map would let a bumper and a reader that landed on different
module copies diverge, and the reader would serve stale content. Per-VM.
# Asset
Promote the bundle's spawned children from temporary to permanent scene state and remove the Asset component, "baking" the bundle's contents directly into the owning scene. Subsequent saves persist the baked entities verbatim and stop replaying the bundle template.
Location: `src/lua/lib/components/Asset.component`
# field_constraints (module)
Generic field-constraint dispatch. Installs
`_G.__zero_check_field_constraint(value, constraint)`, the Luau half of
the opaque field-constraint primitive: a component field descriptor
may carry a `constraint` value (an arbitrary table with a `kind`
string), and the engine calls this hook whenever a constrained field's
default resolves or a write lands. The engine copies `constraint`
verbatim — it never reads inside it — and treats the hook's first
return as accept/reject, the second as the rejection reason.
## What it does
1. Reads `constraint.kind` and looks up a registered validator for
that kind.
2. Built-in kinds (currently `dataContract`) resolve their validator
module lazily on first use, so component registration never depends
on load order.
3. Calls the validator with `(value, constraint)` and returns its
`(ok, reason?)` result.
A malformed constraint (not a table, or missing `kind`) and a missing
validator both reject — the primitive fails closed rather than
silently accepting an unconstrained value.
## Exports
- `M.register(kind, fn)` — register the validator for a constraint
kind. `fn(value, constraint) -> ok, reason?`. One validator per kind;
re-registering a kind errors.
- `M.check(value, constraint) -> ok, reason?` — run the check directly
(same function installed as the engine hook).
## Consumers
`Field.dataRef` (`field.module`) emits `constraint = { kind =
"dataContract", contract = ... }`; `data_contract.module` registers
the `dataContract` validator against this registry.
# shared
A module script.
# opts
The contract an options table is held to. A call that reads a fixed set of
keys acts on those keys and no others, so a key it does not know names a
change the caller asked for and never got — a cube asked for `size = 3` that
comes back one unit wide, reporting success. `Opts.check` makes that a refusal
carrying what to write instead: the key, the accepted set, and the nearest
accepted spelling.
The same three parts answer for a name of any kind — `tools.use` names the
near-miss toolbox, `MaterialAuthor` names the near-miss shader property — so
the distance search lives here too, and every caller ranks candidates the same
way.
Pure Luau, no engine dependencies.
## Exports
- `Opts.check(where: string, value: any, accepted: { string }, inTable?: string)` — raise unless every key of `value` is named by `accepted`. `nil` accepts.
- `Opts.forward(where: string, own: { string }, fn, ...) -> ...` — call `fn(...)`, and when it refuses an option, append the vocabulary `where` itself takes.
- `Opts.unknown(value, accepted) -> { string }` — the keys `value` carries that `accepted` does not name, sorted.
- `Opts.nearest(key: string, candidates: { string }) -> string?` — the candidate `key` is most plausibly a misspelling of, or nil.
- `Opts.editDistance(a: string, b: string, limit: number) -> number` — single-character edits between two names, capped at `limit + 1`.
## Usage
```luau
local Opts = require("@builtin::modules.opts")
-- The keys this call reads, named once.
local CUBE_OPTS = { "animate", "scale" }
function M.cube(name, x, y, z, opts)
Opts.check("primitives.cube", opts, CUBE_OPTS)
-- primitives.cube: unknown option 'scail'. Accepted: animate, scale. Did you mean 'scale'?
-- The spawn target is handed to `entity.spawn`, so it is that call's
-- contract a key is checked against — `own` says what this one takes.
local e = Opts.forward("primitives.cube", { "opts.scale", "opts.animate" }, entity.spawn, name)
-- entity.spawn: unknown option 'size'. Valid options: name, hidden, …
-- (primitives.cube itself takes: opts.scale, opts.animate)
end
```
A call taking more than one table names which one the key was written in:
```luau
Opts.check("primitives.ground", color, { "r", "g", "b", "a" }, "color")
-- primitives.ground: unknown option 'red' in color. Accepted: r, g, b, a. Did you mean 'r'?
```
## Notes
- Refusals raise at level 0: the message is the whole error, so a caller reads
the contract rather than the line inside the library that checked it.
- A key of any type is reported by how it is written, so the positional form of
a named shape (`{ 200, 50, 50 }` for `{ r = , g = , b = }`) names its indices
rather than passing as a table with nothing the call reads.
- `nearest` is case-insensitive, and a candidate containing the key scores by
how much longer it is, so `roughnes` prefers `roughness` over
`clearcoat_roughness`. How far a plain misspelling may stray scales with the
key's own length — a four-letter key admits two edits, a longer one three —
so a short key is not answered with an unrelated short name.
- Equal scores go to the shorter candidate, then the alphabetically earlier
one, so the answer never depends on the order candidates were listed in.
- `forward` matches the phrase both this module and the engine's own option
checks use for the condition (`unknown option '<key>'`). Every other error
passes through as raised.
# asset_residency
Compute backends for the **lazy** residency properties on every `AssetRef`
(`has_backing_asset`, `has_runtime_changes`, `cpu_resident`, `gpu_resident`).
The AssetRef
metatable's `__index` dispatcher (`assetType.shared.ref`) calls these only when a
caller actually reads a flag — nothing is computed at resolve time, so an asset
costs nothing for residency until it is observed.
The three backing/runtime states:
| `has_backing_asset` | `has_runtime_changes` | Meaning |
|---|---|---|
| true | false | USED, UNMODIFIED |
| true | true | USED, MODIFIED (copy-on-write fork) |
| false | true | RUNTIME-GENERATED |
`cpu_resident` and `gpu_resident` are orthogonal to those three, and to each
other. `cpu_resident` asks *"is this asset referenced from a live
script-component context, so its bytes are warmed in memory?"* (the
`/runtime/assets/` CPU layer); `gpu_resident` asks whether the device holds a
texture or mesh under the asset's guid. They are separate pools and an asset
can be in one and not the other — `asset.observe()` lists both. See
`docs/specs/runtime-asset-copying.md` — "CPU residency vs GPU residency".
The backing/runtime flags are derived from where the asset lives — source under
`/zero/source/…` (backed), a copy-on-write fork / runtime-generated asset under
`/zero/runtime/assets/<identity>.<category>/` (runtime change) — using the
canonical `<identity>.<category>/` suffix shape, generic over any category.
`cpu_resident` and `gpu_resident` are live queries through `asset.cpuResident`
and `asset.gpuResident`.
# preview
Render an asset's preview in the preview studio: an isolated camera, lit only
by its own three-point rig, over a neutral backdrop, that nothing in the scene
reaches. `ref:preview()` renders any asset by the recipe its type declares under
`preview:` in its `type.yaml`, and these are the primitives it calls: they spawn
temporary entities, frame an offscreen camera over them, render one frame,
read it back as a PNG, and tear the entities down.
## Surface
- `preview.fromEntities(makeFn, opts?) -> PreviewResult` — run `makeFn` to
instantiate entities, frame a camera to their bounds, render one offscreen
frame, and return the still PNG.
- `preview.fromRecipe(ref, recipe, opts?) -> PreviewResult` — render `ref` by a
declared recipe: `instantiate` stands it through its own `instantiate`,
`surface` paints the material its type's `ref.previewMaterial` returns on the
recipe's mesh, and `image` fits a file the asset holds to the frame on the
studio backdrop without rendering anything.
- `preview.backdrop() -> { r, g, b }` — the studio backdrop's colour in a
finished preview, 0-255.
`PreviewResult` is `{ available, imageBase64?, rendered?, width?, height?,
bounds?, stats?, reason? }` — `imageBase64` is the rendered PNG when `available` is true, and
`reason` explains why when it is false. `opts` is `{ size? = { width, height },
angle? = { yaw, pitch } }`.
## Example
local result = asset.resolve("gold", "material"):preview({ size = { width = 512, height = 512 } })
-- result.imageBase64 holds the rendered PNG (base64)
# editor_observe
What an editor action committed, and why it committed less than it was asked for.
Every editor action — a gizmo drag, a delete, a duplicate, a command dispatch, a
selection gesture — closes by publishing one record here, and returns that same
record to its caller. Nothing is measured per frame: a record is written where
the work happens and read lazily.
```lua
local Obs = require("@builtin::modules.api.editor.editor_observe")
Obs.observe() -- the whole document: last of each kind, history, counts
Obs.last() -- the most recent action of any kind
Obs.last("drag") -- the most recent drag
Obs.history() -- every retained action, oldest first
```
The same reading is a tool: `tools.use("editor", "observe")`.
## What every record carries
- `action` — `drag`, `grab`, `delete`, `duplicate`, `command` or `select`.
- `outcome` — `committed`, `partial`, `refused`, `cancelled` or `noop`.
- `reason` — the nearest cause, from the closed set below, when the outcome is
anything but a clean commit.
- `detail` — the engine's own words for that cause.
- `entities` — one row per entity the action touched or tried to, each with
`before`, `requested` and `after`, and its own `reason` when it is not a clean
commit.
- `committed` / `changed` / `refused` — how many rows fall in each.
- `seq`, `atMs`, `durationMs` — which action this is and how long it was open.
## The reason set
`selectionEmpty`, `entityMissing`, `writeRefused`, `writeDiverged`, `pivotLost`,
`userCancelled`, `commitFailed`, `duplicateRefused`, `despawnRefused`,
`unchanged`, `commandMissing`, `commandDisabled`, `predicateRaised`,
`bodyRaised`, `hitNothing`, `pointerBlocked`. `Obs.REASONS` maps each to its
one-line meaning, so a caller can enumerate the set rather than guess at it.
## A drag that moved less than asked
The drag record separates the three quantities that are usually conflated:
- `pointerAsked` — what the pointer's position asked for, before snapping.
- `applied` — what the gizmo handed the engine, after snapping. `snapping` and
`snapIncrement` say why the two differ.
- each entity's `after` — the transform the engine **holds**, read back from the
engine rather than recomputed from the drag's own arithmetic.
An entity whose `after` differs from its `requested` reads `writeDiverged`; one
whose write raised reads `writeRefused` with the message; one despawned mid-drag
reads `entityMissing`. A drag that ended on Esc reads `cancelled` /
`userCancelled`, and one whose selection emptied under it reads `cancelled` /
`pivotLost` — three terminal states a single "the object did not move" cannot
tell apart.
## A drag that never began
A press that lands on a handle and starts no drag publishes a `grab` record
instead — `Obs.last("grab")`, or `Gizmo.lastGrab()`. `pointerBlocked` says the UI
layer held pointer focus, so the press never reached the handle. A press away
from every handle is a selection click rather than a grab, and records nothing.
One record per press: a frame that ticks the gizmo more than once reports the
press once.
`unit` names what `pointerAsked` and `applied` are measured in: `metres` for a
translate or plane drag (a world-space `{x,y,z}` delta), `radians` for a rotate
(`{radians}`), `factor` for a scale (`{factor}`).
## Per-frame cost
The editor's own per-frame work is named in the profiler rather than pooled into
`lua_update`: `script.editor.gizmo.tick`, `script.editor.viewport.select`,
`script.editor.selection.highlight`, and `script.editor.panel.<id>` for each
dock panel's rebuild. Read them with `profiler.stats()`.
# asset_observe
What the engine is holding for content, and why one asset cannot be used.
Backs `asset.observe`, `asset.diagnose`, `asset.cpuResident`,
`asset.gpuResident` and `asset.unusableReasons`, composing the readings only
the engine can take (`__assetObserve`) with the resolution, import and VFS
surfaces already in Luau.
## The reading
`M.observe()` returns the residency document the engine published, with each
device row joined to the asset its guid names:
```lua
local r = asset.observe()
for _, t in r.textures do print(t.identity or t.key, t.bytes) end
print(r.totals.textureBytes, r.totals.meshBytes, r.totals.cpuCount)
```
Three pools, named because they are different pools — `textures` and `meshes`
are the device's, `cpu` is the set a live script-component context holds. An
asset can be in one and not the others. `totals` carries the aggregates the
rows sum to, so a listing reconciles against `renderer.textureMemory()` and the
`meshes` category of `renderer.gpuMemory()`. `devicePublished` and
`cpuPublished` say whether the engine can answer at all, which reads
differently from an engine answering with nothing resident.
A row carrying an `identity` reached the device through that asset; a row
without one is held under a guid no asset claims — a camera's own render
target, a glyph atlas a script built.
## The diagnosis
`M.diagnose(ref)` answers for one asset, from the engine's own reading rather
than from what the caller asked for:
```lua
local d = asset.diagnose("myTexture")
if not d.usable then print(d.reason, d.detail) end
print(d.primary) -- the file the type's declared `primary` resolved to
```
`reason` is one of `M.reasons()`, and each is a state the engine distinguishes:
| Reason | What the engine read |
|---|---|
| `noSuchAsset` | nothing resolves under that name |
| `noPrimaryFile` | the type's declared `primary` list matched no file in the asset's folder |
| `payloadEmpty` | the primary resolved to a file holding no bytes |
| `decodeFailed` | the bytes are not the payload the file claims to be — the format's identifying bytes disagree, or the engine's decoder read them and refused |
| `importFailed` | an import ran over this asset's source and failed; the importer's own error text travels alongside |
| `importInFlight` | an import over it is queued or running, so its payload is not settled yet |
Each is the reading at the moment of the call, so a payload being removed is
reported at whichever stage the call catches it: `payloadEmpty` while the file
is there with nothing in it, `noPrimaryFile` once it is gone. The verdict is
whether a load would succeed now — a resource already on the device stays
resident under a payload that has since gone, which `M.gpuResident` reports.
`primary` is worth reading even when an asset loads: a type declares its payload
as a list tried in order, so an asset that lost its encoded payload keeps
loading from whatever image is left beside it, including a thumbnail. Naming
the file is what makes that visible.
Reading the payload costs a decode wherever the engine has a decoder for the
container — a ZTEX header parse, or the image decode the texture loader itself
performs, taken at a single texel.
# result
The handle `asset.list` returns.
A query result **is** the array of matched `AssetRef` handles. Entry `1` is the
first match, `#rows` is the count, `ipairs(rows)` walks it, and anything that
accepts a list of refs accepts it unchanged. The handle adds methods on top of
that array through a metatable, so reading the result stays one expression.
```lua
local hero = asset.list({ type = "mesh", fields = { tags = "hero" } }):first()
local prop = asset.list({ type = "mesh", path = "/zero/source/props" }):random()
asset.list("material")
:filter(function(m) return m.name:find("rust") ~= nil end)
:each(function(m) print(m.identity) end)
```
## Methods
| Method | Returns |
|---|---|
| `:first()` / `:last()` | One entry, or nil when the query matched nothing. |
| `:random()` | One entry chosen at random, or nil. |
| `:count()` | How many entries matched. |
| `:isEmpty()` | Whether nothing matched. |
| `:each(fn)` | The same result, after calling `fn(entry, index)` in order. |
| `:map(fn)` | A new result holding `fn(entry, index)` per entry. |
| `:filter(predicate)` | A new result holding the entries kept. |
| `:sort(comparator?)` | A new, ordered result; ordered by identity when the comparator is omitted. |
| `:toArray()` | The entries as a plain table, without the handle. |
`:map`, `:filter`, and `:sort` return new results carrying the same methods, so
they chain. `:sort` leaves the result it was called on as it is.
## Ordering
`asset.list` already returns its matches ordered — by identity unless the query
asks for another field with `order` — so `:first()` names the same asset on
every call. `:sort` is for orders the query cannot express, such as one computed
from an entry's fields.
## What the handle does not change
The metatable carries `__index` alone. Length, `ipairs`, `pairs`, `next`,
`table.*`, and serialisation all read the array part, so a result behaves
exactly as a plain array everywhere those are used.
# asset_warmup
`asset.warmup` — a **read-only closure inspector**.
Returns the transitive set of assets that the CPU-residency system *would* load
when the given asset enters `/runtime/` — i.e. the asset plus everything
reachable through its declared content references. It does **not** itself change
residency or touch bytes:
```lua
local closure = asset.warmup("@builtin::meshes.cube")
-- → the asset + every texture/material/mesh it transitively references
```
Residency is owned by the engine's reference-counted CPU-residency system, which
acquires the closure on a `/runtime/` enter (usage 0→1) and releases it on the
last drop (→0), loading/unloading bytes at the per-node 0↔1 boundaries so a
texture shared by two materials stays resident until BOTH are gone. This helper
just lets scripts/agents **inspect** that closure (debugging, "what would loading
X pull in?") without any side effect — it avoids `asset.resolve` (which fires the
BlobStore prefetch) and uses the side-effect-free `asset.guid` / `asset.deps` /
`asset.identity`.
The closure is the generic `.refs` graph, so this is fully type-agnostic —
material/mesh/user-types all resolve the same way with zero per-type knowledge.
By default it follows `asset_ref` / `component` (content) edges and skips
`asset_type` (structural) / `require` (code); `opts.vias` overrides. Deduped by
guid via an explicit work-stack so diamonds appear once and cycles terminate.
Installed onto the FFI `asset` namespace by the prelude.
# Importer (asset type)
An importer is a Luau function that converts a source file (GLB,
PNG, CSV, custom binary) into one or more derived assets registered
in the engine. Importers extend the engine's file-format knowledge —
adding a new importer means "the engine now understands `.foo`
files". Importers run automatically on VFS writes that match their
claimed extensions.
## When to use one
- You're bringing a new file format into the engine and want to
derive engine-native assets from it (meshes, textures, animations,
data tables).
- You want one VFS write to fan out into multiple derived assets
(e.g. a GLB write derives mesh, texture, and animation assets).
- You want format ingestion to be idempotent + hot-reloadable —
importers re-run on source-file changes.
If you only need to consume an existing engine asset type, you don't
need an importer; just resolve it via `asset.resolve`. If you're
building an authoring tool that emits assets, that's a `.tool`, not
an importer.
## Where it lives
- Source: `/zero/source/.../<name>.importer/`
- Identity: `<name>` (the `.importer` suffix strips).
- Folder shape:
- `init.luau` (or `init.lua`) — exports `canImport` and `import`, and
`extensions`: the file extensions it claims, lower-case without the dot.
**Required.**
- `README.md` — instance documentation. **Required.**
- `.metadata` — agent-editable tags + free-form fields. **Required.**
- `settings.luau` — the importer's own settings schema, declared with
`Field` descriptors. Optional; an importer without one takes no settings.
- `settings.yaml` — the importer's settings values. Optional.
## Settings
An importer that takes settings declares them in its own `settings.luau` and
receives the resolved values as `ctx.settings` in `import(ctx)`.
`asset.schema(importerRef)` reports the schema, `asset.settings(importerRef)`
the values, and `asset.setSettings(importerRef, patch)` changes them. What an
importer stamps onto the assets it creates is typically one of these: the
texture, SVG and model importers each declare `textureSettings =
Field.settingsOf("@builtin::assetTypes.texture", NoSync)` — the texture type's
own fields — and pass it to `asset.create` as `importerSettings`, the importer
layer of each texture's settings. A change to an importer's settings counts as
a change to what an import is made from, so an unchanged source is imported
again after one.
## How to create one
```luau
asset.create("importer", "<name>")
-- Creates: /zero/source/<name>.importer/
-- init.luau (canImport / import stubs)
-- README.md (instance README template)
-- `folder` places it in a subfolder of /source instead of the root:
asset.create("importer", "<name>", { folder = "importers" })
```
## How it operates
1. **Registration.** Writing `init.luau` into a `.importer/` folder
indexes the importer with the importer registry.
2. **Dispatch.** When a file is written to VFS, the engine asks every
registered importer `canImport(path, bytes)` (cheap predicate).
The first importer that accepts dispatches to its `import`
function.
3. **Derivation.** `import(path, bytes)` returns a list of
`{ path, kind }` pairs naming the assets to derive. The engine
instantiates each derived asset against its registered category.
4. **Idempotency.** `import` MUST be deterministic — importing the
same source bytes twice produces identical derived assets. The
importer pipeline uses content-hashing to short-circuit re-imports
when bytes haven't changed.
5. **Hot reload.** Editing the importer reloads it; subsequent writes
use the new logic. Already-imported assets stay imported with the
old derivation until their source is imported again — writing the
source path re-dispatches the importers, and
`tools.use("importers", "run", <path>)` re-imports a target, many
targets, or a whole folder on demand.
## Discovery
- `asset.list("importer")` — every registered importer.
- `asset.inspect("<name>")` — claimed extensions, derivation
conventions, source, this type README.
- `cat /zero/source/<name>.importer` — same summary.
- `tools.use("importers", "list")` — every registered importer, with
its name and identity.
- `tools.use("importers", "explain", <path>)` — which importers claim
a given source and which gate would block the import.
## Authoring conventions
- Keep `canImport` cheap — it's called for every VFS write. Match on
extension or a quick header probe; defer real parsing to
`import`.
- Declare the extensions the importer claims as `M.extensions` and have
`canImport` claim by that list. The Inspector lists them, and
`require("@builtin::assetTypes.importer.shared").extensionsOf(importerRef)`
answers them (lower-case, without the dot; nil for an importer that
declares none).
- Name derived assets predictably (e.g.
`imported/<source-stem>/<asset-name>`). Predictable paths let
callers reference derived assets directly.
- Make `import` pure relative to its inputs. Don't read mutable
globals or side-channel state.
- Log derivations via `print` / the script log so authors can see
what got produced from each source.
## Common pitfalls
- **Expensive `canImport`.** It runs on every VFS write; an
expensive predicate slows down the entire authoring loop.
- **Non-idempotent `import`.** Re-importing produces different
output → content hashing falsely caches stale results. Use only
deterministic transforms.
- **Conflicting importers.** Two importers claiming the same
extension causes ambiguous dispatch. First-registered wins, but
this is fragile — coordinate ownership of file extensions.
- **Derived-asset suffixes.** Make sure derived paths end in the
right `.<category>` suffix; otherwise they won't register as
recognized assets.
## Related types
- `.module` — for plain ingestion helpers that don't need the
importer dispatch path.
- `.tool` — for one-off conversions an agent invokes manually.
- `.service` — if your importer needs persistent state (e.g. a
cache); the service owns it, the importer reads from it.
## In the Inspector
Imports: the extensions the importer claims (`M.extensions`), each file it imported in this session with the state its job reached, and **Find assets it produced**, which lists the assets in the world whose import provenance names this importer.
# persist.serializer
Live-state serializer driver for persist — the play-mode half of the scene-save
story. `scene_saver.serializeEntity` already turns a live entity into the v6
per-entity body, but it is only ever fed ids from the edit-mode dirty-mark queue
(empty in play). This driver supplies the play-mode side: it enumerates the
active layer's live entities, filters them by **provenance** (execute-origin
only, via `persist.origin`) and **authored-ness** (`scene_saver`'s
spawner-managed / temporary filter), and assembles a loadable v6 scene body.
Lighting stays entity-primary: it comes from `Light` components on the live
entities, not from a separate lighting block.
# playerSetupValidation
Agent-facing validation for authored player setups. A player-spawn scene is built from PlayerSpawn entities (each naming a PlayerPrototype subtree to instantiate on join) and PlayerPrototype roots (marked PrototypeOnly, carrying a Camera in their subtree and a `body` field naming the entity to adopt). `checkEntity` inspects one such entity and returns human-readable messages that name what is wrong and what to do about it; `checkScene` validates the scene's player intent against its PlayerSpawn / Camera counts; `checkActiveScene` runs both across the live active-layer scene and returns a flat `{ entity, message }` list — the queryable surface an agent reads to see what to fix.
`checkActiveScene` answers for the settled scene: it holds while a scene load or a mode-flip transition is rebuilding the live tree, then judges what the rebuild lands on. Each PlayerSpawn's prototype resolves wherever the materialisation holds it — the live entity in edit, and in play, where the loader keeps authored prototype subtrees out of the scene, the captured authored template each joining player is cloned from — so the same rules run over the same authored structure in both modes, and a prototype removed from a scene that materialises prototypes reads as the missing reference it is.
Location: `src/lua/lib/modules/api/engine/playerSetupValidation.module`
# scene_build
Turns builder code into scene records, and scene records into scene entities.
`run(builder)` opens an entity capture scope, calls `builder`, and composes every
entity it created into records — then despawns them. The builder writes ordinary
spawn code; what comes back is data. A builder that raises leaves nothing behind.
What a component the builder attached creates while running its own lifecycle
belongs to that component: the record names the COMPONENT, and the same lifecycle
runs again wherever the record is put back. That is what makes a nested build one
build — a placement the builder makes runs its own module and owns what it lands,
and the build around it describes the placement.
`reconcile(records, target, owner)` applies records to the scene under `target`,
writing `owner` and the record's place in the hierarchy onto each entity it
places as the `buildOwner` / `buildKey` attributes. A rebuild reads that pair
back off the entities under `target` and lands on the same ones, so nothing has
to be remembered between two rebuilds — the pair is in the scene, and a reload
brings it back with the entity. It returns the ids it landed on, keyed by record
identity.
A record is identified by its place in the hierarchy — the chain of names from
the build root down to it, `base/coin_3` — so dropping a line from the middle of
a builder, inserting one, or emitting two entities in the other order leaves
every other entity's id where it was. Children of one parent that share a name
carry their rank among those, `base/crate#2`, counted in the order the builder
created them.
Together these are the bake: code in, durable static-id scene content out.
## Who states an entity
An entity names the build that placed it, so `ownerOf(id)` answers which
build owns it — and answers nil for an entity an author spawned. `sourceOf(owner)`
names the file that build is written in, from the `source` the reconcile running
it passed.
That pair is what makes ownership legible where an author meets it. A build
states its entities in full every time it runs, so a value set on one of them
holds until the next run. `notePreview(id, values)` records what the entity
carries after such a change and `takePreview(id)` reads it back exactly once —
in the run that states the entity again, which is where what the author set and
what the build says can be named side by side.
# persist.origin
Provenance / origin-context layer for the persist (create-flow) system.
The problem persist must solve: when the agent freezes live play state into
`scene.json`, it must capture **only** the things that would not otherwise be
reproduced on the next load — i.e. creations whose stack root is the agent's
ad-hoc `execute()` context. Anything created by `onLoad` / the scene loader or by
a component callback re-runs on every load, so freezing it would duplicate it.
This layer tags each live creation with its origin context (via wrappers
installed over the entity API) and exposes the current origin, so the serializer
can keep execute-origin entities and drop the rest. See
`docs/specs/multiplayer/candidate-c-persist-flow.md`.
# mode_flip_guard
Two tiny cross-module transient signals about the edit↔play mode flip:
- **owned** — "the `layers` module currently owns the mode-flip reset, so the
`player_spawner` / `camera_spawner` `onModeChange` watchers should stand
down."
- **in flight** — "a mode-flip transition is materialising the scene right
now, so the live entities are a partial rebuild of it."
Formerly `_G.__zero_layers_owns_mode_flip`. Moved off `_G` ahead of the
read-only `_G` seal — the flags are runtime writes (set when `layers` drives
a mode flip), which would break under a sealed `_G`. `require()` is cached
per VM, so the module-local upvalues are shared state across every
requirer within a VM — exactly the cross-module reach the old `_G` key
provided.
- **Setter**: `layers.module` claims ownership before any flip, and raises the
in-flight signal for the span of the transition it runs.
- **Readers**: `player_spawner.module`, `camera_spawner.module` stand down
while owned so the player/camera respawn happens exactly once via the
`layers` transition's reload fan-out (not a second time from their own
`onModeChange` watchers); `playerSetupValidation.module` judges the authored
scene once the transition has settled.
## Surface
| Symbol | Notes |
|---|---|
| `M.setOwned(v: boolean)` | Set whether the layers module owns the current mode-flip reset. |
| `M.isOwned() -> boolean` | True while the layers transition owns the flip; spawners stand down. |
| `M.setInFlight(v: boolean)` | Set whether a mode-flip transition is materialising the scene. |
| `M.isInFlight() -> boolean` | True for the span of the transition; the live entities are a partial rebuild of the scene. |
# `scene.shared.saver`
Luau-side scene saver. Writes `scene.json` v6 — **authored intent
only**. No captured runtime state.
## Public surface
```lua
local saver = require("@builtin::assetTypes.scene.shared.saver")
saver.save(name, opts?)
-- name: logical scene name ("main") OR full VFS path
-- opts.layer: layer name to read from (default layers.active.name or "main")
-- Returns: the absolute VFS path written.
```
## What is captured
| Field | Source | Notes |
|---|---|---|
| `format`, `version` | constants | Always `"scene"` + `6`. |
| `entities` | `entity.findAll()` walk | Excludes `temporary` entities + the spawner-managed Player + primary Camera. |
| `entities[].transform` | local position / rotation / scale | NOT world transform — local only. |
| `entities[].components` | component.list + component.get | Strips `_`-prefixed runtime fields. |
| `entities[].attributes` | `attribute.list` + `attribute.get` | The entity's key/value bag, written as a `{ [key] = value }` map; omitted for an entity that holds none. Holds the authored keys — a `_`-prefixed key is the engine's own session bookkeeping. |
| `lighting` | `lights.snapshot()`, filtered | Scene-level `clear_color` + `sky` only. Directional / ambient / point lights persist as `Light` entities in `entities[]`. |
| `player` | `sceneProxy.player_config` | Carried forward from load-time, authored only. |
| `camera` | `sceneProxy.camera_config` | Carried forward from load-time, authored only. |
## What is NOT captured
- Local player's position / rotation. Runtime drift; not authored intent.
- Primary camera's transform. Runtime; behavior-driven.
- Any entity flagged `temporary = true`. Debug helpers, runtime markers.
- Internal component fields prefixed with `_` (e.g. `_velocity` on CharacterController).
The spawner-managed entities (Player + primary Camera) are excluded
because they're created on load by player_spawner / camera_spawner
from the scene's declarative `player` + `camera` blocks. Saving them
would double them on the next load.
## When to use
`layers.active:save(name)` (Phase 6) routes through this module. Callers
that want a state snapshot (save-game systems, undo stacks) should use
`world.debugSaveToDisk(...)` instead — that captures runtime state by design.
# layers
Top-level scene-management global + Scene proxy. § 17 step 9 of
`docs/plans/2026-05-01-player-camera-unification.md`. Renamed from the
legacy `scene.*` global to match `/runtime/layers/` and to give
scene-management state (active root, additives, callback registries)
a single anchor distinct from per-scene authoring.
## Global surface
| Symbol | Type | Notes |
|---|---|---|
| `layers.active` | `Scene \| nil` | Current root non-additive scene as a proxy. Live read; updates on every load/unload. |
| `layers.list()` | `{ Scene }` | Every loaded scene (root + additives) as proxies. |
| `layers.find(ref)` | `Scene \| nil` | Lookup by `AssetRef<scene>` or layer-id string. |
| `layers.is_loaded(ref)` | boolean | |
| `layers.load(ref, opts?)` | — | Loads ref. `opts.additive = true` layers on top; default = non-additive (replaces root). |
| `layers.unload(ref?)` | — | Unloads ref; nil = active root. |
| `layers.reload(ref?)` | — | Equivalent to unload + load with same ref. |
| `layers.onLoad(cb) -> handle` | function | Subscribe to any scene-load event. Returns handle for `offLoad`. |
| `layers.offLoad(handle)` | function | |
| `layers.onUnload(cb) -> handle` / `offUnload(h)` | function | Same shape for unload events. |
| `layers.observe()` | `SceneObservation` | What every load did and what each loaded scene costs, in one read. |
| `layers.lastLoad()` | `SceneLoadReport \| nil` | The last load's report; nil when nothing has loaded. |
| `layers.lastUnload()` | `SceneUnloadReport \| nil` | The last unload's, under the name the layer was loaded with. |
| `layers.loadHistory()` | `{ SceneLoadReport }` | The completed load reports still held, oldest first. |
| `layers.problems(ref?)` | `{ SceneLoadFailure }` | What a layer failed to produce, and why. |
| `layers.whyPartial(ref?)` | `(reason?, detail?)` | The nearest cause a layer is not whole, from a closed set. |
| `layers.inventory()` | `{ SceneLayerInventory }` | What the engine attributes to each loaded layer. |
| `layers.cost()` | `{ SceneLayerCost }` | Each layer's per-frame entrypoint tick, summed across the window. |
| `layers.resetCostWindow()` | — | Open a new cost window. |
## Scene proxy surface
A `Scene` proxy is a sealed table with fields and `:method` callables.
| Field / method | Notes |
|---|---|
| `s.asset` | `AssetRef<scene>` |
| `s.name`, `s.path` | informational (logging, debug) |
| `s.guid` | the scene asset's guid — the layer's identity |
| `s.sceneIdentity` | the identity of the scene asset the layer was loaded from (e.g. `@builtin::scenes.test_arena`), whichever VM loaded it |
| `s.additive` | boolean — additive overlay vs root scene |
| `s.parent`, `s.children` | additive ownership chain |
| `s:root()` | walks `.parent` to the non-additive root; returns self for roots |
| `s.players`, `s.camera` | populated by §17 step 10 (player + camera spawners) |
| `s.entrypoint` | `AssetRef<script> \| nil` |
| `s.visible` | boolean — read via field, toggle via `s:set_visible(b)` |
| `s.state` | `"loading"` / `"loaded"` / `"ready"` / `"partial"` / `"unloading"` — the layer's lifecycle state. Both `"ready"` and `"partial"` are terminal: the load has settled. |
| `s.ready`, `s.loaded` | booleans in lock-step with `state`. `ready` is true once the layer has settled, in either terminal state — it is the flag to wait on. |
| `s.ok` | boolean — whether everything the scene declared was produced and the layer has run clean since. False whenever `state` is `"partial"`, including when the tick starts raising long after the load settled. |
| `s.failures` | `{ SceneLoadFailure }` — every distinct failure the load and the layer's per-frame tick produced, each with the `count` of how often it repeated. |
| `s:unload()` | unload this scene; additives use `layers.unload(layerId)`, roots use `layers.active:reload()` |
| `s:reload()` | unload + reload same ref |
| `s:set_visible(b)` | show/hide without unloading |
| `s:load_additive(ref)` | load a sibling additive overlay; returns the new proxy |
## Example
```lua
-- Switch root scene
layers.load(asset.ref("@builtin::scenes.baseline_visual"))
-- Subscribe to all loads
local h = layers.onLoad(function(scene)
print("loaded:", scene.name)
end)
-- Layer an HUD overlay on top
layers.load(asset.ref("@builtin::scenes.hud"), { additive = true })
-- Iterate every loaded scene
for _, scn in ipairs(layers.list()) do
print(scn.name, scn.additive and "(additive)" or "(root)")
end
layers.unload() -- unload the active root
layers.offLoad(h)
```
## What a load did, and what a scene costs
Every load writes one record at its own completion point; the readings below
compose that record, so a caller that did not make the load can still ask what
it did. A load that produced failures settles into `"partial"` rather than
`"ready"`, and names the nearest cause from a closed set: `loaderRaised`,
`entrypointCompileFailed`, `entrypointBodyRaised`, `entrypointRaised`,
`buildRaised`, `entityFailed`, `parentMissing`, `parentRefused`,
`parentAbandoned`, `componentUnresolved`, `componentRefused`,
`subscriberRaised`, `updateRaised`.
```lua
layers.load(asset.ref("scenes.arena", "scene"))
-- Did it come up whole?
if not layers.active.ok then
local reason, detail = layers.whyPartial()
print(reason, detail)
for _, f in layers.problems() do
-- `count` rises when the same failure happens again, so a per-frame
-- tick that keeps raising stays one line.
print(f.phase, f.reason, f.entity, f.component, f.hook, f.count)
end
end
-- What did the swap do?
local r = layers.lastLoad()
print(r.name, r.outcome, r.durationMs)
print("replaced", r.replaced and r.replaced.name) -- the root that went
print("cascaded", #r.cascaded) -- the overlays with it
print(r.entities.added, "arrived,", r.entities.removed, "left")
print(r.phases.teardown, r.phases.instantiate, r.phases.settle)
-- Which loaded scene is expensive? `totalMs` is a SUM across the window, so
-- reset it first and read `avgMs` for the per-tick figure.
layers.resetCostWindow()
task.wait(1)
for _, c in layers.cost() do
print(c.name, c.avgMs, "ms/tick over", c.calls, "ticks")
end
-- What does each layer hold, and what does no layer claim?
for _, l in layers.inventory() do print(l.name, l.entities, l.ok) end
print(layers.observe().totals.unattributed)
```
The `scene` toolbox carries the same two readings as `scene.observe` and
`scene.whyPartial`.
## Authoring surface
`layers.*` is the sole public scene-management surface. It accepts
AssetRefs (not paths), drives a callback registry, and constructs
Scene proxies that carry players / camera / hooks. The previous
`scene.*` global has been removed — its low-level FFI bindings now
live behind `layers.*` and the Scene proxy methods.
# component_snapshot
Serialized snapshots of script-component public data.
A component's `public` table is a live proxy — its fields live behind
`__iter` / `__index` metamethods, so copying or JSON-encoding the proxy
directly yields an empty table. This module materializes the plain,
serializable form. It is the script-component parallel to `ecs.snapshot`
(native components).
```lua
local componentSnapshot = require("modules.component_snapshot")
-- one component: live proxy -> plain table
local data = componentSnapshot.snapshot(entity(id).component.get("Camera"))
-- whole entity: { [type] = data }, named/multi instances nested as
-- { [type] = { [instanceName or "__default"] = data } }
local all = componentSnapshot.snapshotEntity(entity(id))
```
`componentSnapshot.plainCopy(value)` materializes ONE live value the same
way — a table behind a proxy is walked into a plain table, every other
value passes through — for callers reading a single field rather than a
whole component.
Serialized form: entity refs become id strings, asset refs become
`{ __ref, name, type }` envelopes, vectors and colors their plain-table
forms; framework functions are omitted. `snapshot` returns nil for a proxy
with no serializable fields.
Persistence (scene serializer, bundle capture, prototype spawn) and
inspection tooling (`debug.inspect`) read component data through this
module. Live gameplay reads and writes stay on the proxy itself via
`entity(id).component.get` / `getAll`.
# asset_instance_inspector
The `Asset` component's custom entity-inspector view: an instance of an asset
placed in the scene, read against the asset it came from.
The generic field grid shows the component's `source`, `idMap` and `diff` — a
reference and two opaque tables. This view shows what they mean instead:
- a summary of how the instance stands — in sync with its asset, or how many
parts are edited, added or removed in the scene;
- the `source` field, with the reference chip's reveal / open / replace;
- **Overrides** — every part that differs from the asset, with what changed
(`position`, `name`, `Model tintR`, …) and a Revert (or Remove, for an
entity added under the instance);
- **Linked parts** — every part still as the asset has it;
- Revert all to asset, and Unpack into scene.
It reads the instance through the component's own `overrides()` and writes
through `revert(part?)` and `bakeIntoScene()`, so the view holds no knowledge of
how the diff is measured.
`M.sections(entityId, proxy)` answers the view as plain data; `M.build(ctx)`
renders it into the entity inspector's card with the Context's widgets, the
`source` field going through `ctx.setSettings` as one undoable edit.
```luau
local Inspector = require("@builtin::modules.editor.asset_instance_inspector")
local sections = Inspector.sections(entityId, entity(entityId).component.get("Asset"))
```
The `Asset` component's `inspector.luau` hands `build` to the entity inspector
as the card's `settings` section, for every entity carrying the component.
# asset_tag (module)
The `assetTag` field-constraint validator. Registers itself with
`field_constraints.module`'s registry on load so any component field
carrying `constraint = { kind = "assetTag", tag = ... }` — which is
exactly what `Field.taggedRef` emits — is checked against it.
## What it does
Given a field's written value and its constraint:
1. Passes `nil` — an unset slot holds nothing to check.
2. Reads the value's identity — a bare string, or a table's `__ref` /
`guid` / `identity` / `name`.
3. Resolves it as an asset of any type (`asset.resolve(identity)`), since
the tag is the whole gate.
4. Answers `asset.has_tag(identity, constraint.tag)`.
Acceptance rides on the tag rather than the asset's type, so an asset
becomes assignable to a tagged slot the moment it is tagged — no edit to
the field or to anything that reads it.
A rejection names the tag the slot wants, the tags the asset carries
instead, and the `asset.list({ fields = { tags = ... } })` call that
enumerates what would fit, so the message carries its own next step.
## Exports
- `M.check(value, constraint) -> ok, reason?` — the validator function,
also registered under the `assetTag` kind.
# bitmask_bits
The validator behind `Field.bitmask(bits, default, mode)`. A component field
declaring a bit mask carries a `bitmask` constraint, and the engine calls this
on the field's default and on every write to it.
A value passes when it is a whole number in `0 .. 2^bits - 1`. `nil` passes, so
a mask field may be left unset.
Each rejection names the width and what about the value is not a mask of it —
a fraction names no set of bits, a negative number is below the empty mask, and
a value past `2^bits - 1` addresses bits the consumer does not have. Without the
check a field declared as a `u32` on the engine side accepts `-1`, `2.5` and
`1e12` and reads each of them back unchanged, so the read-back is no evidence
the value took.
# range_bounds (module)
The `range` field-constraint validator. Registers itself with
`field_constraints.module`'s registry on load so any component field
carrying `constraint = { kind = "range", min = ..., max = ... }` — which
is what `Field.range` emits — is checked against it.
## What it does
Given a field's written value and its constraint:
1. Passes `nil` — a ranged field may be left unset.
2. Rejects a constraint carrying neither bound, since there is no
interval to check against.
3. Rejects a non-number value, naming the interval and the type it got.
4. Rejects a NaN or an infinity, which sits in no interval.
5. Answers whether the value is inside `constraint.min` ..
`constraint.max`, either of which may be absent to leave that side
open.
Every rejection names the interval, so the message tells the caller what
they may write instead of only what they may not.
## What a range is for
A field whose value means something only inside an interval — a
normalized amplitude, a fraction, a probability, a count that starts at
zero — declares that interval once, and every write is held to it. What
the field reads back is then a value the system consuming it can use,
and a number that lands outside is reported where it was written rather
than wherever it is eventually read.
## Where the bounds come from
`Field.range(min, max, default, mode)` validates the interval at
construction: at least one bound is required, both must be numbers when
given, `min` may not sit above `max`, and a non-nil default must be
inside. A field whose interval is wrong is therefore a load-time error
rather than a runtime surprise, and this validator only ever sees a
well-formed interval.
The engine carries `constraint` verbatim and never reads inside it, so
the bounds live entirely in Luau.
## Exports
- `M.check(value, constraint) -> ok, reason?` — the validator function,
also registered under the `range` kind.
# enum_values (module)
The `enum` field-constraint validator. Registers itself with
`field_constraints.module`'s registry on load so any component field
carrying `constraint = { kind = "enum", values = { ... } }` — which is
what `Field.enum` emits — is checked against it.
## What it does
Given a field's written value and its constraint:
1. Passes `nil` — an enum field may be left unset.
2. Rejects a constraint carrying no members, since there is nothing to
check against.
3. Rejects a non-string value, naming the members and the type it got.
4. Answers whether the value is one of `constraint.values`.
Every rejection lists the whole member set, so the message tells the
caller what they may write instead of only what they may not.
## Where the members come from
`Field.enum(values, default, mode)` validates the member list at
construction: the members must be non-empty, distinct strings, and a
non-nil default must be one of them. A field whose members are wrong is
therefore a load-time error rather than a runtime surprise, and this
validator only ever sees a well-formed set.
The engine carries `constraint` verbatim and never reads inside it, so
the member list lives entirely in Luau.
## Exports
- `M.check(value, constraint) -> ok, reason?` — the validator function,
also registered under the `enum` kind.
# data_contract (module)
The `dataContract` field-constraint validator. Registers itself with
`field_constraints.module`'s registry on load so any component field
carrying `constraint = { kind = "dataContract", contract = ... }` —
which is exactly what `Field.dataRef` emits — is checked against it.
## What it does
Given a field's written value and its constraint:
1. Reads the value's identity — a bare string, or a table's `__ref` /
`guid` / `identity` / `name`.
2. Resolves it as a `.data` instance (`asset.resolve(identity,
"data")`).
3. Delegates to the instance's own `ref:satisfies(constraint.contract)`
— the same contract-chain check `data.assetType` exposes to authored
code — and returns its `(ok, reason?)` result verbatim.
A value that isn't resolvable as a `.data` instance (wrong category, or
no such asset) is rejected with a plain reason rather than crashing
through to `asset.resolve`'s error.
## Exports
- `M.check(value, constraint) -> ok, reason?` — the validator function,
also registered under the `dataContract` kind.
# Light
Adds a light source to an entity. The entity's world transform determines the light's position (point) or direction (directional). Point light positions are auto-synced from that world transform, so a light on a child entity burns where the entity stands — the position `entity.position` reports. A point light's `intensity` is on one scale with the `SpotLight` component's `intensity`/`brightness`: the same number at the same `radius`/`range` puts the same light on a surface either kind faces from the same place, and a spot spends it on the cone it opens on rather than all around itself.
Kinds: `"point"`, `"spot"`, `"directional"` (the scene's sun), `"ambient"`, `"distant"` (a parallel light beside the sun).
Public fields: `kind`, `colorR/G/B`, `intensity`, `radius`, `directionX/Y/Z`, `castsShadows`, `lightChannels`, `mobility`. `range`, `color` and `direction` are aliases accepting the composite/renamed forms.
Methods: `:setColor(color)` (color: `{r, g, b}` array or `{r=, g=, b=}` map), `:setIntensity(i)`, `:setRadius(r)`, `:setDirection(dir)`, `:setKind(lightKind)`.
`kind = "spot"` opens a cone along the entity's forward axis, at the engine's default 30° outer and 20° inner half-angles; the `SpotLight` component is the one that carries the cone angles and the `face` axis as fields. `"directional"` and `"distant"` are parallel lights: they arrive from the same direction at every point in the world and no distance attenuates them, so their `intensity` reads against the sun's rather than against a point light's. The `SpotLight` README carries the rest of how to balance a mixed point-and-spot rig.
```luau
entity(id).component.add("Light", { kind = "point", intensity = 2, radius = 10 })
entity(id).component.add("Light", { kind = "directional", direction = {-0.5, -1, -0.3} })
```
# Model
Loads and displays a 3D mesh on an entity. Disabling the component hides the mesh. Supports procedural meshes (`cube`, `sphere`, ...), model files, and remote URLs. Tint and outline are shader effects applied to the mesh.
**Material lives on this component.** There is no standalone `Material` component — a material only renders where there is a mesh to render it on, so the `material` field is part of Model (and SkinnedModel). Set it at add time or assign the field later; properties are registry-wide.
Public fields: `model`, `material` (`AssetRef<material>`), `tintR/G/B`, `tintBlend`, `outlineR/G/B`, `outlineIntensity`.
Methods: `:setTint(color, blend?)` (color: `{r, g, b}` array or `{r=, g=, b=}` map), `:clearTint()`, `:setOutline(color, intensity?)`, `:clearOutline()`, `:setMaterialProperty(prop, value)`, `:getMaterialProperty(prop)`, `:getMaterialPropertyNames()`.
```luau
entity(id).component.add("Model", { model = "cube", material = "gold" })
entity(id).component.get("Model").material = "checkerboard" -- swap material
entity(id).component.get("Model"):setTint({1, 0, 0}, 0.5)
entity(id).component.get("Model"):setMaterialProperty("roughness", 0.2)
```
# shared Module
Cross-tool helpers for the `capture` toolbox. Every tool inside this
toolbox can pull these in with `require(".shared")`.
## Purpose
This module holds the small set of helpers that more than one tool in
the toolbox needs — typically the `ok(value, formatted?)` /
`fail(message, exitCode?)` constructors that produce the canonical
`captureToolResult` shape, plus any toolbox-wide parsers,
validators, or shared state. Keep it focused: per-tool implementation
stays inside each `<x>.tool/init.luau` (with its own `--!desc` /
`--!arg` / `--!return` / `--!example` annotations).
## Usage
```luau
local shared = require(".shared")
function M.someTool(...)
if not ok then
return shared.fail("reason", 1)
end
return shared.ok(value, "")
end
```
## Exports
See `init.luau` for the full export surface. Typical exports include
`shared.ok(value, formatted?)` and `shared.fail(message, exitCode?)`
for building the toolbox's standard result envelope.
# environment Module
Environment / reflection capture — bake the scene into reflection-probe cube slots from world positions, persist them as `faces6` `.texture` assets, set per-probe blend data so surfaces reflect the probes covering them, and capture the sky into its own slot as the fallback under them. Public Luau surface over the `__environment` Internal FFI namespace, auto-injected as `_G.environment` via the prelude.
## Purpose
The generic "render the scene into a cubemap from a point" capability the reflection-probe system is built on. Captures are queued for the render system (which owns the live scene); `captureSlotToAsset` additionally yields a few frames while the GPU readback completes. Persisted cubes are `faces6` `.texture` assets (px/nx/py/ny/pz/nz PNGs + a `cube.yaml` sidecar — see `docs/specs/cubemap-textures.md` §4 for the face convention).
For probe authoring use the higher-level `reflectionProbe` module; reach for `environment` when you need the raw per-slot primitives.
## Usage
```luau
-- Register probe blend data: index i maps to cube slot i.
environment.setProbes({ { x = 0, y = 2, z = 0, radius = 12 } })
-- Bake slot 0 from a point (queued, next frame).
environment.captureSlot(0, 0, 2, 0)
-- Bake + persist to /source/probe_lobby.texture/ (yields; call from a
-- task/coroutine/execute context).
local path, err = environment.captureSlotToAsset("probe_lobby", 0, 0, 2, 0)
-- Restore a persisted cube into a slot WITHOUT re-rendering.
environment.loadSlotFromAsset("probe_lobby", 0)
-- Capture the sky alone into the fallback slot: a surface no probe covers
-- reflects the sky rather than black.
environment.captureSky()
```
## Exports
- `environment.setProbes(probes) -> boolean` — set active probes' blend data; array of `{ x, y, z, radius, priority? }`, index i → cube slot i, gathered highest `priority` first
- `environment.captureSky(x?, y?, z?) -> boolean` — render the sky alone into the fallback slot and arm it (queued)
- `environment.setSkyFallback(active) -> boolean` — arm/disarm the fallback against the sky already captured (arming is refused while the slot holds none)
- `environment.captureSlot(slot, x, y, z) -> boolean` — bake the scene into a slot from a point (queued)
- `environment.captureSlotToAsset(name, slot, x, y, z, timeoutFrames?) -> (string?, string?)` — bake + persist as a `faces6` `.texture`; yields
- `environment.loadSlotFromAsset(name, slot) -> (boolean, string?)` — upload a persisted cube into a slot without re-rendering
Back-compat single-global-reflection helpers (slot 0 + one full-coverage probe):
- `environment.capture(x, y, z) -> boolean`
- `environment.captureToAsset(name, x, y, z) -> (string?, string?)`
- `environment.loadFromAsset(name) -> (boolean, string?)`
# Camera
Manages viewport priority, render-to-texture, and capture. State is stored in the native `Camera` ECS component; the Rust camera system handles render scheduling and render targets.
Public fields: `fov`, `near`, `far`, `priority`, `textureHandle` (the guid of the texture the camera renders into; empty = main viewport), `renderLayers` (which render layers this camera draws — a space-separated spec of names, e.g. `"all"`, `"all !ui"`, or `"default sky"`; `ui`/`sky`/`debug`/`EditorUI` are built-in layers), `postProcessing` (whether this camera runs the post-process chain), `debugChannel`, plus the behavior slot below.
Methods: `:lookAt(target)` (entity id string OR `{x, y, z}` table), `:render()`, `:capture()`, `:setTargetTexture(tex?)` (a `renderer.texture.create` handle to render into, or nil for the viewport).
```luau
entity(id).component.add("Camera", { fov = 90, priority = 10 })
entity(id).component.get("Camera"):capture()
```
The scene's play-mode camera is reached as `layers.active.camera`, a handle that
reads and writes the fields above on whichever entity currently carries them.
`layers.active.camera.entity` is the entity ref for that camera and
`layers.active.camera.entityId` its id string, so a script that needs to attach
something to the camera — an `AudioListener`, a child entity — goes through the
ref:
```luau
local cam = layers.active.camera
cam.fov = 70 -- the camera's settings
cam.entity.component.add("AudioListener") -- the entity carrying them
```
## How the camera moves: `behavior` and `follow`
A `Camera` does not move itself. `behavior` names a component that does, and setting it attaches that component to this entity. Clearing it detaches whatever was attached.
```luau
local cam = entity(id).component.get("Camera")
cam.behavior = asset.ref("@builtin::controller.orbital_follow", "component")
cam.follow = playerBody
```
`follow` is the standard slot every shipped behavior reads. Set the follow target on the **Camera**, not on the behavior, so swapping behaviors keeps it. A behavior that finds its own `follow` field empty falls back to this one, which is what lets a rig keep tracking the player across a behavior swap.
`followResolves` answers whether that slot names an entity that is live — `true` while it names a live one or names nothing at all, `false` once the target is despawned or the id names no entity. A rig whose target does not resolve holds its last pose, and this is the field that tells it from a rig posed correctly on a subject that has not moved. `camera.get` carries the same value beside `follow`, and a write naming an id with no entity behind it draws a warning where it lands.
```luau
cam.followResolves -- false once the followed entity is gone
tools.use("camera", "get", id).followResolves -- the same answer off the tool
```
The shipped behaviors live under `@builtin::controller.*`: `orbital_follow`, `third_person_follow`, `first_person`, `free`, `orbit`, `chase`, `isometric`, `rts`, `birds_eye`, `side_scroller`, `cinematic`, `menu`.
## Writing your own
Any component can be a camera behavior. Write one that moves its own entity and attach it the same way:
```luau
cam.behavior = asset.ref("MyChaseCam", "component")
```
`follow` lives on the Camera, so a behavior gets no property notification of its own when the target changes. Declare `onFollowChanged(newFollow, oldFollow)` to be told the moment it does — the Camera calls it on the component it attached, which is what lets a rig re-pose on the new subject at once. A behavior that reads `Camera.follow` on its own schedule declares nothing and is attached the same way.
```luau
typed function public:onFollowChanged(newFollow: any, oldFollow: any)
-- pose this entity against the new target
end
```
To make it appear in the discovery catalog alongside the shipped ones, declare the `cameraBehavior` tag in the component's `.metadata`:
```json
{ "tags": ["cameraBehavior"] }
```
The tag governs **discovery**, not attachment. A tagged component is listed by `layers.active.camera.behaviors` and is what tooling offers when something asks "which camera behaviors exist"; an untagged component attaches just as well and simply stays out of that list. Tag the ones you want other people (and agents) to find.
```luau
for name, ref in pairs(layers.active.camera.behaviors) do
print(name, ref.identity)
end
```
## Texture colour space
`textureColorSpace = "display"` applies the display transform when rendering into a texture. `"linear"` writes scene-linear values instead. `postProcessing` independently controls the effects chain in either mode. Floating-point targets retain values above one; normalized targets clamp to their representable range. The main viewport uses display encoding.
# AreaLight
A rectangular light source on an entity. The rect is centred on the entity's
world position, faces along the entity's world rotation applied to `face`
(default "Front", the entity-local -Z that `transform.forward` reports and that
`entity:lookAt` aims), and spans `width` by `height`. A rect under a carrier moves
and turns with it. Its light arrives from a surface rather than
a point, so shading falls off gradually across a wall, highlights stretch to the
shape of the source, and shadows carry a penumbra that widens with the rect's
size and the caster's distance.
# persist
The create-flow persist namespace. Persist freezes live play state into a scene
the way the agent's ad-hoc `execute()` context created it — its north star is
"what you see is what you get": it preserves the exact live state, capturing only
what would **not** otherwise be reproduced on the next load (so re-runnable
`onLoad` / loader / component output is never duplicated).
`require("modules.persist")` re-exports the three subsystems; each can also be
required directly.
- [`origin`](origin.module/README.md) — provenance / origin-context layer: which
live creations trace to the agent's `execute()` stack (so only those are frozen).
- [`serializer`](serializer.module/README.md) — enumerates the active layer's
live entities, filters by provenance and authored-ness, and assembles a
loadable v6 scene body (the play-mode half of scene-save).
- [`player_camera`](player_camera.module/README.md) — freezes the live player
avatar and camera into the scene's player/camera config.
# scene runtime
What a `.scene` folder does when it is loaded, saved, built, or swapped.
A `<name>.scene/` carries `scene.json` (the entity arrangement), an optional
`build.luau` (the code author's half), an optional `entrypoint.luau`, and a
`scene_dirty/` overlay of unsaved edits. Reading, composing and writing all
four is the scene type's own behaviour, so it lives in the type.
## Sub-modules
| Module | Responsibility |
|---|---|
| `loader.module` | Reads scene.json v6 and v7, refuses versions it does not know, composes canonical + dirty overlay, spawns the entities, and runs the entrypoint. |
| `saver.module` | Persists scene state as a delta overlay against the canonical file, promotes that overlay on save, and answers which edits are still pending. |
| `build.module` | Runs a `build.luau` inside an entity capture scope and composes what it spawned into scene records. |
| `observe.module` | The read side — the per-scene counts and costs `layers` and `loader` write. |
| `swapOrchestrator.module` | Drives multiplayer room transitions and the scene-swap gate, so a local swap does not leak despawns to peers in the old room. |
## Reaching it
```luau
local rt = require("@builtin::assetTypes.scene.shared")
rt.saver.hasPendingSceneEdits(layers.active.name)
```
Fields resolve lazily. The prelude installs `swapOrchestrator` and starts
`saver`'s dirty writer in a fixed order, which a table that required all five
on load would take away from it.
# capture
Render a screenshot of the scene from anywhere — an explicit position, a named camera station around an entity, a specific camera, or a mirror of the active viewport — and a grid of them in one image.
The picture of the machine's own camera (a webcam, a phone camera) is `cameraInput`, covered by the `topics/camera-input` guide; this toolbox photographs the scene.
Every tool spawns an ephemeral offscreen camera, triggers one render, and returns the picture as an image value — returned from a tool, a probe or `execute`, it reaches you as a picture, with what the capture has to say about it (its size, the frame it is of, the cells of a grid) beside it. A PNG is written as well when you pass a `path`. The convenience wrappers render from a world-space position (with optional look-at), frame a target entity, mirror the active viewport camera including its layers, or render a specific camera by name from its exact authored pose and settings (`fromCamera`, with optional per-capture overrides that never touch the camera); they run as a background task that waits for the GPU readback and cleans up — which only works in play mode, since the task scheduler ticks there. The low-level oneshot spawns the camera, triggers one render, and returns the handles synchronously — the only path that works from edit mode, and the one the `capture` MCP tool is built on. A cleanup tool clears the capture output directory.
## Where a shot lands
A capture given a `path` writes the PNG there and says where those bytes
stand, beside the path it wrote: `durable` is true when the PNG is on the path
it names, and false when the play shadow took it: live in this session and
discarded on a guarded play-exit unless kept, with `survivesRestart` saying
whether a restart keeps it and `playShadow` naming the routes that keep it.
Every capture carries it on the picture it returns as `durability`. Shooting
gameplay means shooting in play, which is exactly when a `/source` destination
is the shadow's, so this is the field that says whether a shot survives leaving
play — `vfs.promotePlayShadow(path)` keeps it with play still running.
## The feed
Every capture publishes the frame it made to `@builtin::tools.capture.feed`,
so a viewer can show the newest one without taking a shot of its own.
`Feed.latest()` is that frame: a `src` an image widget draws (the render
target's guid while the engine still holds it, the written path when the
capture produced a file), with the size, the source and the pass beside it.
`Feed.watch(fn)` calls `fn` as each new frame arrives. The editor's Capture
panel is built on it, and the `watch` layout puts that panel under the scene.
A viewer calls `Feed.shown()` to say it is drawing frames from the feed. From
then on the feed holds the newest capture's render target so the picture stays
up after the capture that made it is finished with it, and lets it go when a
later frame replaces it; `Feed.shown(false)` stops. With nobody drawing, a
capture's target is released the moment its capture is done, and nothing is
held.
## Viewpoints
A **viewpoint** names where the camera stands and which way is up in the image: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`. It replaces reverse-engineering a `{yaw, pitch}` pair, and names the seven stations that answer "is this built correctly".
`basis` says which axes the name is measured against, and it is **required** alongside a viewpoint when a subject is being framed:
- **`"local"`** — the subject's own axes. `front` is the side it faces however it is turned, and the frame is fitted to its own extents rather than to the world-axis box around them.
- **`"world"`** — the world axes. `front` is whatever faces world +Z.
For a crate turned 40 degrees those are different pictures, so the call says which one it wants. Framing a world position rather than an entity, both values mean the world axes and either is accepted.
## Passes
`pass` names **what** the frame holds, by name. `"final"` is the lit picture; a built-in diagnostic pass (`albedo`, `normal`, `depth`, `motion_vectors`, …) replaces the shaded image with the buffer behind it; and any capture **view** a render feature has published (`renderer.captureView.list()` enumerates them) is selectable by its own name. A name that is neither is refused against the list of what is registered.
The name carries two decisions: which channel the camera renders, and which render layers that channel is legible under. A diagnostic buffer is read flat, so the sky behind it and the post-process chain over it come off; a view that draws its own geometry declares the layers it needs and is rendered under them. Stating `renderLayers` answers that second question yourself, for the one call.
`passes` spreads the same vocabulary across the cells of a collage, and each cell reports the layers it was read under.
## Projection
`projection = "orthographic"` covers a fixed world-space height at every distance. Parallel edges stay parallel and two equal-size objects at different depths cover equal pixels, which is what makes it the projection to check a shape or compare sizes under — a perspective frame's convergence hides both. `orthoHeight` sets that height; omit it and it is fitted to what is being framed.
Any camera can be orthographic, not only a capture: it is a field on the `Camera` component.
## Isolate
Framing a subject fits the **frame** to it; it does not decide what is drawn in that
frame. Everything else standing there is still rendered — and a scene's default player
spawn sits at the world **origin**, which is exactly where a prop usually gets built, so
a tight shot of a new prop at the origin can come back showing the player instead.
`isolate = true` draws the subject without the other geometry. It changes exactly that — lighting, sky and post-process are untouched, so a final frame stays a final frame, and a flat read of the geometry is a `pass`. Excluded geometry still casts shadows onto the subject and still bounces light into it, the same as any render-layer exclusion. In a `setups` collage each cell shows the entity its own set-up frames alone, and a set-up can state `isolate` for its own cell; a set-up aimed from a position or a camera frames no entity and is refused when it is to be isolated.
## The tone the frame carries
Every capture reports `luma` on its cell record: the tone of the pixels it just
encoded, in code values on the 0-255 scale the image is delivered at.
```
luma = { pixels, min, max, mean, p1, p5, p50, p95, p99, span, spread, crushed, clipped }
```
`span` is `max - min` — the whole range, a single stray pixel included. `spread`
is `p95 - p5` — the range the body of the picture occupies, which is the reading
that answers whether a shot is legible. `crushed` and `clipped` are the shares of
the picture sitting at code 0 and at code 255.
A frame's mean cannot answer that question: half a picture at 40 and half at 200
carries the same mean as a flat field at 120, and only one of them has anything
in it. A subject can be modelled, shaded, lit and drawn and still arrive inside a
handful of code values, in which case the image looks like a wash and every other
field here reports success. `spread` is the number that says so.
The same reading is available anywhere pixels are: `cpu:tone()` on a
`TextureCpuHandle` — from `renderer.texture.readback(rtHandle)` on a capture
handle, from a render-to-texture camera's target, from a CPU canvas — and
`cpu:histogram()` under it, the per-code-value counts for luminance and for each
of the three channels, which every reading above is a sum over.
`cpu:histogram(x, y, w, h)` counts one rectangle of the picture alone, so a
reading per region of the frame — how much of each cell is dark, lit or a
given colour — is one call per region.
## The grade an offscreen frame carries
A capture spawns its own camera and renders offscreen, and the post-process
chain runs over that frame the way it runs over the viewport's — for a station,
for an orbit around an entity, for a named camera, for a render-to-texture
camera alike. Inside an effect, `engine.view_proj` / `engine.prev_view_proj` /
`engine.inv_view_proj` are the camera THAT render was drawn from and
`engine.resolution` is that target's own size, so a grade that reconstructs
world space from scene depth reconstructs against the station and lens the
capture asked for. That makes an offscreen capture an oracle for an authored
look: it photographs a chosen station and lens without taking the on-screen
camera away from whoever else is driving the scene, and moving that camera
somewhere else does not move the frame this one returns.
An offscreen render keeps no view history of its own, so `engine.prev_view_proj`
there holds that same matrix rather than the frame before it, and a pass taking
camera motion from the two reads none. On the viewport the pair is a frame
apart, so camera motion is the one reading a capture answers differently from
the screen.
A render feature's passes reach an offscreen render the same way, against the
same camera — the `renderFeature` assetType README carries that half, including
`@frame.camera`, its own per-target camera input. `render(ctx)` itself runs once
a frame for the main viewport, so `ctx.viewport` is the window's size rather
than the captured target's; a feature sizes its scratch with `screen = true` and
lets the engine track the drawn target.
`postProcessing = false` takes the chain off the frame a capture returns, and
with it the film a render feature draws — the flare and grain, a short focus's
bokeh, the shutter's smear. It is a `Camera` property rather than a render
layer, so `renderLayers` does not reach it. A capture naming a diagnostic `pass`
drops the chain by default, so the buffer comes back as the value it encodes.
## Collage
`collage` renders a grid of frames into one image. What varies across its cells is what you pass — `setups` for a cell per whole camera set-up, `viewpoints` for a cell per view of one subject (or `"sides"` for all six), `passes` for a cell per render pass, `duration` for a cell per sample over time. A collage with no axis is refused rather than quietly sampling time.
`setups` is the shot list: each entry is written the way a single capture is aimed (`source`, `camera`, `entity`/`entities`, `position`, `lookAt`, `rotation`, `distance`, `angle`, `margin`, `viewpoint`, `basis`, `projection`, `orthoHeight`, `fov`, `near`, `far`), plus `label` to name the shot on the result, and a field an entry leaves out comes from the collage's own options. `setups` and `viewpoints` are one axis — both say where the camera stands — so a request names one of them; either crosses with `passes` into a 2D grid, a row per station and a column per pass.
The shot most of this exists for is one call: six sides of one object, each an orthographic elevation, nothing else in frame.
Authoring the cameras themselves — creating, listing, aiming, framing, and setting their parameters — is the `camera` toolbox.
# player_prototype_spawn
The deterministic spawn core for authored player prototypes. A PlayerSpawn entity names a PlayerPrototype subtree to instantiate; `spawnFor` clones that subtree from its captured authored template, prunes clone-subtree nodes whose networkScope excludes the caller's owner/authority role, marks the clone RuntimeOnly, and turns the clone root into the joining user's hidden UserIdentity — plugging it into the players registry, join/leave, ownership, and sync. The body named by `PlayerPrototype.body` is adopted as the user's avatar and placed at the PlayerSpawn's world transform; the prototype's CameraRig follows it. `installJoinHook` wires `spawnFor` to a scene's user-join event so a joining user is placed automatically.
Location: `src/lua/lib/modules/api/engine/player_prototype_spawn.module`
# `scene.shared.loader`
Luau-side scene loader. Reads `scene.json` v6, dispatches to engine
FFIs, runs the sibling `entrypoint.luau`.
## Public surface
```lua
local loader = require("@builtin::assetTypes.scene.shared.loader")
loader.load(ref, opts?)
-- ref: AssetRef<scene> | identity string | VFS path
-- opts.layer: layer name (default "main")
-- Returns: the Scene proxy (with player_config + camera_config attached).
loader.unload(layerName)
-- Wrapper around layers.unload(name); symmetric to M.load.
-- Cannot unload "main".
```
## Behavior
1. Resolve `ref` to a VFS path; append `/scene.json` if needed.
2. `vfs.read` + `Json.decode` (from `modules.json`) → body table.
3. Version check — refuse anything but `version: 6`.
4. Attach `body.player` + `body.camera` to the Scene proxy as
`player_config` / `camera_config` (consumed by player_spawner +
camera_spawner).
5. Activate the target layer via `layers.find(layerName)` (the loader
threads the layer through `entity.spawn` opts), spawn `body.entities`
in two passes (shells + transforms first, parents + components
second), then restore the previous active layer.
6. Apply `body.lighting` via `lights.setup` + `lights.addPointLight`.
Field name mapping: `directional` → `sun`, `clear_color` →
`clearColor` (both engine API names and v6 field names are accepted).
7. Auto-discover sibling `entrypoint.luau` via `vfs.exists`; if
present, compile with `loadstring` + run.
## Performance
~5 ms for a 1000-entity scene (≈5k FFI calls × ~1 µs each). Acceptable
for a load-time operation. If a particular call class becomes hot,
batch primitives can be added in a narrow follow-up.
## Why pure Luau
Per § 10 of the unification integration design, the scene format is
data + dispatch — no Rust-only primitives needed. The existing ECS
FFI (`entity.spawn`, `entity(id).component.add`, etc.) covers
everything. Keeping the loader in Luau means iteration speed,
auditability, and hot-reload.
## FFI corrections vs. the original plan
The plan was drafted against assumed API names. The actual engine FFIs differ:
| Plan assumed | Actual engine API |
|---|---|
| `json.parse(s)` | `require("..json").decode(s)` (`modules.json`) |
| `json.encode(t)` | `require("..json").encode(t)` |
| `entity.spawn(n, { layer }).id` | layer activation via `layers.find(layer)` + `entity.spawn(n, opts).id` |
| `entity.setParent(id, pid)` | `entity(id).setParent(pid)` |
| `unloadLayer(name)` | `layers.unload(name)` |
| `lights.setup({ directional })` | `lights.setup({ sun })` |
| `lights.setup({ clear_color })` | `lights.setup({ clearColor })` |
| `lights.addPointLight(table)` | `lights.addPointLight(name, x, y, z, opts)` |
The loader accepts both the v6 field names and the engine API names for
the lighting fields so that round-tripping through the scene saver and
back works without a conversion step.
# entity_records
Walks a live entity hierarchy into the flat record array the engine's entity
templates use — `{ name, original_id, parent_id, position, rotation, scale,
hidden, components }` per entity, root first, parents before children.
`bundle` composes its `entity_template` through this module; scene builds
compose their baked output through it. One record shape, one implementation.
`compose(rootId)` walks one hierarchy. `composeMany(rootIds)` walks several,
which is what a build needs — a builder may leave more than one unparented root.
Temporary descendants are pruned; an explicitly-named temporary root is kept.
# Scene (asset type)
A scene is a saved, loadable snapshot of an entity scenegraph inside a
world: the entity hierarchy, transforms, components, lighting, and a
declared `player` intent, plus an auto-discovered startup script. A scene
is the unit of "level" or "screen" — a title screen, a lobby, a gameplay
area, and an end-of-game summary each get their own scene.
Scenes load as **layers**. One root scene is active at a time; additional
scenes can load additively as overlays on top of it. Each scene owns its
own multiplayer relay room, so switching scenes moves connected players
together.
## When to use one
- You want a savable, re-loadable snapshot of an entity scenegraph plus
its lighting and player setup.
- You want a runnable starting state for a level or screen.
- You want a scenes-as-data flow: `layers.load("lobby")`, edit live, then
`layers.active:save()` to publish the edits back to the scene.
For a reusable entity assembly (a prop, a vehicle, a UI panel) that many
scenes spawn into themselves, use a `.bundle`. A scene is a whole-world
snapshot; a bundle is one prefab that lives inside scenes.
## Folder shape
A scene asset is a folder ending in `.scene`. Its identity is the folder
name with the suffix stripped (`Lobby.scene` → `Lobby`).
- `scene.json` — the scenegraph: `entities[]`, lighting, and the
top-level `player` intent. **Required.** Written by the engine — a build,
a save, a human dragging something in. Not hand-edited.
- `build.luau` — what the scene is made of, written as code. Runs while you
author; saving bakes its result into `scene.json` (see Building a scene).
- `entrypoint.luau` — the startup script. Auto-discovered as a sibling of
`scene.json`; define lifecycle callbacks here (see The entrypoint).
- `<name>.<type>/` — an asset the build makes, authored here by `build.asset`
(see The assets a build makes), alongside `.build.assets`, which records
which `build.luau` produced each of them.
- `prefabs/` — optional subfolder for scene-local bundles.
- `scripts/` — optional subfolder for scene-local Luau helpers, required
from `entrypoint.luau`.
- `README.md` — optional per-scene notes about THIS scene. Author one only
when there is scene-specific detail worth recording; the general scene
model lives here, in `guides { path: "types/scene" }`.
`scene.json` is version 7. It carries `"format": "scene"`,
`"version": 7`, an `entities[]` array, and a top-level `"player"` string
intent.
## The `player` intent
Every scene declares how it handles players with a top-level `player`
field in `scene.json`:
- `"spawns"` — the scene carries an authored player setup, and the engine
spawns a player for each connecting user from it. This is the default
for a freshly created scene.
- `"none"` — the scene spawns no player and authors no camera. Author your
own Camera entity for the view (menus, UI-only screens, cinematics).
### How `"spawns"` works
A `"spawns"` scene authors two things in `scene.json`:
1. A **PlayerPrototype** — an entity carrying the `PlayerPrototype`
component, whose `body` field names a child entity (the body). The
body carries an avatar (an `Asset` pointing at an avatar, e.g.
`@builtin::avatars.humanoid`). A `CameraRig` child carries a `Camera`
with a camera behavior (e.g. `orbital_follow`). The prototype root is
authored `PrototypeOnly` so it exists as a clonable template.
2. A **PlayerSpawn** — an entity carrying the `PlayerSpawn` component,
whose `prototype` field names the PlayerPrototype to instantiate and
whose transform is where players appear.
The scaffolded scene groups these under two organizing entities,
`PlayerSetups` (holding the prototype) and `Spawns` (holding the spawn),
so the authoring tree stays legible.
On each user join — entering play, and each subsequent connect — the
engine runs the spawn for that user:
- It clones the PlayerPrototype subtree.
- The clone root becomes the user's **internal identity** — a registry-only
entity named `user` carrying the `UserIdentity` component. The identity
is the anchor for who the player is; it has no world presence.
- The body named by `PlayerPrototype.body` is adopted as the user's
**avatar** — their visible presence in the world — and placed at the
PlayerSpawn's world transform. Move the PlayerSpawn (or drag it in the
editor) to set where players appear.
- The `CameraRig`'s Camera follows the bound avatar.
The player prototype is live in edit mode so you can see and adjust it. In
play mode it is deactivated and marked internal — it stays a clonable source while
each user gets their own clone.
### Reaching the live player
The player surface lives on the root scene's registry,
`layers.active.players`:
- `players.localPlayer` — this client's player, a curated handle.
- `players.localReady` — `true` once the local player's avatar is bound
(or the scene opted its avatar out).
- `players:list()` / `players:count()` — the players connected in this
scene's room.
- `players:get(userId)` — a connected player by account id.
- `players:ownerOf(avatar)` — the player whose body is a given avatar.
A player handle is a data record about who the player is, plus a link to
their body:
- `player.avatar` — the body's entity ref. Act on the body through it
(`player.avatar.position`, raycasts, rig access).
- `player.isLocal` — whether this client owns the player.
- `player.ready` — whether a live avatar is bound.
- `player.userId` / `player.identity` / `player.displayName` — the
player's account identity.
- `player.avatar = ref` — bind a live body entity as the player's body
(assign nil to clear). To use a `.bundle` / `.avatar` asset, spawn it
into the world first, then bind the resulting entity.
The player's identity entity is the internal anchor; the handle exposes the
body (`player.avatar`) and the player's data, and reaching for
`.entity` / `.id` / `.component` raises with a redirect to `player.avatar`.
## Building a scene
`build.luau` declares what the scene holds. It is ordinary engine code —
`entity.spawn`, `component.add`, transform writes, `for` loops, a `require`
of a module that computes a layout — and what it creates is what ends up in
the scene.
```lua
function content()
for i = 0, 4 do
local crate = entity.spawn("crate")
crate.localPosition = { i * 2, 0.5, 0 }
crate.component.add("Model", { model = asset.resolve("cube", "mesh") })
end
end
```
The build runs while you author, in **edit** mode, and what it created lands in
the live scene. Saving the scene bakes it into `scene.json`, which is what makes
the entities durable content: a human can select and inspect them, they
replicate, and they are there when the world opens without `build.luau` running
at all. Play mode never runs it — in play the entities load from `scene.json`
like anything else the scene holds.
**Editing the file rebuilds the scene in place.** Writing `build.luau` is the
signal; there is nothing to reload, and the rebuild saves. An entity keeps the
id it had, so a reference to it — a spawn naming a prototype, another entity's
component naming this one — survives every rebuild. Add a crate to the loop and
the other four do not move.
The write returns as soon as the file is on disk and the rebuild finishes behind
it, so what it landed comes back as a notice:
```
[info] a write to build.luau rebuilt the scene { content="6", scene="scenes.main",
editorOnly="1" }
```
An edit saved while a rebuild is still running is an edit to code that rebuild
has already read, so it gets a rebuild of its own once that one finishes, and
says which it was:
```
[info] a write to build.luau arrived during a rebuild and was rebuilt after it
{ content="7", scene="scenes.main", editorOnly="1" }
```
Several edits saved during the same rebuild get the one run after it, which
reads the file as it stands then.
A scene that declares a build also runs it when it **loads** in edit, so the
scene shows what the code says now, including an edit made while the world was
closed. A load whose `.build.state` records the current script reads
`scene.json` as the build and runs nothing. When the load does run it, a
world's own scene saves the result, unless the scene holds unsaved edits: those
stay the author's to keep or discard, and the file write waits for their save.
A library scene (one under `/libs/`, the `@builtin` library included) is read
by its load: the build lands in the live scene and its files stay as the
library states them. Play reads `scene.json`, so it comes up without that build
until `:build()` saves it.
### The two surfaces
- `content()` — the scene's own entities. Present in play.
- `editorOnly()` — entities present while authoring and absent in play. They
carry the `EditorOnly` participation the runtime deactivates and hides when
play begins, and they are baked too, so a collaborator opening the world sees
the same ones. Anything an author needs to see and a player does not goes here
— an alignment guide, a spacing marker, a debug volume.
Both are optional. A scene with neither has no build.
### What a rebuild touches
Only the entities the build itself placed. Anything a human dragged in, or a
tool placed directly, is invisible to a rebuild and never moved or removed.
Within what it owns, the build is authoritative: an entity the code stopped
emitting is despawned, a component it stopped adding is removed.
The build writes its own name and each entity's place in the hierarchy onto the
entity, and `scene.json` records that pair beside the entity's name and
transform — which is why a rebuild after a reload, in a later session, lands on
exactly the entities it placed the first time without anything having been
remembered.
### When the file did not change
`:build()` runs the build against what it resolves right now — for the case
where something the builder *reads* changed and the file did not: a module it
requires, an asset it resolves. After a write it is unnecessary; the write has
already run it.
```lua
layers.active.asset:build() -- rebuild and save
layers.active.asset:build({ save = false })
```
It answers with how many entities each surface placed —
`{ content = 6, editorOnly = 1 }` — and with nil for a scene that has no
`build.luau`. Called while a build for that scene is already running, it returns
at once with `{ inFlight = true, scene, trigger, startedBy, message }` naming
that build instead of starting a second one; `inFlight` is what tells the two
answers apart.
An operation the bake cannot hold is **refused** rather than applied. A build
accepts what its records carry: entity lifecycle, transforms, hierarchy, names,
visibility, active state, participation mode, network scope, attributes, and
components with their public data. Anything else raises while the build runs,
naming the operation and the build it answered to, instead of leaving the scene
depending on something no reload brings back. When that happens the previous
build stays exactly as it was. A runtime resource a build needs — a mesh it
generates, a texture it writes — is made as an asset instead, below. Everything
else a refusal names goes outside the builder, or into a component the builder
attaches, which runs at play.
### The assets a build makes
`build.asset(type, name, produce)` makes an asset the build's content names — a
mesh a lathe produces, a texture a pattern writes. `produce` takes nothing and
returns the creation parameters `asset.create` takes for that type; the call
returns the asset's `AssetRef`, which is what a component field holds.
```lua
function content()
local tower = build.asset("mesh", "tower", function()
local positions, indices = lathe(profile, 16)
return { positions = positions, indices = indices }
end)
entity.spawn("tower").component.add("Model", { model = tower })
end
```
The asset is authored inside the scene's own folder — `Lobby.scene/tower.mesh`
— and the scene records which `build.luau` produced it in `.build.assets`. So
`produce` runs when the build script changed and never otherwise: a reload
re-runs the build and reuses the asset, while editing the profile re-authors
`tower.mesh` **in place**, keeping its path and its guid. That is what holds the
reference — `scene.json` names assets by guid.
For a part of a scene that several scenes place — or that one scene places many
times with different settings — see `guides { path: "types/sceneModule" }`,
which is the same idea with declared `inputs` a placement sets.
`guides { path: "core/scenes-as-code" }` covers both.
## The entrypoint
`entrypoint.luau` is the scene's **runtime** — what happens while the game is
being played. What the scene *is* comes from `build.luau` above; nothing here
has to construct it.
`entrypoint.luau` is auto-discovered as a sibling of `scene.json`. The
loader runs it in a fresh environment and folds its top-level
`function name(...)` declarations into the matching scene lifecycle event.
Define the callbacks the scene needs — the recognised set:
- `onLoad()` — the scene loaded in **play** mode. Gameplay-start hook,
runs on every connected client.
- `onHostLoad()` — the scene loaded in **play** mode, on the client that
owns the scene's synced content (the relay room creator; offline and
single-player count as host). Fires once. Entities spawned here — and
anywhere downstream — become synced automatically, so this is where
shared world content is spawned: it runs on exactly one peer, and every
other peer receives the entities from the relay snapshot.
- `onEditLoad()` — the scene loaded in **edit** mode. Custom per-scene
authoring tools.
- `onUnload()` — before the scene's entities are torn down. Extra cleanup;
engine-managed teardown (entity despawn, resource release) runs
separately.
- `update(dt)` — per frame in play mode, while the gameplay clock
runs.
- `editorUpdate(dt)` — per frame in edit mode, running or paused.
Play-paused ticks neither hook. The scene loader partitions these
strictly on mode; a component's hooks of the same name read
`engine.paused` instead, so the two rules differ — `man components`
has that table.
- `localPlayerReady(player)` — once the local player's avatar is ready.
Gameplay wiring for the local player: attach state, bind UI, swap the
avatar. The avatar is already placed at the PlayerSpawn, so this is for
behavior, not positioning.
- `playerJoined(player)` — a remote player joined this scene's room. Use
for per-player UI (nameplates, greetings). The local player arrives
through `localPlayerReady`.
- `playerLeft(eid)` — a remote player left this scene's room. Receives the
departed entity's id (the entity is already despawned).
Each scene's entrypoint runs in its own environment, so two scenes can
declare the same top-level names without clashing. Keep each callback
short; push per-scene logic into `scripts/` modules and `require` them.
## Layers
Scenes load through the `layers` namespace.
- `layers.load(ref, opts?)` — load a scene. `ref` is a scene AssetRef or
identity string. Without `opts.additive`, this is a **root** load: it
replaces the current root scene. `opts`:
- `additive` — load as an overlay alongside the current root instead of
replacing it.
- `persistent` — keep an additive overlay mounted across mode flips and
root swaps.
- `origin` — a `{x, y, z}` world offset for the overlay's entities.
- `deferred` — spread the entity spawn across frames (for
thousand-entity scenes); the load signals complete only once the
scene has settled.
- `layers.active` — the current root scene proxy (nil before any load).
- `layers.list()` — every loaded layer (root + additive overlays).
- `layers.find(ref)` / `layers.is_loaded(ref)` — look up a loaded layer by
ref.
- `layers.onLoad(cb)` / `layers.onUnload(cb)` / `layers.onBeforeLoad(cb)` —
subscribe to every scene load / unload (distinct from a scene proxy's
own `:on_load` / `:on_unload`, which fire only for that scene).
A root scene carries the `players`, `camera`, and `settings` surfaces;
additive overlays inherit the root's players and lighting and do not carry
their own.
### The scene proxy
`layers.active` (and `layers.find` / `layers.list` entries) returns a
scene proxy:
- Fields: `.guid` (canonical identity), `.name`, `.path`, `.asset`,
`.additive`, `.visible`, `.state` (`"loading"` → `"loaded"` → `"ready"`
→ `"unloading"`), `.ready`, `.players`, `.camera`, `.settings`,
`.entrypoint`.
- `.camera` — a proxy over the scene's primary camera entity; read and
write the live Camera component through it, and
`.camera.behaviors._list()` enumerates the available camera behaviors.
- `:save(opts?)` — publish the live scenegraph to canonical `scene.json`
(see Saving). A convenience that forwards to the scene ASSET's `save`
(`self.asset:save(opts)`): the proxy is the runtime view and owns nothing
on disk, so persistence lives on the asset.
- `:reload()` — unload and reload the same scene (a runtime respawn).
- `:clearDirty()` — discard unsaved edits and respawn from canonical.
Forwards to the scene asset's `discard` (`self.asset:discard(opts)`).
- `:sceneAsset()` — the durable `AssetRef<scene>` this live layer maps to.
- `:promoteDirty()` / `:writeDirty()` / `:hasDirty()` — manage the dirty
overlay directly.
- `:unload()` — despawn the layer and free its slot.
- `:onReady(cb)` — fire once the scene reaches `ready` (latched: a late
subscriber fires immediately).
## Multiplayer & relay rooms
Every scene has its own relay room, keyed by the world guid, the boot
profile, the mode, and the scene guid. Switching scenes with
`layers.load` runs a room transition — leave the old room, tear down,
join the destination scene's room — so connected users move together and
teardown of the old scene never leaks to peers in the new one.
In play mode connected to a relay, the scene's entities are
host-authoritative: only the room creator instantiates them (marked
synced), and every joiner receives them from the relay snapshot rather
than spawning their own. This is why shared world content belongs in
`onHostLoad` — spawning it in `onLoad` would run on every peer and
duplicate it. Each player's own avatar spawns per-user from the
PlayerSpawn on every client.
Edit mode and offline boots spawn locally and never auto-sync.
## Loading and saving
**Loading** — `layers.load(ref)` for a root scene:
1. Despawns the current root scene's non-persistent entities.
2. Instantiates the entities from `scene.json` (including the player
prototype and spawn for a `"spawns"` scene).
3. Applies lighting and sky.
4. Runs `entrypoint.luau` and fires lifecycle callbacks as their events
occur.
**Saving** — persistence lives on the scene ASSET. `sceneRef:save()`
writes the scene's current state back to canonical `scene.json`; when the
scene is loaded it captures the live scenegraph, and when it is not loaded
it promotes the pending dirty overlay. `layers.active:save()` is the
convenience form — it forwards to the active layer's mapped asset
(`layers.active.asset:save()`). Writing canonical is the only path that
does so: edit ↔ play cycles never save, so hitting play to test never
overwrites the scene. Between saves, live edits accumulate in a per-scene
dirty overlay that the loader merges on top of canonical.
`sceneRef:discard()` throws the overlay away and restores the saved state
— deleting the overlay, and respawning the live layer when the scene is
loaded. It works whether or not the scene is loaded, because the overlay
belongs to the asset, not the runtime layer. `layers.active:clearDirty()`
forwards here. Saves capture entity data, not the entrypoint script —
edits to `entrypoint.luau` and `scripts/` are always preserved.
`opts.to` on `save`/`discard` targets a different scene ("save as"),
taking either a bare name (`"level_2"`) or a full VFS path.
## Lighting
Lights are ordinary entities: a directional light, an ambient light, and
a sky are entities carrying `Light` / `ProceduralSky` components in
`scene.json`. Author, move, and tune them like any other entity. If a
scene is missing any of {directional light, ambient light, sky}, the
loader adds a temporary default for the absent one so a scene never loads
dark or skyless; the defaults are never saved into the scene.
Scene-level lighting state that is not an entity — clear color and sky
descriptor — lives under `layers.active.settings.lighting` (`clear_color`,
`sky`); writing those fields updates the engine live.
## Modes
Scenes load in both edit and play mode (see `man modes`). The mode
governs behavior:
- An entrypoint's `update(dt)` runs in play while the gameplay clock
runs, and its `editorUpdate(dt)` runs in edit. A component's hooks of
the same name follow the clock rather than the mode — `man
components` has the four states that produces.
- Player prototypes are live in edit and deactivated + internal in play,
where each user instead gets a clone. `EditorOnly` entities follow the
same edit-live / play-internal toggle.
- `onLoad` / `onHostLoad` fire on a play-mode load; `onEditLoad` fires on
an edit-mode load.
## How to create one
```luau
asset.create("scene", "<Name>")
-- Creates /zero/source/<Name>.scene/ with:
-- build.luau where this scene's content is written. A new one starts
-- with the sun / ambient / sky lighting and the "spawns"
-- player setup, commented for editing — everything else the
-- scene comes to hold is written here too.
-- scene.json what that code produces, baked.
-- entrypoint.luau commented lifecycle-callback stubs.
```
A freshly created scene has a player with a following camera on the first press
of play. It carries no ground, so the first thing to author is what the player
stands on — a `content()` line away in `build.luau`.
## Discovery
- `asset.list("scene")` — every registered scene.
- `asset.inspect("<name>")` — entity count, lighting summary, source path,
and this type README.
## Authoring conventions
- Keep static layout in `scene.json` — geometry, lights, props, the player
prototype and spawn.
- Keep dynamic setup in `entrypoint.luau` lifecycle callbacks — event
listeners, gameplay state, services attached to spawned entities.
- Spawn shared, synced world content from `onHostLoad`; spawn each
player's own gameplay wiring from `localPlayerReady`.
- Set the world's startup scene in `/.world_settings` — that is the scene
the world boots into.
- Y is up; +Z is forward.
- Renaming the folder changes the scene's identity — update every
`layers.load` callsite and the world's startup-scene field.
## Related types
- `.bundle` — reusable entity hierarchies that scenes spawn into
themselves.
- `.service` — cross-scene background work that outlives a scene load.
- `.world_settings` — picks the startup scene and world-level renderer /
physics options.
## In the Inspector
Selecting a scene opens its **Scene** section, read from `scene.json` without loading the scene: how many entities it holds, how many of them are roots (and their names), how players join and the file's format version when it states them, and whether the scene has edits not yet saved.
# component_proxy
Computed-property registry shared by every component proxy. The proxy
ITSELF is installed in Rust
(`crates/zero_scripting/src/world_context.rs::setup_public_dirty_tracking`)
— this module owns only the cross-VM-instance bookkeeping for
`public.X = computed(fn)` declarations: the global registry table and
the sentinel-string protocol that lets dispatch survive any
serialization the engine performs on the public table. Installs
`computed` as a `_G` global via `M.installGlobal()`.
## Exports
- `M.computed(fn: (any) -> any) -> string` — stash `fn` keyed by a deterministic sentinel and return the sentinel string. Errors if `fn` is not a function.
- `M.installGlobal()` — install `computed` as a `_G.computed` so component modules can write `public.X = computed(fn)` without an explicit require. Prelude calls this once.
## Usage
```luau
local CP = require("@builtin::modules.component_proxy")
-- Once, at boot (prelude does this):
CP.installGlobal()
-- In a component module's top-level body:
public.area = computed(function(self) return self.w * self.h end)
```
## Notes
- The sentinel prefix (`__zero_computed__`) and registry global name
(`__zero_computed_registry__`) are duplicated in
`crates/zero_scripting/src/world_context.rs`
(`COMPUTED_SENTINEL_PREFIX`). Renaming either side requires the
matching rename on the other.
- The registry is a `_G` table; it survives module hot-reload but every
call to `computed(fn)` registers a fresh sentinel (sentinel includes
`tostring(fn)`), so re-running a component module replaces its
computed entries rather than leaking old ones — provided `fn` is a
fresh closure each time.
- `installGlobal` is idempotent. Re-running it just overwrites the
global with the same function.
# resource_handle
Recognises a **live GPU resource handle** — the value `renderer.mesh.create`,
`renderer.material.create` and `renderer.texture.create` answer with.
A handle is a table of the shape `{ kind = "<Category>Handle", category, guid,
name }`, and its `guid` names a slot in the **running process's** GPU registry.
It is minted when the resource is created and identifies that resource for as
long as the registry lives. An `AssetRef`'s guid is content identity — it names
a stored asset, and any session that can reach the world's content resolves it.
The two look alike at a glance and answer differently at every **session
boundary**, which is what this module exists to tell apart:
- **The replication wire.** A peer runs its own GPU registry, so a handle's guid
means nothing there. The sync layer drops such a value and the receiver falls
back to the field's declared default.
- **Durable content.** A scene record outlives the session that wrote it. The
next session — a peer loading the world, or the same engine after a restart —
mints its own registry, so a recorded handle names a resource that session
never created.
`asset.create(category, name, handle)` is the crossing: it stores the resource
and hands back a ref whose guid does survive both boundaries.
A record is also the medium a scene reloads through **inside** one session: a
play flip unloads the live entities and rebuilds them from the same bytes a peer
receives, and the handle is valid there. So a handle a record leaves out is
**parked** in `modules.session` under the entity and component it came from —
`park` as the record is written, `withParked` as it is applied. A load inside
the minting session puts it back; every other session opens an empty store and
the field takes its declared default.
## API
```luau
local ResourceHandle = require("modules.resource_handle")
local mesh = renderer.mesh.create(geometry)
ResourceHandle.isLive(mesh) -- true
ResourceHandle.isLive(asset.resolve("wall.texture")) -- false (an AssetRef)
ResourceHandle.isLive("cube") -- false
ResourceHandle.label(mesh) -- "mesh handle 'msh_grass_1'"
-- Component field maps: `data` unchanged when it holds no handle.
local data, dropped = ResourceHandle.withoutLive({ model = mesh, visible = true })
-- data -> { visible = true }
-- dropped -> { model = "mesh handle 'msh_grass_1'" }
-- What a record leaves out, held for the rest of this session.
ResourceHandle.park(entityId, "@builtin::components.Model", { model = mesh })
local applyData, restored = ResourceHandle.withParked(data, entityId, "@builtin::components.Model")
-- applyData -> { visible = true, model = mesh }
-- restored -> 1
```
`withoutLive` returns the **same table** when there is nothing to leave out, so
the common case allocates nothing. When it does drop, the second return names
each field and the handle it stood for, so the caller reports what happened at
the point it happens rather than leaving the field to fail later.
`park` replaces whatever that entity-and-component pair held before, so a field
that stops holding a handle stops being restored with the ones that do.
`withParked` fills only the fields the record has no value for — a record that
carries a value is the authority on it.
## Consumers
`scene_saver` asks per field as it serialises an entity, reports the entity,
component and field once per session, and parks the handles it left out.
`scene_loader` asks per component as it applies a record: it reports the whole
scene's count of recorded handles in one line — one generator run writes the
same field on every entity it made, so a per-entity report would say the same
thing hundreds of times — and puts the parked ones back. `dirty_hot_reload`
applies a peer's body to a live entity and leaves the handles in it out.
# value_type
Converts a **handle-backed value type** — `ColorSequence`, `NumberSequence` —
between the live object a session holds and the durable payload its
`serialize()` produces.
A sequence has two forms:
| Form | Shape | Meaning |
|------|-------|---------|
| live | `{ kind = "ColorSequence", __h = 13 }` | `__h` names a curve in **this process's** curve registry |
| durable | `{ kind = "ColorSequence", keypoints = { … } }` | the curve itself, which any session rebuilds |
`__h` is minted when the curve is created and is meaningful for exactly as long
as that registry lives, so a record carrying it names nothing in the session
that reads it back. The keypoints are the authored data, and they cross every
boundary intact — which is why a record carries the durable form and a live
component field carries the live one.
Both forms carry `kind`, so a value read out of a component field says which
converter owns it whether it came from memory or from a record. The two are told
apart by which of `__h` / `keypoints` the table itself holds.
`Field.table` copies the table it is handed and drops its metatable, so a live
value read back off a component field answers no methods at all. The field gives
that same stored table back on every read, so `bind` puts the methods on it once
and the field answers them from then on.
## API
```luau
local ValueType = require("modules.value_type")
local color = ColorSequence.new({ 1, 0.3, 0.1 }, { 0.1, 0.1, 1 })
ValueType.kindOf(color) -- "ColorSequence"
ValueType.isLive(color) -- true
ValueType.isPayload(color:serialize()) -- true
-- The keypoints out of either form. The second argument is the kind to read a
-- bare `{ __h = n }` as, for a caller that knows its field's type.
ValueType.keypoints(component.color, "ColorSequence")
-- The durable payload for a live value; nil for anything else.
ValueType.serialize(color) -- { kind = "ColorSequence", keypoints = { … } }
-- The live value a payload names, rebuilt in this session; nil for anything else.
ValueType.revive(record.color)
-- A whole component field map, with every payload rebuilt.
local applyData, rebuilt = ValueType.withRevived(record.data)
-- Put the methods back on a value read out of a component field.
ValueType.bind(component.color, "ColorSequence"):evaluate(0)
```
`withRevived` returns the **same table** when the map carries no payload, so the
common case allocates nothing; the second return says how many fields were
rebuilt.
## Consumers
`scene_saver` asks per field as it serialises an entity, and writes the payload
in place of the handle. `scene_loader` rebuilds the payloads in a component's
record before applying it, so the component takes the field holding the same
curve it held when the record was written. `dirty_hot_reload` does the same as
it applies a body, and reduces the live side of its record comparison to the
durable form so both sides are compared in one shape. `ParticleEmitter` reads
the keypoints out of whichever form its `color` / `size` / `transparency` /
`squash` fields arrive in.
# Player
Identity tag for a player entity. Attaches the Rust `PlayerOwned` marker via `__native` in `awake` and removes it in `onDestroy`. No public state, no update loop, no visuals.
```luau
entity(id).component.add("UserIdentity")
entity(id).component.has("UserIdentity") -- true
```
Future rules (not yet enforced):
- **Non-serialising.** Scene and world saves omit this component. Players are attached at runtime per peer, not inherited from the save file.
- **Non-removable.** Once an entity is a player, removing this component is a no-op. A player stays a player for its lifetime outside an explicit identity swap.
Visuals, cameras, input, and movement live in separate components (e.g. `PlayerController`, `PlayerTemplate` bundle content) so adding or removing them never affects identity.
# camera_spawner
Generic primary-camera spawner. § 17 step 10 of
`docs/plans/2026-05-01-player-camera-unification.md`.
Hooks `layers.onLoad`: on every non-additive scene load it spawns a
`Camera`-bearing entity with `behavior` resolved through:
1. `sceneProxy.camera_default` — per-scene override (future field)
2. `world.camera_default` — world-level default
3. Neither set: `log.error` + skip the spawn (no hardcoded fallback).
The spawned entity is `temporary` so `layers.active:save()` doesn't bake it.
`follow` is set to the first `Player` entity in the scene at spawn
time; later scripts can re-target by writing
`entity(camId).component.get("Camera").follow = someId`.
Additive overlays do NOT spawn their own camera — they ride on the
parent non-additive scene's camera.
## Surface
| Symbol | Notes |
|---|---|
| `M.install()` | Idempotent. Registers the `layers.onLoad` / `onUnload` hooks. Called once from the engine prelude. |
| `M.ensure(sceneProxy) -> entity_id \| nil` | Manually spawn the primary camera for `sceneProxy`. Use from scene entrypoints that want to control spawn ordering. Returns nil when the camera was already in place. |
# renderer
GPU-resource factory plus CPU codec/store wrappers — the GPU/CPU half of the asset↔resource split. Moves a resource Disk → CPU → GPU in explicit steps and returns inert handles describing each live resource. assetType behaviours, components, and every other module call `renderer.*`.
A resource moves through three states, each an explicit call:
- `ref:load()` → `renderer.<res>.loadCpu(ref)` decodes the asset's primary content into the guid-keyed CPU store and returns a CPU handle (holds only a guid plus dims/counts and the read/encode/unload ops).
- `renderer.<res>.create(cpuHandle | rawData, guid?)` uploads CPU → GPU, keyed by guid, and returns a GPU handle. `create` never takes an `AssetRef` — load the CPU first.
- `renderer.destroy(handle)` frees the GPU resource; a CPU handle's `:unload()` frees the CPU copy.
A `MeshHandle` / `TextureHandle` / `MaterialHandle` is an inert data record (`{ kind, category, guid, name, ... }`) describing one live GPU resource. Every GPU op lives on `renderer.*` and takes the handle.
Anywhere a call names a resource it takes any of the forms you already hold — the handle `create` returned, the CPU handle `loadCpu` returned, the guid, or the `AssetRef` `asset.resolve` returned. Handles compose: what one call hands back, the next call accepts.
## Types
- `Aabb` — `{ min = {x,y,z}, max = {x,y,z} }`.
- `MeshHandle` / `MeshCpuHandle` / `MeshGeometry` / `MeshBuffers`.
- `TextureHandle` / `TextureCpuHandle` / `TexturePixels`.
- `MaterialHandle` / `MaterialContent`.
## renderer.mesh
- `encode(geom) -> string?` / `decode(zmsh) -> (geom?, errmsg?)` — the ZMSH CPU codec.
- `loadCpu(ref) -> MeshCpuHandle` — Disk → CPU load (called by `meshRef:load()`).
- `create(src, guid?) -> MeshHandle` — create or fetch a GPU mesh from a `MeshCpuHandle`, raw geometry `{positions, indices, normals?, uvs?, colors?}`, compute buffers `{vertexBuffer, indexBuffer, vertexCount, indexCount, aabbMin?, aabbMax?}`, or a `MeshHandle`.
- `geometry(mesh) -> MeshGeometry` — the mesh's vertex data, the read that pairs with `create`.
- `update(handle, src) -> MeshHandle` — overwrite the GPU resource in place under the same guid.
- `readback(mesh, into?) -> MeshCpuHandle`: GPU → CPU recovery of a runtime mesh's geometry. `into` lands the copy under a CPU-store key of the caller's own, which stays resident until the caller unloads it, whatever re-registers or unloads the mesh itself.
- `isResident(mesh) -> boolean` / `isCpuResident(mesh) -> boolean`.
Geometry moves in four directions, two per axis — DATA the caller holds, and the geometry of a mesh the ENGINE holds:
| From | → bytes | → geometry |
|---|---|---|
| geometry (data the caller holds) | `encode(geom)` | — |
| ZMSH bytes (data the caller holds) | — | `decode(zmsh)` |
| a mesh the engine holds | `encodeCpu(mesh)` | `geometry(mesh)` |
`geometry(mesh)` reads the resident CPU copy when there is one and reads the geometry back off the GPU when there isn't (yielding a frame or two), so a mesh straight out of `create` answers it:
```luau
local h = renderer.mesh.create({ positions = P, indices = I, tangents = T })
local geom = renderer.mesh.geometry(h) -- { positions, indices, tangents, ... }
```
An optional stream is present only when the mesh carries one. The GPU vertex layout has no "unset" — it holds a zeroed tangent, a zeroed skin binding, and a second UV set copied from the texture UVs for every mesh — so a stream saying only what its absence already says is reported absent. `geom.tangents == nil` is therefore the answer to whether a mesh has a tangent basis, and `geom.uvs1 == nil` to whether it has a second UV set.
## renderer.texture
- `encode(rgba, width, height, opts) -> (string?, errmsg?)` / `encodeFromImage(bytes, opts) -> (string?, errmsg?)` / `decode(ztex) -> (rgba?, width?, height?)` — the ZTEX CPU codec.
- `withSampler(ztex, { filter?, wrapU?, wrapV?, anisotropy? }) -> (string?, errmsg?)` — the same payload with only its recorded sampler state changed; every level is byte-identical.
- `loadCpu(ref, encodeOpts?) -> TextureCpuHandle` — Disk → CPU load with per-pixel access.
- `create(src, guid?) -> TextureHandle` — from a `TextureCpuHandle`, raw pixels `{rgba, width, height, srgb?}`, a `TextureHandle`, or render-target dimensions `{width, height, name?}` (an empty GPU texture a render pass writes into).
- `update(handle, src) -> TextureHandle`, `destroy(handle)`, `capture(handle) -> string` (CPU readback result key), `readback(texture) -> TextureCpuHandle`, `isResident(texture) -> boolean`.
## renderer.material
- `create(content, key) -> MaterialHandle` — register or update a material's GPU record (`content = { shader, properties, textures, aliases?, name? }`) under its canonical registry key.
- `animatedTexture(texture, opts?) -> MaterialHandle` — a material that PLAYS a layered texture: its layers bound as the frames, its timing beside them, the built-in `animatedTexture` shader turning the clock into the layer showing now. One call from an imported GIF / APNG / animated WebP to a material an entity can wear. `guides { path: "topics/animated-images" }` is the whole surface.
- `setProperty(key, name, value)` — push one changed uniform.
- `setTexture(key, slot, ref)` — push one changed texture slot (the texture must already be GPU-resident).
- `destroy(key) -> boolean` — drop the definition filed under `key`, so `describe` and `list` stop answering for it. A surface already wearing the handle goes on drawing it.
A `MaterialHandle` and its `guid` are two different currencies. What a surface wears is the HANDLE — `Model:applySessionMaterial(handle)`, or a Model's `material` field. The handle's `guid` is the material's registry key, which is what `setProperty`, `setTexture`, `describe` and `destroy` take. A component field resolves an asset, so a bare registry key in one leaves the component waiting for an asset to register under that name.
## renderer.instanceData
Four `vec4` lanes of generic shader data per entity — the channel that lets ONE material serve many entities that differ in a value, instead of one material per entity. A surface shader reads lane `i` back as `input.shader_data[i]`, and the engine attaches no meaning to what a lane holds.
- `laneCount() -> number` — lanes per entity, so a lane index runs `0 .. laneCount() - 1`.
- `set(target, lane, x, y?, z?, w?)` — write one lane of an entity's block. `target` is an entity proxy or an entity-id string naming a live entity; `y` / `z` / `w` default to 0. The lane reaches the block where the call is made, and the draw on the next frame.
- `clear(target)` — drop the entity's whole block, so its draws read zero again. Takes an entity that has already gone, and does nothing for one holding no block.
A lane index outside the block, or a target no live entity answers to, is an error at the call rather than a write that lands nowhere. A feature picks the lane indices it owns and names them where it writes them: `@builtin::systems.globalIllumination.lightmap` holds lane 0 for a receiver's atlas rect and lane 1 for its layer and enable flag, which is the shape to copy.
The same lanes reach a GPU-driven population through `renderer.mesh.drawInstanced`'s `instanceDataBuffer`, on slots no entity owns.
## renderer.featureTexture
The texture analogue: a shared `rgba16f` 2D array every surface shader can sample as `zero_feature_texture(uv, layer)`, and the array a `SpotLight` projects a layer of through its cone via `cookieLayer`.
- `configure(width, height, layers)` — size the array. A call for the size it already has is left alone; one that changes the size reallocates, and the replacement is zeroed, so it empties every layer in the array — the layers other features and other cookies own along with your own.
- `setLayer(layer, textureKey, x, y)` — copy a GPU-resident `rgba16f` texture into one layer at that offset, GPU to GPU with no readback. Several small images pack into one layer at different offsets.
- `state() -> { width, height, layers, filled }` — the extent the array carries and `filled`, the ascending 0-based indices of the layers a fill has landed in since it was last sized. One array is shared by every feature and every light cookie, and it has no allocator, so this is what a feature asks to tell whether the array it sized and filled is still the array it is writing into. A layer some other `configure` emptied leaves `filled` without it, which is what separates that from a fill that never landed. What it describes is the array a shader samples: the source texture a `setLayer` copied from is a GPU resource of its own and keeps the bytes it was written with, so `filled` is the reading that answers whether the layer behind a `cookieLayer` is live right now. Measured at the end of the last rendered frame, like every reader below.
```luau
renderer.featureTexture.configure(256, 256, 4)
renderer.featureTexture.setLayer(0, "my_gobo", 0, 0)
-- ... elsewhere in the scene, another feature sizes the array ...
local ft = renderer.featureTexture.state()
if ft.width ~= 256 or table.find(ft.filled, 0) == nil then
-- layer 0 was emptied under us: size it back and re-fill.
end
```
## Reading back what the renderer did
Every call above states an intention. What the renderer made of it is a separate
document, published once per frame and read through these:
- `drawDiagnostics() -> { DrawDiagnostic }` — every renderable that is NOT
drawing what its material says. A surface rendering as the magenta
placeholder, one the renderer could bind nothing for, and one drawing a
program whose most recent compile failed all land here, each naming the entity,
the program asked for, the program bound, and the one cause. It covers every
renderable the renderer holds, whether or not a camera reached it — a row with
`observed = false` and `outcome = "notDrawn"` is the renderer's own resolution
for one this frame drew nowhere, so a broken surface off-screen reads the same
as one in frame. An empty result means every renderable the renderer holds is
drawing the program its material named. **Start here when a surface looks
wrong.**
- `material.renderState(key) -> MaterialObservation?` — the blend / cull /
topology / queue / depth key the renderer holds a material under, and whether
a pipeline exists for it. `nil` means the renderer holds no material under
that key, which is what writes reaching nothing on screen look like. `index`
is the material index its row is filed under, and `textures` the texture key
each slot of that row names, so a `setTexture` the renderer has taken up
reads there.
- `materialCost() -> { MaterialObservation }` — draws, instances, placeholder
draws and material-owned bind groups (`binds` set, `bindsElided` skipped) per
material, over the published frame.
- `shaderCost() -> { ShaderCost }` — pipeline build time and permutation count
per program, summed since engine start, beside the compile gate's word.
- `observe() -> RenderObservation` — the whole document the four above read.
`/zero/runtime/generated_materials` serves the same document as files.
## renderer.destroy
- `destroy(handle) -> boolean` — free the GPU resource behind a `MeshHandle` / `TextureHandle`, routing by the handle's `category`.
## Usage
```luau
local renderer = require("@builtin::modules.api.engine.renderer")
local gpu = renderer.mesh.create({ positions = { ... }, indices = { ... } })
renderer.destroy(gpu)
```
# modules.api.engine.dirty_hot_reload
Picks up dirty-overlay changes from disk while a scene is loaded in
edit mode, applies them to the live entities, and keeps the in-memory
representation in lockstep with `scene_dirty/` files. Pairs with
`scene_saver` (writes deltas) + `scene_loader` (composes canonical +
overlay at load time).
## Parents
A record's `parent` names the entity it stands under: the entity whose id is
that text, else the entity whose display name is exactly that text. A `*` or
`?` in it is a character of the name, never a pattern.
A record whose parent no entity provides waits. An entity that does not stand
yet is not built, one that already stands keeps its state, the layer logs the
record once, and `debugState(guid).waiting` maps its id to the parent it
names. While a record waits, the scene saver leaves it as it stands rather
than writing a standing entity's older state over it, until an author edits
that entity, which the saver then records as it stands. When a record builds
an entity, the records waiting on that entity's id or display name are built
under it, so a child record that arrives before its parent's record lands
where a load of both places it. Each waiting record is built as its overlay
file states it at that moment, so a record rewritten while it waited,
including by this session's own saver, builds from its current bytes. A load
gives the same account of a record whose parent stays missing: the loader
abandons it with `parentMissing`.
# modules.api.engine.players
The curated player surface. Wraps the raw `UserIdentity` component proxy
in a view that exposes only the safe authoring API:
`.isLocal` / `.avatar` / `.ready` / `.userId` / `.identity` /
`.displayName`.
`.avatar` is the visible body's entity REF (a live proxy), or nil until a
body is bound — act on it directly (`player.avatar.position = { x, y, z }`).
Want the id? `player.avatar.id`. Assign a live entity ref to bind it as
the body — `player.avatar = entityRef` (assign nil to clear) is the one
path that binds a body to the player.
`.entity` / `.entityId` / `.id` / `.component` are intentionally REFUSED.
The identity entity is a internal anchor with no world presence — reach the
visible body via `player.avatar`. Position writes always target the avatar.
Used by every `layers.active.players.localPlayer` / `onLocalReady` /
`playerJoined` / `playerLeft` call site so user scripts can't reach
past the curated wrapper into the underlying component proxy.
# player_spawner
Legacy v6 default-identity avatar setup. v7 scenes place players through
the PlayerSpawn / PlayerPrototype flow; this module stands down (`M.ensure`
returns early) for any scene carrying a string `playerIntent`.
Hooks `layers.onLoad`: on every non-additive v6 scene load it locates the
`ensure_default_player`-spawned identity entity and binds its avatar:
- `world.avatar_default_<mode>` (per-mode world default). The avatar bundle
combines visual + controller in one unit (mesh + skeleton + Locomotion +
MovementState + CharacterController).
- Per-scene `settings.player.avatar_<mode>` overrides the world default when
set; `""` is the explicit opt-out (the scene builds its own body in
`entrypoint.luau::onLocalReady`).
It spawns a fresh body entity from the bundle, then binds it by assigning
the identity's `avatar` field, which marks the body synced + PlayerOwned
and attaches its `PlayerAvatar` link.
Idempotent per layer: reload doesn't re-instantiate; `onUnload` clears the
per-layer "already applied" flag.
## Surface
| Symbol | Notes |
|---|---|
| `M.install()` | Idempotent. Registers `layers.onLoad` / `onUnload` hooks. Called once from the engine prelude. |
| `M.ensure(sceneProxy) -> entity_id \| nil` | Apply the avatar bundle to the identity entity. Returns the identity id, or nil when none exists yet (or the scene is v7). |
# scene_observe
The record of what a scene load did, and what each loaded scene costs. Backs
`layers.observe`, `layers.lastLoad`, `layers.lastUnload`, `layers.loadHistory`,
`layers.problems`, `layers.whyPartial`, `layers.inventory`, `layers.cost` and
`layers.resetCostWindow`, and the `scene` toolbox's `observe`, `whyPartial` and
`cost` above them. `layers` and `scene_loader` are its writers.
## One record per load
A load opens a record; the loader, the lifecycle dispatchers and the scene's
own build write their failures and phase timings into it; the load's own
completion point closes it with the duration the engine already measured for
its `DONE` line. Reading is lazy — nothing here walks the world, and the only
writer that runs every frame is `noteUpdate`, which the entrypoint tick calls
once per scene that declares one.
```lua
local r = layers.lastLoad()
print(r.name, r.outcome, r.durationMs)
print(r.entities.added, "arrived,", r.entities.removed, "left")
print(r.phases.teardown, r.phases.instantiate, r.phases.settle)
```
`outcome` is `"ok"` when everything the scene declared was produced,
`"partial"` when the load finished with failures in it, `"failed"` when the
load raised and left no layer, and `"unchanged"` when the scene asked for was
already the active root — that load rebuilt nothing, so the layer keeps the
record of the load that built it and still answers why it is not whole.
`reason` names the nearest cause from the closed
set — `loaderRaised`, `entrypointCompileFailed`, `entrypointBodyRaised`,
`entrypointRaised`, `buildRaised`, `entityFailed`, `parentMissing`,
`parentRefused`, `parentAbandoned`, `componentUnresolved`, `componentRefused`,
`subscriberRaised`, `updateRaised` — ranked so it names the thing to fix rather
than the last thing to break.
## Failures reach the layer they belong to
Every write of a failure calls the subscriber `layers` registers through
`M.onFailure`, which republishes the record onto that layer's proxy: `ok`
false, `failures` the records, and `"partial"` in place of `"ready"` once the
layer has settled. A tick that starts raising on frame 400 moves the layer the
same way one raised during the load does.
A failure identical to one already held raises that one's `count`, so a tick
raising every frame keeps one record. `FAILURE_LIMIT` bounds the distinct
records a report holds, and `failuresOmitted` states how many arrived past it.
## Cost
`noteUpdate` accumulates each layer's entrypoint tick into a per-guid entry —
calls, total, last, max, average and how many raised — summed across the window
`M.window()` reports. `M.resetWindow()` opens a new one, leaving the load
history alone. An unload drops the layer's entry.
# lighting
Scene-lighting base capability: ambient + directional ("sun") light and procedural-sky setup with modify-or-spawn, read-merge-write semantics, plus clear color, and the per-frame reads of the resolved lights. Identity is the component, never the entity name: the sun is the `Light` the renderer's snapshot names as the directional holder, the ambient is the light-role component of kind "ambient", and the sky is the entity carrying a sky-role component. Name-based lookup, word resolution, and sky-material orchestration (presets, material swap) live in the `lighting` toolbox.
`sun` and `ambient` configuration patch the scene's resolved sun / ambient light components, spawning a carrying entity when the scene has none. A partial update (e.g. only `intensity`) reads the component's current fields first and merges the given fields on top, so unspecified fields keep their authored values instead of resetting to a default. `sky` writes the sky entity's ProceduralSky component the same way — any of its fields, plus `enabled = false` to remove the sky component.
## Types
- `SunOpts` — `{ direction?, color?, intensity?, castsShadows? }`.
- `AmbientOpts` — `{ color?, intensity? }`.
- `ProceduralSkyOpts` — ProceduralSky component fields, plus `enabled = false` to remove the sky.
- `SetupOpts` — `{ sun?, ambient?, sky?, clearColor? }`.
- `SetupResult` — `{ sun?, ambient?, sky? }`, the entity ids each provided section resolved to.
- `Sun` — `{ direction, color, intensity, castsShadows, entityId? }`.
- `Ambient` — `{ color, intensity, entityId? }`.
- `LightRow` — one punctual light as the renderer resolved it.
## Exports
Writes — each takes the opts above and answers with the entity the light resolved to:
- `M.setSun(opts: SunOpts) -> string` — set the scene's sun, returning its entity id.
- `M.setAmbient(opts: AmbientOpts) -> string` — set the scene's ambient term, returning its entity id.
- `M.applySetup(opts: SetupOpts) -> SetupResult` — apply a lighting setup in one call. All fields optional — only provided fields change. `clearColor` is `{r, g, b}`.
- `M.setProbeVolume(key: string, volume)` / `M.removeProbeVolume(key: string)` — publish or retract a baked irradiance probe volume.
Reads — each takes no arguments, and answers from the resolved lighting state at a cost independent of scene size:
- `M.sun() -> Sun` — the sun the frame is shaded by.
- `M.ambient() -> Ambient` — the ambient term the frame is shaded by.
- `M.lightRows() -> { LightRow }` — every punctual light the renderer resolved this frame.
Identity:
- `M.lightKind(componentType: string, component: any?) -> string?` — the kind a light-role component names.
- `M.lightOfKind(entityId: string, kind: string) -> any` — the light-role component of that kind on one entity.
## Usage
```luau
local lighting = require("@builtin::modules.api.engine.lighting")
lighting.setSun({ direction = { 0.3, -1, 0.2 }, intensity = 2.0 })
lighting.setAmbient({ intensity = 0.3 })
lighting.applySetup({
sun = { direction = { 0.3, -1, 0.2 }, intensity = 2.0 },
ambient = { intensity = 0.3 },
sky = { timeOfDay = 14 },
})
local sun = lighting.sun()
print(sun.intensity, sun.entityId)
```
A reader takes no arguments and a setter takes the opts table, so `lighting.sun(opts)` is refused with the name of the call that applies it.
# session
Session-scoped key-value state. The store lives in the module's environment,
which loads once per engine process, so values survive VM reloads (play flips,
hot reloads) and die with the process. Use it for state whose lifetime must
equal the session's runtime artifacts (GPU resources, runtime materials, bake
outputs), which outlive a world save and would leave stale claims otherwise.
```lua
local Session = require("modules.session")
Session.set("my_system_" .. entityId, handle)
local handle = Session.get("my_system_" .. entityId)
```
# lightmap
Bakes full global illumination (shadowed direct light plus multi-bounce
indirect gathered by path tracing through the scene BVH) into per-entity
lightmaps: the bake output is read back, dilated across UV seams, and
copied into the receiver's region of ONE shared atlas that the PBR module
samples through the receiver's per-instance lanes — the receiver keeps the
material it was authored with. Each texel traces
`samples` cosine-weighted chains of up to `bounces` surface interactions,
so color bleed, emissive surfaces, and sky occlusion all land in the map.
Receiver selection is the `Model.mobility` axis: only static-mobility
world entities bake. Movable geometry samples the published probe fields
instead. Set `Model.mobility = "static"` to override a wrong derivation.
Every bake persists: the dilated texels land in the scene's
baked-lighting container (`lightmapData` asset) keyed by the entity, and
the receiver's `Model.lightmapData` ref points at it. A fresh boot
restores the lightmap from the asset (`applyFromAsset`, called by the
Model component's awake) with zero re-baking.
## Exports
- `Lightmap.bake(entityId: string, opts: BakeOpts?) -> BakeReport` — bake one static entity's lightmap into the shared atlas and point the receiver at it. Yields while the GPU bake + read-back complete.
- `Lightmap.bakeAll(opts: BakeOpts?) -> { BakeAllEntry }` — bake every static receiver in the world, reusing a single light + BVH cache across them.
- `Lightmap.applyFromAsset(entityId: string) -> (boolean, string?)` — restore a receiver's baked lightmap from its persisted container entry (the boot path; no re-bake).
- `Lightmap.clear(entityId: string)` — destroy the bake buffer, the runtime lightmap texture, and the per-entity material, restore the entity's original material, and remove the container entry.
- `Lightmap.bufferName(entityId: string) -> string` — stable per-entity bake-output buffer name (`"gi_lightmap_<sanitized id>"`).
- `Lightmap.materialName(entityId: string) -> string` — stable per-entity material name produced by `bake`.
Types:
- `ColorRGB = { r: number, g: number, b: number }`
- `BakeOpts = { resolution?, texelsPerMeter?, samples?, bounces?, intensity?, rayMax?, shadowSoftness?, sky?, lightsCache?, bvhCache?, rastCache?, containerCache? }`
- `BakeReport = { ok, resolution, texelCount, validTexels, ms, bufferName, materialName, textureGuid }`
- `BakeAllEntry = { id: string, report: BakeReport }`
## Usage
```luau
local Lightmap = require("@builtin::systems.globalIllumination.lightmap")
local report = Lightmap.bake(entityId, { resolution = 64, samples = 16, bounces = 3 })
print(report.ms, "ms")
-- Bake every static receiver in the world:
local reports = Lightmap.bakeAll({ samples = 32 })
-- Tear down:
Lightmap.clear(entityId)
```
## Notes
- `bake` refuses non-static receivers with an error naming the mobility
rule — the published probe fields are the movable-geometry
counterpart.
- `bakeAll` builds a single BVH and gathers lights once, threading the
caches through every per-entity bake. Add/move lights between
invocations to invalidate.
- The lightmap texture is a runtime GPU resource under a stable
per-entity guid (`BakeReport.textureGuid`); re-bakes update it in
place, so the material keeps rendering the latest bake with no
re-binding.
- Texel coverage rides in the texture's alpha; the upload dilates
covered texels over uncovered neighbours so bilinear filtering never
bleeds black across UV island seams.
- `clear` is a no-op when the entity is missing or never baked.
# outline
The public outline API — mark entities to draw with a coloured silhouette rim.
Its registry (entity id → `{ color, thickness }`) is read every frame by the
`@builtin::renderFeatures.outline` render feature, which draws a screen-space
jump-flood rim hugging each marked entity's silhouette (uniform width, no gaps on
hard-edge or multi-part meshes). The feature is ensured live on first use, so
`outline.set(entity)` is all a caller needs.
```luau
local outline = require("modules.outline")
outline.set(entity, { color = { 1, 0.5, 0, 1 }, thickness = 3 })
outline.clear(entity)
outline.clearAll()
```
# Default
The neutral fallback surface — a plain white, non-metallic PBR material with
mid roughness (`0.5`), sampling the built-in white texture. This is what an
object renders as when no material is assigned. Built on
`@builtin::shaders.pbr`.
## Inputs
- `colors.base_color` — the flat tint (white by default).
- `floats.roughness` (`0.5`) / `floats.metallic` (`0.0`) — a neutral matte
dielectric.
- `textures.base_color_texture` — the built-in `default:white` texture.
# material_schema
A shader's declared property vocabulary, and the routing of an authored key
onto it.
A material's properties belong to its **shader**. The uniform buffer is packed
by reflected field name, so a key the shader does not declare is never read by
anything: it sits in the material's property table, reads back as though it
applied, and contributes nothing to the render. This module is the single place
that answers "is this key real, is it a texture slot, or is it neither" — so
every path that accepts material properties gives the same answer.
Consumed by `material.assetType` (the `.material` asset path) and by
`renderer.material.create` (the runtime GPU path).
## API
```luau
local schema = require("modules.material_schema")
local s = schema.forShader("pbr") -- nil when the shader can't resolve
schema.route(s, "base_color", {1,0,0,1}) -- "property", "base_color"
schema.route(s, "color", {1,0,0,1}) -- "property", "base_color" (alias)
schema.route(s, "base_color_texture", tex) -- "texture", "base_color_texture"
schema.route(s, "baseColor", {1,0,0,1}) -- "unknown", "baseColor"
schema.declaredNamesList(s) -- "alpha_cutoff, anisotropy, …"
schema.undeclaredMessage(name, key, "pbr", s)
```
## Routing order
`route` applies the alias table first, then decides by surface:
1. **Alias** — `color` → `base_color`, but only when the shader declares the
canonical name and *not* the alias, so a shader that genuinely exposes
`color` keeps its own meaning.
2. **Texture** — the schema declares the name as a texture slot, *or* the value
is itself a texture (handle / `AssetRef<texture>`). A texture stored as a
uniform property reads back via `getProperty` but never reaches the bind
group, so the value's own shape decides regardless of the key.
3. **Property** — the schema declares the name as a uniform property.
4. **Unknown** — neither. Nothing reads it; the caller warns with
`undeclaredMessage` and drops it.
## When the shader can't be resolved
`forShader` returns `nil` for a shader that is not registered yet (a runtime
shader still compiling, a name that resolves later). Callers stay permissive in
that case rather than reject every key against an empty vocabulary — the check
applies where the vocabulary is actually known.
# renderError
Shared "this render is broken" marker. `visibleError(entityId)` swaps an entity
to the builtin ERROR text model + error material, so a failed render reads as an
unmistakable 3D "ERROR" sign on screen instead of empty pixels — empty silently
reads as "fine" and a broken object gets mistaken for working.
Every render component (built-in `Model` / `SkinnedModel`, or a user-authored
one) calls this, so the error model is defined in one place: change it here and
every render component updates.
# bakeState
Keeps the scene's baked lighting honest about the scene it was baked
from. A bake is a photograph of a moment: move a wall, edit a static
light, or delete a prop and every lightmap and probe field taken before
that moment describes geometry that is no longer there.
`BakedLighting.component` drives this module off the engine's authored
change feed (`layers.onEntityChanged`). The bake attaches that component
itself, so producing baked artifacts and maintaining them are one act.
## Invalidation is scene-wide
Indirect light is global. One moved wall changes the bounce reaching
every surface around it, so invalidating only the wall would leave its
neighbours holding light that wall no longer casts. `invalidate` drops
every lightmap, retires every probe field, and reinstates every withheld
light row.
That is also what makes the invalidation visible: the viewport goes from
baked to realtime light the moment the edit lands, instead of continuing
to show a stale photograph.
## What reaches a bake
`affects(entityId)` answers it:
| Edit | Reaches the bake | Reason |
|---|---|---|
| Static geometry moved / changed | yes | `geometry` |
| Static light edited | yes | `light` |
| A tracked receiver or light despawned | yes | `removed` |
| Movable geometry, dynamic light | no | realtime, never baked |
A despawned entity can no longer be read, so `dependents()` — refreshed
after every bake and every invalidation — is the record of what the bake
depended on.
## API
- `BakeState.affects(entityId) -> (boolean, reason?)`
- `BakeState.isBaked(entityId) -> boolean`
- `BakeState.dependents() -> { string }`
- `BakeState.hasBake() -> boolean`
- `BakeState.refresh()`
- `BakeState.hasMaintainer() -> boolean`
- `BakeState.ensureMaintainer() -> boolean`
- `BakeState.setLightsBaked(ids, baked) -> number`
- `BakeState.invalidate(reason?) -> InvalidateReport`
- `BakeState.rebake(opts?) -> RebakeReport`
`rebake` recomputes the volumes already placed and the static receivers
already there. Placing new volumes is an authoring decision the `baking`
tools own.
# isolate
Show the subject without the other geometry drawn, then put everything back.
A prop inside a built scene is behind whatever stands in front of it. The
render-layer system already expresses "draw only these" — an entity is a member
of layers, a camera renders a filter over them — but it does not remember what
it displaced, so every caller wanting one clean shot of one thing hand-rolls the
same record, swap and restore. This is that, once, with the restore guaranteed.
## What it changes
Exactly one thing: the other geometry is not drawn.
Sky, post-process and UI are left exactly as the capture's own render-layer spec
stated them, so a final frame stays a final frame. A caller who wants the
geometry read flat — no lighting, no atmosphere — asks for a **pass**
(`pass = "albedo"`, `pass = "normal"`), which is what passes are for.
## What it does not change
Excluded geometry still **casts shadows** onto the subject and still bounces
light into it. Lights are not filtered by render layer at all, and the layer mask
drives object culling and the geometry passes rather than the shadow passes. This
is the render-layer system's deliberate behaviour — a wall stays out of the shot
while it still exists for lighting, shadows and physics — so a shadow with no
visible caster in an isolated capture is the system working, not a bug.
## The layer
One reserved layer name, `captureIsolate`, reused by every isolated capture.
Membership is a 32-bit mask with six reserved bits. A layer minted per call would
exhaust the namespace in twenty-six captures and leave a trail of empty layers
that `renderLayer.list` would show forever.
## Restoring
`begin` records each affected entity's membership before moving anything, so the
token describes the whole subtree even if the move fails part-way. `restore`
puts every entity back to the layers it carried — and an entity that carried
**no** explicit membership ends with none, rather than an explicit `default` that
looks identical in a picture and different in the data.
`restore` is safe to call twice and safe on a token whose subjects have since
despawned, which is what lets a caller restore unconditionally on every exit
path: success, failure, and once around a whole collage grid rather than between
its cells.
# vfs Module
Public Luau surface over the `__vfs` Internal FFI namespace — the
engine's virtual filesystem.
## Purpose
Wrap the raw `__vfs.*` FFI namespace in a typed Luau table that gets
auto-injected as `_G.vfs` via the prelude. The standard read / write /
move / remove / mkdir / list / exists / readAsync / reload / evict
surface, plus trusted-only `watch` / `unwatch` for the bootstrap VM.
`vfs.read` yields on a byte-cache miss: the module's last step installs
the `vfs_async_read` fallback over the synchronous read, so every VM that
holds this namespace, the trusted VM included, reads a path through the
same two steps and gets the same bytes for it.
## Usage
```luau
-- Read / write
local src = vfs.read("@builtin/components/Camera.luau")
vfs.write("/zero/source/notes.md", body)
-- What a write landed: where the bytes went, and what the static passes
-- read in the Luau among them
local ok, report = vfs.write("/zero/source/game/Vent.component/init.luau", code)
if report.diagnostics then
for _, d in report.diagnostics do
print(d.severity, d.path, d.line, d.message)
end
end
if not report.durable then
log.warn(report.warning .. " " .. report.playShadow)
end
-- Listing
for _, e in ipairs(vfs.list("/zero/source")) do
print(e.isDirectory and "[d] " or " ", e.name)
end
-- Async fetch
local png = task.await(vfs.readAsync("/zero/runtime/screenshots/last.png"))
-- Reclaim RAM after processing a big binary
vfs.evict("/zero/source/textures/imported_huge.png")
-- Reload a module after editing its source on disk
if vfs.reload("@mylib/utils.helpers") then
local m = require("@mylib/utils.helpers") -- sees the new source
end
```
`vfs.reload(identity)` answers whether a module was cached under that name
and has now been dropped. A `false` says the name matched nothing — either a
misspelled identity or a module this VM never required — so the next
`require()` returns whatever it would have returned anyway.
A module that caches state derived from OTHER files keeps serving the old
value when those files are rewritten, since only its own source is watched.
Reload it by identity to make the next `require()` recompute.
## Exports
- `vfs.read(path, opts?) -> string?`
- `vfs.write(path, content, opts?) -> (boolean, WriteReport | string)`
- `vfs.move(src, dst, opts?) -> (boolean, WriteReport | string)`
- `vfs.copy(src, dst) -> (boolean, WriteReport | string)`
- `vfs.remove(path, opts?) -> (boolean, string?)`
- `vfs.mkdir(path, opts?) -> boolean`
- `vfs.list(path?) -> { VfsListEntry }`
- `vfs.exists(path, opts?) -> boolean`
- `vfs.readAsync(path, opts?) -> promiseId`
- `vfs.reload(modulePath?) -> boolean`
- `vfs.evict(path, opts?) -> boolean`
- `vfs.mutationSeq() -> number`
- Trusted-only: `vfs.watch(path, callback) -> number` /
`vfs.unwatch(watcherId) -> boolean`.
# feed
The newest frame a capture produced on this engine, and who is watching for it.
Every capture core publishes here. `latest()` is that frame: a `src` an image
widget draws (the render target's guid while the engine still holds it, the
written path when the capture produced a file), with the size, the source and
the pass beside it. `watch(fn)` calls `fn` as each new frame arrives, and
`unwatch(token)` drops the subscription.
`shown()` says a viewer is drawing frames from the feed. From then on the feed
holds the newest capture's render target so the picture stays up after the
capture that made it is finished with it, and lets it go when a later frame
replaces it; `shown(false)` stops. With nobody drawing, a capture's target is
released the moment its capture is done.
The release path asks `retains(rtHandle)` before destroying a target, which is
how a held frame survives its own capture. `capture.shared.releaseFrame` is the
one place that asks.
# viewpoints
The named camera stations a capture can be taken from, and the rule that decides
what a name like `front` means.
A **viewpoint** is a direction with an image-up: where the camera stands relative
to what it is looking at, and which way is up in the resulting picture. Naming
one replaces reverse-engineering a `{yaw, pitch}` pair, and names the seven
stations that answer "is this built correctly" — the six sides and the corner.
| Viewpoint | Sees |
|-----------|------|
| `front` | the side the subject faces |
| `back` | the opposite side |
| `right` | the subject's right |
| `left` | the subject's left |
| `top` | plan view, the subject's front toward the top of the image |
| `bottom` | the underside |
| `iso` | the corner view where all three axes project equally |
An entity faces its local -Z, so the camera that sees its front sits on its +Z.
`top` and `bottom` take their image-up from the subject's forward axis, because
world up is undefined when you are looking straight down it — that is what puts
the subject's front at the top of a plan view instead of leaving the roll to
whatever the yaw happened to be.
## Basis
`basis` says which axes the name is measured against, and it has **no default**:
- **`"local"`** — the subject's own axes. `front` is the side the subject faces,
whichever way it is turned, and the frame is fitted to the subject's own
extents (`entity:orientedBounds()`).
- **`"world"`** — the world axes. `front` is the side facing world +Z, whatever
the subject is doing.
For a crate turned 40 degrees these are different pictures, so the caller states
which one it wants. A default would mean one call site's `front` is the crate's
front and another's is the world's, and the difference only shows up in the
image. With no subject to take axes from — a capture framed on a world position
rather than an entity — both values mean the world axes, and both are accepted.
## Orthographic size
`orthoHeightFor` answers the question an orthographic frame asks that a
perspective one does not: how big is the frame? A perspective camera backs off
until the subject fits; an orthographic camera does not converge, so its size is
decided by the subject alone — project the bounds' eight corners onto the
camera's up and right axes and take whichever needs more room.
Its `half` extents and `axes` must be stated in the same frame. A resolved
viewpoint carries both: `frame` axes to use with local-frame extents, and its
world axes to use with world extents.
# frame_bounds
Bounds union and exact frustum fitting — the math behind "put this subject
in frame".
`fit` solves for the camera distance that keeps every corner of a box inside
the frustum at a given orbit angle. That is exact for any shape at any angle,
which a circumscribed sphere is not: a sphere frames the DIAGONAL of the box
from every direction, so a 14 x 0.5 x 14 plate backs the camera off nearly
1.4x further than its silhouette needs.
Two consumers share this one implementation: the capture toolbox's framed
shots and editor frame-selection. Toolboxes are self-contained and never
cross-require, so the math lives here as a module rather than inside either.
```lua
local FrameBounds = require("@builtin::modules.frame_bounds")
local box = FrameBounds.union({
FrameBounds.ofEntity(entity.find("player")),
FrameBounds.ofEntity(entity.find("prop")),
})
local framed = FrameBounds.fit(box, { fov = 60, aspect = 16 / 9, angle = { 0, 20 } })
-- framed.px/py/pz is where the camera goes, framed.cx/cy/cz is what it looks at.
```
## Exports
- `FrameBounds.union(boxes) -> Aabb?` — the AABB enclosing every box in the
list. `nil` when the list is empty (or holds nothing with `min`/`max`), which
is the signal that there is nothing to frame.
- `FrameBounds.ofEntity(target) -> Aabb?` — world-space bounds of an entity and
its descendants (`hierarchyBounds`, covering a character root plus its
skinned mesh and bones), falling back to the entity's own mesh `bounds`.
`nil` when the target has no renderable geometry.
- `FrameBounds.fit(box, opts?) -> Framed` — a camera pose that frames `box`.
`opts` is `{ fov = 60, aspect = 16/9, margin = 1.15, angle = { yawDeg, pitchDeg } }`;
`angle` also accepts `{ yaw = , pitch = }`. Returns
`{ px, py, pz, cx, cy, cz, distance, radius, fov, near, far, center, size }` —
camera position, look-at point (the box centre), the fitted distance, the
bounding-sphere radius, geometry-derived near/far clip planes, and the box's
centre and full size. A degenerate (point) box gets a short fixed distance
so the camera is not sitting inside the subject.
- `FrameBounds.fitDistance(hx, hy, hz, dx, dy, dz, tanH, tanV) -> number` — the
corner projection itself: the smallest distance along the orbit direction
`(dx, dy, dz)` (subject toward camera, unit length) that keeps all eight
corners of the half-extent box inside a frustum with half-angle tangents
`tanH` / `tanV`. `fit` applies `margin` to this; call it directly when you
already have a direction and want the raw fit.
# persist.player_camera
Freezes the **live** player and camera into a scene's player/camera config. The
persist north star is "what you see is what you get": the freeze preserves the
exact live state, it never synthesizes.
**Player.** The live player body is `layers.active.players.localPlayer.avatar` —
whatever entity is in that slot right now, however it was built (procedurally
spawned, instantiated, hand-assembled). The scene config (`player.avatar_<mode>`)
is a bundle ref the spawner instantiates on the next load, so the freeze converts
the live avatar entity-tree into that bundle.
**Camera.** The live camera state is captured into the scene's camera config so
the next load restores the same view.
# @builtin::assetTypes.scene.shared.swapOrchestrator
Owns the user-scope orchestration for scene swaps: leaves the prior
scene's multiplayer room, gates concurrent loads via the
`__scene_load.begin/finish` token, drives `scene_loader.M.load` for
the new scene, and joins the new scene's room. Keeps the swap atomic
from a multiplayer-sync standpoint (room transition + entity churn
happen inside a single barrier).
# Error Test
Intentionally broken material used to exercise the renderer's error-handling and fallback paths. It targets `@builtin::shaders.error_shader` and carries a bright magenta base colour (`1.0, 0.0, 1.0`) — the classic "missing/failed" flag colour — with mid roughness (`0.5`), opaque render type, and back-face culling.
## Inputs
- `colors.base_color` (`1.0, 0.0, 1.0, 1.0`) — magenta error flag tint.
- `floats.roughness` (`0.5`) — neutral surface roughness.
- `render.type` (`opaque`) / `render.queue` (`2000`) — drawn in the standard opaque pass.
# Physics
Adds rigid body dynamics to an entity. Body kind is `"dynamic"` (affected by forces), `"static"` (immovable), or `"kinematic"` (moved by script, affects other bodies).
Public fields: `kind`, `gravityScale`, `mass`, `linearDamping`, `angularDamping`, `ccd`, `interpolation`, `lockRotationX/Y/Z`, `lockTranslationX/Y/Z`.
`ccd` (continuous collision detection) sweeps the body along its motion so it cannot pass through thin geometry between two steps. It is off by default: the sweep runs on every body that travels further than its own thickness in one step — most of a collapsing structure or a debris burst — and it dominates the physics budget when a scene has many such bodies. Turn it on for the individual bodies that would visibly tunnel: bullets, thrown props, a wrecking ball.
```luau
entity(id).component.add("Physics", { kind = "dynamic", ccd = true })
```
Methods: `:applyForce(x, y, z)`, `:applyImpulse(x, y, z)`, `:applyTorque(x, y, z)`, `:setVelocity(x, y, z)`, `:addVelocity(dx, dy, dz)`.
```luau
entity(id).component.add("Physics", { kind = "dynamic" })
entity(id).component.get("Physics"):applyImpulse(5, 0, 0)
```
# prototype_lifecycle
The play-mode invariant for authored player prototypes and EditorOnly entities. In play a PlayerPrototype subtree is the clone source, not a live scene entity: the scene loader spawns it only in edit, and the spawn-on-join flow instantiates a clone per joining user in play. This module deactivates and hides any prototype still live when play begins (the fallback when the loader spawned one) and every EditorOnly entity, so neither is simulated nor rendered in play; in edit both are fully live. Runtime clones are tracked here and despawned on the return to edit.
Location: `src/lua/lib/modules/api/engine/prototype_lifecycle.module`
# PlayerPrototype
Marks the root of a player-setup subtree — the entity and children that define what a joining user becomes (avatar slot, spawn behaviour, controller). On awake it flips the entity to PrototypeOnly participation, so the prototype lives in the editor and drives spawning without being saved as a world entity itself; `kind` and `label` describe it for authoring surfaces. In edit mode it draws a cyan silhouette outline so the prototype root is easy to pick out, and surfaces its own setup-validation messages (missing camera, avatar slot, or wrong participation) on the component's error channel.
Location: `src/lua/lib/components/PlayerPrototype.component`
# PlayerSpawn
A world entity that spawns players from a prototype. It carries the fields describing how spawning happens: which `prototype` to instantiate, the `team` and `role` assigned to spawned players, a `maxPlayers` cap (0 = unbounded), the `spawnPolicy` (how many players spawn from this point), and the `placement` (where a spawned player is positioned). The entity's own transform is the spawn point when `placement` is `at_spawn_transform`. In edit mode it draws an orange silhouette outline at the spawn point and surfaces its own setup-validation messages (missing or mis-targeted prototype) on the component's error channel.
Location: `src/lua/lib/components/PlayerSpawn.component`
# orbital_follow
Three-ring orbit camera. Framing is authored as three rings around the follow target — a bottom, a centre and a top, each with a `height` above the target's origin and a `radius` out from its axis. Vertical look slides the camera along the spline through those rings; horizontal look orbits around them. The shape of the orbit is geometry you can see and edit rather than a pair of pitch limits you cannot.
Public fields: `follow`, `ringBottom` / `ringCenter` / `ringTop` (each `{ height, radius }`), `verticalAxis` (0 at the bottom ring, 1 at the top), `horizontalAxis` (the world heading the camera looks along, in radians about +Y from world +Z), `radiusScale`, `aimHeight` (the aim point above the target's origin), `damping` (per-axis time constants in seconds), `cameraRadius` (sphere-cast radius for the pull-in through geometry), `sensitivity`, `invertY` (off by default — pushing the mouse forward looks UP, drawing the camera toward the bottom ring; on pitches the other way), `zoomSpeed`, `minRadiusScale`, `maxRadiusScale`.
The rig owns that heading. Binding a follow target aims it along the subject's facing, so the shot opens from behind whoever the camera is told to follow, and look input turns it from there — the subject's facing is never read again. That is what makes the camera steerable for a character who turns to face where it is walking: hold "forward" and the character walks the line the camera looks down, instead of chasing a heading that moves as it turns.
The default rings frame a roughly 1.8 m humanoid standing at its origin: the centre ring sits above head height at the widest radius for a resting shot from behind and slightly above, the bottom ring drops to knee height and pulls in to look up, and the top ring climbs and tightens for a near-overhead look down.
Writing any framing field re-poses the rig immediately with damping bypassed, so editing a ring moves the camera to the shot that ring describes.
`follow` falls back to the `Camera.follow` slot when it is empty, so a rig selected through `Camera.behavior` tracks whatever that camera already follows.
Controls: mouse to orbit and to slide along the rings (play mode requests pointer lock, so no button is held), scroll to scale the ring radii.
```luau
local cam = entity(id).component.get("Camera")
cam.behavior = asset.ref("@builtin::controller.orbital_follow", "component")
entity(id).component.get("controller.orbital_follow").ringCenter = { height = 2.6, radius = 6 }
```
## Attach through `Camera.behavior`
The Camera owns the behavior slot, so assigning the rig there is what lets the Camera tell it when `follow` changes. A rig added straight onto the entity with `component.add` still poses, but the Camera does not know about it and a `follow` assigned afterwards will not reach it.
## Player cameras: leave `follow` unset
Entering play drops and respawns `PlayerPrototype` subtrees, so the body is recreated with a NEW entity id. A `follow` authored on the rig keeps pointing at the original, which is no longer live, and the camera freezes at its last pose instead of tracking the player.
For a player camera, set the target on the **Camera** and leave the rig's own `follow` empty:
```luau
entity(rigId).component.get("Camera").follow = body -- PlayerPrototype rebinds this on every spawn
```
`PlayerPrototype` refreshes `Camera.follow` to the live body each time it clones, and the rig reads that slot whenever its own `follow` is empty. Set the rig's `follow` only for a camera that watches a fixed, non-respawning subject.
# sky Module
Public Luau surface over the `__sky` Internal FFI namespace — sky
type, time of day, day/night cycle, procedural parameters, presets,
explicit sun direction.
## Purpose
Wrap the raw `__sky.*` FFI namespace in a typed Luau table that gets
auto-injected as `_G.sky` via the prelude. Set queues sky-config
mutations into the scripting mutation queue; get reads back via the
sky-snapshot bridge.
## Usage
```luau
sky.preset("clear_day")
sky.set({ time_of_day = 14, sync_sun_to_light = true })
print(sky.getTimeOfDay()) -- 14
sky.setSunDirection({ 0.5, -1, 0.3 })
local cfg = sky.get()
print(cfg.type, cfg.material_name, cfg.solid_color)
```
## Exports
- `sky.set(opts)` / `sky.get() -> table`
- `sky.setTimeOfDay(t)` / `sky.getTimeOfDay() -> number`
- `sky.preset(name)`
- `sky.setSunDirection(dir)`
# colorSequence
Roblox-shaped RGB keyframe-curve value type. A `ColorSequence` is an
immutable curve of up to 64 keypoints, each carrying
`(time, value, envelope)` where `value` is a `{r, g, b}` triple and
`envelope` is a per-channel random range half-width. The per-channel
envelope is a notable improvement over Roblox, where ColorSequence
ships with no envelope at all — a long-standing community wishlist
item.
Exposed as the `ColorSequence` Luau global via `--!global`. Authored
scripts call `ColorSequence.new(...)` directly without a `require`.
The Rust FFI lives at `__sequences.*` (registered by
`crates/zero_scripting/src/ffi/bindings/curves.rs`); this module is
the typed Luau wrapper.
## Exports
- `ColorSequence.new(...) -> ColorSequenceObj`:
- `ColorSequence.new({r, g, b})` — constant color (accepts a
3-array or `{r=, g=, b=}` record).
- `ColorSequence.new(c0, c1)` — two-point lerp from `c0` to `c1`.
- `ColorSequence.new({ keypoint, ... })` — explicit keypoints.
`envelope` may be omitted (= `{0,0,0}`), a single number
(broadcast to all channels), or a 3-array. First key must anchor
at `time = 0`, last at `time = 1`. NaN / Inf rejected.
- `ColorSequence.deserialize(payload) -> ColorSequenceObj` — rebuild
from a `{ kind = "ColorSequence", keypoints = {...} }` payload
produced by `:serialize()`.
### Keypoint shapes
An entry of a keypoint list takes any of three written shapes, and the
shapes mix within one list:
| Written | Read as |
|---|---|
| `{ time = 0.5, value = {1,0,0}, envelope = 0.05 }` | the named record |
| `{ 0.5, {1,0,0}, 0.05 }` | time, then colour, then envelope |
| `{ 1, 0, 0 }` | a bare colour, timed by its place in the list |
A bare colour takes its time from its place: the entries spread evenly
across `[0, 1]`, so `{ {1,0.85,0.35}, {1,0.35,0.05} }` is a ramp from
the first colour at `t = 0` to the second at `t = 1`, and a list of one
colour holds that colour across the whole domain.
`@builtin::systems.particles.curves` exposes a `ColorSequence.new` that
reads these same three shapes, so a colour ramp written for an emitter
spec is the literal this constructor takes. The two differ in what they
do with the times: this one keeps the written times and leaves the
`first at 0, last at 1` rule to raise, while the particles reader sorts,
clamps and forces the endpoints. The particles reader also carries an
alpha channel this one drops, and holds 16 stops where this one holds
64, resampling a longer list down to its own width.
## Methods (called via `:`)
- `seq:evaluate(t) -> (r, g, b)` — deterministic linear interpolation
at `t` (multiret). `t` clamps to `[0, 1]`; NaN coerces to `0`.
- `seq:sample(t) -> (r, g, b)` — `evaluate(t)` plus per-channel
`(math.random() − 0.5)·2·envelope(t)` jitter. Per-VM
`math.randomseed` controls jitter reproducibility.
- `seq:keypoints() -> { { time, value = {r,g,b}, envelope = {r,g,b} } }`
— array snapshot, sorted ascending by time.
- `seq:duration() -> number` — always `1.0` for a well-formed
sequence.
- `seq:serialize() -> { kind, keypoints }` — scene-save payload.
- `seq:destroy()` — drop the FFI handle. Luau removed `__gc` on
tables, so eager cleanup is the caller's responsibility for
per-frame-rebuild patterns; otherwise the entry dies with the VM.
## Substrate
Backed by `zero_curves::Channel` (Linear interp). Each sequence
builds the value and envelope channels once at construct time and
reaches into them on every sample — zero per-call allocation, safe
for hot particle loops. Validation errors name the specific rule
(`"first keypoint must anchor at time = 0"`,
`"too many keypoints (max 64)"`, …).
Foundation for VFX property-over-lifetime: declarative particles
(#2572), GPU emitter (#873), beams, trails. Issue: #2673.
# numberSequence
Roblox-shaped scalar keyframe-curve value type. A `NumberSequence` is
an immutable curve of up to 64 keypoints, each carrying
`(time, value, envelope)`. The envelope is a per-keypoint random
range half-width applied at `:sample(t)` time; `:evaluate(t)` is
always deterministic.
Exposed as the `NumberSequence` Luau global via `--!global`. Authored
scripts call `NumberSequence.new(...)` directly without a `require`.
The Rust FFI lives at `__sequences.*` (registered by
`crates/zero_scripting/src/ffi/bindings/curves.rs`); this module is
the typed Luau wrapper.
## Exports
- `NumberSequence.new(...) -> NumberSequenceObj` — three signatures:
- `NumberSequence.new(v)` — constant value across `[0, 1]`.
- `NumberSequence.new(v0, v1)` — two-point lerp from `v0` to `v1`.
- `NumberSequence.new({ { time, value, envelope? }, ... })` —
explicit keypoints. `envelope` defaults to `0`. First key must
anchor at `time = 0`, last at `time = 1`. NaN / Inf rejected.
- `NumberSequence.deserialize(payload) -> NumberSequenceObj` —
rebuild from a `{ kind = "NumberSequence", keypoints = {...} }`
payload produced by `:serialize()`.
## Methods (called via `:`)
- `seq:evaluate(t) -> number` — deterministic linear interpolation at
`t`. `t` clamps to `[0, 1]`; NaN coerces to `0`.
- `seq:sample(t) -> number` — `evaluate(t) + (math.random() − 0.5)·2·envelope(t)`.
Per-VM `math.randomseed` controls jitter reproducibility.
- `seq:keypoints() -> { NumberKeypoint }` — array snapshot, sorted
ascending by time.
- `seq:duration() -> number` — always `1.0` for a well-formed
sequence.
- `seq:serialize() -> { kind, keypoints }` — scene-save payload.
- `seq:destroy()` — drop the FFI handle. Luau removed `__gc` on
tables, so eager cleanup is the caller's responsibility for
per-frame-rebuild patterns; otherwise the entry dies with the VM.
## Substrate
Backed by `zero_curves::Channel` (Linear interp). Each sequence
builds the value and envelope channels once at construct time and
reaches into them on every sample — zero per-call allocation, safe
for hot particle loops. Validation errors name the specific rule
(`"first keypoint must anchor at time = 0"`,
`"too many keypoints (max 64)"`, …).
Foundation for VFX property-over-lifetime: declarative particles
(#2572), GPU emitter (#873), beams, trails. Issue: #2673.
# modules.api.engine.player_lifecycle
Wires the UserIdentity component's avatar-bind events into the
`localPlayerReady` lifecycle hook. Owns the local-avatar-bound latch
(fires once after the avatar entity is bound + has settled past the
bundle.instantiate deferred-mutation pipeline) and the opt-out path for
legacy v6 scenes that declare the avatar slot as `""`.
Exposes the `__layers_local_avatar_bound` + `__layers_local_avatar_opt_out`
dispatch channels wired in `install()`; the UserIdentity component's
lifecycle and the legacy v6 player_spawner route through them.
# settings
World-settings reader and writer for `/zero/source/.world_settings`.
The single canonical surface for any script that needs to read or
mutate engine settings (renderer culling, physics gravity,
LSP strictness, startup scene, etc.). Auto-injected as the
global `settings` by the prelude — user code never needs to `require`
this module.
Settings live in a TOML file inside the world's manifest. Reads always
re-parse the file so callers see the live state (no stale cache);
writes go through `vfs.write`, so play mode locks settings the same
as any other source file. Call `wld.edit()` first to unlock for
mid-play writes.
## Exports
- `settings.get(key: string) -> any` — raw value at a dotted key, or `nil`.
- `settings.getString(key: string, default?: string) -> string` — type-narrowed string accessor.
- `settings.getNumber(key: string, default?: number) -> number` — type-narrowed number accessor.
- `settings.getBool(key: string, default?: boolean) -> boolean` — type-narrowed boolean accessor.
- `settings.set(key: string, value: any)` — set + write.
- `settings.setMany(updates: { [string]: any })` — batched set + single write.
- `settings.all() -> { [string]: any }` — snapshot of the full settings document.
## Usage
```luau
-- Read
local mode = settings.getString("render.culling_mode", "gpu")
local gravity = settings.getNumber("physics.gravity", -9.81)
if settings.getBool("render.shadows", true) then ... end
-- Write
settings.set("render.culling_mode", "cpu")
settings.setMany({
["render.culling_mode"] = "cpu",
["physics.gravity"] = -3.7,
})
-- Inspect everything
for section, keys in pairs(settings.all()) do
print("[" .. section .. "]")
for k, v in pairs(keys) do print(" " .. k .. " =", v) end
end
```
## Notes
- The typed accessors (`getString` / `getNumber` / `getBool`) fall back to the documented default (`""` / `0` / `false`) on type mismatch — they never coerce.
- Modifying the snapshot returned by `settings.all()` does NOT propagate. Persist changes with `set` or `setMany`.
- Each `get*` re-reads the file. Settings access is infrequent enough that the parse cost is negligible; the trade-off is no stale-cache class of bug from foreign writes.
# field
Single-field component schema builder. Every `public.X` on a component
declares its type, default value, and Sync/NoSync replication mode in
one place via the `Field.<kind>(default, mode)` constructor that
produces a `FieldDesc<T>` descriptor. The engine walks `public` at
component registration, extracts each descriptor into the
per-component asset-fields + sync registries, runs each asset default
through the category-aware resolver, and replaces the descriptor with
its unwrapped default so user code reads `public.X` as the value (not
the descriptor).
This module is exposed as the `Field`, `Sync`, and `NoSync` Luau
globals via `--!global` directives. Component authors call
`Field.<kind>` and `Sync` / `NoSync` directly without a `require`.
`--!global-types` exports `SyncMode`, `FieldDesc<T>`, `AssetRef<C>`,
`EntityRef`, and `ComponentRef<T>` to every typed source.
## Exports
- `Field.number(default, mode, marker?) -> FieldDesc<number>`
- `Field.range(min, max, default, mode, marker?) -> FieldDesc<number>`
- `Field.string(default, mode, marker?) -> FieldDesc<string>`
- `Field.enum(values, default, mode, marker?) -> FieldDesc<string>`
- `Field.bool(default, mode, marker?) -> FieldDesc<boolean>`
- `Field.vec2(default, mode, marker?) -> FieldDesc<vec2>`
- `Field.vec3(default, mode, marker?) -> FieldDesc<vec3>`
- `Field.quat(default, mode, marker?) -> FieldDesc<quat>`
- `Field.color(default, mode, marker?) -> FieldDesc<color>`
- `Field.entityRef(default, mode, marker?) -> FieldDesc<EntityRef>`
- `Field.assetRef<C>(category, default, mode, marker?) -> FieldDesc<AssetRef<C>>`
- `Field.dataRef<C>(contract, default, mode, marker?) -> FieldDesc<AssetRef<"data">>`
- `Field.componentRef<T>(componentType, default, mode, marker?) -> FieldDesc<ComponentRef<T>>`
- `Field.table<T>(default, mode, marker?) -> FieldDesc<T>`
- `Field.alias(target, description?) -> FieldDesc<any>`
- `Sync` / `NoSync` — string singletons used as the replication-mode argument.
Every constructor's trailing `marker` argument accepts the bare
`Serialized` marker (unchanged meaning), a plain string read as the
field's description, or a `FieldOptions` table carrying `serialized`
and/or `description`.
Types (global via `--!global-types`):
- `SyncMode = "Sync" | "NoSync"`
- `FieldOptions = { serialized: SerializedMode?, description: string? }`
- `FieldDesc<T> = { __zero_field: boolean, kind: string, default: T?, sync: boolean, serialized: boolean?, description: string?, enforced: boolean, category: string?, componentType: string? }`
- `AssetRef<C> = { __ref: string, type: C, name: string, guid: string, identity: string, path: string, primary: string? }` — the envelope `asset.resolve` / `asset.ref` / `asset.list` hand back. The category is in `type`; `primary` is the single content file the resolver found, which `getSource` reads, falling back to `path`.
- `EntityRef` — the live entity proxy (`entity(id)` / `entity.spawn()` / `bundle:instantiate()` return it; carries `id`, `name`, `position`, `component`, …). An `entityRef` field accepts a proxy OR a raw id string on write and reads back a live proxy (`.id` for the string, `nil` if unset); the stored/serialised form is the plain id.
- `ComponentRef<T> = { __ref: string, entity: string, componentType: T }`
## Usage
```luau
-- Inside a `public` block on a component
public = {
positionX = Field.number(0, Sync),
speed = Field.number(5, Sync, "Metres per second at full throttle."),
enabled = Field.bool(true, Sync),
material = Field.assetRef("material", "@my-library::materials.gold", Sync),
source = Field.assetRef("bundle", nil, Sync),
target = Field.entityRef(nil, Sync),
aimCam = Field.componentRef("Camera", nil, NoSync),
idMap = Field.table({} :: { [string]: string }, NoSync),
}
```
## Notes
- `Sync` / `NoSync` is REQUIRED on every constructor. Passing `nil`,
`true`, `false`, or anything else raises a load-time error so a
multiplayer-by-default engine can't silently ship single-player
components.
- `Field.assetRef`'s `category` argument is inferred as a singleton
string type (`C`). Cross-category assignment is therefore a type
error: `AssetRef<"mesh">` does not unify with `AssetRef<"material">`.
- `Field.componentRef`'s `componentType` argument works the same way —
the engine validates the referent's component type at every write.
- `Field.table<T>`: pin the shape by ascribing the default,
`Field.table({} :: MyShape, NoSync)`.
- `Field.range` and `Field.enum` carry a `constraint` the engine checks
on the field's default and on every write, through
`field_constraints.module`. A value outside the declared interval or
member set is refused with the interval or the whole set named, so
what the field reads back is a value its consumer can use.
- The engine resolves any string default for `assetRef` through
`asset.resolve(identity, category)` at component registration, so the
first read of `public.<field>` returns a resolved envelope, not a raw
string.
- `Field.dataRef` rides the same `assetRef` machinery (category
`"data"`) plus a contract gate: every write (and the registration-time
default) must resolve to a `.data` instance whose dataType contract
chain includes `contract`, checked through the generic field-constraint
hook. A rejected write raises and leaves the field's previous value
unchanged.
- Constrained fields (`Field.dataRef`) are top-level only — declaring
one inside `Field.struct` / `Field.list` aborts component registration
with an error.
- `FieldDesc.enforced` is true exactly for the kinds that carry a
`constraint` (`enum`, `range`, `bitmask`, `dataRef`, `taggedRef`,
`instantiableRef`) — the field the `field_constraints` hook checks on
every write and default. Every other kind reads `enforced = false`.
# material_records
The material GPU records this session has released and nothing has filed again.
A `.material` asset files its GPU record at its point of use — `matRef:handle()`
— and memoises the handle it filed on the ref's runtime, so a second bind of the
same material costs a table read where the first cost a parse and an upload.
That memo names a record the renderer's registry holds, and
`renderer.material.destroy` takes the record away while the asset and its memo
stand. The asset is what files the next record, so it has to know which names
that applies to.
This set is that knowledge: a release puts the key in, filing a record under the
key takes it out, and the point of use reads it for the cost of one table index.
Written by `renderer.material.destroy` and `renderer.material.create`; read by
the `.material` assetType's `handle()`.
## API
```luau
local MaterialRecords = require("modules.material_records")
-- A point of use holding a memo asks whether the record it names still stands.
if MaterialRecords.released[identity] ~= nil then
-- The record is gone: file another one, which takes the mark off.
end
MaterialRecords.noteReleased(key) -- the record under `key` has been released
MaterialRecords.noteFiled(key) -- a record stands under `key` again
```
## Lifetime
The set lives in the session store, so it holds for as long as the GPU records
it answers for: a VM reload leaves those records standing and leaves this
standing with them, and both die with the engine process.
# texture_ref
The GPU key a material's texture slot binds by, resolved from whatever form the
author wrote it in.
The renderer binds a texture slot by **exact GPU-cache key**, and that key is
the guid an upload lands under. A reference in any other form — an asset
identity (`wall.texture`), a bare name, a `.texture` path, the image path a
texture was imported from — names a real texture the cache has never heard of.
Bound as written it leaves the slot on the shader's declared fallback: white for
a `base_color_texture`, black for a sky panorama. The material then renders as
though nothing was bound, and nothing distinguishes that from a texture the
author meant to be dark.
This module is the single place that answers "what key does this reference
bind by", so the same string binds the same texture wherever it is written.
Consumed by `material.assetType` (the `.material` asset path),
`renderer.material.create`, and `renderer.material.setTexture`.
## API
```luau
local TextureRef = require("modules.texture_ref")
TextureRef.resolve("wall.texture") -- "9f2c…", "asset"
TextureRef.resolve("9f2c…") -- "9f2c…", "asset" (idempotent)
TextureRef.resolve("/source/sky/pano.png") -- "f181…", "asset" (→ pano.texture)
TextureRef.resolve("color:1,0,0,1") -- unchanged, "procedural"
TextureRef.resolve("video_0") -- unchanged, "live"
TextureRef.resolve("nothing_here") -- unchanged, "unresolved"
TextureRef.isProcedural("default:white") -- true
TextureRef.unresolvedMessage(ref, "renderer.material.setTexture")
```
`resolve` also **materialises** the texture (Disk→CPU→GPU via `texRef:handle()`)
— the guid only becomes a cache key once the upload has landed.
## Resolution order
1. **Procedural** — `color:` / `default:` / `runtime:`. The GPU texture cache
produces these itself, so they pass through untouched.
2. **The reference itself** — a guid, an asset identity, a bare name, or a
`.texture` path, resolved through `asset.resolve(ref, "texture")`.
3. **The imported container** — when the reference is a loose raster path
(`png` / `jpg` / `jpeg` / `webp`), the sibling `<stem>.texture` the texture
importer promotes it into. The importer *removes* the loose original, so the
path an author copied out of an import log names a file that no longer
exists; this step follows the image to where its texture actually went.
4. **The alternate identity** — a slot's stable identity, passed by the
`.material` path so a binding whose guid was orphaned by a delete + recreate
still finds the texture the author named.
5. **A resident GPU key** — a live texture with no asset behind it: a video
frame (`video_0`), a rasterised text texture, a render target. Binds exactly
as written.
Anything left is `"unresolved"`. The reference is still returned unchanged — a
texture that lands later is picked up by the renderer's own pending retry — but
the caller has the outcome and reports it with `unresolvedMessage` instead of
letting the slot fall back in silence.
# ProceduralSky
The editable atmospheric sky: a day/night gradient with a sun disc, glow, stars
and a moon, all derived procedurally from the directional light's elevation. Add
it to an entity like a light:
```lua
entity.spawn("sky").component.add("ProceduralSky", { timeOfDay = 18.5 })
entity.spawn("sky").component.add("ProceduralSky", { preset = "sunset" })
entity.spawn("sky").component.add("ProceduralSky", {
zenithColor = { r = 0.05, g = 0.1, b = 0.3 },
horizonColor = { r = 0.9, g = 0.4, b = 0.2 },
})
```
A `ProceduralSky` owns its material outright: it registers a **runtime GPU
material** of its own over the builtin procedural-sky shader and pushes every
visual parameter (colours, sun, stars, moon, turbidity, exposure) straight to
that record. The component's fields **are** the material definition — no
`.material` asset backs it, so retuning a scene's sky changes that scene's sky
and nothing else. The renderer reads the values from the material's group(1)
uniform like any other material — there is no sky-specific render path.
Those values also travel the native `Sky` bridge into the scene sky config,
alongside the time-of-day / auto-cycle / sun-sync controls. That config is what
`sky.get()` reports and what a saved scene records, so both name the sky being
drawn.
With `syncSunToLight` on (the default), the sky's `timeOfDay` is what the scene
is lit by: the scene's directional light takes its direction, colour and
intensity from the sun's position, so the sun in the sky and the sun the scene
is lit by are the same sun through a whole day/night cycle. Turn it off for a
light the sky leaves alone.
The intensity that arrives is the day/night curve between night and noon, and
`sunPeakIntensity` is the noon end of it — `1.0`, the sky's own daylight, unless
a scene asks for more. A harder sun on water or snow is stated here; setting the
light itself does not survive, because the sky writes over it every time the
hour moves.
Like the directional light, the sky is conceptually **singular per scene**. A
`ProceduralSky` fully defines its material on `awake` (every parameter from its
own fields), so switching scenes never inherits a previous scene's overrides.
Day/night comes from the sun's elevation, so evening presets render dark —
drive the look by `timeOfDay` (which orients the sun).
## Fields
| Field | Type | Default | Meaning |
|---|---|---|---|
| `preset` | string | `""` | Named look applied before explicit fields: `clear_day`, `sunset`, `sunrise`, `overcast`, `night`. |
| `timeOfDay` | number | `14.0` | 0..24 hours; orients the sun and the day/night gradient. |
| `autoCycle` | bool | `false` | Advance `timeOfDay` each frame. |
| `cycleSpeed` | number | `60.0` | Game-seconds per real-second for the auto cycle. |
| `syncSunToLight` | bool | `true` | Drive the directional light from the sun. |
| `sunPeakIntensity` | number | `1.0` | What the sun's light measures at noon; the day/night curve runs from night up to this. |
| `zenithColor` / `horizonColor` / `groundColor` | color | — | Sky gradient colours. |
| `sunSize` / `sunIntensity` | number | `0.02` / `20.0` | Sun disc size + brightness. |
| `starsIntensity` | number | `1.0` | Night star-field intensity. |
| `moonSize` | number | `0.03` | Moon disc size. |
| `turbidity` | number | `4.0` | Atmospheric haziness. |
| `exposure` | number | `1.0` | Sky exposure multiplier. |
## Methods
| Method | Description |
|---|---|
| `sky:setTimeOfDay(hours)` | Set the time of day (0..24). |
| `sky:applyPreset(name)` | Apply a named look. |
| `sky:setZenithColor(c)` / `setHorizonColor(c)` / `setGroundColor(c)` | Set a gradient colour (`{r,g,b}` array or map; >1 auto-scales /255). |
# Skybox
The scene's sky as a single **material**. Add it to an entity and the engine
renders that material across the sky behind everything else:
```lua
entity.spawn("sky").component.add("Skybox", { material = "sky_cubemap" })
entity.spawn("sky").component.add("Skybox", { material = "my_custom_sky" })
entity.spawn("sky").component.add("Skybox", { kind = "none" }) -- explicit no sky
```
`Skybox` is the generic, material-driven sky: it points at **any** sky material
— a builtin (`sky_procedural`, `sky_solid`, `sky_cubemap`, `sky_equirect`) or
your own authored sky-domain material — and makes it the active sky. For the
editable procedural atmosphere (day/night, sun, stars, moon), use the
[`ProceduralSky`](../ProceduralSky.component/README.md) component instead — it is
a `Skybox` over the `sky_procedural` material plus typed parameter controls.
A Skybox **materialises its material's GPU handle** when it binds it. The sky's
point-of-use is the sky pass, which never binds the material the way a `Model`
does, so the component performs the Disk→CPU→GPU upload (`:handle()`) itself. The
renderer binds the sky by the material's **identity** (the key it is resident
under once materialised), so a material-mode sky renders correctly after boot and
mode flips — no manual `:handle()` needed.
Like the directional light, the sky is conceptually **singular per scene**: the
engine copies the most recently authored sky into the scene-wide sky config the
renderer reads each frame. A sky belongs to the scene that spawned it; removing
the component reverts the scene to the engine fallback sky. State is bridged
through the native `Sky` ECS component (sky type `material` / `none`), so there
is no global sky singleton — two scenes can never clobber each other.
## Fields
| Field | Type | Default | Meaning |
|---|---|---|---|
| `material` | string | `sky_procedural` | The sky material to render. Any registered material whose shader is a sky-domain shader. Defaults to the procedural sky so an empty `Skybox{}` is never a black void. |
| `kind` | string | `material` | `"material"` renders `material`; `"none"` turns the sky pass off — the explicit authored form of "this scene has no sky". |
## Methods
| Method | Description |
|---|---|
| `skybox:setMaterial(name)` | Point the sky at a different material (materialises its handle). |
| `skybox:setNone()` | Turn the sky off explicitly. |
·computeshader · born here
❒asset # lightmap_bake
Bakes a lightmap on the GPU — one thread per lightmap texel. Each thread
reads its world position + normal, evaluates the shadowed direct
irradiance (every scene light, visibility via a BVH shadow ray), then
gathers indirect irradiance by tracing `indirectSamples` cosine-weighted
path chains of up to `bounces` surface interactions. Every path vertex
contributes its emissive plus its albedo (carried on the triangle
records by `compute.buildBvh`) times the shadowed direct light there; a
segment that escapes contributes the sky. Direct + indirect accumulate
as RGBA into `output` (A = coverage).
Bindings (see bindings.yaml): `output` (read_write), `texel_positions`,
`texel_normals`, `bvh_nodes`, `triangles`, `lights`, `params` — all
`array<vec4<f32>>`.
Usage: dispatch by identity with `compute.dispatch("@builtin::systems.globalIllumination.lightmap_bake", ...)` (buffers in bindings order) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset ·computeshader · born here
❒asset # lightmap_blur
Edge-aware à-trous denoise pass over the indirect half of a lightmap
bake. Dispatched by the `lightmap` module between the bake and the
read-back: four 3x3 passes with doubling tap stride ping-pong the
indirect texels through a scratch buffer, smoothing Monte-Carlo
variance over a wide world-space radius. Per-tap weights from the
bake's position and normal buffers keep the smoothing on the local
surface — taps across a UV chart seam or on different geometry weigh
~zero. The direct half never passes through this kernel, so baked
shadow edges keep their full sharpness.
# volumeProbe
Bake a 3D grid of SH L2 irradiance probes and publish it into the
renderer's irradiance-volume set: every standard-PBR fragment inside the
volume's bounds (movers, avatars, freshly spawned props) takes its
ambient term from the field, sampled in fragment via trilinear
interpolation across the 8 nearest grid points — no per-receiver setup.
The bake path-traces every probe sample through the scene BVH for up to
`bounces` surface interactions, so probes capture indirect light, color
bleed, emissive surfaces, and sky occlusion. Probe storage is a flat
array of SH coefficients — 9 `vec4` per probe (RGB packed in `xyz`,
padding in `w`). Re-bake when geometry or lights change.
Every bake persists: the SH brick lands in the scene's baked-lighting
container (`lightmapData` asset) keyed by the volume's `fieldId`, and the
volume's `lightmapData` ref points at it. A fresh boot restores and
republishes the field from the asset (`restore`, called by the
VolumeProbe component's awake) with zero re-baking.
## Exports
- `VolumeProbe.bake(volumeEntityId: string, opts: BakeOpts?) -> BakeReport` — bake a single probe volume, publish its field, and persist it.
- `VolumeProbe.bakeAll(opts: BakeOpts?) -> { BakeAllEntry }` — bake every entity with a `VolumeProbe` component, sharing scene-geometry + BVH caches and one container across volumes.
- `VolumeProbe.restore(volumeEntityId: string) -> (boolean, string?)` — restore + republish a volume's persisted field (the boot path; no re-bake).
- `VolumeProbe.retire(volumeEntityId: string)` — full teardown: retract the published field, destroy the SH buffer, remove the container entry, clear the ref.
- `VolumeProbe.fieldLive(volumeEntityId: string) -> boolean` — whether a field is already published under the volume's key this session.
- `VolumeProbe.publishKey(volumeEntityId: string) -> string` — the stable key the field publishes and persists under (`fieldId`, falling back to the entity id).
- `VolumeProbe.findCovering(position: Vec3) -> (string?, boolean)` — the volume covering a world position, plus whether the point is contained (false = nearest fallback).
- `VolumeProbe.readIrradiance(volumeEntityId: string) -> { any }` — read the baked field back (`{ pos, color }` per probe).
- `VolumeProbe.bufferName(volumeEntityId: string) -> string` — stable per-volume buffer name.
Types:
- `Vec3 = { x: number, y: number, z: number }`
- `ColorRGB = { r: number, g: number, b: number }`
- `BakeOpts = { samples?, bounces?, intensity?, rayMax?, sky?, baseColor?, bvhCache?, containerCache? }`
- `BakeReport = { ok, probeCount, resolution, bounds, ms, bufferName }`
- `BakeAllEntry = { id: string, report: BakeReport }`
## Usage
```luau
local VolumeProbe = require("@builtin::systems.globalIllumination.volumeProbe")
local report = VolumeProbe.bake(volumeEntityId, { samples = 64 })
print(report.probeCount, "probes baked in", report.ms, "ms")
```
## Notes
- Volume size + grid resolution come from the `VolumeProbe` component
on the volume entity (`sizeX`, `sizeY`, `sizeZ`, `resX`, `resY`,
`resZ`). `bake` reads them each call so component edits propagate to
the next bake without restart.
- `rayMax` bounds every path segment; volumes that span large open
scenes may want a larger value than the 100m default.
- `bakeAll` builds one BVH and shares it across every volume — adding
or moving lights between invocations is fine, but adding new meshes
requires a fresh `bakeAll` for the BVH cache to refresh.
# baking
Shared utilities for the globalIllumination package's lightmap and probe
bakers — light gathering, surface-attribute gathering (albedo + emissive
per instance), transform helpers, and the scene-geometry gather that
feeds `compute.buildBvh`. The lowest layer of the package; both bakers
consume the same primitives so the lighting model stays consistent
across the lightmap and probe pipelines.
## Exports
Constants:
- `Baking.SH_COEFFS = 9` — number of L2 SH basis functions per probe.
- `Baking.SH_FLOATS_PER_PROBE = 36` — std430-padded SH stride.
- `Baking.TRI_FLOATS = 72` — std430-padded triangle stride (matches the BVH builder).
- `Baking.BVH_NODE_FLOATS = 8` — floats per BVH node on the GPU.
- `Baking.LIGHT_FLOATS = 16` — GPU light record stride.
Functions:
- `Baking.vec3 / add / sub / scale / dot / cross / length / normalize` — vec3 helpers used everywhere downstream.
- `Baking.gatherLights() -> { Light }` — enumerate every `Light` component in the scene.
- `Baking.packLights(lights) -> FloatArray` — pack lights for compute upload.
- `Baking.transformPoint / transformNormal / makeMatrix / entityWorldMatrix` — 4x4 matrix helpers.
- `Baking.surfaceAttributes(entityId, cache) -> { albedo, emissive }` — the entity's bake-facing surface description: linear albedo (base_color factor × average base-color texture color, clamped below 1) and emissive (color × strength).
- `Baking.gatherMeshInstances(excludeIds?) -> { instances, release }` — every mesh-bearing entity as a `{ guid, transform, attributes }` record for `compute.buildBvh`, CPU mesh items materialised; `release()` drops them again.
- `Baking.getEntityTriangles(entityId) -> { Triangle }` — one entity's mesh triangles in local space (used by the UV rasterizer).
Types:
- `Vec3 = { x: number, y: number, z: number }`
- `ColorRGB = { r: number, g: number, b: number }`
- `UV = { x: number, y: number }`
- `Mat4 = { number }` — 16-float row-major.
- `Light = { id, kind, position, direction, color, intensity, radius }`
- `Triangle = { v0, v1, v2, n0, n1, n2, uv0, uv1, uv2 }`
## Usage
```luau
local Baking = require("@builtin::systems.globalIllumination.baking")
local lights = Baking.gatherLights()
local gather = Baking.gatherMeshInstances({ entityId })
local bvh = compute.buildBvh(gather.instances)
gather.release()
```
## Notes
- All vec3 helpers return new tables — none mutate their arguments. Safe
to call in hot paths but allocates per call.
- `gatherMeshInstances` walks `entity.findAll` and materialises each
mesh-bearing entity's CPU item (`ecs.Mesh` guid → mesh asset →
`ref:load()`), so the engine-side BVH build can read the geometry —
triangles never enter the Luau heap. Call `release()` after the build;
assets whose `keepCpu` setting holds them stay resident.
- `surfaceAttributes` deduplicates the texture averaging through the
`cache` table — pass one table per gather so entities sharing a
material average its texture once. Albedo lanes are clamped to 0.96
so multi-bounce transport converges.
# uv_rasterizer
UV-space scan-line triangle rasteriser for the lightmap bakers. Walks
every triangle of an entity's mesh, projects it into UV pixel space,
and for each covered texel writes the barycentric-interpolated
world-space position and normal. Output is two flat `vec4`-strided
float arrays ready for compute upload.
## Exports
- `Rast.rasteriseEntity(entityId: string, resolution: number?) -> RasterizeResult` — rasterise one entity into per-texel position + normal buffers.
Types:
- `FloatArray = { number }`
- `RasterizeResult = { positions: FloatArray, normals: FloatArray, texelCount: number, validCount: number }`
## Usage
```luau
local Rast = require("@builtin::systems.globalIllumination.uv_rasterizer")
local rast = Rast.rasteriseEntity(entityId, 64)
-- rast.positions: buffer, 64*64 texels x 4 f32 — (x, y, z, valid) per texel
-- rast.normals: buffer, 64*64 texels x 4 f32 — (nx, ny, nz, pad) per texel
```
## Notes
- Texels with no triangle coverage have `positions[i*4+4] == 0`. Bakers
honour this `w` flag to skip empty texels; the lightmap shader uses
it as a bilinear-filter weight to mask seams.
- Triangle coverage is determined via the standard edge-function test
with a `1e-6` epsilon to keep adjacent triangles from leaving cracks
along shared edges.
- Default `resolution` is 64 when the caller passes `nil` or a value
less than 1.
- Output is in world space — vertex positions are transformed by the
entity's world matrix (`Baking.entityWorldMatrix`) before being
written; normals get the 3x3 portion of the same matrix.
·renderfeature · born here
❒asset # pbr
The engine's standard physically-based material — the full glTF metallic-roughness
workflow lit by the shared `@builtin::shaderModules.pbr_shading` model. This is the default surface shader a
model uses when its material doesn't specify another, and it is also registered
under the legacy alias **`standard`** (the name many materials reference).
It is a `surface()` shader: it fills a `PbrSurface` and the engine generates every
pipeline entry point and lights it. Every parameter defaults to a neutral value, so
a material that sets nothing renders as a clean dielectric; the advanced lobes only
cost anything when a material opts into them.
## Properties
| Property | Type | Default | Meaning |
|---|---|---|---|
| `base_color` | color | `[1,1,1,1]` | Albedo tint, multiplied by `base_color_texture` and vertex colour. |
| `emissive` | color | `[0,0,0,1]` | Emissive tint, multiplied by `emissive_texture` and `emissive_intensity`. |
| `emissive_intensity` | float | `1.0` | HDR multiplier on the emissive output. |
| `roughness` | range | `0.5` | Perceptual roughness, multiplied by the green channel of `roughness_metallic_texture`. |
| `metallic` | range | `0.0` | Metalness, multiplied by the blue channel of `roughness_metallic_texture`. |
| `reflectance` | range | `0.5` | Dielectric reflectance knob (neutral 0.5 → 4% F0). |
| `ior` | float | `1.5` | Index of refraction → dielectric F0 and the refraction direction. |
| `normal_scale` | float | `1.0` | Strength of the tangent-space `normal_texture`. |
| `occlusion_strength` | range | `1.0` | Blend of the `occlusion_texture` (R) into the ambient term. |
| `clearcoat` | range | `0.0` | Strength of a second dielectric clearcoat specular lobe. |
| `clearcoat_roughness` | range | `0.0` | Roughness of the clearcoat lobe. |
| `anisotropy` | float | `0.0` | Anisotropic highlight stretch along the tangent ([-1,1]). |
| `sheen_color` | color | `[0,0,0,1]` | Tint of the sheen lobe — the retroreflective rim of velvet, satin and brushed fabric. Black leaves the base untouched. |
| `sheen_roughness` | range | `0.3` | Width of the sheen lobe. |
| `iridescence` | range | `0.0` | Blend toward the thin-film response, the hue that shifts with view angle. |
| `iridescence_thickness` | float | `400.0` | Film thickness in nanometres — which wavelengths cancel and which reinforce. |
| `iridescence_ior` | float | `1.3` | Index of refraction of the film. |
| `subsurface_color` | color | `[0,0,0,1]` | Tint of the light that entered the surface, scattered beneath it and left again. Black leaves the base untouched. |
| `subsurface_radius` | float | `0.5` | How far the diffuse falloff wraps past the terminator, taken per channel against `subsurface_color`. |
| `fibre_color` | color | `[0,0,0,1]` | Tint of the second of the two highlights a hair or fur strand gives. Black leaves the base untouched. |
| `fibre_specular` | range | `0.35` | Strength of the first strand highlight, in the light's own colour. |
| `fibre_shift` | float | `0.06` | How far apart the two strand highlights sit along the fibre. |
| `fibre_roughness` | range | `0.1` | Width of the first strand highlight. |
| `transmission` | range | `0.0` | Specular transmission — refracts the environment through the surface. |
| `thickness` | float | `0.0` | Volume thickness for Beer–Lambert transmission absorption. |
| `attenuation_color` | color | `[1,1,1,1]` | Transmission absorption tint. |
| `attenuation_distance` | float | large | Beer–Lambert absorption distance. |
| `uv_scale` | float2 | `[1,1]` | UV tiling applied before every texture sample. |
| `uv_offset` | float2 | `[0,0]` | UV offset applied before every texture sample. |
| `alpha_cutoff` | range | `0.0` | Mask threshold — fragments with `alpha <` this are discarded (0 = no cutoff). |
| `double_sided` | bool | `false` | Flip the shading normal on back faces. |
| `receives_fog` | bool | `true` | Whether the screen-space fog passes (height fog, aerial perspective) reach this surface. `false` leaves an opaque deferred draw at the colour it shaded to, however deep in the medium it stands — see below. |
### Emissive authoring
The glow output is `emissive × emissive_intensity` (× the `emissive_texture`). The glow
**colour** is `emissive`, a vec; the **intensity** over it is `emissive_intensity`, a
scalar. So `{ emissive = {0.1, 0.9, 1, 1}, emissive_intensity = 5 }` is a cyan glow at 5×,
and it is these two names that `getProperties()` lists and `setProperty` writes.
A spelling another engine's convention uses for the same role — `emissive_color`,
`emission`, `emissive_tint` — is resolved onto `emissive` when the material is authored,
so content carried in from glTF, Unity or Godot reaches the property under the name this
shader declares. Where two such spellings meet on one of the two names, the shape of each
value says which of them it is: a vec is the colour and a number is the intensity, so
`{ emissive_color = {0.2, 0.8, 1, 1}, emissive = 5 }` is the same cyan glow at 5x. Two
values of one shape are one property's, and the one written under the name the shader
declares applies while the other is named in a warning.
A bare positive scalar `emissive` with no colour beside it is the "turn emission on"
shorthand and means a white glow at that intensity, so `emissive = 20` on a white material
lights up.
### The lobes over the base layer
Beyond metallic-roughness, the shading model carries six layers this material
declares. Each is gated on its own neutral value, so a material that sets none of
them shades as a plain dielectric and each one costs only where it is asked for.
| Layer | Turned on by | What it looks like |
|---|---|---|
| Clearcoat | `clearcoat > 0` | A second dielectric specular lobe on top — car paint, lacquer, a varnished surface. |
| Anisotropy | `anisotropy ~= 0` | The highlight stretches along the tangent — brushed metal, a vinyl record. |
| Sheen | `sheen_color` above black | A retroreflective rim that brightens toward grazing angles — velvet, satin, dusty cloth. |
| Iridescence | `iridescence > 0` | The specular hue shifts with view angle — an oil slick, a soap film, anodised metal, a beetle shell. Thickness picks the colours. |
| Subsurface | `subsurface_color` above black | Light wraps past the terminator and glows through thin geometry lit from behind — skin, wax, marble, a leaf. `thickness` gates how much reaches the far side. |
| Fibre | `fibre_color` above black | The two offset highlights a strand gives instead of the one a surface gives — hair, fur. Taken around the world tangent as the fibre axis. |
All six are evaluated inside the engine's own light loop, once for every light
that reaches the surface, so each one is shadowed, attenuated and cone-masked
with the base layer it sits over.
An oil-slicked wet surface is `iridescence = 1`, an `iridescence_thickness`
around 300-500 nm and a low `roughness` over a dark `base_color`. Skin is a warm
`subsurface_color` with a `subsurface_radius` near 0.5. Velvet is a `sheen_color`
at the fabric's own hue over a dark base.
### Standing outside the fog
`receives_fog = 0` declares that the screen-space fog passes — `heightFog` and
`aerialPerspective` in `@builtin::systems.atmosphere` — leave this surface at the
colour it shaded to.
The declaration travels to those passes as a per-pixel surface flag written into
the alpha of the G-buffer normal (`@builtin::shaderModules.surface_flags`). Only
the deferred G-buffer fragment entry writes that channel, so the declaration
reaches the fog from an **opaque** draw on the **deferred** path. A draw the
renderer sends through the forward fragment entry — every draw on the forward
path, and the transparent phase on either path — writes no G-buffer channel to
carry it, and its pixels take the fog like any other.
## Textures (glTF metallic-roughness set)
| Slot | Default | Channels |
|---|---|---|
| `base_color_texture` | white | RGBA albedo (sRGB). |
| `roughness_metallic_texture` | white | G = roughness, B = metallic (glTF packing). |
| `emissive_texture` | white | RGB emissive (sRGB). |
| `normal_texture` | flat-normal | Tangent-space normal map. |
| `occlusion_texture` | white | R = ambient occlusion. |
Because the engine holds the full `PbrSurface`, the capture tool's debug passes
(albedo / roughness / metallic / AO / emissive / world-normal / shadow / depth /
motion) all read real material data.
# material_remap
Canonical property/texture-role dictionary used when a material **swaps
shaders**. The new shader exposes a different vocabulary than the old one —
one shader's `MAIN_TEX` is another's `albedo` is a third's
`base_color_texture` — so values would be lost on a naive swap. This module
maps any known alias onto a canonical **role** so floats, colors, and texture
links carry across the swap to the best of our ability.
Consumed by `material.assetType`'s `setShader` ref method (and, transitively,
by the `appearance` toolbox's `swapShader`).
## API
```luau
local remap = require("modules.material_remap")
remap.propertyRole("MAIN_COLOR") -- "base_color" (scalar/color side)
remap.textureRole("MAIN_TEX") -- "base_color_texture" (texture side)
-- Remap current property values onto the new shader's accepted names.
local mapped, carried, dropped =
remap.remapProperties(currentProps, shaderRef:getProperties())
-- Canonicalize texture slot names so links bind on the new shader.
local texMapped, texCarried = remap.canonicalizeTextures(currentTextures)
```
## Why two tables
`albedo` as a **color** is the base-color factor; `albedo` as a **texture** is
the base-color map. `mat.yaml` keeps `colors:` / `floats:` separate from
`textures:`, so the caller always knows which side it's remapping. Splitting
`PROPERTY_ROLES` from `TEXTURE_ROLES` removes the ambiguity instead of guessing
from the value shape.
## Property remap vs texture remap
- **Properties** are remapped against the target shader's *real* reflected
field names (`shaderRef:getProperties()`, naga reflection). Direct name match
wins; otherwise role-to-role. Properties the new shader doesn't expose are
dropped.
- **Textures** are canonicalized to the engine's builtin on-disk slot
convention (`base_color_texture`, `normal_texture`, …) because the engine
does not yet surface a target shader's reflected texture-slot list to Luau.
Unknown slots pass through unchanged.
The alias lists are deliberately broad (glTF / Unity / Unreal / Godot /
hand-rolled WGSL conventions). Add new aliases here rather than special-casing
a shader at a call site.
# Mesh
Mesh asset. `data.zmsh` is the engine-native geometry payload (`renderer.mesh.encode`); consumers reference the asset's guid.
# BakedLighting
Keeps the scene's baked lighting in step with the scene. Watches the
engine's authored-change feed and reacts the moment an edit reaches the
bake: the artifacts that described the old scene are dropped, the
withheld lights come back, and (with `autoBake` on) the scene re-bakes
itself once the edits settle.
The bake attaches this automatically — a scene that has been baked is a
scene that maintains its bake.
## Fields
| Field | Default | Purpose |
|---|---|---|
| `autoBake` | true | Re-bake once the edits settle. |
| `settleSeconds` | 1.5 | Quiet time after the last edit before re-baking. |
| `resolution` | 0 | Lightmap resolution for the re-bake; 0 uses the bake's default. |
| `samples` | 0 | Sample count for the re-bake; 0 uses the bake's default. |
With `autoBake` off the stale bake is still dropped — the scene shows
honest realtime light rather than a stale photograph — and re-baking
becomes an explicit `baking.all`.
## Cost
Nothing until something is baked, and nothing at all in play mode.
Authored edits are an edit-mode concern and static geometry does not move
at runtime, so the engine's change feed is inert while the game runs.
Dragging a wall across the room drops the bake once, on the first frame
of the drag, and re-bakes once after `settleSeconds` of quiet — not once
per frame.
## Usage
```luau
-- opt out of automatic re-baking, keep automatic invalidation
entity.find("scene").component.add("BakedLighting", { autoBake = false })
```
# vfs_async_read
Pure-Luau wrapper that grafts a yielding `vfs.read` over the engine's
sync `vfs.read` binding. The `vfs` API module
(`modules/api/engine/vfs`) calls `M.installInto(vfs)` on itself as it
loads, so every VM holding `vfs` (user VMs and the trusted VM alike)
reads through it; user code never requires this module directly: it
just calls `vfs.read(path)` and the wrapper handles cache misses by
yielding the running coroutine until the bytes resolve.
## Exports
- `M.installInto(vfs: VfsNamespace)`: replace `vfs.read` (and `vfs.readBounded`) with the yielding wrapper. No-op when the target lacks `read` and `readAsync` as functions.
- `M.CANNOT_WAIT`: `"VFS_READ_CANNOT_WAIT"`, the code a read raises when it names a known file whose bytes have not arrived and the reading code cannot yield.
Types:
- `VfsNamespace = { read?, readAsync?, readBounded? }`: the engine's `vfs` global shape.
## Usage
```luau
-- The `vfs` API module installs the wrapper onto itself as it loads:
require("@builtin::modules.vfs_async_read").installInto(vfs)
-- After install, every `vfs.read` call yields on cache miss:
local bytes = vfs.read("/zero/source/some.asset")
```
## Where a read can wait
- On a miss the wrapper schedules `vfs.readAsync(path, opts)`, which
fires `BlobProvider::fetch` under the hood, and `task.await`s the
promise. The resolved value can still be `nil` if the path isn't in
the manifest at all.
- Waiting takes a yield, so it happens where `coroutine.isyieldable()`
is true: a task, the component hooks the engine runs on a coroutine
(`awake`, `start`, `update`, `fixedUpdate`, `onEnable`,
`onOwnerChanged`, `onSyncReceived`), a replicated event's handlers,
and a world or scene entrypoint's hooks.
- Where it is false (`onDisable`, `onDestroy`, `onPropertyChanged`, a
received synced-function call, a `vfs.watch` callback, a module's top
level while `require` runs it, a metamethod, a `table.sort`
comparator, a VM's main thread) a miss the engine's read answers
`nil, "pending"` for (a file `vfs.exists` knows that is no directory
and whose bytes can still arrive, a render surface being captured)
raises `VFS_READ_CANNOT_WAIT: vfs.read("<path>") ...`, naming the path
and the places it can be read from. Every other miss answers nil
there as everywhere else.
- `installInto` is idempotent in the trivial sense: calling it twice
re-wraps `vfs.read` around the already-wrapped function, which is
fine but pointless.
# Polygon Synty Character
A low-poly rigged humanoid character (Synty POLYGON style) bundled for the
character-controller system — a ready-to-drive sample body with its skeleton,
so the controller has a real avatar to move, animate, and test against.
Decomposed from `PolygonSyntyCharacter.fbx`.
## Composition
- Root `PolygonSyntyCharacter` with the imported node hierarchy.
- Carries the skinned character mesh and its `.rig` (skeleton) as subassets.
## Usage
Referenced by the character-controller package as its default humanoid body;
spawn it and attach a controller to walk it around.
# PlayerNameLabel
Drives this entity's `Text3D` with the owning player's name, read from the nearest ancestor `PlayerAvatar`'s synced `displayName` so every peer renders the same name without a local registry lookup. The label re-rasterises when the display name changes, and in play it hides itself on the local player's own avatar — name labels exist so other players are identifiable. An unowned avatar reads a placeholder.
Location: `src/lua/lib/components/PlayerNameLabel.component`
# camera_rig
Shared internals for the camera rigs — the parts every follow camera needs
before it gets to have a personality.
`third_person_follow` (a shoulder rig) and `orbital_follow` (a three-ring
orbit) resolve their follow target the same way, damp toward a goal the same
way, avoid geometry the same way, and write their pose the same way. Only the
placement of the camera relative to the subject differs, so everything else
lives here and the rigs stay small enough to read in one screen.
```lua
local CameraRig = require("controller.camera_rig")
function update(dt)
local target = CameraRig.resolveFollow(public.entity.id, public.follow)
if target == nil then return end
local t = entity(target)
local px, py, pz = t.position.x, t.position.y + public.height, t.position.z
local h, radius = CameraRig.ringAt(public.rings, public.orbitT)
local cx = px + math.sin(public.yaw) * radius
local cy = py + h
local cz = pz + math.cos(public.yaw) * radius
cx, cy, cz = CameraRig.pullIn(px, py, pz, cx, cy, cz, 0.3, target)
smoothX = CameraRig.damp(smoothX, cx, public.followTau, dt)
CameraRig.applyPose(public.entity.id, smoothX, cy, cz, px, py, pz)
end
```
## Exports
- `CameraRig.damp(current, goal, tau, dt) -> number` — exponential convergence
toward `goal`. `tau` is the time constant in seconds: the smaller it is the
tighter the camera tracks, and `0` snaps. Frame-rate independent, so a hitched
100 ms frame and six clean 16 ms frames land in the same place, and no frame
length overshoots the goal. Damp each component separately.
- `CameraRig.ringAt(rings, t) -> (number, number)` — height and radius of the
orbit at `t` along the three-ring spline, where `rings` is
`{ bottom = { height, radius }, center = ..., top = ... }`. `t` runs 0 at the
bottom ring to 1 at the top, passing exactly through the centre ring at 0.5;
adjacent rings are blended with a smoothstep so the seam has no visible kink.
Out-of-range `t` clamps rather than extrapolating.
- `CameraRig.resolveFollow(selfId, followField) -> string?` — the entity this
rig follows. Takes the rig's own `follow` field (an entity id or an entity
reference) when it is set, and otherwise falls back to the `Camera.follow`
slot, which is the standard place every camera behavior reads its target
from — that is what makes "swap the behavior, keep the follow target" work.
Returns `nil` when nothing is set AND when the resolved id has no live
entity, which is the normal state for the first frames of play mode while
the avatar is still spawning.
- `CameraRig.pullIn(pivotX, pivotY, pivotZ, camX, camY, camZ, radius, excludeId?) -> (number, number, number)`
— the camera position moved in along the pivot-to-camera line until nothing
is between the two. `radius` is the sphere radius used for the cast and the
distance kept off the surface it lands on; `0` returns the input untouched.
`excludeId` is the entity that is allowed to be in the way (the subject the
camera is looking at).
- `CameraRig.applyPose(selfId, camX, camY, camZ, lookX, lookY, lookZ)` — place
the camera and aim it at a point. Both the pose and the aim point are in the
camera entity's own transform frame: parent-relative when the camera is
parented to its subject, world space when it is standalone. The write goes
to the local transform, so transform propagation composes the world matrix
from `parent_world * local` instead of being fought by a world-space write.
# default.inputMap
The engine's standard control scheme, and the map a world activates for a
player who is the one moving. Its group is `player`, so a map that names
that group — `vehicle.inputMap` does — stands these controls down for as
long as it is live.
## Controls
The `<name>.inputBinding/` folders beside `init.luau` are the controls. A
component activates the map and subscribes to the ones it drives:
| Control | Kind | kbm | gamepad | touch |
|---|---|---|---|---|
| `move` | axis2 | WASD | left stick | left stick |
| `look` | axis2 | mouse delta | right stick | right drag zone |
| `jump` | button | Space | south | button |
| `sprint` | button | Shift | left stick click | button |
| `crouch` | button | Ctrl | east | button |
| `interact` | button | E | west | button |
| `zoom` | axis1 | wheel | shoulders | pinch |
| `pointerLock` | button | Escape | — | — |
| `lookDrag` | button | right mouse | — | — |
`pointerLock` and `lookDrag` refuse gamepad and touch outright, in the
record where a reader sees it: capture belongs to a cursor, and a stick or
a dragging finger says it is steering by being pushed at all.
Vertical movement lives in `flight.inputMap`, which composes beside this
one and belongs to the same `player` group. Driving lives in
`vehicle.inputMap`, which stands this group down while somebody has the
wheel.
## Compatibility registry
The `actions` / `axes` tables in `init.luau` are the registry read by
`Zin.actions` / `Zin.axes`. They are keyboard and mouse only, and they are
what keeps a world written against `Zin.actions.held(...)` working.
The engine's standard control scheme. Every action and axis carries explicit
`kbm` bindings; movement, look, and the primary held actions (sprint, crouch,
jump, vehicle brake) also carry explicit `touch` bindings so the scheme works
identically on a phone with no synthesis needed.
- **Movement** — `move` (WASD, or the left virtual stick on touch),
`move_vertical` (Space/Ctrl, kbm-only — no vertical touch stick in this
scheme).
- **Fly** — `fly_up` (E/Space) and `fly_down` (Q/ShiftLeft), kbm-only with
touch synthesis suppressed — fly-camera vertical movement for controllers
that read actions rather than the `move_vertical` axis.
- **Look** — `look` (mouse delta on kbm, the right drag zone on touch), plus
`look_x` / `look_y` single-axis kbm variants for controllers that want one
component. `look`'s gate passes on pointer-lock or held right-mouse-button
(kbm), OR whenever the touch drag zone is producing input — a phone has
neither a locked pointer nor a right mouse button, so the touch path needs
no arming gesture.
- **Held actions** — `sprint`, `crouch`, `jump` each carry a `right-lower`
zone `touchButton` alongside their kbm keys.
- **Zoom** — `scroll_y` (wheel on kbm, the two-finger pinch delta on touch).
- **Vehicle context** — `vehicle_throttle` / `vehicle_reverse` /
`vehicle_steer` / `vehicle_brake`, active only while the `"vehicle"`
input context is pushed.
Activating any OTHER map's axis with a touch class beyond its primary
registers a `<axis>@<class>` sibling axis (e.g. `look@touch`) — this map's
own `look` axis carries an explicit `touch` binding, so it follows the same
convention once flattened by `Zin.map.activate`.
`Zin.map.ensureActive()` activates this map when no other map is active —
it is the map every kbm-only world already runs, now carrying its explicit
touch classes for phones and tablets. This is also the map a cold
`Zin.actions` / `Zin.axes` read activates automatically, so any world
reading actions plays with zero setup.
# procedural_sky
The editable atmospheric sky: a day/night gradient with a sun disc and
glow, a procedural star field, and a moon opposite the sun, authored
entirely from material properties — no texture backs it.
Day, night and the dawn/dusk blend all derive from the directional
light's elevation, so the sky follows whatever drives the scene's sun;
there is no time-of-day uniform. The gradient's zenith, horizon and
ground colours, the sun's size and intensity, the stars, the moon,
turbidity and exposure come from `properties.yaml`.
Output is radiance scaled by `exposure` — the scene's own tone-mapping
does the range compression, the same contract as every sky-domain
shader.
The `ProceduralSky` component owns the runtime material over this shader
(`__procedural_sky`) and pushes its fields here; `sky.get()` reports the
resulting configuration.
# toml
Pure-Luau TOML parser and emitter. Use whenever a script needs to read
or write a TOML file — settings, importer rules, tool configs, or any
other authored config the user touches by hand. Auto-injected as the
global `toml` by the prelude — no `require` in user code.
`vfs.read` returns bytes. JSON has built-in parsing via `json`, but TOML
— the format used for `.world_settings`, `pyproject`-style configs, and
any human-friendly key=value file — needs a parser. `toml` provides one
with no native dependency, so it works the same on native and WASM.
## Exports
- `toml.parse(src: string) -> { [string]: any }` — TOML bytes to nested Luau table. Throws with the line number on syntax errors.
- `toml.encode(root: { [string]: any }) -> string` — Luau table to canonical TOML bytes (alphabetical section/key order; deterministic output).
## Usage
```luau
-- Parse
local body = vfs.read("/zero/source/myconfig.toml")
local config = toml.parse(body)
print(config.render.culling_mode)
-- Mutate + write back
config.render.culling_mode = "cpu"
vfs.write("/zero/source/myconfig.toml", toml.encode(config))
```
## Supported TOML
- Sections, including dotted (`[a.b.c]`)
- Key/value pairs with dotted keys (`a.b.c = 1`)
- Strings: `"..."` (escaped) and `'...'` (literal); triple-quoted
variants for multi-line bodies
- Integers (with `_` digit separators) and floats (incl. `inf`,
`-inf`, `nan`, exponents)
- Booleans (`true` / `false`)
- Inline arrays (`[1, 2, 3]`)
- Inline tables (`{ a = 1, b = 2 }`)
- `#` comments
## Not implemented
These are rare in settings/config files; add when a real call site
needs them rather than carrying dead code.
- Array-of-tables (`[[name]]`)
- Hex / octal / binary integer literals (`0xff`, `0o77`, `0b1010`)
- Date / time literals
## Notes
- `--!global toml` directive promotes the module's typed functions
onto the runtime universe's globals bucket, so `toml.parse` /
`toml.encode` are available without any per-source `require`.
- The encoder is fully deterministic: sections and keys are sorted
alphabetically, integer-shaped numbers are emitted without a
decimal point (`2` not `2.0`), and nested tables become dotted
section headers (`[a.b]`).
- Parser errors carry the line number for fast diagnosis.
# VolumeProbe
Defines a 3D grid of irradiance probes covering a region of world
space. The baked field publishes into the renderer's irradiance-volume
set: every standard-PBR fragment inside the bounds (movers, avatars,
freshly spawned props) samples it — no per-entity setup.
The entity's world position is the volume centre; `sizeX/Y/Z` are the
extents along each axis; `resX/Y/Z` controls how many probes are packed
into the volume along each axis.
## Fields
| Field | Default | Purpose |
|---|---|---|
| `sizeX/Y/Z` | (10, 4, 10) | World-space size of the volume. |
| `resX/Y/Z` | (8, 4, 8) | Probe grid resolution along each axis. |
| `samples` | 64 | Sphere samples per probe (Hammersley sequence). |
| `bounces` | 3 | Surface interactions each sample path follows. |
| `intensity` | 1.0 | Multiplier on the stored radiance at sample time. |
| `rayMax` | 100.0 | World-space max distance for sample rays. |
| `skyR/G/B` | (0, 0, 0) | Sky radiance seen by rays that escape geometry. |
Bake bookkeeping lives in private fields, reachable through the reflection
triad (`:fields()` / `:getField(name)` / `:setField(name, value)`):
| Private field | Default | Purpose |
|---|---|---|
| `fieldId` | "" | Stable publish/persist key, minted on first bake. |
| `lightmapData` | nil | Baked-lighting container holding this volume's field entry. |
A 12×4×12 volume = 576 probes. Each probe stores 9 vec4 (36 floats) of
SH L2 coefficients = ~81 KB per volume. Bake time on the GPU scales
roughly linearly with `probeCount * samples * log(triangleCount)`.
## Persistence
A bake writes the SH field into the scene's baked-lighting container
(`lightmapData` asset) keyed by `fieldId` and points the `lightmapData`
ref at it. On a fresh boot, awake restores the SH buffer and republishes
the field from the container — no re-bake. `baking.clear` retires the
field (retract + buffer + container entry).
## Usage
```luau
local VolumeProbe = require("@builtin::systems.globalIllumination.volumeProbe")
local probe = entity.spawn("level_probes")
probe.localPosition = { 0, 0, 0 }
probe.component.add("VolumeProbe", {
sizeX = 30, sizeY = 6, sizeZ = 30,
resX = 12, resY = 4, resZ = 12,
samples = 128,
})
VolumeProbe.bake(probe.id)
```
Re-bake whenever scene geometry or lighting changes. `bakeAll()`
processes every volume in the scene using a shared BVH so the
amortised cost stays low.
·computeshader · born here
❒asset # probe_bake
Bakes irradiance-volume probes on the GPU — one thread per probe. Each
thread casts N uniform-sphere rays and path-traces each one through up
to `bounces` surface interactions: every path vertex contributes its
emissive plus its albedo (carried on the triangle records by
`compute.buildBvh`) times the shadowed direct light there, and a segment
that escapes contributes the sky. The resulting incoming radiance is
projected into 9 SH L2 coefficients (Monte Carlo weighted) and written
as 9 RGBA `vec4`s into `output`. Mirrors `lightmap_bake`'s traversal +
light model.
Bindings (see bindings.yaml): `output` (read_write), `probe_positions`, `bvh_nodes`,
`triangles`, `lights`, `params` — all `array<vec4<f32>>`.
Usage: dispatch by identity with `compute.dispatch("@builtin::systems.globalIllumination.probe_bake", ...)` (buffers in bindings order) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
# bakeScene
The scene-reading layer the bake stands on. Before any lightmap or probe
field is computed, the bake has to answer one question about the scene as
it currently stands: what is bakeable, and where. This module is that
answer, read the same way by the `baking` toolbox, by the probe
auto-placement in `volumeProbe`, and by component code driving a bake of
its own.
## What it reads
- **Scope** — `resolveScope` turns a name, id, entity proxy, an array of
those, or `nil` (the whole scene) into the flat set of entity ids a pass
considers. A scope that names nothing raises rather than silently baking
the wrong set.
- **Lights** — `gatherSpatialLights` collects the placed lights
whose reach defines where indirect light matters. Directional and ambient
light is global and carries no position, so it never anchors a volume.
- **Renderables** — `collectStatic` finds the static `Model` receivers a
lightmap is baked onto; `renderableBounds` bounds everything that renders.
Mobility is the axis: static geometry takes lightmaps, movers sample probe
volumes.
## How it places
`clusterLights` groups lights by influence overlap (two lights join when
their influence spheres touch, transitively), so two separated rooms yield
two volumes rather than one box spanning the dead space between them.
`mergeClustersTo` caps the volume count by fusing the nearest clusters, and
`gridForBounds` sizes each volume's probe grid to a target spacing while
holding a hard total-probe budget — a large volume gets coarser probes
instead of a runaway count.
`padBounds`, `boundsCenterSize`, and `lightInfluenceBounds` are the small
bounds arithmetic the rest builds on.
# crouch
Lower the body while held.
# interact
Use whatever is in front of the player.
# look
Where the player is looking.
# lookDrag
Held while the mouse is meant to steer the camera.
# move
Which way the player is going.
# pointerLock
Releases the captured cursor so the pointer can leave the game.
# sprint
Move faster while held.
# Text3D
Renders text in 3D world space. The quad auto-sizes to fit the text content and never clips. Positioning works like TextMeshPro: `offsetX/Y/Z` is a local offset from the entity, `pivotX/pivotY` is the anchor within the quad, and `billboard` makes it face the camera. With `billboard = false` the text reads from the entity's own forward, the local -Z that `transform.forward` reports and that `entity:lookAt` aims.
Sizing — three fields, two jobs:
- `worldHeight` — the quad's height in **world units** (default `1.0`). This is the one field that resizes a label, and it holds that height under every transform between the label and the world: a label hung off a shrunken detail box on a model comes out the size it asked for, and so does one whose own entity carries a `localScale`. Width follows the rasterised text's aspect. Measure the result with `getWorldSize()`, which reports the extent the quad is drawn at.
- `fontSize` — the **raster resolution** in texels (default `32`). Higher values sharpen the texture and change wrapping against `maxWidth`; the world-space size stays `worldHeight`.
- `scale` — rasterisation scale multiplier applied at raster time. Resolution only, like `fontSize`.
`maxWidth` is the width the text wraps at, in pixels of the raster `fontSize` states; `0` keeps it on one line. A label already drawn is laid out again when the field is written, so a caller re-wraps one label to line after line rather than making a label per line.
`alphaCutoff` decides whether the letters are a surface. At `0` (the default) the text is pure alpha blending: it draws over what is behind it and leaves the depth buffer alone, so a screen-space effect that reads scene depth — volumetric fog, screen-space shadows — integrates the whole distance behind the letters and the text sits inside it. Above `0`, coverage at or over the threshold is drawn opaque and written to depth, so those effects stop at the glyph shape instead. `0.5` reads well for most fonts; higher thins the letters, lower keeps more of the antialiased edge.
Public fields: `content`, `fontSize`, `color`, `alignment`, `richText`, `maxWidth`, `outline`, `outlineColor`, `background`, `scale`, `worldHeight`, `alphaCutoff`, `shadowX/Y`, `fontFamily`, `weight`, `slant`, `offsetX/Y/Z`, `pivotX/Y`, `billboard`. `weight` and `slant` are strings (`"regular"` / `"bold"`, `"normal"` / `"italic"`).
Methods: `setText(content)`, `setStyle(options)`, `getText()`, `getSize()`, `getWorldSize()`, `refresh()`.
```luau
entity(id).component.add("Text3D", { content = "Hello World" })
entity(id).component.add("Text3D", { content = "HP: 100", worldHeight = 0.5, fontSize = 96, color = "red", offsetY = 2.0, pivotY = 0 })
-- A title that keeps its letters crisp through volumetric fog.
entity(id).component.add("Text3D", { content = "RAISING", worldHeight = 3.0, alphaCutoff = 0.5 })
```
A label that is not showing, or came out in a face you did not ask for, reads
back out of the text system: `text.observe()` lists every live text object with
the entity that owns it and the texture its raster is in, and `text.face(h)`
names the font face the shaper actually used against the `fontFamily` that was
requested. `topics/text` walks both.
# Locomotion
Blend-space-driven locomotion for entities running the
characterController package. Reads horizontal velocity from a sibling
`MovementState` (or, as a fallback, `CharacterController`) component
on the parent and feeds a single 2D blend space (strafe-angle on X,
speed on Y) with idle / walk-{F,L,R,B} / run-{F,L,R,B} / sprintF
clips.
Pair on the same entity (or a parent of) a `SkinnedModel`. The
component creates the AnimGraph, polls for the SkinnedModel child
once start() has run, registers the clips as blend-space points and
plays the graph. `update()` damps speed + angle and writes them to
the active blend space each frame.
This supersedes the older FSM-based Locomotion controllers
(crouch / jump / fall / land / turn-in-place state machine). Those
behaviours can be added back as separate layered graphs once the
blend-space driver has settled.
# physics
Convenience wrapper around the `__physics` FFI namespace. Covers
raycasting, force / impulse / torque application, velocity reads and
writes, body-property mutators (mass, damping, body type, locks),
collision groups, joints, constraints, colliders, wheel-collider
helpers, and the observation reads that report what the solver holds
for a body and why it is not moving it, in one consistent surface.
Exposed as the `Physics` global via `--!global Physics`.
## Exports
Queries:
- `Physics.raycast(origin, direction, maxDistance?, exclude?) -> table?`
- `Physics.raycastAll(origin, direction, maxDistance?, maxHits?) -> table`
- `Physics.overlapSphere(center, radius) -> table`
- `Physics.sphereCast(origin, radius, direction, maxDistance?, exclude?) -> table?`
- `Physics.capsuleCast(origin, radius, halfHeight, direction, maxDistance?, exclude?) -> table?`
- `Physics.boxCast(origin, halfExtents, direction, maxDistance?, exclude?) -> table?`
- `Physics.getGravity() -> vec3` / `Physics.setGravity(g)`
- `Physics.getVelocity(entityId?) -> vec3?` / `Physics.getAngularVelocity(entityId?) -> vec3?`
Forces / impulses (each accepts either `(vec3)` for the script-context entity or `(entityId, vec3)`; a vec3 may be an `{x, y, z}` table or three loose numbers):
- `Physics.applyForce` / `Physics.applyForceAtPoint` / `Physics.applyTorque` — act for exactly one physics step; call every frame for continuous thrust
- `Physics.applyImpulse` — one-shot velocity change
- `Physics.setVelocity` / `Physics.addVelocity` / `Physics.setAngularVelocity`
Body properties:
- `Physics.setGravityScale`, `Physics.setMass`, `Physics.setLinearDamping`, `Physics.setAngularDamping`, `Physics.setCcdEnabled`
- `Physics.setBodyType(entityId, "dynamic" | "kinematic" | "static")`
- `Physics.setRotationLocks(entityId, x, y, z)` / `Physics.setTranslationLocks(entityId, x, y, z)`
Collision groups & filtering:
- `Physics.setCollisionGroups(entityId, membership, filter)`
- `Physics.ignoreCollision(entityIdA, entityIdB, ignore?)`
Joints / constraints / colliders:
- `Physics.addJoint(a, b, opts?)` / `Physics.removeJoint(id)` / `Physics.setJointMotor(id, vel, maxForce)`
- `Physics.addConstraint(id, opts?)` / `Physics.removeConstraint(id, index?)`
- `Physics.addCollider(id, config)` / `Physics.removeCollider(id)`
- `Physics.addWheelCollider(id, config?)` / `Physics.removeWheelCollider(id)` / `Physics.getWheelState(id)`
Observation (read off the solver, not off the `Physics` component):
- `Physics.observe(entityId?, opts?) -> PhysicsObservation?` — one read of the world, or of one body; `bodies` is an array whose entries each name their own entity
- `Physics.worldState() -> PhysicsWorldState` — bodies by type, awake / asleep, colliders, joints, contact pairs and points, gravity, timestep, and the last step's cost
- `Physics.bodyState(entityId) -> PhysicsBodyState?` — the mass, inertia, gravity scale, damping, locks, CCD, collision groups, sleep timers, velocities, queued force and torque, colliders, contacts, joints and transform constraints the solver holds
- `Physics.whyStill(entityId) -> (string?, string?)` — the one reason the solver is not advancing a body, and the detail behind it
- `Physics.stillnessReasons() -> {string}` — every reason `whyStill` can answer with
- `Physics.contacts(entityId) -> {PhysicsContact}` / `Physics.touching(a, b) -> (boolean, number, {PhysicsContactPoint})`
- `Physics.stepCost() -> PhysicsStepCost?` — the last step's per-stage cost
High-level helpers:
- `Physics.raycastBetween(fromId, toId, maxDistance?) -> table?`
- `Physics.hasLineOfSight(fromId, toId) -> boolean`
## Usage
```luau
-- `Physics` is auto-injected by the prelude — no require in user code.
local hit = Physics.raycast({x=0,y=2,z=0}, {x=0,y=-1,z=0})
Physics.applyImpulse(entityId, {x=0, y=5, z=0})
Physics.addJoint(a, b, { kind = "fixed" })
```
## Notes
- Most apply/set helpers are polymorphic on the first argument: they accept either an explicit `entityId` (with the value as a second arg) or a value directly (using the script-context entity).
- `addJoint` accepts both vec3-style anchor tables (`localAnchor = {x,y,z}`) and pre-split scalar keys (`localAnchorX/Y/Z`); both forms forward as split keys to the Joint component.
- `setCollisionGroups` adds the `CollisionGroup` component if missing.
- `getWheelState` returns `nil` when the entity has no `WheelCollider` component.
- The sweeps (`sphereCast`, `capsuleCast`, `boxCast`) report `distance` as how far the shape's centre travels before its surface meets the geometry, and `normal` as the outward normal of the surface it met — the same normal a ray hit carries.
- Every hit table carries `startedInside`, and it says which surface `normal` describes. `false` — the query travelled to the collider and crossed its surface, so `distance` is how far it went, `point` is where it met the surface, and `normal` is that surface's outward normal. `true` — the query's own start already lay inside that collider, so `distance` is 0, `point` is the start itself, and `normal` is the collider's outward surface normal at the surface point nearest the start: the shortest way out of it. `normal` is a unit vector either way, and every collider shape answers an inside start the same way, so a slope read like `math.acos(hit.normal.y)` is meaningful without knowing which shape was hit.
- `raycastBetween` returns `nil` if the two entities are coincident (< 0.001 units apart).
- `hasLineOfSight` returns `true` even when nothing is hit — line-of-sight only fails if a third entity sits between the pair.
- The observation reads answer in edit mode as well as play mode. `bodyState` reports `exists = false` with `stillness = "noBody"` for an entity that carries no rigid body, and `nil` only when nothing in the scene answers to that id.
- `observe(nil, { bodies = false })` builds the world accounting alone; `{ contactPoints = false }` keeps each contact pair's normal, depth, impulse and point count while leaving out the individual points.
- `stepCost` figures cover the one step that ran, so they are already per-step costs; it is `nil` on a frame where the pipeline did not step.
# debugViz
The shared edit-mode debug-visualization core. Owns the ONE anchor entity, the
ONE vertex-colour line material, the ONE batched vertex/index compute-buffer
pair, and the ONE render feature that every debug-visualization gizmo draws
through. Individual gizmos (bounds boxes, camera frustums, ...) are
**providers** — small modules that append their own geometry into the shared
batch every tick, instead of each owning their own GPU state.
```lua
entity.spawn("DebugViz"):component.add("DebugViz")
```
## How it fits together
- `DebugViz.component` owns the lifecycle: registers the built-in providers
and calls `debugViz.update(dt)` every edit-mode tick; `onDisable`/`onDestroy`
call `debugViz.teardown()`.
- `debugViz.registerProvider(name, fn)` registers `fn(emit)` under `name`.
Registering an existing name replaces its function.
- `debugViz.update(dt)` clears the batch, calls every registered provider with
an `emit` handle, then rebuilds and uploads the combined vertex/index
buffers (grow-only capacity — a stable geometry count costs one buffer write
per tick, not a recreate).
- `debugViz.state()` returns the latest `{ vtx, idx, anchor, indexCount }` for
the render feature to draw.
- `@builtin::renderFeatures.debugViz` reads `state()` every frame and draws
the batch through one Draw pass, using `@builtin::shaders.debugGeometry` — a
solid-colour shader that reads each vertex's baked colour, so every provider
can use its own colour in the same draw call.
The draw needs an anchor entity with an IDENTITY world transform and a
render-object (so the Draw pass has a valid instance slot and a material to
override) — this module owns one, spawned lazily, internal so it stays out of the inspector
and excluded from scene saves.
## Writing a provider
```lua
local debugViz = require("@builtin::modules.debugViz")
local function tick(emit)
emit.box({ x = -1, y = -1, z = -1 }, { x = 1, y = 1, z = 1 }, { 1, 0, 1, 1 })
emit.line({ x = 0, y = 0, z = 0 }, { x = 0, y = 5, z = 0 }, { 1, 0, 1, 1 })
end
debugViz.registerProvider("my_gizmo", tick)
```
`emit.box(min, max, color?)` and `emit.line(a, b, color?)` append world-space
geometry into the shared batch for this tick; `color` is an `{r, g, b, a}`
array baked per-vertex (defaults to amber if omitted). A provider that needs
to exclude the shared anchor entity from its own scene queries reads
`debugViz.anchorId()`.
## API
- `debugViz.registerProvider(name, fn)` — register (or replace) a provider.
`fn(emit)` runs once per edit-mode tick.
- `debugViz.anchorId()` — the anchor entity's id, or `nil` before the first
tick.
- `debugViz.update(dt)` — clear the batch, run every provider, rebuild and
upload the combined geometry.
- `debugViz.state()` — the latest batched draw, or `nil` before the first
tick.
- `debugViz.teardown()` — tear down the feature, material, buffers, and anchor
entity. Registered providers stay registered.
- `debugViz.MATERIAL` — the registry key of the shared vertex-colour line
material the render feature draws with.
# CharacterController
Kinematic mover. Integrates velocity against gravity, walls, ceilings and
ground, and writes the result to the entity's position every frame.
## Driving it
The character moves on a public input bus — `inputX`, `inputZ` and
`inputJump`. Each frame the controller reads the bus, moves on what it found,
and then refills it from the `move` axis and the `sprint` / `jump` actions of
its input map, so the map drives the character whenever nothing else does.
Anything else takes the character over by writing those three fields from its
own `update()` — an AI brain, a networked peer, a replay, or gameplay code
that wants the character committed during an attack. The write works from
**any** `executionOrder`, because the refill happens after the read: whatever
is in the bus when the controller next reads it is what the character does.
- A writer ahead of the controller (`executionOrder` below `-100`) is acted on
the same frame.
- A writer behind it is acted on the next frame.
- Stop writing, and the map has the character back one frame later.
- Pausing gameplay empties the bus, so a character frozen mid-stride stands
where it was paused and a writer that had it committed says so again.
`inputX` / `inputZ` are a world-space velocity in units per second — the
controller applies them as they are, so scale them yourself rather than
expecting `moveSpeed` to. `inputJump` asks for one jump and the next update
consumes it.
A component that walks the character due north, whatever the input map says:
```lua
-- Northbound.component/init.luau
declare { executionOrder = 50 } -- anywhere works; 50 runs after the controller
public = {
speed = Field.number(4.0, Sync),
}
function update(_dt: number)
local cc = public.entity.component.get("CharacterController")
if cc == nil then return end
cc.inputX = 0
cc.inputZ = -public.speed
end
```
`WASDInput` is the same pattern shipped as a component: it reads an input map
of its own and writes the bus of whatever component `targetComponent` names,
which is how a mover that reads no controls of its own gets driven. On a
CharacterController — which reads `move`, `sprint` and `jump` from its own
`inputMap` — a WASDInput writes the bus every frame at its own `speed`, and
that is the speed the character walks at. `CharacterController.install` adds
one when `input = true` asks for it.
## The control scheme
`inputMap` is the scheme the character answers to. It reads three controls from it, each on its own:
- `move` — an axis2, projected against the active viewport camera so "forward" is into the screen.
- `sprint` — held, multiplies `moveSpeed` by `sprintMultiplier`.
- `jump` — pressed, asks for one jump.
A map declaring only some of the three drives the character with those: a scheme with no `sprint` holds one gait, a scheme with no `jump` stays on the ground. The character logs which controls the map left out and names the map, so a game with three verbs ships a three-control scheme. A field naming no map leaves the character answering nothing, and logs that too.
**Repointing it on a live character takes on the next frame.** The map the character was holding is stood down — its controls leave the live set and its touch buttons leave the screen — and the map named here is taken up in its place:
```luau
entity(bodyId).component.get("CharacterController").inputMap = asset.resolve("mygame.input.run", "inputMap")
```
Assigning the map the character already holds changes nothing, so re-stating the scheme leaves the live set where it is. While gameplay is paused the character holds no map at all — the editor's camera has the controls — and it takes whatever `inputMap` names when play resumes.
## Telemetry
These read back what the mover did on the frame just simulated:
- `grounded` — standing on ground no steeper than `maxSlopeAngle`.
- `falling` — in the air and descending.
- `sliding` — on ground too steep to stand on, being carried down it.
- `blocked` — the character asked to move horizontally and the wall pass left
it with none of that movement: pressed into a wall, or wedged where two
surfaces meet. This is what tells a character standing still apart from one
whose walk is being refused.
- `slopeAngle` — the angle, in degrees, of the ground under it.
- `velocityX` / `velocityY` / `velocityZ` / `horizontalSpeed` — its velocity.
- `jumpCount` — jumps used since it last landed.
# MovementState
Component that exposes movement state for controller-driven animation and gameplay consumers.
# AnimGraph
DAG of animation nodes implemented in pure Luau. Nodes are stored in
a slot vector; freed slots are reused on the next `addNode` (mirrors
the legacy Rust crate's `Vec<Option<AnimNode>>` pattern). An AnimGraph
has exactly one designated "output" node. Each frame the engine calls
`graph:update(dt)` followed by `graph:evaluate()`, which recursively
walks reachable nodes and returns the pose buffer for the output node.
The module re-exports the Node hierarchy on the returned table so
callers can reach `AnimGraph.Clip.new(...)`, `AnimGraph.Mixer.new(...)`,
etc. without separate requires.
## Exports
- `AnimGraph.new(opts: { layout: Layout? }?) -> AnimGraph` — construct a new graph; `layout` defaults to a sensible default.
- `AnimGraph:addNode(node: Node) -> number` — insert a node, return its slot id (reuses freed slots).
- `AnimGraph:removeNode(id: number)` — free the node at slot `id` and call `:destroy()` on it.
- `AnimGraph:setOutput(id: number)` — designate which node is the graph's final output.
- `AnimGraph:update(dt: number)` — advance every reachable node.
- `AnimGraph:evaluate() -> TypedBuffer?` — return the output node's pose buffer (caller MUST NOT destroy).
- `AnimGraph:crossfade(from: number, to: number, duration: number)` — sugar that wires a temporary Mixer between two nodes, ramps weights over `duration`, then collapses to the new output when the ramp completes.
- `AnimGraph.Clip`, `AnimGraph.Mixer`, `AnimGraph.BlendSpace2D`, `AnimGraph.Node` — re-exports of the node constructors.
Types:
- `Layout = { ... }` — pose layout used by the graph and its nodes.
## Usage
```luau
local AnimGraph = require("@builtin::systems.anim.AnimGraph")
local graph = AnimGraph.new({ layout = myLayout })
local idle = graph:addNode(AnimGraph.Clip.new("idle"))
local walk = graph:addNode(AnimGraph.Clip.new("walk"))
graph:setOutput(idle)
graph:crossfade(idle, walk, 0.25) -- blend to walk over 250ms
-- Each frame:
graph:update(dt)
local pose = graph:evaluate()
```
## Notes
- The output node MUST be set before `evaluate` is called; otherwise
the return value is `nil`.
- `crossfade` collapses the temporary mixer when the ramp completes,
so long-running graphs don't accumulate orphan mixers.
- Buffer ownership: nodes own their internal buffers and free them in
`:destroy()`. Callers of `evaluate` borrow the returned buffer — do
NOT destroy it.
- `removeNode` frees the slot id and calls `:destroy()` on the node;
the next `addNode` may reuse the same id.
# WheelCollider
Raycast-based wheel collider (Unity-style). Add to a child entity of a rigid body; the system finds the nearest ancestor with a `Physics` component and applies suspension, drive, and friction forces to it.
Drive conventions: positive `motorTorque` drives toward the parent body's local **-Z** (the engine's forward); positive `steerAngle` steers right.
Suspension `springRate`/`damperRate` are clamped per step to what the chassis mass can integrate stably, so an over-stiff spring on a light body settles instead of oscillating — tune rates to the vehicle's mass for the intended feel.
Public fields: `suspensionDistance`, `springRate`, `damperRate`, `targetPosition`, `radius`, `width`, `motorTorque`, `brakeTorque`, `steerAngle`, `forwardFriction`, `sidewaysFriction`, `rollingResistance`, `is2D`. Runtime-read-only (updated every physics tick): `isGrounded`, `compression`, `angularVelocity` — also readable via `Physics.getWheelState(wheelId)`.
Methods: `setMotor(torque)`, `setBrake(torque)`, `setSteer(angle)`.
```luau
entity(wheelId).component.add("WheelCollider", {
radius = 0.3,
suspensionDistance = 0.3,
springRate = 35000,
damperRate = 4500,
})
```
# Joint
Connects two physics bodies with a joint constraint. Both entities must have `Physics` components.
Joint kinds: `"fixed"`, `"hinge"`, `"ball"`, `"prismatic"`, `"spring"`, `"rope"`.
Public fields: `kind`, `connected`, `localAnchorX/Y/Z`, `remoteAnchorX/Y/Z`, `axisX/Y/Z`, `stiffness`, `damping`, `restLength`, `maxDistance`.
Methods: `:setMotor(targetVelocity, maxForce)`.
```luau
entity(id).component.add("Joint", {
connected = frameEntityId,
kind = "hinge",
axis = {0, 1, 0},
})
```
A `"rope"` joint is a hard maximum-distance limit solved inside the physics step: the bodies move freely while the anchors are closer than `maxDistance` (slack rope) and are stopped from separating beyond it (taut rope). It requires `maxDistance > 0`, and `maxDistance` is reactive — writing it on a live joint retunes the limit in place, so a winch can reel a rope in or out without recreating the joint.
```luau
entity(ballId).component.add("Joint", {
connected = hookEntityId,
kind = "rope",
maxDistance = 8,
})
```
# CollisionGroup
Sets collision group membership and filter masks on an entity's physics colliders using a bitmask. Two colliders collide only when `(a.membership & b.filter) ~= 0` AND `(b.membership & a.filter) ~= 0`.
Public fields: `membership` (default `0xFFFFFFFF`), `filter` (default `0xFFFFFFFF`).
Methods: `setGroups(membership, filter)`, `ignoreEntity(entityId, ignore?)`.
```luau
entity(id).component.add("CollisionGroup", { membership = 1, filter = 3 })
```
# debugBillboard
Camera-facing billboard shader for the debug overlay's icon sprites. Each
instance renders a procedural glyph (camera, light, audio, reflection
probe, player) selected by glyph id — drawn analytically in the fragment shader,
so icons stay crisp at any zoom with no texture atlas. Consumed by the
debugViz icon system.
# debugDraw
Procedural geometry builders for GPU debug visualization — wireframe boxes,
spheres, capsules, crosses, octahedra, line segments, and camera-facing icon
billboards. Pure geometry: every builder appends packed vertex bytes to a
caller-supplied accumulator and returns how many vertices it added. Line
geometry is a non-indexed **line soup** (each line is two consecutive vertices)
and billboards are a **triangle soup** (each quad is six consecutive vertices),
so both draw through one static, grow-only sequential index buffer (0,1,2,…)
that never needs re-uploading per frame — the topology comes from the material.
Nothing here touches `compute.*` or `renderer.*` — callers own the buffers and
the Draw pass.
```lua
local debugDraw = require("@builtin::modules.debugDraw")
local vtxParts = {}
local verts = 0
verts += debugDraw.appendBox(vtxParts, bounds.min, bounds.max, { 1, 0.8, 0, 1 })
verts += debugDraw.appendLine(vtxParts, a, b, { 0, 1, 1, 1 })
local vtxBytes = debugDraw.buildVertexBytes(vtxParts)
local vtx = substrate.createBuffer({
name = "my.vtx", type = "f32", len = math.ceil(#vtxBytes / 4),
kind = "gpu", usage = { "vertex" },
})
vtx:writeBytes(vtxBytes)
-- One static sequential index buffer, grown only when the vertex count does.
local idx = substrate.createBuffer({
name = "my.idx", type = "f32", len = verts, kind = "gpu", usage = { "index" },
})
idx:writeBytes(debugDraw.buildSequentialIndexBytes(verts))
```
## Vertex layout
Every packed vertex matches the engine's standard `Vertex` layout (position,
normal, uv, joints, weights, node_index, tangent, color — 92 bytes,
little-endian) so the resulting buffer draws through the ordinary vertex
pipeline via a Draw pass. Debug geometry only needs position + color; every
other field is filled with a default.
## API
Each builder appends line-soup vertices to `vtxParts` and returns the vertex
count it added:
- `appendLine(vtxParts, a, b, color?)` — one segment (2 verts).
- `appendBox(vtxParts, min, max, color?)` — axis-aligned box, 12 edges (24 verts).
- `appendOrientedBox(vtxParts, center, rotation, halfExtents, color?)` — a posed box.
- `appendWireSphere(vtxParts, center, radius, color?)` — three orthogonal rings.
- `appendWireCapsule(vtxParts, center, rotation, radius, halfHeight, color?)` — two rings + connectors.
- `appendCross(vtxParts, center, size, color?)` — a 3-axis marker (6 verts).
- `appendOctahedron(vtxParts, center, size, color?)` — a diamond marker (12 edges).
- `appendBillboard(vtxParts, center, color?)` — one camera-facing quad (6
triangle-soup verts) at `center`; every corner stores `center` and its uv
corner, and the `debugBillboard` surface shader expands it toward the camera.
Helpers:
- `packVertex(x, y, z, color)` — pack one vertex; for building cached geometry blobs.
- `packVertexUV(x, y, z, u, v, color)` — pack one vertex carrying an explicit uv
(for billboard quads).
- `quatRotate(q, v)` — rotate a vector by a unit quaternion.
- `buildVertexBytes(vtxParts)` — concatenate the packed vertices into buffer bytes.
- `buildSequentialIndexBytes(count)` — the fixed `0,1,2,…,count-1` line-list index buffer.
- `VERTEX_STRIDE` — 92, the byte stride of one packed vertex.
See `@builtin::modules.debug_bounds` for a caller and
`@builtin::renderFeatures.debugViz` for the Draw pass that renders the batch.
·renderfeature · born here
❒asset # debugViz render feature
Draws the batched geometry buffer that `@builtin::modules.debugViz` rebuilds
every edit-mode tick from every registered provider — bounds boxes, camera
frustums, and any further gizmo the debugViz core gains. One Draw pass over
the core-owned vertex/index buffer pair, rasterized as GPU lines through the
shared vertex-colour unlit material (`@builtin::shaders.debugGeometry`). An
empty state (no providers with geometry yet, or the driving `DebugViz`
component not yet awake) enqueues nothing.
# Node
Base class for AnimGraph nodes. Subclasses (`Clip`, `Mixer`,
`BlendSpace2D`) typically wrap `setmetatable(Node.new(), <Subclass>)`
and then set subclass-specific fields before overriding the three
lifecycle methods.
## Exports
- `Node.new() -> Node` — allocate a bare Node table (no Channels, no buffer).
- `Node:update(dt: number)` — advance internal state (clip playhead, weight ramps, …). Default is a no-op; subclasses override.
- `Node:evaluate() -> TypedBuffer?` — produce a pose buffer. Caller MUST NOT destroy. Default returns `nil`; subclasses override.
- `Node:destroy()` — free owned Channel/buffer/Layout handles. Default is a no-op; subclasses override.
Types:
- `Node = { kind: string? }`
## Usage
```luau
local Node = require("@builtin::systems.anim.AnimGraph.Node")
-- Subclassing pattern:
local MyNode = setmetatable({}, { __index = Node })
MyNode.__index = MyNode
MyNode.kind = "MyNode"
function MyNode.new()
local self = setmetatable(Node.new(), MyNode)
-- subclass-specific fields
return self
end
function MyNode:update(dt) ... end
function MyNode:evaluate() ... end
function MyNode:destroy() ... end
```
## Notes
- `Node` is the base. Subclasses live next door under
`AnimGraph.module/Node.module/` (`Clip`, `Mixer`, `BlendSpace2D`).
- Override semantics: default `update` / `evaluate` / `destroy` are
safe no-ops, so subclasses that only need one of them can leave the
rest at the base implementation.
- Buffer ownership: the buffer returned by `evaluate` is owned by the
node and must NOT be destroyed by the caller.
# ceiling
Single-ray ceiling-detection helper for the character controller.
Casts upward from the character's head to detect overhead obstacles
(head bonk, low clearance). No state.
## Exports
- `M.cast(x: number, y: number, z: number, height: number, skinWidth: number, selfId: string) -> hit?` —
cast a ray from `(x, y + height - skinWidth, z)` upward for
`skinWidth * 2`. Returns the raycast hit table or `nil`.
## Usage
```luau
local Ceiling = require("@builtin::systems.characterController.characterController.physics.ceiling")
local hit = Ceiling.cast(px, py, pz, 1.8, 0.01, selfId)
if hit then
-- head bonk: zero out upward velocity
end
```
## Notes
- Pure function — no module state, no side effects beyond the engine
raycast itself.
- The ray is intentionally short (`skinWidth * 2`) so a head-bonk
check only fires when the character is actively pressing up
against a ceiling, not when there is ambient overhead geometry
far above.
# ground
Ground-probing and slope-math helpers for the character controller.
A downward raycast helper plus two pure vector helpers (slope angle,
plane projection). No state.
## Exports
- `M.cast(x: number, y: number, z: number, skinWidth: number, maxDist: number, selfId: string, riseDist: number?) -> hit?` —
cast a ray downward from `(x, y + riseDist + skinWidth, z)`, reaching
`maxDist` below the feet. The returned hit's `distance` is adjusted to
be relative to the feet, not the ray origin, and is negative for
ground standing above them.
- `M.slopeAngle(nx: number, ny: number, nz: number) -> number` —
degrees between the supplied normal and world up. Returns 0 on a
zero-length normal.
- `M.projectOnSlope(moveX, moveY, moveZ, normalX, normalY, normalZ) -> (number, number, number)` —
project a movement vector onto the plane perpendicular to a normal:
`v - (v . n) * n`.
## Usage
```luau
local Ground = require("@builtin::systems.characterController.characterController.physics.ground")
local hit = Ground.cast(px, py, pz, 0.01, 0.2, selfId)
if hit then
local angle = Ground.slopeAngle(hit.normal.x, hit.normal.y, hit.normal.z)
if angle <= 45 then
-- walkable
end
end
local px, py, pz = Ground.projectOnSlope(dx, 0, dz, nx, ny, nz)
```
## Notes
- `cast` shifts the origin up by `skinWidth` to avoid starting the ray
inside the ground when the character is sitting on a contact, then
subtracts `skinWidth` back off `hit.distance` so callers see the
true distance from the feet.
- `riseDist` raises the origin further, so the ray also covers ground
that stands above the feet — the ground a walking character climbs
into. A hit up there reports a negative `distance`: how far the
character rises to stand on it.
- `slopeAngle` clamps `cos(angle)` into `[-1, 1]` before `acos` —
callers don't need to normalise the input.
- `projectOnSlope` does not normalise the surface normal; callers
should pass a unit normal (raycast normals already are).
# walls
Wall-detection and slide helpers for the character controller. Sweeps
the character's own capsule along the movement direction, probes a
single height with a horizontal ray, and slides a movement vector along
a wall normal. No state.
## Exports
- `M.castBody(x: number, y: number, z: number, dirX: number, dirZ: number, height: number, radius: number, skinWidth: number, maxSlopeAngle: number, selfId: string) -> hit?` —
sweep the capsule spanning the character from the top of the ground
band above `(x, y, z)` (its feet) up to `y + height`, reaching
`skinWidth` past its own surface. Returns the wall standing across the
path, or `nil` when the path is clear.
- `M.castDirection(x: number, y: number, z: number, dirX: number, dirZ: number, radius: number, skinWidth: number, selfId: string) -> hit?` —
single horizontal ray of length `radius + skinWidth`, from whichever
sample height the caller passes.
- `M.slideAlongWall(moveX: number, moveZ: number, normalX: number, normalZ: number) -> (number, number)` —
strip the component of `move` going into the wall (`v - (v . n) * n`,
XZ only). Returns input unchanged when `dot >= 0` or the XZ-normal
is effectively zero.
## Usage
```luau
local Walls = require("@builtin::systems.characterController.characterController.physics.walls")
local hit = Walls.castBody(px, py, pz, dirX, dirZ, 1.8, 0.3, 0.01, 45, selfId)
if hit then
local slideX, slideZ = Walls.slideAlongWall(dx, dz, hit.normal.x, hit.normal.z)
end
```
## Notes
- `castBody` sweeps the whole body, so an opening narrower than
`2 * radius` stops the character even when nothing stands on the
centre line — two cabinets 6 cm apart are a wall, not a doorway.
- A contact whose surface faces up or down — the ramps the character
walks up, to `maxSlopeAngle`, and anything directly overhead —
belongs to the ground and ceiling passes. `castBody` sweeps on past
those, so a body on a ramp still reports the wall ahead of it.
- `castBody` starts its sweep above the ground band: a cap of `radius`
resting on ground of angle `a` reaches `radius * (1 / cos(a) - 1)`
below the surface uphill of it, and a sweep begun inside a surface
describes the shortest way out of that overlap instead of the surface
standing across the path. The band is measured at `maxSlopeAngle`, the
steepest ground the character walks, and the ground and step passes own
everything under it.
- `slideAlongWall` normalises the wall normal in XZ; callers don't
need to pre-normalise. The Y component of the normal is ignored
because slide-along is a horizontal operation.
- The slide helper returns the input movement unchanged whenever the
movement isn't pressing into the wall — there's no "stick to wall"
behaviour, only "don't pass through".
# BlendSpace2D
2D-coordinate-driven mix of N sample nodes (Node subclass). Each
sample is anchored at a `(x, y)` coordinate; the current parameter
`(px, py)` lands inside one of the Delaunay triangles formed over the
anchors, and barycentric weights for that triangle drive a Mixer-style
weighted blend. Parameter outside the hull → nearest sample with
weight 1.
## Exports
- `BlendSpace2D.new(layout: Layout, samples: { Sample }) -> BlendSpace2D` — construct a BlendSpace2D with N anchored sample nodes. Triangulation runs once at construction.
- `BlendSpace2D:setParams(x: number, y: number)` — set the parameter that drives the per-frame blend.
- `BlendSpace2D:update(dt: number)` — cascade `update(dt)` to every sample source.
- `BlendSpace2D:evaluate() -> TypedBuffer` — compute weights and blend. Caller must NOT destroy the returned buffer.
- `BlendSpace2D:destroy()` — destroy the internal Mixer and clear state. Sample sources are not owned and not destroyed.
Types:
- `Layout = { boneOrder: { string }, stride: number?, slotLayout: { any }? }`
- `Sample = { x: number, y: number, source: any }`
## Usage
```luau
local BlendSpace2D = require("@builtin::systems.anim.AnimGraph.Node.BlendSpace2D")
local bs = BlendSpace2D.new(layout, {
{ x = 0, y = 0, source = idleClip },
{ x = 1, y = 0, source = walkFwd },
{ x = 1, y = 1, source = runFwd },
})
bs:setParams(0.5, 0.0)
bs:update(dt)
local pose = bs:evaluate()
```
## Notes
- Triangulation is a brute-force O(n⁴) Delaunay check. Animation
blend-spaces are tiny (n ≤ 20 in practice) so the cost is < 1ms in
Luau at construction; runtime cost is just triangle containment plus
one Mixer evaluate per frame.
- Internal Mixer is owned by the BlendSpace2D and destroyed on
`destroy()`. Sample sources are owned by the surrounding `AnimGraph`,
not the BlendSpace2D.
- When the parameter is outside the triangulated hull, the
closest sample (by squared XY distance) gets weight 1 and the rest
weight 0 — there is no extrapolation.
# Clip
Single animation clip wrapper (Node subclass). Wraps one `Channel` per
animation channel in the asset, plus a pose buffer and a playhead.
`evaluate()` samples every channel at the current time into the buffer.
The pose buffer is initialised to rest pose
(`translation = 0`, `rotation = identity quat`, `scale = 1`) at
construction. Each `evaluate()` overwrites only the slots authored by
channels; unauthored slots stay at rest pose. This means a Mixer
slerping two clips that don't author rotations produces identity, not
`(0,0,0,0)`.
## Exports
- `Clip.new(animAsset: AnimAsset, layout: Layout, looping: boolean, speed: number?) -> Clip` — construct a Clip from an animation asset.
- `Clip:update(dt: number)` — advance the playhead. Wraps when `looping`, clamps + finishes otherwise.
- `Clip:evaluate() -> TypedBuffer` — sample every channel into the pose buffer and return it. Caller must NOT destroy.
- `Clip:setPlaying(p: boolean)` — pause/resume the playhead.
- `Clip:rewind()` — rewind to t=0, clear `finished`, resume.
- `Clip:destroy()` — free every Channel handle and the pose buffer.
Types:
- `Layout = { boneOrder: { string }, stride: number? }`
- `AnimChannel = { target_bone: string, path: string, times: { number }, values: { number }, interp: string? }`
- `AnimAsset = { duration: number?, channels: { AnimChannel }? }`
## Usage
```luau
local Clip = require("@builtin::systems.anim.AnimGraph.Node.Clip")
local clip = Clip.new(asset, layout, true, 1.0)
clip:update(dt)
local pose = clip:evaluate()
```
## Notes
- The Clip owns its pose buffer and all `Channel` handles. `destroy()`
is required to release them — Lua's GC does not free engine resources.
- Only channels with a known `path` (`translation` / `rotation` / `scale`)
and a `target_bone` present in `layout.boneOrder` are wired up; others
are silently dropped.
- Looping uses modulo wrap. Non-looping clips clamp to `duration` and
set `finished = true`, `playing = false`.
# Mixer
N-input weighted-blend Node subclass. Combines N source nodes into a
single pose buffer via `Blend.weightedInto` using a slot layout
(translation lerp + rotation slerp + scale lerp by default). Inputs
with non-positive weights are skipped at evaluate time.
## Exports
- `Mixer.new(layout: Layout, inputs: { MixerInput }) -> Mixer` — construct a Mixer with N weighted inputs. Allocates an output pose buffer.
- `Mixer:setWeight(i: number, w: number)` — update an input's weight (no-op if `i` is out of range).
- `Mixer:update(dt: number)` — cascade `update(dt)` to every input source.
- `Mixer:evaluate() -> TypedBuffer` — blend inputs into the output buffer and return it. Caller must NOT destroy.
- `Mixer:destroy()` — free the output buffer and blend layout. Does not destroy child sources.
Types:
- `Layout = { boneOrder: { string }, stride: number?, slotLayout: { any }? }`
- `MixerInput = { source: any, weight: number }`
## Usage
```luau
local Mixer = require("@builtin::systems.anim.AnimGraph.Node.Mixer")
local mix = Mixer.new(layout, {
{ source = clipA, weight = 1.0 },
{ source = clipB, weight = 0.0 },
})
mix:setWeight(2, 0.5)
mix:update(dt)
local pose = mix:evaluate()
```
## Notes
- The mixer owns its output buffer and `Blend.layout` handle. Child
source nodes are owned by the surrounding `AnimGraph`, not the
Mixer — `Mixer:destroy()` does not recurse into them.
- Default slot layout assumes a 10-stride bone record:
`translation.xyz` (lerp), `rotation.xyzw` (slerp), `scale.xyz` (lerp).
Override via `layout.slotLayout`.
- Inputs with `weight <= 0` are silently skipped — there is no error
for "no active input"; the output buffer is zeroed instead.
# `retarget` module
`require("modules.retarget")` — skeletal animation retargeting in readable Luau.
Map a clip authored on one humanoid rig onto another, preserving the target's
shape. This is the runtime retarget path; the behaviour lives here, in Luau, so
an agent can follow and tweak it. (`__retarget.oracleBake` is the Rust numerical
oracle this is validated against, not the runtime path.)
## How it works
Each `.rig` carries a **profile** — a canonical-role → bone map (the driver).
Retarget is two steps:
1. **Bone map** — source bone → its role → the target bone filling that role.
Roles are the shared vocabulary, so any two rigs interoperate through their
profiles without a per-pair mapping.
2. **Shape-preserving transfer** — the target keeps its own bone lengths and rest
orientations; the animation contributes only delta-from-rest motion. Rotations
are bind-pose-corrected (the target reproduces the source's world-space motion
relative to its own rest). Translation is re-expressed in the target parent's
frame and size-scaled, then applied RELATIVE to the target's bind: every bone
starts at the target's own offset and the clip adds its displacement from rest
on top — a bone with no translation motion stays put (proportions preserved),
while a bone that moves (the hips' vertical bob, the root's stride) carries that
motion across, size-scaled to the target.
Retarget is **cold**: `bake` once per (clip, target rig) and cache; the hot path
just samples the baked clip, exactly like a native one.
## Surface
- `parseRig(rig)` → enriched rig. Accepts a parsed `.rig` table, a `.rig` JSON
string, or an already-parsed rig (idempotent).
- `plan(srcRig, tgtRig)` → `{ mapped, unmappedSource, unmappedTarget, … }` —
which roles map across the rigs, and which don't (the diagnostic).
- `bake(clip, srcRig, tgtRig)` → a decoded clip table in the target's bone space.
`clip` is a decoded clip (`{ name, duration, channels, bone_names }`).
- `bakeBytes(clipBytes, srcRig, tgtRig, cacheKey?)` → retargeted clip `zanim`
bytes, with an in-memory cache keyed by `cacheKey`.
- `loadRig(ref)` → parsed rig from a `.rig` asset.
- `clearCache(cacheKey?)` → drop cached bakes (call after editing a rig profile).
## Example
```lua
local retarget = require("modules.retarget")
local src = retarget.loadRig(asset.ref("synty_character", "rig"))
local tgt = retarget.loadRig(asset.ref("hero", "rig"))
-- inspect the mapping
local plan = retarget.plan(src, tgt)
print(plan.mapped.leftarm.source, "->", plan.mapped.leftarm.target)
-- bake a walk clip onto the hero rig (cold, cached), then sample it like any clip
local walkBytes = vfs.read(asset.source("walk", "animation") .. "/data.zanim")
local walkOnHero = retarget.bakeBytes(walkBytes, src, tgt, "walk|hero")
local bind = skeleton.bindClip(walkOnHero, heroBoneOrder)
```
# characterController
Physics-based character controller — capsule movement with
ground / wall / ceiling detection. A kinematic controller using
raycasts for collision; handles gravity, jumping, slope traversal,
step climbing, wall sliding, coyote time, and moving platforms. The
module owns a per-entity registry; attach once, call `move` / `jump`
each frame, then drive a per-frame `update(dt)`. The
`install` / `uninstall` / `preset` surface wires up the standard set
of components (CharacterController + Locomotion + MovementState +
optional WASDInput and third-person camera child) in one call.
Require it to use the surface:
```luau
local CharacterController = require("@builtin::systems.characterController.characterController")
```
## Exports
- `CC.attach(entityId: string, userConfig: Config?)` — register an entity, merge user overrides over `Defaults.standard`.
- `CC.detach(entityId: string)` — drop the registry entry.
- `CC.move(entityId: string, direction: Vec3)` — set per-frame world-space movement input (`y` is ignored).
- `CC.jump(entityId: string)` — request a jump; honours coyote time and jump buffer.
- `CC.getState(entityId: string) -> State` — snapshot copy of grounded / falling / velocity / etc.
- `CC.getVelocity(entityId: string) -> (number, number, number)` — current `vx, vy, vz`.
- `CC.setGravity(entityId: string, gravity: number)` — update gravity at runtime.
- `CC.setMaxSlope(entityId: string, degrees: number)` — update max walkable slope angle.
- `CC.setConfig(entityId: string, key: string, value: any)` — update a single config key.
- `CC.getConfig(entityId: string) -> Config` — config copy.
- `CC.update(entityId: string, dt: number)` — advance one frame (resolve movement, write position, tick state machine). `dt` is clamped to 0.1.
- `CC.isAttached(entityId: string) -> boolean` — whether the entity has a registration.
- `CC.install(entityId: string, opts: InstallOpts?) -> InstallResult` — high-level wire-up (components + optional camera / mesh / WASDInput).
- `CC.uninstall(entityId: string) -> UninstallResult` — remove everything `install` added.
- `CC.preset(entityId: string, presetName: string) -> PresetResult` — apply a preset to an already-installed character in place.
Types:
- `Vec3 = { x: number, y: number, z: number }`
- `Config = { height?, radius?, maxSlopeAngle?, stepHeight?, gravity?, jumpForce?, coyoteTime?, jumpBuffer?, maxJumps?, airControlFactor?, groundSnapDistance?, skinWidth?, moveSpeed? }`
- `State = { grounded, falling, sliding, wallContact, wallNormal, velocity, groundNormal, slopeAngle, jumpCount, timeSinceGrounded, timeSinceWallContact, onMovingPlatform, platformEntity? }`
- `BundleRef = { guid?, __ref?, identity?, path?, name? }`
- `InstallOpts = { preset?, camera?, input?, mesh?, overrides? }`
- `InstallResult = { entityId, cameraId?, meshHostId? }`
- `UninstallResult = { entityId, despawned }`
- `PresetResult = { entityId, preset, applied }`
## Usage
```luau
local CharacterController = require("@builtin::systems.characterController.characterController")
-- Low-level: manual attach + per-frame drive.
CharacterController.attach(entityId, { gravity = -15, jumpForce = 7 })
CharacterController.move(entityId, { x = 1, y = 0, z = 0 })
CharacterController.update(entityId, dt)
-- High-level: one-call wire-up.
CharacterController.install(entityId, {
preset = "humanoid_default",
camera = true,
overrides = { jumpForce = 8 },
})
```
## Notes
- `attach` merges user config over `Defaults.standard`; passing
unknown keys is allowed (they survive into the config table).
- `update` clamps `dt` to 0.1s to prevent capsule tunnelling on
lag spikes.
- `getState` and `getConfig` return shallow copies — mutating the
result does not affect the controller. The returned `velocity` /
`wallNormal` / `groundNormal` are also shallow copies.
- `install` is idempotent per component — re-running on an already
wired entity adds nothing new. `uninstall` removes the components
and any third-person-camera child that targets this entity.
- `preset` mutates fields in place and never adds or removes
components — call `install` first.
# rigmath
Quaternion algebra, vector helpers, and forward kinematics over a bone
hierarchy. One definition of the math, shared by retargeting and IK.
Quaternions are `{ x, y, z, w }` arrays and vectors are `{ x, y, z }` arrays,
matching the conventions the engine's rig data already uses, so values read
straight out of `ecs.Skeleton.bones` or a parsed `.rig` need no conversion.
## Exports
Vectors:
- `vdot(a, b) -> number` — dot product.
- `vcross(a, b) -> { number }` — cross product.
- `vlen(v) -> number` — Euclidean length.
- `vsub(a, b)`, `vadd(a, b)`, `vscale(v, s) -> { number }` — component-wise arithmetic.
- `vnormalize(v) -> { number }` — unit vector; a zero-length input returns zero.
- `vperpendicular(v) -> { number }` — a deterministic unit vector at right angles to `v`.
Quaternions:
- `IDENTITY` — `{ 0, 0, 0, 1 }`.
- `qmul(a, b) -> { number }` — Hamilton product; applies `b`, then `a`.
- `qnormalize(q)`, `qinverse(q) -> { number }`.
- `qrotvec(q, v) -> { number }` — rotate a vector.
- `shortestArc(a, b) -> { number }` — the rotation carrying unit vector `a` onto `b`.
- `axisAngle(axis, angle) -> { number }` — from an axis and radians.
- `qslerp(a, b, t) -> { number }` — shortest-arc interpolation.
- `qangle(q) -> number` — rotation magnitude in radians, `[0, pi]`.
- `signedAngle(a, b, axis) -> number` — roll from `a` to `b` about `axis`, in radians.
- `swingTwist(q, axis) -> ({ number }, { number })` — twist about `axis`, then the remaining swing.
Scalars:
- `isFinite(n) -> boolean`, `clamp(v, lo, hi) -> number`.
Forward kinematics:
- `computeGlobals(bones) -> (gRot, gPos)` — global rest transforms from local ones.
- `computeBoneLengths(bones, gPos) -> { number }` — each bone's distance to its farthest child.
## Usage
```luau
local rigmath = require("modules.rigmath")
-- Point a bone's forward axis at a target.
local dir = rigmath.vnormalize(rigmath.vsub(targetPos, bonePos))
local swing = rigmath.shortestArc(rigmath.qrotvec(boneRot, { 0, 0, 1 }), dir)
local aimed = rigmath.qmul(swing, boneRot)
-- Blend the result in at a weight.
local final = rigmath.qslerp(boneRot, aimed, 0.5)
```
## Notes
- Degenerate input never produces NaN. A zero-length vector normalizes to zero,
a degenerate quaternion normalizes to identity, and `shortestArc` on
antiparallel vectors resolves to a half turn about a perpendicular axis.
- `computeGlobals` tolerates any bone ordering, including a parent listed after
its child, and falls back to the local transform for a bone left unresolved by
a cyclic parent.
- Bone `parent` indices are 0-based with -1 for a root, matching the rig format;
the returned arrays are 1-based and parallel to the input.
# velocity
Velocity integration and per-frame movement resolution for the
character controller. Owns no state — every call takes the current
position / velocity / config / state and returns the new position
delta plus the updated velocity. Internally uses the `Ground`,
`Walls`, and `Ceiling` raycasting helpers.
## Exports
- `M.resolve(pos: Vec3, vel: Vec3, moveInput: MoveInput, config: Config, state, dt: number, selfId: string) -> (dx, dy, dz, vx, vy, vz, groundHit, wallHit, ceilHit)` —
apply gravity + air control, resolve wall slide / corner pin /
ceiling bonk / ground snap / slope projection / steep-slope slide /
step climbing in that order.
Types:
- `Vec3 = { x: number, y: number, z: number }`
- `MoveInput = { x: number, z: number }` — world-space horizontal input.
- `Config = { height?, radius?, maxSlopeAngle?, stepHeight?, gravity?, airControlFactor?, groundSnapDistance?, skinWidth? }`
## Usage
```luau
local Velocity = require("@builtin::systems.characterController.characterController.movement.velocity")
local dx, dy, dz, vx, vy, vz, groundHit, wallHit, ceilHit =
Velocity.resolve(pos, vel, moveInput, config, state, dt, selfId)
```
## Notes
- The function is pure: it returns nine values but mutates nothing.
The caller is responsible for writing the position delta back via
`entity.localPosition.set` and updating its velocity field.
- Air control blends horizontal velocity toward the input with
`airLerp = min(1, airControlFactor * 10 * dt)`. Grounded mode uses
direct assignment for responsive feel.
- Ground cast distance is `groundSnapDistance` when grounded and
`100` when airborne (so a fast fall doesn't tunnel past the floor).
Snap commit threshold is tight (the frame's own vertical travel),
so airborne ground hits don't snap unless they are within that
travel window.
- A grounded frame looks for ground above the feet as well, by the
rise its own travel covers on ground of `maxSlopeAngle` — that is
the ground a walking character climbs into. The seat takes it up to
the rise the surface it found covers over that travel, plus a step;
a surface further up stands across the path, and the character keeps
the ground it already has.
- Steep-slope (above `maxSlopeAngle`) hits set `groundHit._sliding =
true` so the state machine treats the frame as `sliding` rather
than `grounded`.
- A frame whose horizontal movement the wall pass cancels outright —
pressed straight into a wall, or wedged where two surfaces meet —
sets `wallHit._blocked = true`, so a refused walk reads as refused
instead of as a walk that happened to cover no ground.
- Step climbing only fires when `state.grounded`, horizontal motion
exists, and no wall hit was registered.
# defaults
Default configuration presets for the character controller. Each
preset is a flat `Config` table that `CharacterController.attach` /
`install` merges over with user overrides. No behaviour, no state —
just constants.
## Exports
- `M.standard: Config` — baseline humanoid tuning. Used when no user
config is supplied to `attach`.
- `M.platformer: Config` — higher gravity, stronger jump, double jump,
generous coyote time, high air control.
- `M.heavy: Config` — taller capsule, lower max slope, weaker jump,
minimal air control.
Types:
- `Config = { height, radius, maxSlopeAngle, stepHeight, gravity, jumpForce, coyoteTime, jumpBuffer, maxJumps, airControlFactor, groundSnapDistance, skinWidth }`
## Usage
```luau
local Defaults = require("@builtin::systems.characterController.characterController.data.defaults")
-- Use a preset directly:
CharacterController.attach(entityId, Defaults.platformer)
-- Or merge selective overrides:
local cfg = {}
for k, v in pairs(Defaults.standard) do cfg[k] = v end
cfg.jumpForce = 8.0
CharacterController.attach(entityId, cfg)
```
## Notes
- Pure data, no engine calls. Safe to require at module load time.
- These tables are shared singletons — mutating `Defaults.standard`
in place will affect every later `attach` that uses it. Copy
before mutating.
# preset
A preset holds typed values for one declarer's settings schema: an
assetType's, an importer's, a component's, or any asset that declares its
own settings. `preset.capture` saves the settings something has now,
`preset.apply` writes a preset's values into something its target governs,
`preset.values` reads them checked against the target's current schema, and
`preset.setValues` replaces them, checked first. Exposed as the `preset`
global via `--!global preset`.
A preset's `preset.yaml` names its target as a guid envelope,
`target: { __ref: <guid>, type: <type>, name: <name> }`, which the
preset's `.refs` pins by guid with the target's checksum. A target written
as an identity string is accepted on read, resolves by identity, reads as
unpinned, and is rewritten as the envelope on the preset's next local
edit.
## Exports
- `preset.values(source: AssetRef<preset>) -> ({ [string]: any }, Report)` — the values that pass the target's current schema, and a report naming every one that does not.
- `preset.apply(source: AssetRef<preset>, into: AssetRef | ComponentTarget) -> ApplyResult` — write the valid values into an asset the target governs, or into a component on an entity whose component is the target. Struct groups merge field by field. Raises when another declarer governs `into`.
- `preset.capture(from: AssetRef | ComponentTarget, name: string, opts?: CaptureOpts) -> AssetRef<preset>` — save an asset's settings, or a component's live values on an entity, as a new preset targeting the declarer that governs them. A bare name lands in `/source/presets/`; an absolute `.preset` path lands where it says.
- `preset.setValues(source: AssetRef<preset>, values: { [string]: any })` — replace the whole values table; any violation refuses the write, naming every one.
Types:
- `ComponentTarget = { entity: string, component: string }` — a component on an entity.
- `Report = { format: string, target: string, targetGuid: string?, pinned: boolean, targetChanged: boolean, rejected: { Violation } }` — `target` is the target's identity; `pinned` is true when `.refs` pins the target's guid; `targetChanged` is true while the pinned checksum differs from the target's current one.
- `Violation = { path: string, message: string }`
- `ApplyResult = { applied: { string }, rejected: { Violation }, targetChanged: boolean }`
- `CaptureOpts = { overwrite: boolean? }` — `overwrite = true` re-authors an existing preset, keeping its guid.
## Usage
```luau
-- Capture a texture's settings, then apply them to another texture
local p = preset.capture("hero_sprite", "pixel_art")
preset.apply(p, "enemy_sprite")
-- Apply a component preset to a component on an entity
preset.apply("synty", { entity = id, component = "Locomotion" })
-- Capture a component's live values
local tint = preset.capture({ entity = id, component = "Model" }, "red_tint")
-- Read the values and what the target rejects
local values, report = preset.values("pixel_art")
for _, v in ipairs(report.rejected) do print(v.path, v.message) end
-- Replace the values
preset.setValues("pixel_art", { filter = "nearest", generateMipmaps = false })
```
## Notes
- Every read re-checks the values against the target's current schema, so a value a later schema drops or retypes is reported by path rather than applied.
- `asset.create("preset", name, { target = <identity or guid>, values = { ... } })` authors a preset directly, with the same checks as `preset.capture`.
- Submodules: `preset.document` (the `preset.yaml` format), `preset.target` (target resolution and checking), `preset.heal` (rewriting an older preset document in the current form on a local edit).
# WASDInput
Action-to-input-bus driver. Reads the `move` axis and `jump` action
each frame, remaps `move` to the active viewport camera's
forward/right basis (via `camera.main()` + `entity(camId).transform.forward`),
and writes the resulting world-space horizontal velocity to a target
component's input fields:
- `target.inputX`, `target.inputZ` — world-space velocity × speed
- `target.inputJump` — boolean, true while the jump action is held
Defaults to writing to `CharacterController` on the same entity, but
the field names are the contract — any component that exposes
`inputX/inputZ/inputJump` is a valid sink (vehicles, hovercraft,
custom CCs).
## Why generic
Independent of which camera is active and independent of the visual
layer:
- Works with any camera (third-person, top-down, FPS, fixed, vehicle).
- Works with no camera (falls back to world-axis WASD).
- Works without `Locomotion` or any animation system — CC moves,
visualization is opt-in.
- Works without a follow rig — `camera.main()` is the seam, not a
specific camera component.
## Public fields
| Field | Default | Purpose |
|---|---|---|
| `speed` | `5.0` | Multiplier on the unit-length input vector |
| `targetComponent` | `"CharacterController"` | Component to write `inputX/inputZ/inputJump` onto |
A custom control scheme is authored as an inputMap that rebinds the
`move` axis and `jump` action — not per-instance key fields on this
component.
## Example
```lua
-- Player with CC + WASDInput + any camera
entity(playerId).component.add("CharacterController")
entity(playerId).component.add("WASDInput")
-- Active viewport camera (any kind)
local cam = entity.spawn("cam", { parent = playerId })
cam.component.add("Camera", { fov = 60, priority = 0 })
cam.component.add("orbital_follow", { follow = playerId })
-- Press W → CC.inputX/inputZ are written camera-relative each frame
-- → CC translates the body → MovementState reports velocity →
-- (optional) Locomotion rotates the visible mesh + plays anims.
```
# machine
State machine helper for the builtin character controller. Owns the
state table shape (grounded / falling / sliding / wall contact /
blocked / jump counts / moving-platform tracking) and the timing rules
(coyote time, jump buffering, multi-jump cap) that the controller
consumes each tick.
Pure logic — no engine globals beyond `entity()` for platform position
lookups.
## Exports
- `M.new() -> State` — allocate a fresh state with default values.
- `M.update(state: State, groundHit, wallHit, ceilHit, vel, config, dt, now)` — run ground/wall sensors and update grounding, timing, and moving-platform fields.
- `M.shouldJump(state: State, config: Config, now: number) -> boolean` — decide whether a queued jump should fire this tick.
- `M.executeJump(state: State, config: Config) -> number` — mutate state for a jump and return the Y velocity to apply.
- `M.requestJump(state: State, now: number)` — register a jump press (may be deferred via jump buffer).
- `M.clearJumpRequest(state: State)` — drop any pending jump request.
Types:
- `Vec3 = { x: number, y: number, z: number }`
- `RaycastHit = { point?, normal: Vec3, entityId?, _sliding?, _blocked? }` — shape of `Physics.raycast`-style hits the controller produces.
- `State = { grounded, falling, sliding, wallContact, blocked, wallNormal, velocity, groundNormal, slopeAngle, jumpCount, timeSinceGrounded, timeSinceWallContact, onMovingPlatform, platformEntity, ...internal timing fields }`
- `Config = { maxJumps?, coyoteTime?, jumpBuffer?, jumpForce? }` — character tuning fields read each tick.
## Usage
```luau
local State = require("@builtin::systems.characterController.characterController.state.machine")
local s = State.new()
State.update(s, groundHit, wallHit, ceilHit, vel, config, dt, now)
if State.shouldJump(s, config, now) then
local jumpY = State.executeJump(s, config)
end
```
## Notes
- `update` is destructive — it mutates `state` in place. The state table
is the single source of truth; callers should not rebuild it each
tick.
- The jump-buffer window persists across frames; `shouldJump` consumes
it via the `_jumpBufferTime`/`_jumpRequested` pair.
- Moving-platform detection relies on the platform entity's
`localPosition` — non-entity hits leave `onMovingPlatform = false`.
- `blocked` is true on a frame the wall pass left the character with
none of the horizontal movement it asked for — `wallHit._blocked`,
which `Velocity.resolve` marks. It is what tells a character standing
still apart from one whose walk is being refused.
- `ceilHit` is accepted for API symmetry but currently unused; future
ceiling-bump handling will read it without changing the signature.
# document
A preset's `preset.yaml`: decoding it, reading it and writing it.
The typed form names the declarer the values are for and the values:
```yaml
target: { __ref: "<guid>", type: assetType, name: texture }
values:
filter: nearest
```
`target` is written as a guid envelope, which the preset's `.refs` pins by
guid; `type` and the target's plain `name` say what it points at to a person
reading the file, and `name` is never an identity, so the guid is the one link.
A `target` written as an identity string (`"@builtin::assetTypes.texture"`,
or a world asset's identity) is accepted on read. The component-only form,
`component:` + `properties:`, reads as authored.
## Exports
- `Document.pathOf(ref) -> string`
- `Document.decode(body: string) -> Doc`
- `Document.read(ref) -> Doc`
- `Document.encode(envelope: Envelope, values) -> string`
- `Document.write(ref, envelope: Envelope, values)`
Types:
- `Envelope = { __ref: string, type: string?, name: string? }`
- `Doc = { format: "typed" | "legacy", target: string?, envelope: Envelope?, component: string?, title: string?, values: { [string]: any } }`
## Usage
```luau
local Document = require("@builtin::modules.preset.document")
local doc = Document.read(presetRef)
```
# heal
Rewrites a preset's `preset.yaml` into the form presets are written in, on a
local edit of this world's own content: a target written as an identity
string that resolves becomes the guid envelope, and a component-only
document whose component accepts every property it holds becomes the typed
form, its target the component and its legacy `name` moved to the
`.metadata` `title`. Every value is kept as decoded. A remote-origin edit and
library-installed content are left as they are. A component-only document
that cannot heal stays as it is, and the reason is warned once.
## Exports
- `Heal.ifNeeded(ref, origin: string?) -> boolean` — true when the document was rewritten.
- `Heal.writeTyped(ref, doc, declarer, values)` — write `values` in the typed form targeting `declarer`, moving a component-only document's `name` to the `.metadata` `title`. `preset.setValues` writes through it too.
- `Heal.forget(ref)` — drop what the module remembers about a removed preset.
## Usage
```luau
local Heal = require("@builtin::modules.preset.heal")
Heal.ifNeeded(presetRef, change.origin)
```
# target
The declarer a preset's values are for: resolved by the guid its target
envelope names (pinned in the preset's `.refs`) or by the identity string a
hand-written target names, checked against that declarer's current schema,
and compared with the checksum the pin recorded.
## Exports
- `Target.literalFor(ref) -> string` — the guid a preset pins for `ref`.
- `Target.describe(ref) -> string` — `ref`'s identity.
- `Target.envelopeFor(ref) -> Envelope` — `{ __ref, type, name }` for `ref`.
- `Target.resolve(presetRef, doc) -> Resolved`
- `Target.resolveLiteral(literal: string) -> Resolved`
- `Target.declarerOfAsset(ref) -> (declarer, schema)`
- `Target.isComponentTarget(v) -> boolean` — true for `{ entity = <id>, component = <name> }`.
- `Target.declarerOfComponent(t) -> (component, schema, proxy)` — the component asset governing a component on an entity, its schema, and the live component.
- `Target.check(schema, values) -> (valid, rejected)`
- `Target.valuesOf(presetRef, doc) -> (values, report, resolved?)` — what `preset.values` answers, from a document already read.
- `Target.merge(schema, current, patch) -> values`
- `Target.refuse(prefix: string, violations)` — raises.
Types:
- `Resolved = { literal: string, name: string, ref: any?, guid: string?, schema: any?, pinned: boolean, changed: boolean }`
- `Violation = { path: string, message: string }`
- `Report = { format, target, targetGuid?, pinned, targetChanged, rejected }`
## Usage
```luau
local Target = require("@builtin::modules.preset.target")
local declarer, schema = Target.declarerOfAsset(texRef)
```