# Chase Camera
Sticky low-pass-filtered follow cam. Stays behind the target's facing direction at a fixed `distance` + `height`, no user-driven orbit, no mouse-look. Designed for vehicles + fast platformers where `orbital_follow`'s orbit feels too floaty.
## Vs. `orbital_follow`
| | `orbital_follow` | `chase` |
|---|---|---|
| Orbit | RMB-drag (yaw + pitch) | none — derives from target facing |
| Mouse-look | yes | no |
| Position lag | n/a (camera is at fixed orbit) | per-second exponential lerp |
| Rotation lag | n/a | separate per-second exponential lerp |
| Best for | character action, RPG | vehicles, platformers, ski/snowboard |
The split between `positionSmoothing` and `rotationSmoothing` lets you tune "position snaps, rotation drifts" (or vice versa) for the cinematic feel you want.
## Fields
| Field | Default | Notes |
|---|---|---|
| `target` | `""` | Entity ID. Falls back to `Camera.follow` when empty. |
| `distance` | `6.0` | Horizontal trail distance behind target. |
| `height` | `2.5` | Vertical offset above target origin. |
| `minDistance`, `maxDistance` | `1.5`, `25.0` | Scroll-zoom bounds. |
| `zoomSpeed` | `0.12` | `exp(-scroll * zoomSpeed)` per tick. |
| `positionSmoothing` | `0.05` | 0 = instantly stuck, 1 = static. Per-second blend. |
| `rotationSmoothing` | `0.15` | Same shape — higher = more "looks where it's been". |
| `lookAhead` | `0.6` | How far in front of target the camera aims. |
## Use as a camera behavior
```lua
world.camera_default = asset.ref("@builtin::controller.chase")
-- Or per-scene
layers.active.camera.behavior = asset.ref("@builtin::controller.chase")
layers.active.camera.follow = layers.active.players.localPlayer
```
The component carries the `cameraBehavior` tag in `.metadata`.
## Controls
- **Scroll** — zoom in/out (distance only; height ratio stays constant)
- No user-driven rotation — the camera always trails the target's facing.
## Pair with
- `standard_controller` — the controller already rotates the player to face the input direction; chase locks behind that facing.
- `topdown_controller` — works, but `birds_eye` is usually a better fit for top-down gameplay.
- A custom vehicle controller that rotates with steering — chase will track the steering arc.
# controller.orbit
Orbit camera controller. Orbits around a target entity or position. Mouse drag to rotate, scroll to zoom, optional auto-rotate.
Public fields: `inputMap` (the camera-rig scheme, `@builtin::inputMaps.cameraRig`, by default; point it at a world's own map to rebind this camera), `target` (empty = use targetX/Y/Z), `targetX`, `targetY`, `targetZ`, `distance`, `minDistance`, `maxDistance`, `sensitivity`, `zoomSpeed`, `autoRotate`, `autoRotateSpeed`.
Controls: RMB drag to rotate, scroll to zoom.
```luau
entity(id).component.add("controller.orbit", { target = "player", distance = 10 })
```
# entityOps.addComponents
Attach component(s) — with their setup data — to one or many entities
in a single call, creating the entities first when asked.
```lua
entityOps.addComponents("crate", { Physics = { kind = "dynamic" } })
entityOps.addComponents({ "crate_1", "crate_2" }, { "Physics", "Audio" })
entityOps.addComponents("spinner", { Rotator = { speed = 2 } }, { create = true })
```
Targets are entity **names or ids** (arrays and `scene.find` results
pipe straight in). Components are a name, an array of names, or a
`{ Name = dataTable }` map carrying each component's setup data — names
accept the leaf (`"Physics"`), the full identity
(`"@builtin::components.Physics"`), or a VFS path, resolved before
anything mutates.
`create = true` spawns a fresh root entity for any string target that
matches nothing, so one call both creates the entity and gives it its
setup. Returns one `{ id, name, added, created? }` record per entity.
# entityOps.component
Inspect a component **type** so you set fields that exist instead of
guessing. Reports the component's public fields (name + type + current
value), its callable methods, its lifecycle hooks, and its asset-ref
fields.
```lua
entityOps.component("Camera") -- fields, methods, hooks + default values
entityOps.component("Model", { on = "my_prop" }) -- actual values on that entity
entityOps.component("@builtin::components.Light")
```
Resolves the component by name / identity / VFS path. Pass `on` (an
entity name or id that carries the component) and every field's `value`
is that entity's **actual** value, read off its live component proxy;
omit `on` and each `value` is the **declared default**, as written in the
component's source.
A live value keeps its shape: a number reads back as a number, a vector or
struct as its plain table. A field that resolves on read reports the identity
of what it points at — an asset (`resource` / `assetRef`) as a
`{ __ref, identity, name, type }` envelope whose `identity` you hand straight
back to `addComponents`, an `entityRef` as the entity id. A field whose live value could not be read
carries an `error` instead of a value, so a value that failed to read is
never mistaken for the default.
```lua
-- on a wall carrying a mesh and a custom material
entityOps.component("Model", { on = "wall_north" })
-- fields: model = { __ref = "336f4feb-…", identity = "@builtin::meshes.cube", name = "cube", type = "mesh" }
-- material = { __ref = "99d699e9-…", identity = "@user::materials.brick", name = "brick", type = "material" }
```
Returns `{ identity, description, builtin, executionOrder, hooks,
fields, methods, assetFields, source }`. Reach for it before
`entityOps.addComponents` / `entityOps.spawn` to learn the exact field
names and value shapes — the fix for setting fields a component doesn't
have.
To inspect the components + values on an *entity* (rather than a
component type's schema), use `debug.inspect`.
# entityOps.duplicate
Copy entities that already exist — one duplicate, or a numbered run of
them spread along an axis.
```lua
entityOps.duplicate("crate")
entityOps.duplicate("crate", { count = 5, offset = { 2, 0, 0 } })
entityOps.duplicate("pillar", { count = 4, offset = { 0, 3, 0 }, name = "pillar_%d" })
entityOps.duplicate({ name = { "tree" } }, { offset = { 10, 0, 0 } })
```
Targets take a name or id, a list of them, `scene.find` records, or a
**query** resolved for you — so everything matching `{ name = { "tree" } }`
is copied without looking the entities up first.
`count` makes that many copies of **each** source. It must be a whole
number of at least 1.
`offset` is a world-space `{x,y,z}` step that **accumulates**: copy 1 sits
one offset from the source, copy 2 sits two, copy 3 sits three. Every copy
is placed relative to the **source**, so a run spreads evenly instead of
compounding rounding from copy to copy.
`name` names each copy, and a `%d` in it becomes the copy's 1-based index
— `"pillar_%d"` yields `pillar_1`, `pillar_2`, … Without a `name`, copies
are named after their source (`crate_copy`, or `crate_copy_1`,
`crate_copy_2`, … for a run).
Copies carry the source's render layer.
An option key outside the documented set is an error that names it and
lists the accepted keys, so a mistyped `conut` is reported instead of
returning copies that quietly ignored it.
Every call returns the resulting
`{ id, name, position, localPosition, rotation, localRotation, eulerAngles, scale, parent }`
per copy, in creation order — the copy and the read are the same call.
# entityOps.fromAsset
Instantiate an existing asset into the scene — **resolution-first**: pass
a name / path / identity string, the tool resolves it and reports the
canonical asset it hit, so you never need to know the exact identity up
front.
```lua
entityOps.fromAsset("@builtin::avatars.humanoid")
entityOps.fromAsset("oak_chest", { type = "bundle", position = {2, 0, 0} })
entityOps.fromAsset("gold", { type = "material" })
entityOps.fromAsset("/zero/source/generated/meshes/a_statue.bundle", { link = false })
```
Dispatch by resolved category:
- **bundle / avatar** — instantiated via its own `:instantiate()`. `link`
(default true) keeps the live Asset-component link so the source drives
the hierarchy; `link = false` bakes the hierarchy into the scene.
- **mesh** — a renderable (Model + mesh Collider).
- **material** — a sphere carrying the material.
- **texture** — a plane sampling the texture (through a fresh unlit
material, so it never rewrites another preview).
- **anything else** — instantiated through its own `:instantiate()` when
it has one, else a legible error.
`type` limits the asset search to one category (avoids name clashes).
`position` / `rotation` / `scale` place it; `synced` replicates gameplay
edits to peers. Returns `{ id, name, resolved = { category, identity,
path } }` — `resolved` is the canonical asset your string hit.
To spawn from hand-named components instead of an existing asset, use
`entityOps.spawn`.
# entityOps.modify
Change entity state — one tool for delete / enable / disable / hide /
show / temporary / reparent / rename, over one or many entities resolved
by **name or id**.
```lua
entityOps.modify("debug_probe", { hidden = true })
entityOps.modify({ "probe_a", "probe_b" }, { delete = true })
entityOps.modify("old_character", { enabled = "toggle" })
entityOps.modify("hat", { parent = "player_head" })
```
Ops: `delete` (removes the whole subtree; wins over everything else),
`enabled`, `hidden`, `temporary` (booleans, `"toggle"` flips the current
state), `parent` / `unparent` (with `keepWorldTransform`, default true),
and `rename` (single target only).
Returns one `{ id, name, applied }` record per target. `scene.find`
results pipe straight in as targets.
# entityOps.removeComponents
Detach component(s) from one or many entities — the counterpart to
`entityOps.addComponents`.
```lua
entityOps.removeComponents("lamp", "Light")
entityOps.removeComponents({ "crate_1", "crate_2" }, { "Physics", "Audio" })
entityOps.removeComponents({ component = { "Light" } }, "Light")
entityOps.removeComponents("lamp", "light") -- partial name, resolved
```
Targets take a name or id, a list of them, `scene.find` records, or a
**query** resolved for you — so a component is stripped from everything
carrying it without naming a single entity.
Component names match loosely: the leaf (`"Light"`), the full identity
(`"@builtin::components.Light"`), a VFS path, or a case-insensitive
partial. The response reports the **canonical** name it resolved to, so a
half-remembered name both works and teaches you the real one.
A name that could mean several components is an error listing the
candidates rather than a guess, and a name matching nothing is an error
too — nothing is silently skipped.
Every entity comes back as `{ id, name, removed, absent }`:
- `removed` — components it carried and lost.
- `absent` — components it never had, so nothing happened for them.
That split makes a call which changed nothing visible, instead of letting
it read like a success.
# entityOps.roots
Find the root ancestor of one or many entities — walks each up its
parent chain to the top-level entity.
```lua
entityOps.roots("player_hand")
entityOps.roots({ "wheel_fl", "wheel_fr" })
```
Targets are entity names or ids (a single one, an array, or `scene.find`
records). Returns one `{ target, targetName, root, rootName, depth }`
per target; an entity that is already a root reports itself with
`depth = 0`.
# entityOps shared
Cross-tool helpers for the `entityOps` toolbox — the pieces more than
one of its tools reuse.
- **Entity resolution** — `resolveEntity` / `resolveTargets` / `toList`
turn a name / id / proxy / `scene.find` record (single or a list) into
live entity proxies, raising a legible error on anything that doesn't
match.
- **Subtree removal** — `collectDescendants` / `despawnSubtree` remove an
entity and its whole subtree, raising rather than leaving a half-removed
tree behind.
- **Slot capture** — `captureSlot` snapshots the parent + local transform
+ name + sync flag an entity occupies, so a replacement can take its
exact place.
- **Asset resolution** — `resolveAsset` turns a caller's name / path /
identity string (or an already-resolved AssetRef) into a resolved
asset, reporting the canonical identity/path it hit, optionally limited
to one asset category.
Tools are thin wrappers over these; the helpers call the engine APIs.
# entityOps.spawn
Spawn entities from a template — you name the components they carry and
their field values, plus a transform.
## Many entities in ONE call
Pass a `{ defaults, items }` list: `defaults` holds what every entity
shares, each `items` entry overrides only what differs. A six-walled room
is one call, not six.
```lua
entityOps.spawn({
defaults = { components = { Model = { model = "cube" }, BoxCollider = {} },
scale = { 4, 3, 0.2 } },
items = {
{ name = "wall_n", position = { 0, 1.5, -2 } },
{ name = "wall_s", position = { 0, 1.5, 2 } },
{ name = "wall_e", position = { 2, 1.5, 0 }, rotation = { 0, 90, 0 } },
{ name = "wall_w", position = {-2, 1.5, 0 }, rotation = { 0, 90, 0 } },
},
})
```
- A per-item key wins over the matching `defaults` key.
- `components` merges **per component**: an item naming `Model` replaces
only that entry and keeps the shared `Collider`.
- Every item is validated **before any entity is created** — a typo in the
last entry fails the whole call rather than leaving a half-built scene.
- An unrecognised key is an error naming the accepted set, at the top
level, in `defaults`, and in an item alike.
- Returns one `{ id, name, components }` record per item, in item order.
## One entity
```lua
entityOps.spawn(
{ Model = { model = "cube" }, BoxCollider = {} },
{ position = {0, 1, 0} })
entityOps.spawn(
{ Light = { kind = "point", intensity = 5 } },
{ name = "lamp", position = {0, 3, 0} })
entityOps.spawn({}, { name = "group" }) -- bare entity, no components
```
Components is a `{ [ComponentName] = { field = value } }` map. Each is an
**authored** component (leaf name like `"Model"`, a full identity, or a
VFS path) added with its data so it **serializes** — native ECS
components are intentionally not exposed here, so you never author
something that renders at runtime but vanishes on save.
Transform: `position` `{x,y,z}`; `rotation` a `{x,y,z,w}` quaternion or
`{pitch,yaw,roll}` euler degrees; `scale` a number or `{x,y,z}`. Also
`temporary`, `hidden`, `synced` (replicate gameplay edits to peers), and
`attributes`.
Returns `{ id, name, components }` with the component leaf names added.
To instantiate an existing **asset** (a bundle / avatar / mesh /
material / texture) rather than hand-name components, use
`entityOps.fromAsset`.
# entityOps.swap
Replace one entity with another entity that **already exists in the
scene**. The replacement is moved into the target's slot — same parent,
local position and rotation, the target's scale (`keepScale = false`
opts out) and name (`keepName = false` opts out) — and the target is
removed with its whole subtree.
```lua
entityOps.swap("DefaultBody", "my_knight")
entityOps.swap("placeholder_car", "sports_car", { keepName = false })
entityOps.swap("DefaultBody", "my_knight", { dryRun = true })
```
Both arguments are entity names or ids. `dryRun = true` reports what
would move and what would be removed without mutating.
To replace an entity with a content **asset** (a bundle/mesh/…) rather
than another scene entity, use `entityOps.fromAsset` (spawn) or
`appearance.replaceWithContent` (in-place slot swap).
# entityOps.transform
Move, rotate and scale entities that already exist — over one or many
entities.
```lua
entityOps.transform("lamp", { position = { 0, 3, 0 } })
entityOps.transform({ "wall_n", "wall_s" }, { offset = { 0, 2, 0 } })
entityOps.transform({ name = { "wall" } }, { offset = { 0, 2, 0 } })
entityOps.transform("crate", { rotation = { 0, 45, 0 }, scale = 2 })
entityOps.transform("lamp") -- no ops: a pure read
```
Targets take a name or id, a list of them, `scene.find` records, or a
**query** resolved for you — so you can move everything matching
`{ name = { "wall" } }` without looking the entities up first.
**Absolute** ops carry their space in the name: `position` / `rotation`
are world, `localPosition` / `localRotation` are parent-relative, `scale`
is a number or `{x,y,z}`.
**Relative** ops are `offset` (position delta), `rotate` (applied on top
of the current rotation) and `scaleBy` (multiplier). `space` —
`"world"` (default) or `"local"` — governs these.
`snap` quantizes the resulting position to grid steps.
Rotations take a `{x,y,z,w}` quaternion or `{pitch,yaw,roll}` euler
degrees, the same forms `entityOps.spawn` accepts.
Absolute and relative forms of one channel are exclusive — passing
`position` and `offset` together is an error rather than a silent
precedence rule.
An option key outside the documented set is an error that names it and
lists the accepted keys, so a mistyped `positon` is reported instead of
returning a successful-looking read of an entity that never moved.
Per-entity values in one call:
```lua
entityOps.transform({
{ target = "wall_n", position = { 0, 1, -5 }, rotation = { 0, 0, 0 } },
{ target = "wall_e", position = { 5, 1, 0 }, rotation = { 0, 90, 0 } },
})
```
Every record is validated and resolved before any of them is applied, so
a bad entry fails the whole call rather than moving its siblings and
leaving itself behind.
Every call returns the resulting
`{ id, name, position, localPosition, rotation, localRotation, eulerAngles, scale, parent }`
per entity — the mutation and the read are the same call.
# Toolbox (asset type)
A toolbox is a namespace folder that groups related `.tool/` children
plus shared helpers. Toolboxes are the addressing layer for tools —
every tool's identity is `<toolbox>.<name>`, so the toolbox name
appears in every callsite. Toolboxes also own a `shared.module/`
helper module that the toolbox's tools `require(".shared")` for
common utilities (result envelopes, validators, formatters).
## When to use one
- You have two or more related tools that belong together (an `anim`
toolbox with `play` / `stop` / `list`; an `assets` toolbox with
`compose` / `validate`).
- You need a shared helper module exclusively used by a tool set.
- You want a clean dot-namespaced identity for your tools
(`my_tools.foo`, `my_tools.bar`).
If you only have one tool and no shared helpers, you still need a
toolbox — it's the required parent.
## Where it lives
- Source: `/zero/source/.../<Name>.toolbox/`
- Identity: `<Name>` (the `.toolbox` suffix strips).
- Folder shape (container-style — open child set):
- `README.md` — instance documentation. **Required.**
- `.metadata` — agent-editable tags + free-form fields. **Required.**
- `shared.module/` — optional shared helper module
(`init.luau` + `README.md`).
- `<x>.tool/` — any number of `.tool/` children.
The validator permits unlisted children (`allow_unlisted: true`) so
tool subfolders don't need to be declared per-name.
## How to create one
```luau
tools.createToolbox({ name = "<Name>", description = "..." })
-- or the generic scaffold:
asset.create("toolbox", "<Name>")
-- Creates: /zero/source/<Name>.toolbox/
-- README.md (instance README template)
-- shared.module/ (ok / fail result-envelope constructors)
-- `folder` places it in a subfolder of /source; `into` nests it in a
-- resolved `.package`:
asset.create("toolbox", "<Name>", { folder = "tools" })
```
Then add tools under it:
```luau
local box = asset.resolve("<Name>.toolbox")
asset.create("tool", "<toolName>", { into = box })
```
## How it operates
1. **Registration.** Writing into a `.toolbox/` folder indexes the
toolbox. It becomes the parent namespace for any `.tool/`
children.
2. **Tool identity composition.** Each child `.tool/`'s identity is
`<toolbox>.<tool>`. Listing the toolbox's tools enumerates the
children.
3. **Shared helpers.** Tools inside the toolbox pull
`shared.module/` in via `require(".shared")` (relative require
from the tool's `init.luau`).
4. **Hot reload.** Editing `shared.module/init.luau` reloads every
tool in the toolbox (they all `require` it). Editing the toolbox
folder structure (adding / removing tools) reflects in
`tools.list` immediately.
## Discovery
- `asset.list("toolbox")` — every registered toolbox.
- `asset.inspect("<name>")` — child tools, source, this type
README.
- `ls /zero/source/<Name>.toolbox` lists the files the folder holds.
- `tools.list()` — every tool across every toolbox.
## Authoring conventions
- **Per-tool implementation lives in each `<x>.tool/init.luau`.**
The tool's full function body — argument validation, the call
into engine APIs, the return value — goes in `init.luau` with
`--!desc` / `--!arg` / `--!return` / `--!example` annotations
above the function. **Every exported function is declared
`typed function`** — the keyword compiles to the runtime argument
check, so a plain `function` accepts any input and reports success
on garbage. Do NOT make `init.luau` a one-line forwarder onto a
function defined in `shared.module/`.
- Keep `shared.module/` thin: result envelope constructors
(`shared.ok(value, formatted)`, `shared.fail(message, exitCode)`),
argument validators, common formatters — anything genuinely used
by more than one tool. Per-tool logic does NOT belong here.
- Group by domain, not by surface — `assets` toolbox holds asset
operations, `entity` toolbox holds entity operations. Tools that
feel unrelated probably want different toolboxes.
- Document the toolbox's surface in `README.md` — what problem space
it covers, who calls into it, any shared invariants.
## Common pitfalls
- **Missing parent toolbox.** Tools cannot exist outside a toolbox.
Always scaffold the toolbox first.
- **Cross-toolbox shared.** Each toolbox has its own
`shared.module/`. Don't try to share helpers across toolboxes; if
you need cross-toolbox helpers, factor them into a standalone
`.module`.
- **Toolbox identity.** Strip the `.toolbox` suffix in identity
references (`my_tools`, not `my_tools.toolbox`).
## Related types
- `.tool` — the required child(ren) of a toolbox.
- `.module` — for cross-toolbox utilities.
## In the Inspector
Tools: how many tools the box holds, whether it ships a shared module, what each tool does, and a link to each tool.
# Tool (asset type)
A tool is a single agent-callable function whose schema is its
`typed function` signature. Tools are the workflow surface — composed
Luau operations that bundle multiple engine APIs into one named call,
suitable for agents (and for use from `execute`). Each tool has a
declared input shape, return shape, and a strict result envelope
(`ZmToolResult`).
**Every exported tool function is declared `typed function`.** The
`typed` keyword is what makes the signature real: it compiles to a
runtime dispatch wrapper that checks each argument against its
declared type and raises on a mismatch. A plain `function` — even one
carrying Luau type annotations — is checked by nothing at call time,
so the tool accepts any input and reports success on garbage. See
"The typed signature is the schema" below.
## When to use one
- You're composing several engine APIs into a workflow that's worth
naming and discovering (`tools.list`, `search_tools`).
- You want an agent to be able to call this function by name without
reading source code — the typed signature is the API contract.
- The operation has clear success / failure modes that fit
`{ exitCode, data?, stdout?, message? }`.
If the function is just a Luau utility called from other code, use a
`.module`. If it's stateful background work, use a `.service`. If
it's pure GPU compute, use a compute `.shader`.
## Where it lives
Tools nest under a `.toolbox/` parent — toolboxes are the namespace.
- Source: `/zero/source/.../<Toolbox>.toolbox/<name>.tool/`
- Identity: `<Toolbox>.<name>` (dotted form — the toolbox name is
part of the tool identity).
- Folder shape:
- `init.luau` (or `init.lua`) — exports the function, and carries its
schema in `--!desc` / `--!arg` / `--!return` / `--!example`
annotations above it. The typed signature IS the tool's signature.
**Required.**
- `README.md` — instance documentation. **Required.**
- `.metadata` — tool-level tier and tags. **Required.**
## How to create one
Use the workflow tool that scaffolds the right `.tool/` shape under
an existing toolbox:
```luau
-- The `tools` global authors a tool inside an existing toolbox:
tools.create({
name = "<name>",
toolbox = "<Toolbox>",
description = "What this tool does, agent-facing.",
code = "return shared.ok(true)", -- the function body
})
-- Or scaffold the folder manually:
local box = asset.resolve("<Toolbox>.toolbox")
asset.create("tool", "<name>", { into = box })
```
Always nest under an existing toolbox — if you don't have one yet,
call `tools.createToolbox({ name = "<Name>", description = "..." })`
first.
## How it operates
1. **Registration.** Writing `init.luau` into a `.tool/` folder
registers the tool with the tools registry. It becomes callable
as `tools.use("<toolbox>", "<name>", ...)` from Luau,
`use_tool { toolbox, tool, args }` over MCP, and
`zero <toolbox> <name> [args]` from the engine shell.
2. **Schema validation.** The `typed function` signature declares the
parameter shape. Its runtime dispatch wrapper checks each argument
before the body runs and raises a message naming the argument, the
expected type, and what arrived.
3. **Return envelope.** Tools MUST return `ZmToolResult`:
```
{ exitCode = 0|n, data? = any, stdout? = string, message? = string }
```
`exitCode = 0` means success; non-zero means failure, with
`message` carrying the human-readable reason.
4. **Discovery.** The tool surfaces in `tools.list()`,
`search_tools { query: "..." }`, and `asset.inspect`'s
`exposes:` section (parsed from the typed signature and the
`--!` annotations).
5. **Hot reload.** Editing `init.luau` re-registers the tool.
Subsequent calls see the new behaviour / schema.
## Discovery
- `tools.list()` — every registered tool (any toolbox).
- `search_tools { query: "..." }` — MCP-side tool search.
- `asset.list("tool")` — same set, asset-style listing.
- `asset.inspect("<toolbox>.<name>")` — signature, parameters,
return shape, this type README.
- `ls /zero/source/<Toolbox>.toolbox/<name>.tool` lists the files the folder holds.
## Authoring conventions
- **Implementation lives in `init.luau`.** The tool's full function
body — argument validation, the call into engine APIs, the
return value — goes in `init.luau`. The toolbox's
`shared.module/` is for genuinely cross-tool helpers (`ok` /
`fail` constructors, shared parsers / validators), not per-tool
logic. Do NOT make `init.luau` a one-line forwarder onto a
function defined in `shared.module/`.
- Use `shared.module/` in the parent toolbox for `ok` / `fail`
result constructors. Don't hand-roll the envelope per tool. Pull
it in with `local shared = require(".shared")` from any tool
that needs the helpers.
- Annotate every public function with `--!desc` / `--!arg` /
`--!return` / `--!example` directly above the function — the LSP
and toolbox surfaces extract these.
- **Declare every exported function `typed function M.name(arg: T,
opt: U?): R`.** This is not conditional on the signature being
"clear" — a loose argument is the case that needs the checked
contract most. Give the argument the type it actually accepts
(a union, a named table type, a literal enum) instead of dropping
to an unchecked `function`.
- Keep tools focused. One tool, one named workflow. Split rather
than grow.
- Tag tools in the sibling `.metadata` (`{ "tags": [...], "tier":
"core|content|specialized" }`) so `search_tools` can find them by
domain (e.g. `entity`, `scene`, `assets`, `physics`).
## The typed signature is the schema
The `typed` keyword compiles the function into a dispatch wrapper
that validates arguments at call time. Without it there is no check
at all, so the tool runs its body on whatever arrived and returns a
success envelope built from garbage. Type each argument as narrowly
as the operation actually accepts:
```luau
export type Mode = "edit" | "play"
export type SpawnOpts = {
position: { number }?,
parent: EntityRef?,
count: number?,
}
--!desc Spawn a primitive and return the entities it created.
--!arg kind Primitive shape to spawn.
--!arg opts Placement and repetition options.
--!return The spawned entity refs.
--!example "cube", { count = 4 }
typed function M.spawn(kind: ("cube" | "sphere" | "plane"), opts: SpawnOpts?): { EntityRef }
```
- **A fixed set of accepted words is a literal union**, never
`string`. `("cube" | "sphere")` rejects `"Cube"` at the call and
lists the accepted values in the error; `kind: string` accepts
`"banana"` and fails somewhere deeper, or not at all.
- **A structured argument is a named `export type`**, never
`{ [string]: any }`. Naming it puts every field and its type on
the tool's page and checks them on entry.
- **A named type is only as strong as its fields.** `opts: DrawOpts`
where `DrawOpts = { value: any }` passes for a table and checks
nothing inside it — the same hole, one level down. The argument
checker walks nested fields, so type each one; a type name is not
a place to put the work off.
- **A dispatch table keyed by an `op` string is several tools.**
`{ op: string, [string]: any }` is one unchecked entry point
wearing N signatures — split it so each operation carries its own
checked arguments.
- **An entity or asset argument is `EntityRef` / `AssetRef<kind>`**,
never `any` and never a bare name string.
- `any` is correct only for a value the tool genuinely does not
constrain — a passthrough payload, arbitrary user data. It is not
a placeholder for a type not yet written down.
## Common pitfalls
- **A plain `function` export.** The tool registers and dispatches,
so nothing looks wrong — it just never rejects bad input, and
every call reports success. Declare it `typed function`.
- **Annotations without `typed`.** `function M.f(x: string)` reads
as typed and checks nothing at runtime; the annotation only
informs the LSP. The keyword is what enforces it.
- **Wrong return envelope.** Returning a raw value instead of
`{ exitCode = 0, ... }` will be flagged by callers expecting
`ZmToolResult`.
- **Tool identity.** The fully-qualified identity is
`<toolbox>.<name>`, NOT just `<name>`. Toolbox-less tools don't
exist by design.
- **Missing dependency.** Tools requiring another package /
service must declare it; otherwise the call fails at runtime
with a missing-module error.
## Related types
- `.toolbox` — the required parent namespace for tools.
- `.module` — for Luau libraries with no agent-facing schema.
- `.service` — for stateful background work, not callable
request/response operations.
## In the Inspector
Tool: how the tool is called (its signature), what it does, each argument, what it returns, and its examples, with **Open** for its source.
# Module (asset type)
A module is a reusable, stateless (or globally-stateful) Luau library
that other code pulls in with `require()`. Modules are the unit of
factored-out logic in a world: helpers, math libraries, format
parsers, registries, shared algorithms.
## When to use one
- You have logic you want to share across multiple components,
services, scenes, or tools.
- You have a registry, lookup table, or shared cache that callers all
read from / write to.
- You want a stable API surface (`M.fn`) other code can depend on.
If the logic is bound to one entity, use a `.component`. If you want a
named UI of fields rather than functions, use a `.preset`.
## Where it lives
- Source: `/zero/source/.../<name>.module/`
- Identity: the source path with `/` written as `.` and each segment's
type suffix dropped — `/zero/source/demo/beats.module` is
`demo.beats`, and `/zero/source/kit.package/beats.module` is
`kit.beats`, the container's name kept and its `.package` suffix
gone. `asset.create` returns this string, `asset.resolve(...).identity`
reads it back, and the `require` forms below write it as
`<identity>`.
- Folder shape:
- `init.luau` (or `init.lua`) — module body. **Required.** Returns
a table (conventionally named `M`).
- `README.md` — instance-level documentation. **Required.**
- `.metadata` — agent-editable tags + free-form fields. **Required.**
## How to create one
```luau
asset.create("module", "<name>")
-- Creates: /zero/source/<name>.module/
-- init.luau (canonical `local M = {} … return M`)
-- README.md (instance README template)
-- `folder` places it in a subfolder of /source, and that subfolder
-- leads the identity: `{ folder = "lib" }` produces `lib.<name>`.
-- `into` authors it inside a resolved container (a package, a toolbox),
-- whose own identity leads the same way:
asset.create("module", "<name>", { folder = "lib" })
asset.create("module", "<name>", { into = asset.resolve("myPkg.package") })
```
## How it operates
1. **Registration.** Writing `init.luau` into a `.module/` folder
indexes the asset and registers the module path with the resolver.
2. **Loading.** The first `require("<identity>")` runs
the file's body and caches the returned table. Subsequent requires
from anywhere return the **same table** — this is the
shared-state lever for "module-local" registries.
3. **Hot reload.** Editing the file re-runs the module body and folds
what it returns into the table already loaded, so that table keeps
its identity: a caller that captured it with `require` reads the new
source's functions and values through the reference it already
holds, and a later `require` returns that same table. A key the new
source drops goes away; a key something set at runtime stays. The
body's own `local`s start again from their initial values, so state
that must outlive an edit lives in `modules.state()`
(`guides { path: "core/scripting-and-tasks" }`).
4. **Identity resolution.** A module is reached by its `<identity>`,
and where the caller lives decides which spelling of it resolves.
For a module in a **world**, from a caller in that same world —
another module, a component, a scene entrypoint, or an `execute`
chunk:
- `require("<identity>")` — the identity on its own, resolved
against the caller's own root. It carries every folder segment,
so a module created with `{ folder = "demo" }` is
`require("demo.beats")`.
- `require("@root::<identity>")` — the same root-relative
resolution, written out.
- `require(".sibling")` — a module in the same folder, by its bare
name; `..name` steps up one folder, `...name` two.
Logs, stack traces and hot-reload notices name that same module
`@local.source.<identity>` — its key under the world source root.
The `<identity>` tail of that key is the form above, so a trace
reading `@local.source.demo.beats` is `require("demo.beats")`.
For a module in a **library** — content under
`/zero/source/libs/@<lib>/` — `@<lib>::` addresses that library's
root from anywhere: `require("@builtin::modules.transform")`.
Mounting a world as a library moves its files under such a root and
flips the caller root to `@<lib>::`, which is what keeps the
root-relative forms above resolving across the move.
## Discovery
- `asset.list("module")` — every registered module.
- `asset.inspect("<name>")` — public functions, source path, this
type README.
- `ls /zero/source/<name>.module` lists the files the folder holds.
## Authoring conventions
- Return a single `M` table from `init.luau`. Top-level statements
with side effects run on first require — useful for one-time
initialization, dangerous if they touch the engine before it's
ready.
- Write exported functions as `typed function`, not `function`: the
argument types are enforced at the call and the checker knows the
signature, so a caller's mistake is reported where they made it.
- Annotate exported functions with `--!desc` / `--!arg` / `--!return`
/ `--!example` so the LSP, `tools.list`, and `asset.inspect`
surface them. A function with no `--!desc` is reachable only by
someone already reading this file.
- Declare a VALUE with `Field.<kind>(default, mode, description)`, and
a table whose members are reached through a metatable with
`--!members <TypeName>` — the latter gives the surface its accepted
member set, its type, and its documentation from one table. See
`guides { path: "core/authoring" }` for all four rules and
`modules/api/engine/entity.module/members.module` for a worked
member table.
- Keep module state in module-local upvalues or in `modules.state()`.
Globals leak across reloads. Module-locals start again from their
initial values with each hot reload; the table `modules.state()`
returns is the same table on both sides of every reload.
- Prefer focused modules over kitchen-sink modules. If two halves of a
module have no shared state, split them.
## Common pitfalls
- **Cyclic requires.** A `require` chain that loops will return the
partial table (the half built before the cycle was detected).
Design for one-way dependencies.
- **Engine-time side effects.** Don't `entity.spawn` at module
top-level — the engine may not be ready. Expose an `M.init` and
call it from a scene entrypoint.
- **Hot-reload + module-local cache.** A module that caches
expensive computation in upvalues loses that cache on reload —
fine for dev, but be aware in performance work.
- **`init.lua` vs `init.luau`.** Either works, `.luau` is canonical.
- **Writing one while play runs.** A `/source` write made while play is
running lands on the play shadow, and a guarded play-exit discards it
unless it is kept. One `.module` write shadows `init.luau`, the
`README.md` and the `.metadata` together.
`vfs.write(path, bytes, { durable = true })` skips the shadow, and
`vfs.promotePlayShadow` keeps what is already on it — the `core/vfs`
guide has the model and what each route costs.
## Related types
- `.component` — for entity-bound state + lifecycle hooks.
- `.service` — generates content you don't have yet (a mesh, sound, texture, …) via a metered provider.
- `.tool` — for a single agent-callable function with a YAML schema.
- `.package` — to group several modules + components + scenes into
one shippable folder.
## In the Inspector
Exports: what the module exports (name, kind, signature), parsed from its source without running it, with **Check** (the LSP's diagnostics for its entry file) and **Open**.
# shared
Cross-tool helpers for the [name] toolbox. Every tool inside the
toolbox can pull these in with `require(".shared")`. Holds the small
set of helpers that more than one tool needs — typically the
`ok(value, formatted?)` / `fail(message, exitCode?)` constructors that
produce the canonical `[name]ToolResult` shape, plus any toolbox-wide
parsers, validators, or shared state. Per-tool implementation stays
inside each `<x>.tool/init.luau`; this module is not a place to
forward logic from.
## Exports
- `M.ok(value: any?, formatted: string?) -> [name]ToolResult` — success envelope (`exitCode = 0`, `ok = true`).
- `M.fail(message: string, exitCode: number?) -> [name]ToolResult` — failure envelope (`exitCode` defaults to 1, `ok = false`).
Types:
- `[name]ToolResult = { stdout: string, stderr: string, exitCode: number, ok: boolean, value: any? }`
- `stdout` — human-readable formatted output; empty when the op produces no text.
- `stderr` — error message on failure; empty on success.
- `exitCode` — POSIX-style exit code: `0` success, `1` generic failure, `2` bad arguments.
- `ok` — `true` iff `exitCode == 0`.
- `value` — structured return from the underlying op; `nil` for void ops.
## Usage
```luau
local shared = require(".shared")
function M.someTool(args)
if not args.id then
return shared.fail("missing id", 2)
end
return shared.ok({ id = args.id }, "created " .. args.id)
end
```
## Notes
- Pure constructors — no engine calls, no state. Safe to call from any
context.
- Tools that opt into this shape get a stable envelope every other tool
in the toolbox can rely on. Tools that do something custom can return
whatever shape they want — `[name]ToolResult` is opt-in.
- Per-tool implementations still own their own `--!desc` / `--!arg` /
`--!return` / `--!example` annotations even when their result is
constructed through these helpers.
# 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.**
- `about.yaml` — three short plain-language texts saying what the type
is for (see "Say what your type is for" below). Every built-in type
ships one.
- `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.
## Say what your type is for (`about.yaml`)
Every type carries a small `about.yaml` beside its `type.yaml`, and the README
you write for the type documents how to author an asset of it. Both are
required, and both are meant to be written as you make the type, while you know
what it is for. `about.yaml` answers three questions in plain words, one key
each:
```yaml
intent: "Sets the typeface of the text in your game: UI, labels, signs and 3D lettering."
function: "A font, from a TTF, OTF or WOFF file, made available to all of Zero's text, including 3D text."
usage: "You choose it by name for your UI or your text, and the text appears in that typeface."
```
- **`intent`** is what the type is for in someone's game. Name the whole range
of things it covers, in the words a game maker would use for them.
- **`function`** is how it works today: what an asset of the type is made of and
what the engine does with it.
- **`usage`** is what a person or an agent does with one: how they pick it, place
it, call it, or hand it to something else.
A few habits keep it useful. Write about the type as a whole, so the text holds
for every asset of it, and leave out any one asset's name. Say what the type
does right now. Stay with words a game maker would say, and mention a file name
or a call only where a game maker would know it. The limits are in bytes:
`intent` 800, `function` 1500, `usage` 1200. Each is generous, and a few
sentences usually say it.
Why bother: agents working in a world read these three texts to learn what your
type is for, so they reach for it when it fits and use it the way you meant. People
searching find the type by what they want to make, and the three texts let them
tell at a glance whether it is the one they need.
`asset.create("assetType", name)` scaffolds the file with all three keys, and a
README whose first paragraph is a `[[describe]]` note. Each value starts with
`[[describe]]` followed by a note on what to write; replace the whole value with
your own words. A value or a README line that still starts with `[[describe]]`
counts as not written yet, which the push's check of each asset it carries against
its type reports, naming the file. A type you leave as it was is not asked, so
older types stand.
Many types at once, or one you would rather not write by hand: `services
describe` lists the types that lack these texts and what filling them costs, and
`{ mode = "all" }` writes them into the same files, where you edit them like
anything else you wrote.
Where the texts show up: `asset.inspect("<typename>", "assetType")` returns them
as `detail.about`, `assetTypeRef:getAbout()` returns them as a table with the
keys `intent`, `function` and `usage`, the type's `types/<typename>` guide page
ends with an "About this type" section holding them, and the Inspector's Type
section lists them at the top. The guide page is this README plus that section,
so a README that explains the type well and an `about.yaml` that says what it is
for read well together.
## Ask your assets to say what they are
A type can ask each of its assets to carry a description: a file, written by the
author or generated, that says what that asset is and what it is for. The people
and agents who find an asset read it to decide whether it fits, and the author
knows what it is for at the moment of making it.
**1. Declare the file with a `described` check.** This is the whole requirement:
```yaml
files:
required:
- path: "README.md" # any file name works
kind: "file"
content:
- { check: "described", message: "Say what this widget is for and where it goes." }
```
With that in place:
- An asset whose file is missing, empty, or still has a line starting with
`[[describe]]` does not meet the type's requirements. `asset.validate` names it,
`zm status` and `world.publishBlockers()` list it, and a push that carries it is
refused with `message`.
- `services describe` finds it (`mode = "find"` lists it and the price) and can
write the description for it (`mode = "all"`). It writes only this file.
For a YAML file that holds several texts, name the keys and their limits in bytes,
the way this type does for `about.yaml`:
```yaml
content:
- check: "described"
keys:
- { key: "intent", max_bytes: 800 }
- { key: "usage", max_bytes: 1200 }
message: "Write intent and usage."
```
`headings` names sections a Markdown file must have. `assetTypes/README.md` has the
full check.
**2. Optionally, steer what gets generated with a `describe:` block:**
```yaml
describe:
guidance: "Say what the widget shows, what input it reacts to, and where it fits in a UI."
examples:
- "<identity of an asset of this type whose description reads well>"
```
`guidance` says what a description of your type covers, and `examples` names assets
whose descriptions show the shape you want. Without the block, generation still
works from the general describing guide and the check's `message`.
An example is an asset of your type in the library whose description is a true,
written one, never a scaffold's placeholder, and never the asset being described.
When the library has none, leave `examples` empty rather than name one that is not.
A type whose assets the model cannot perceive (a sound it cannot hear, a motion it
cannot see) says so in `guidance`: the description comes only from what the asset's
own files state, such as its name and the `.metadata` where a generation service
records the prompt that made it, and when they state nothing the model answers
`cannot` with that reason. `services describe` then fails that one asset with the
reason and leaves its file as it was.
**3. Start the file unwritten in your template.** Put a heading and one line that
starts with `[[describe]]` and a short note on what to write, in
`template/<file>`, so a new asset starts unwritten rather than with text that only
looks written. Do not put example content in it: every new asset carries the
template verbatim, and the same text on many assets makes them impossible to tell
apart in search.
Types whose assets say what they are through their own content (a mesh, a texture,
a sound) leave this out: declare nothing and nothing is asked.
## 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.onReplace` | `(dest, files) -> { [filename] = contents }` | On an `asset.create` that writes over an asset already at `dest`, after `onCreate`. Receives the files `onCreate` produced and returns the files to write, so a type that re-derives its content (a rig re-imported from its source file) can carry what an author added to the asset it replaces. Optional. |
| `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.
## The names your type's assets hold (`references:`)
An asset that names another asset by a string depends on it: the named asset
travels with it into a pack or a pull, and a name nothing answers to is an
unresolved dependency `worldValidation` reports and the push gate refuses. The
engine reads `require(...)` and the asset lookups on its own; a name your type's
format or API carries anywhere else is one you declare, in `type.yaml`:
```yaml
references:
# A top-level key of this type's own files that holds a name (or a table of them).
keys:
- { key: "shader", type: "shader" }
# A call's argument, counting from 1, wherever the call is written.
args:
- { call: "g:node", position: 2, type: "procNode" }
# A field of the options table a call takes.
fields:
- { call: "fx.beam", field: "material", type: "material" }
```
`type` is the category the name resolves in, one of `asset.categories()`. An
argument declared once per category, at the same `call` and `position`, takes
the name from whichever category answers. A key or a field holds one category:
declared again with another `type`, it takes the later one. The block is
declared as the type registers, when the library or the world holding it is
laid into the VFS, so every reference table reads it whatever script has or has
not run. `asset.declareReferenceKey`, `asset.declareReferenceArg` and
`asset.declareReferenceField` declare the same things from a script, for a
surface that is no type's.
## 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.
A type whose instances are never searchable (a run record, a log) says so with
`indexing: none`; every other type declares a recipe.
A type without an `about.yaml` beside its `type.yaml` refuses every push that
carries it, whether or not the push edited the type. Texts in it that are not
written yet follow the rule every `described` file follows: they refuse the
push that edits, stages or commits the type.
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).
## A container whose contents work together (`system: true`)
A type that is a container can say its contents are one working whole by
declaring `system: true` at the top level of its `type.yaml`. `package` declares
it: the components, modules, scenes and tools a package ships are parts of one
system. The declaration is plain data, read from the decoded `type.yaml`
(`require("@builtin::assetTypes.assetType.shared.declaration").of("package").system`),
and a type that leaves it out is an ordinary type.
## 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, plus its `about.yaml` texts as `detail.about`.
- `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.
# 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`.
# 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.
# 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.
# 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)
```
# MeshCollider (component)
Collision against mesh geometry. There are no size fields: the collider is the
mesh, scaled by the entity's transform.
## The two fits
| `fit` | Shape | Bodies | Concavity |
|-------|-------|--------|-----------|
| `"hull"` (default) | Convex hull of the mesh's points | Static and dynamic | Filled in, so a doorway becomes a slab |
| `"exact"` | The mesh's own triangles | Static | Followed exactly |
`"exact"` is for static bodies because a triangle mesh is a surface with no
interior: rapier cannot derive a mass tensor from one, and a dynamic body built
from it would fall through everything it touched. Setting `fit = "exact"` on a
dynamic body raises an error naming `"hull"` rather than quietly substituting it.
## Choosing the geometry
`mesh` names the asset to collide against. Left unset, the collider tracks
whatever mesh the entity renders, including a later swap. Set it to collide
against a cheaper proxy than the one on screen, which is the usual move for
detailed scenery where the visual mesh carries far more triangles than collision
needs.
```lua
entity.find("rock").component.add("MeshCollider", {}) -- what you see
entity.find("level").component.add("MeshCollider", { fit = "exact" }) -- concave, static
entity.find("rock").component.add("MeshCollider", { mesh = "rock_collision" }) -- cheap proxy
```
Because `mesh` is an asset reference, a collision proxy is visible to the asset
graph and survives a rename.
## Cost
Built shapes are shared: every collider asking for the same mesh, fit and scale
gets the one built shape, so a field of a hundred identical rocks pays for one
hull rather than a hundred.
## Fields
| Field | Meaning |
|-------|---------|
| `mesh` | Mesh asset to collide against. Unset tracks the entity's renderable. |
| `fit` | `"hull"` or `"exact"`. |
| `isTrigger` | Report overlaps without blocking. |
| `friction` | Surface friction. |
| `restitution` | Bounciness. |
# volumetrics
Volumetric (3D-texture) rendering, end to end, in one package. Built
entirely on the engine's generic `compute.*` GPU primitives — no
Rust changes are needed to author new volumetric content.
## Layout
| Asset | Identity | Role |
|---|---|---|
| `Volume.component` | `@builtin::systems.volumetrics.Volume` | Attach a 3D voxel texture to an entity; raymarches it through the entity's transform AABB. |
| `VolumetricSky.component` | `@builtin::systems.volumetrics.VolumetricSky` | Singleton procedural cloud layer with TAA. |
| `ParticipatingMedium.component` | `@builtin::systems.volumetrics.ParticipatingMedium` | Mark an entity as fog/smoke/dust that attenuates the light passing through it. |
| `mediaTransmittance.module` | `@builtin::systems.volumetrics.mediaTransmittance` | Light-space optical-depth map: the registry, the settings, and the basis the map is built in. |
| `mediaShadow.renderFeature` | `@builtin::systems.volumetrics.mediaShadow` | Builds the optical-depth map and attenuates each surface by the density in front of it. |
| `volume.module` | `@builtin::systems.volumetrics.volume` | Builders (`noise`/`sphere`/`box`/`wrap`) + the `.zvol` header reader + occupancy + brick paging + the per-entity raymarch dispatch. |
| `VolumeSequence.component` | `@builtin::systems.volumetrics.VolumeSequence` | Play an open volume sequence through an entity's transform AABB. |
| `volumeSequence.module` | `@builtin::systems.volumetrics.volumeSequence` | A run of `.zvol` frames streamed through a fixed ring of resident volumes. |
| `volumeDemos.module` | `@builtin::systems.volumetrics.volumeDemos` | Shared pipeline for the showcase scenes (fill + emissive raymarch + composite). |
| `volumetricSky.module` | `@builtin::systems.volumetrics.volumetricSky` | Cloud volume + raymarch + TAA + composite orchestration. |
| `shaders/*.shader` | `@builtin::systems.volumetrics.shaders.*` | `volume_raymarch`, `volume_pages`, `volume_pack`, `cloud_noise[_v3/_v5]`, `cloud_raymarch[_v3/_v4/_v5]`, `cloud_taa`. |
| `importers/vdb_to_zvol.importer` | (auto-discovered) | `.vdb` → dense `.zvol` via the `openvdb` WASM plugin (procedural fallback when the plugin isn't loaded). |
| `presets/cumulus_default.preset` | `@builtin::systems.volumetrics.cumulus_default` | Canonical full `VolumetricSky` weather preset. |
| `demo/*.scene` | `@builtin::systems.volumetrics.demo.*` | `sdf_solids`, `smoke_plume`, `nebula`, `godrays`, `zvol_viewer`, `sky`. |
## Path convention — fully-qualified builtin identities
Every intra-package reference uses the asset's fully-qualified builtin
identity, the same convention every other builtin package follows:
```luau
local Volume = require("@builtin::systems.volumetrics.volume")
local Sky = require("@builtin::systems.volumetrics.volumetricSky")
local V = require("@builtin::systems.volumetrics.volumeDemos")
-- shader identities, e.g. asset.resolve("@builtin::systems.volumetrics.shaders.volume_raymarch")
```
Qualified identities are caller-independent: they resolve identically
from a module, a scene entrypoint, or a component. Caller-relative
(`.sibling`) requires are deliberately avoided here because the
component runtime caller identity is `@builtin::components.<Name>`, so a
`.sibling` require from a component resolves against the wrong root.
(An earlier revision of this package used a `~volumetrics` package-root
alias for relocatability; that alias is not resolvable on current main,
so the package uses qualified identities throughout.)
## Usage
```luau
-- Per-entity volume. The component resolves the name through `Volume.get`,
-- so a volume paged into a brick pool renders from the pool under the same
-- name:
local Volume = require("@builtin::systems.volumetrics.volume")
local cloud = Volume.noise({ name = "cloud", size = 64, scale = 4 })
cloud:buildSparse({ brickSize = 8 })
cloud:releaseDense()
entity(id).component.add("Volume", { volumeName = "cloud", density = 6.0 })
-- Procedural sky:
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
preset.apply("cumulus_default", { entity = sky.id, component = "VolumetricSky" })
-- VDB import: drop a `.vdb` into the VFS; the importer writes a `.zvol`
-- alongside it. Load it with the zvol_viewer demo scene.
-- Animated run: one `.zvol` per frame, played through a bounded ring.
local Seq = require("@builtin::systems.volumetrics.volumeSequence")
Seq.open({ name = "plume", frames = framePaths, resident = 3, fps = 24 })
entity(id).component.add("VolumeSequence", { sequenceName = "plume", density = 6.0 })
```
## Plugin dependency
The VDB importer delegates real OpenVDB parsing to the `openvdb` plugin
in the builtin library, `@builtin::plugins.openvdb`. Without the plugin
the importer falls back to a deterministic procedural torus so the
pipeline stays functional.
# DecalProjector
Turns its entity into an oriented **box decal projector** — the deferred-decal
equivalent of Unreal's Decal Actor or Unity's Decal Projector. The entity's
Transform is the projection volume: position and rotation orient the box, scale
sizes it, and the projector stamps its `material` along the box's local −Z onto
every surface inside the box, reconstructing each covered pixel's world position
from scene depth and fading on faces turned away from the projection axis so the
decal wraps the surfaces facing it.
## Surface
| Field | Type | Meaning |
|-------|------|---------|
| `material` | `AssetRef<material>` | What the projector stamps. Its base-colour texture + tint drive the decal — a textured material projects its texture, a flat-colour material projects its colour. Defaults to `@builtin::materials.decal_default`. |
| `opacity` | `number` | Master fade, `0..1`. |
Registers into the `@builtin::modules.decals` registry on `awake`/`onEnable`
(and re-registers on property change / in edit mode), which the
`@builtin::renderFeatures.decals` render feature reads every frame.
## Example
```lua
-- A downward projector that stamps a texture onto whatever is under it.
local p = entity.spawn("floorDecal")
p.localPosition = { 0, 3, 0 }
p.localScale = { 6, 6, 4 }
p.localRotation = { 0.70710678, 0, 0, 0.70710678 } -- project down (-Y)
p.component.add("DecalProjector", {
material = "@builtin::materials.decal_default",
opacity = 1.0,
})
```
# SphereCollider (component)
A spherical collision shape. Works with or without a `Physics` component: on its
own it is static collision geometry, and alongside one it moves with the body.
## Sizing
`radius` is in the entity's local space, multiplied by the entity's world scale.
Leave it unset and the sphere derives from the entity's model bounds.
```lua
entity.find("ball").component.add("SphereCollider", { radius = 0.5 })
entity.find("zone").component.add("SphereCollider", { radius = 5, isTrigger = true })
```
## Fields
| Field | Meaning |
|-------|---------|
| `radius` | Sphere radius. Unset derives from model bounds. |
| `isTrigger` | Report overlaps without blocking. |
| `friction` | Surface friction. |
| `restitution` | Bounciness. |
## Composing
A collider on a child entity attaches to the nearest ancestor carrying a
`Physics` component. A character is a `CapsuleCollider` for the body and a
`SphereCollider` trigger on a head entity; queries report whichever shape they
hit, so the head stays distinguishable from the torso.
# DirectionalLight
A directional light in addition to the scene's sun: parallel light arriving from
one direction everywhere in the world, the way a body far enough away delivers
it. A second star over an alien planet, a moon hanging opposite the sun, a cold
fill from the far side of a set.
The sun is `Light { kind = "directional" }` — one direction, one colour and the
cascade set, held as a single field, so a second `Light` of that kind moves the
sun rather than joining it. This component adds a light instead. Several can
coexist; they share the scene's light buffer with point, spot and area lights,
so the cap is the combined count (64).
Public fields: `directionX/Y/Z` (the direction the light TRAVELS, normalised by
the engine), `colorR/G/B`, `intensity`, `mobility` (`auto` / `static` / `mixed`
/ `dynamic` — how a GI bake treats it).
Input aliases accepted by `component.add`: `direction`, `color`, `brightness`.
Methods: `:resolveMobility()`, `:lightMobility()`.
`lighting.lightRows()` reports one of these with `kind = "distant"`; the row's
`direction`, `color` and `intensity` are what the shading reads, and its
`position` follows the carrying entity. A GI bake gathers it the way it gathers
the sun, so a `mobility = "static"` light is baked whole and its live
contribution withheld.
```luau
entity(id).component.add("DirectionalLight", {
direction = { 0.3, -0.5, 0.4 },
color = { 0.7, 0.8, 1.0 },
intensity = 0.6,
})
```
# lightCookies
Projects an authored image through a spot light's cone — a gobo, window blinds,
a headlight cutoff, a projector pattern.
## How it divides up
The renderer already projects a texture through a spot's frustum. At each shaded
point it reads `zero_feature_texture(uv, cookieLayer)`, with `uv` from that
point's position in the light's own clip space, and multiplies the cone by what
it finds. `SpotLight.cookieLayer = -1` means no cookie.
What the renderer does not decide is what goes in the layer. That is this
package: resample an authored image into one full layer of the shared
feature-texture array, and point a spot at that layer.
## Using it
```luau
local lightCookies = require("@builtin::systems/lightCookies.package/lightCookies")
-- Size the array once for every layer the scene will use.
lightCookies.configure({ resolution = 256, layers = 4 })
lightCookies.project(lampId, "/textures/cookies/window_blinds.png", { layer = 0 })
```
Or attach a `LightCookie` component next to the `SpotLight` and author
`texture` / `layer` / `gain` / `tint` on it.
`lightCookies.clear(lampId)` stops the projection; the layer keeps its contents.
## When a fill lands
`fill` and `project` queue the fill and return its layer at once. The module's
own loop writes it over the frames that follow: the rasterize is issued on the
next frame and the copy into the layer on the frame after, and the fill is
written once `renderer.featureTexture.state().fills` reports the copy landed in
its layer, one fill at a time in the order they were asked for. The loop belongs to the module, so a fill
lands even when the code that asked for it (a one-shot script, a task scope, a
component that is destroyed) has ended.
`lightCookies.status(layer)` reports where the last fill of a layer stands:
```luau
local s = lightCookies.status(0)
-- s.state: "queued" | "rasterizing" | "copying" | "written" | "refused"
-- s.reason: why a refused fill was refused, in the engine's words
-- s.filled: whether renderer.featureTexture.state().filled lists the layer
```
`status` reads a fill `"written"` from the first reading of the array that
reports its copy landed, so `s.state` and `s.filled` agree whichever of the
module's loop and the reader runs first in a frame.
A fill names a layer below the count `configure` sized the array to; one past
it is refused at the call, which returns nil and the reason. A fill that cannot
land once it is on its way (the GPU refuses its rasterize because its image was
given back first, a later `configure` left the array without its layer, or its
copy never reached the array) reads `"refused"` with the reason, and is also
logged as a warning.
A `LightCookie` component whose fill is refused at the call, such as one
authored before the scene's `configure` has sized the array, asks again each
time `configure` sizes it. `lightCookies.observe().configurations` counts
those calls for code of its own that does the same.
`lightCookies.observe()` reports how many fills are waiting, the one in
progress, and the loop's own counters.
## An empty layer puts the light out
The cone is multiplied by what it reads from its layer, so a spot pointed at a
layer nothing has filled renders black rather than plain. `project` fills before
it points the light, which is the order that keeps the cone lit; if you set
`SpotLight.cookieLayer` by hand, fill that layer first.
## The array is shared, and sizing it is explicit
The feature-texture array is one resource that every feature using
`zero_feature_texture` draws layers from, and it has no allocator: sizing it
zeroes every layer, including layers another feature owns. So this package never
sizes it as a side effect of setting a cookie, and `layer` is authored rather
than assigned. `lightCookies.configure` is the one call that resizes, made once
by whoever knows what else in the scene wants layers.
A scene that also bakes global illumination shares this array with the lightmap
atlas. Size it for both, and give cookies layers above the ones the bake fills.
## A cookie fills a whole layer
The cone's lookup runs over the layer's entire `uv` range, so an image is
resampled to the layer's extent rather than placed in a corner of it. One layer
carries one cookie.
## Alpha
The source's alpha is how much of the pattern applies. At alpha 0 the cone
passes through unmodulated, so a fully transparent image is a no-op rather than
a black cone.
# restirLighting
Direct light from many small sources at a cost that does not grow with how many
there are.
A shading pass that loops every light pays for every light at every pixel, which
is why the number of lights has to be capped. Resampled importance sampling
breaks that link: each pixel draws a few candidates, keeps ONE of them with a
probability proportional to what it would contribute, and carries a weight that
makes the survivor stand for the whole set. What it keeps is then reused — by its
neighbours this frame, and by itself on the next one — so the set a pixel
effectively samples keeps growing while the number of candidates it draws stays
where you put it.
A cave of glowing crystals, a street of practicals, a control room of indicator
lamps: hundreds of sources, and each pixel looks at about a dozen.
## Using it
Put an `EmissiveSource` on the geometry that glows:
```lua
local id = entity.spawn("crystal", { position = { 2, 1, 0 } }).id
entity(id).component.add("EmissiveSource", {
color = { 0.3, 0.8, 1.0 },
intensity = 8.0,
radius = 6.0,
size = 0.4,
})
```
Or register sources from a script, which is how a generated scene does it:
```lua
local restir = require("@builtin::systems.restirLighting.restirLighting")
for i = 1, 400 do
restir.set(`lamp{i}`, {
position = { math.random() * 60 - 30, 1.5, math.random() * 60 - 30 },
color = { math.random(), math.random(), math.random() },
intensity = 4.0,
radius = 5.0,
})
end
```
The pass starts with the first source and stops with the last. Settings are on
`restir.configure`, or on a `ResampledLighting` component for a scene to author:
```lua
restir.configure({ candidates = 16, spatialSamples = 6 })
```
## What a pixel pays
`candidates + spatialSamples + 1` evaluations, reported by
`restir.perPixelCost()`. None of those terms is the source count.
The estimate is formed on a grid of one cell per 2x2 pixels (`resolution =
"full"` gives every pixel its own cell), across four passes:
| Pass | Runs over | Does |
|---|---|---|
| `restir_sample` | grid cells | draws `candidates` sources at random, resamples to one, merges the cell's own history |
| `restir_spatial` | grid cells | borrows from `spatialSamples` neighbours, each evaluated against this cell's surface |
| `restir_resolve` | pixels | shades the chosen source with the pixel's own normal, roughness and albedo |
| `restir_denoise` | pixels | averages that light over the cells around the pixel and composites it |
Which source to look at is a low-frequency decision a cell of pixels can share.
What the surface does with it is not, and is computed per pixel.
One source per cell is correct on average and different from its neighbour,
which is the grain a single sample carries. `restir_denoise` averages across the
cells around a pixel, weighing each by how nearly its surface is this one, so the
estimate keeps its value and loses its variance.
`mode = "reference"` replaces the reservoir lookup with a loop over every
registered source at every pixel. That is the picture the resampled mode is
estimating and the cost it is measured against — the same shading, the same
filter and the same composite, so the two are comparable directly.
## Temporal reuse
A cell keeps its reservoir across frames and merges it with the fresh
candidates, so a good find keeps paying after the frame that made it. The stored
world position is what says the cell is still looking at the same surface; a
camera move or an occluder passing in front fails that test and the history is
dropped rather than smeared.
`historyCap` bounds how many candidates the history is allowed to stand for.
Without it a converged cell would outvote every new candidate forever and a
lighting change would never work its way out. At the default of 20 a change is
absorbed over roughly twenty frames.
Removing a source, or turning its `intensity` down to 0, releases it; the pass
stops with the last one.
## Where it sits
Sources registered here are additional to the lights the engine holds rows for.
The passes run in `afterLighting` at orders 80-84 and add to the scene as it was
drawn, reading position, normal, albedo and metallic-roughness back out of the
G-buffer.
A source lights every surface within `radius` that faces it, along the straight
line between them.
## One frame, several targets
A render feature's `render` runs once a frame, and the passes it enqueues run
once per render target — the window, an offscreen capture, a camera rendering to
a texture. Each pass therefore reads the frame size out of the textures it is
handed and works out its own cell grid, so every target resamples at its own
resolution. A reservoir carries the grid width it was written under, which is
what stops one target's history being read as another's.
The reservoir buffers are sized to the widest grid any target has asked for. A
target wider than they have yet been sized for reports the grid it needed, and
the frame after that covers it.
## Reading what it did
`restir.stats()` returns the counters the passes keep on the GPU for the most
recent frame a read-back has landed for, carrying that frame's number so a
repeated read is distinguishable from a fresh one. `meanWeight` is counted in
sixteenths with each cell capped at 16 so the total fits a 32-bit counter;
`peakWeight` alongside it is exact to a thousandth, and is where an estimator
that has come loose shows first.
# 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`
# 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. |
# 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.
# 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.
# Transform
Position, rotation, and scale for an entity, as the authoring face of the transform the
entity holds. Add it to an entity to reach these fields and methods.
Public fields: `positionX/Y/Z`, `rotationX/Y/Z/W` (quaternion), `scaleX/Y/Z`. They hold
the entity's LOCAL transform — where it stands relative to its parent — and an entity
with no parent stands at those coordinates in the world.
The entity's transform is what the component reports, and the fields mirror it. Adding
the component to an entity that already stands somewhere reads that placement into the
fields and leaves the entity where it is. A field the init table names is written onto the
entity instead, so `component.add("Transform", { positionX = 1, positionY = 2, positionZ =
3 })` moves the entity to `(1, 2, 3)`; every axis the table says nothing about keeps the
value the entity already holds, so adding it with `{ scaleY = 2 }` alone stretches the
entity on Y and leaves it standing where it stood.
Writing one field moves the entity on the axis that field names. The other axes of the
same vector are read off the entity first, so a position written after something else
moved the entity keeps the move on the axes it says nothing about.
Methods take one table each and are called with a colon:
| Method | Argument |
|---|---|
| `t:setPosition({x, y, z})` | position table, `{x, y, z}` or `{x =, y =, z =}` |
| `t:addPosition({dx, dy, dz})` | offset table, added to where the entity stands now |
| `t:setRotation({x, y, z, w})` | quaternion table, `{x, y, z, w}` or `{x =, y =, z =, w =}` |
| `t:rotateAxisAngle({ax, ay, az}, angle)` | axis table, and an angle in radians |
| `t:setScale(2)` or `t:setScale({x, y, z})` | one number for a uniform scale, or a table |
| `t:lookAt(target)` | a position table, or an entity id / name / proxy |
| `t:getPosition()` / `t:getRotation()` / `t:getScale()` | none |
`setPosition`, `addPosition`, `setRotation`, `rotateAxisAngle` and `setScale` write the
entity's local transform. `rotateAxisAngle` composes onto the rotation the entity
already holds, and reads its axis in the frame that rotation is relative to — the
parent's, and the world's for an entity with no parent.
The three getters report what the entity's transform holds at the moment they are
called, so a rotation written by `lookAt` — or a position written by another script —
reads back through them.
`lookAt` is the one method on a WORLD position. It takes one target: a position table
(`{x, y, z}` or `{x =, y =, z =}`), or an entity — an id string, an entity name, or an
entity proxy — whose world position is aimed at. It answers whether the rotation was
written, and the reason when it was not. The `Camera` component's `lookAt` reads the
same slot.
```luau
local t = entity(id).component.get("Transform")
t:setPosition({ 0, 5, 0 })
t:setScale({ 2, 2, 2 })
t:rotateAxisAngle({ 0, 1, 0 }, math.pi / 2)
local pos = t:getPosition()
```
# BoxCollider (component)
A box collision shape. Works with or without a `Physics` component: on its own
it is static collision geometry, and alongside one it moves with the body.
## Sizing
`half` is the box's half-extents in the entity's local space, multiplied by the
entity's world scale. Leave it unset and the box derives from the entity's model
bounds, so a bare `BoxCollider` matches what is rendered at any scale.
```lua
entity.find("crate").component.add("BoxCollider", {}) -- fits the mesh
entity.find("wall").component.add("BoxCollider", { half = { 2, 4, 0.2 } }) -- authored
```
## Fields
| Field | Meaning |
|-------|---------|
| `half` | Half-extents. Unset derives from model bounds. |
| `isTrigger` | Report overlaps without blocking. |
| `friction` | Surface friction. |
| `restitution` | Bounciness. |
## Composing
A collider on a child entity attaches to the nearest ancestor carrying a
`Physics` component, so a body made of several shapes puts each on its own
entity and each keeps its own material and trigger flag.
Past a few dozen boxes sharing one material, `CompoundCollider` is cheaper: it
is a single broadphase volume where separate components are one each.
# VehicleController
Drives a car, truck, kart or buggy built from `WheelCollider`s. Put it on the
chassis, the dynamic rigid body the wheels sit under, and the player's
controls reach the wheels on keyboard, gamepad and touch alike: the controls
come from `@builtin::inputMaps.vehicle` through `Zin.controllers.vehicle`.
| Control | Keyboard | Gamepad | Touch |
|---|---|---|---|
| throttle | `W` / `S` | right / left trigger | stick, vertical |
| steer | `D` / `A` | left stick | stick, horizontal |
| brake | `Space` | south | button |
| handbrake | `Ctrl` | east | button |
| boost | `Shift` | north | button |
| recover | `R` | select | button |
| zoom | scroll | shoulders | pinch |
What each one does to the vehicle:
- **throttle** drives the driven wheels (`drive`: `all`, `front` or `rear`),
the torque fading to nothing at `topSpeed`. Pulled back while the vehicle
rolls forward it brakes; at a stop it reverses, up to `reverseTopSpeed`.
Pushed forward while it rolls backward it brakes too.
- **steer** turns the steered wheels, the pair nearest the chassis' forward
(its local -Z), at up to `maxSteerAngle` at rest, narrowing to
`steerAtTopSpeed` at `topSpeed`, turning at `steerRate`.
- **brake** brakes every wheel with `brakeTorque`; **handbrake** locks the
rear pair with `handbrakeTorque`.
- **boost** multiplies the drive torque and the top speed by `boost`.
- **in the air**, with no wheel on the ground, the chassis turns toward
level, its nose raised to `airPitch` by throttle and lowered by reverse or
brake, spinning at `airSpin` where it is steered; `airControl` is how fast
it gets there (0 lets it fly as it left the ground).
- **recover**, or lying on its side or roof slower than 1 m/s for
`recoverAfter` seconds, sets the chassis back on its wheels where it lay,
`recoverLift` above it, facing the way it faced.
- **zoom** changes how far the `camera`'s chase controller trails.
The component writes `motorTorque`, `brakeTorque` and `steerAngle` on the
wheels, each only when its value changes. Only the peer that owns the chassis
reads its controls.
## Fields
| Field | Default | Unit | Meaning |
|---|---|---|---|
| `inputMap` | `@builtin::inputMaps.vehicle` | | The controls it reads. |
| `takeControlOnAwake` | `true` | | Take the controls as it wakes; `false` waits for `takeControl()`. |
| `drive` | `all` | | Which wheels the throttle drives. |
| `motorTorque` | 1500 | N·m | Drive torque per driven wheel at full throttle. |
| `reverseTorque` | 800 | N·m | Drive torque per driven wheel at full reverse. |
| `brakeTorque` | 3000 | N·m | Brake torque per wheel at full brake. |
| `handbrakeTorque` | 6000 | N·m | Brake torque per rear wheel under the handbrake. |
| `topSpeed` | 25 | m/s | Forward speed where the drive has faded to nothing. |
| `reverseTopSpeed` | 8 | m/s | Reverse speed where the drive has faded to nothing. |
| `boost` | 1.6 | × | Drive and top speed multiplier while boosting. |
| `maxSteerAngle` | 30 | degrees | Full lock at rest. |
| `steerAtTopSpeed` | 8 | degrees | Full lock at `topSpeed`. |
| `steerRate` | 150 | degrees/s | How fast the wheels turn toward the lock asked for. |
| `airControl` | 4 | rad/s² | How fast the chassis turns in the air toward the attitude asked for. |
| `airPitch` | 20 | degrees | Nose angle held in the air at full throttle or full reverse. |
| `airSpin` | 90 | degrees/s | Spin about the up axis at full steer in the air. |
| `recoverAfter` | 3 | s | Time overturned before it rights itself; 0 waits for the control. |
| `recoverLift` | 1 | m | Height above where it lay that a righted vehicle is set down. |
| `camera` | | | A camera with `@builtin::controller.chase` that zoom moves. |
## Methods
- `takeControl()`: the controls go live; the walking controls stand down.
- `releaseControl()`: the controls go back; the wheels coast straight.
- `hasControl() -> boolean`
- `driveState()`: `{ throttle, steer, brake, handbrake, boost, speed,
groundedWheels, wheels, steerAngle, overturned, recoveries, lastRecover }`.
## Example
A monster truck: a heavy chassis on four big wheels with long travel, the
suspension force raised to carry the landings.
```luau
local truck = entity.spawn("truck")
truck.position = { 0, 2.2, 0 }
truck.component.add("Physics", { kind = "dynamic", mass = 2000 })
truck.component.add("BoxCollider", { half = { 1.3, 0.4, 2.2 } })
for _, at in { { -1.5, -1.7 }, { 1.5, -1.7 }, { -1.5, 1.7 }, { 1.5, 1.7 } } do
local wheel = entity.spawn("wheel", { parent = truck.id })
wheel.localPosition = { at[1], -0.2, at[2] }
local tyre = entity.spawn("tyre", { parent = wheel.id })
tyre.component.add("Model", { model = "cylinder" })
tyre.localRotation = { 0, 0, 0.7071068, 0.7071068 }
tyre.localScale = { 1.6, 0.6, 1.6 }
wheel.component.add("WheelCollider", {
radius = 0.8, width = 0.6,
suspensionDistance = 0.8, springRate = 25000, damperRate = 5000,
maxSuspensionForce = 40000,
visual = tyre,
})
end
truck.component.add("VehicleController", { motorTorque = 4000, brakeTorque = 8000, topSpeed = 25 })
```
A car the player gets into takes the controls when the driver enters:
```luau
car.component.add("VehicleController", { takeControlOnAwake = false })
car.component.get("VehicleController"):takeControl() -- driver in
car.component.get("VehicleController"):releaseControl() -- driver out
```
# controller.character
Character-oriented controller component for builtin movement-driven actors.
# Video
Plays an MP4 video onto a plane mesh attached to this entity. Creates a hidden child entity with a `Model` + `Material` that displays the video render target — same pattern as `UiPanel`'s 3D mode. The video renders to a standard render target (`rt:video_N`), so it can also be used in any material via `material.setTexture(...)`.
Public fields: `url`, `loop`, `autoplay`, `rate`, `width`, `height` (0 = auto aspect), `maxResolution`, `showOverlay`.
Methods: `play()`, `pause()`, `stop()`, `seek(time)`, `getInfo()`, `getHandle()`.
```luau
entity(id).component.add("Video", {
url = "@builtin/videos/test_bars.mp4",
loop = true,
autoplay = true,
})
```
# Birds Eye Camera
Strict overhead camera — looks straight down (-Y world) and follows the target's X/Z position. No yaw, no pitch, no orbit; only `height` (zoom).
## Vs. the other top-down behaviours
| Behavior | What it does | When to pick it |
|---|---|---|
| `birds_eye` | locked overhead, follows target XZ only | tactical / map views, navigation overlays |
| `rts` | overhead with yaw + WASD pan + RMB rotate | strategy games where the user drives the view |
| `isometric` | fixed angle (not straight down) + WASD pan | classic isometric game-feel |
## Fields
| Field | Default | Notes |
|---|---|---|
| `inputMap` | `@builtin::inputMaps.cameraRig` | The map whose `zoom` control adjusts the height. Point it at a world's own map to rebind this camera. |
| `target` | `""` | Entity ID. Falls back to `Camera.follow` when empty. |
| `height` | `25.0` | World-Y distance above target. |
| `minHeight` | `5.0` | Scroll-zoom lower bound. |
| `maxHeight` | `120.0` | Scroll-zoom upper bound. |
| `zoomSpeed` | `0.15` | Multiplicative per scroll tick (`exp(-scroll * zoomSpeed)`). |
| `smoothing` | `0.18` | Per-second blend. `0` = instantly stuck to target. `1` = never moves. |
| `offsetX`, `offsetZ` | `0.0` | Constant XZ offset added to the target's position. |
## Use as a camera behavior
```lua
-- World-default
world.camera_default = asset.ref("@builtin::controller.birds_eye")
-- Per-scene swap
layers.active.camera.behavior = asset.ref("@builtin::controller.birds_eye")
layers.active.camera.follow = layers.active.players.localPlayer
```
The component carries the `cameraBehavior` tag in `.metadata`, so the slot validation in `Camera.behavior = ref` will accept it.
## Controls
- **Scroll** — adjust height (zoom in/out)
- No rotation, no panning — the target moves; the camera just follows.
# CompoundCollider (component)
One collision shape built from many box children, sharing a single broadphase
volume and a single material.
## When to reach for it
| Situation | Use |
|-----------|-----|
| A handful of shapes, each wanting its own material or trigger flag | Separate collider components on child entities |
| Many boxes sharing one material, such as a voxel chunk or a destructible wall | `CompoundCollider` |
The difference is cost. Separate collider components are one broadphase entry
each; a compound is one entry for the whole set, with contacts still resolved
per child. That crossover is what makes a compound the right answer for a voxel
chunk and the wrong one for a character's head.
## Children
Each child is `{ offset = vec3, half = vec3 }` in the entity's local space. The
whole set scales with the entity's transform.
```lua
entity.find("chunk").component.add("CompoundCollider", {
children = {
{ offset = { 0, 0, 0 }, half = { 0.5, 0.5, 0.5 } },
{ offset = { 1, 0, 0 }, half = { 0.5, 0.5, 0.5 } },
},
})
```
## Fields
| Field | Meaning |
|-------|---------|
| `children` | The box set. |
| `isTrigger` | Report overlaps without blocking, for every child. |
| `friction` | Surface friction, shared by every child. |
| `restitution` | Bounciness, shared by every child. |
# third_person
Third-person camera controller. Orbits behind/above a target entity. Right-click drag to rotate around the target, scroll to zoom. Falls back to a static center if no target.
Public fields: `target`, `centerX`, `centerY`, `centerZ`, `height` (vertical offset above target origin), `distance`, `minDistance`, `maxDistance`, `sensitivity`, `zoomSpeed`, `minPitch`, `maxPitch`, `yaw` (exposed for parent scripts).
Controls: RMB drag to orbit while nothing else in the world binds the right
button, scroll to zoom.
```luau
entity(id).component.add("third_person", { target = "player", distance = 5 })
```
# 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 })
```
`mass` is the body's total mass in kilograms. At its default of 0 the mass comes from the collider volume at 1 kg per cubic metre, so a bigger collider makes a heavier body; a value greater than zero sets the total mass exactly.
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)
```
# Collider
Gives an entity a collision shape. Can exist without a `Physics` component (static collision geometry like walls/floors). For dynamic bodies, `"mesh"` is automatically promoted to `"convex"` since concave trimesh colliders only work with static bodies in Rapier3D.
Shapes: `"box"`, `"sphere"`, `"capsule"`, `"convex"`, `"mesh"`, `"compound"`.
Sizing: dimensions are half-extents in the entity's local space, and the collider is multiplied by the entity's world scale — it scales with the entity, the same way its mesh does. When dimensions are omitted, the shape derives from the entity's model bounds (still scaled), so a bare `Collider` matches the rendered geometry. Providing dimensions (`size`/`sizeX`/`sizeY`/`sizeZ`, `radius`, `height`) makes them authoritative over the model bounds — but the world-scale multiply still applies. So a box with `sizeX = 0.5` on an entity scaled `[50, 0.2, 50]` is a `25 x 0.1 x 25` half-extent collider, not `0.5`; pass local half-extents and let scale do the rest (or leave the entity at scale 1 to size in world units). The rule is the same with or without a `Physics` component.
Public fields: `shape`, `sizeX`, `sizeY`, `sizeZ` (box half-extents, local — scaled by the entity), `radius` (sphere/capsule), `height` (capsule), `isTrigger`, `friction`, `restitution`.
Methods: `:setShape(shape)`, `:setSize(size)` (accepts `{x,y,z}` array or `{x=,y=,z=}` map), `:setRadius(r)`, `:setTrigger(bool)`.
```luau
entity(id).component.add("Collider", { shape = "box" }) -- box fitted to the entity's model
entity(id).component.add("Collider", { shape = "box", sizeX = 2, sizeY = 4, sizeZ = 0.2 })
entity(id).component.add("Collider", { shape = "sphere", radius = 0.5 })
entity(id).component.add("Collider", { shape = "capsule", radius = 0.3, height = 2 })
```
# 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} })
```
# shadowSystem
Cascaded shadow map (CSM) **policy** for crisp directional shadows across a
large view distance. The rendering *mechanism* lives in the engine; this
package is the per-frame policy that drives it.
## Layout
- `shadowSystem.module` — the policy: PSSM frustum splitting, per-cascade
bounding-sphere fit + texel-snap stabilization, and the matrix math. Pushes
per-cascade light-space matrices + split distances to the renderer via
`__rendering.setShadowCascades` each frame.
- `ShadowCascadeDriver.component` — attach ONE to a scene entity to drive the
module every frame from the active camera + sun.
- `depthBounds.renderFeature` — reduces the frame's depth target to the
nearest and farthest view depth any surface occupies, and hands the result to
the module.
## Flow
1. Add a `ShadowCascadeDriver` component to any entity in the scene.
2. Each frame it slices the camera frustum into N cascades (N from
`render.shadow_cascades`), fits a stable light-space ortho to each, and
pushes the matrices + split distances to the renderer.
3. The renderer renders one shadow depth pass per cascade; the fragment shaders
pick a cascade by the fragment's distance from the camera.
## Fitting splits to the depth that is there
Splits are placed over a range. Taken from the camera's near and far planes
that range is whatever the scene *could* occupy; taken from the depth buffer it
is what the scene *does* occupy. A courtyard that stops at 40 m under a 200 m
shadow distance spends most of its cascade texels on empty space.
```lua
entity.find("scene").component.add("ShadowCascadeDriver", {
fitToDepth = true, -- place splits over the measured depth
depthMargin = 0.1, -- room past the far end for a frame of movement
})
```
The **far** end is what moves. The near end stays where the camera puts it: the
renderer picks a cascade from a fragment's distance and cascade 0 is fitted to
the range's start, so raising that start would leave anything nearer sampling a
box it does not sit inside.
The measurement is one frame old, because a GPU read-back cannot land in the
frame that produced it. `depthMargin` is the room that gives something to move
away in before it falls outside the last cascade.
With `fitToDepth` off, no reduction is dispatched and splits are placed exactly
as before.
Purely additive: with no driver the renderer falls back to its legacy single
camera-fit directional shadow. Quality is controlled by the
`render.shadow_resolution` / `render.shadow_cascades` / `render.shadow_distance`
/ `render.shadow_split_lambda` world settings. `render.shadow_distance` is a cap
on the range the splits run over, and its default of 0 leaves that range to the
frame — the depth its own renderables reach.
## Documentation
See `shadowSystem.module/README.md` and `ShadowCascadeDriver.component/README.md`.
# areaLights system
Lights that emit from a surface rather than from a point.
Every screen, neon sign, glowing window, ceiling panel and lantern face is an
area emitter. Standing a point light in the middle of one gets the brightness
roughly right and the character completely wrong: a point puts a pinpoint
reflection on the floor and a radial falloff on the wall, where the real thing
puts the shape of the emitter and a falloff that follows its extent.
## Using it
An emitter that should follow an entity's face is a component:
```lua
local id = entity.spawn("tv").id
entity(id).component.add("SurfaceLight", {
width = 2.0,
height = 1.2,
face = "front", -- front | back | up | down | left | right
intensity = 6.0,
range = 12,
})
```
The rectangle turns with the entity, so a tilted screen lights the direction it
actually points. Every field is live, and an `intensity` of 0 removes the light.
For code placing emitters directly:
```lua
local surfaceLight = require("@builtin::systems.areaLights.surfaceLight")
surfaceLight.set("strip", {
position = { 0, 5, -12 },
right = { 1, 0, 0 }, up = { 0, 1, 0 },
width = 0.6, height = 7,
color = { 1, 0.72, 0.35 }, intensity = 5,
})
surfaceLight.remove("strip")
surfaceLight.configure({ diffuse = 1, specular = 0.5 })
surfaceLight.clear()
```
## What the knobs do
| field | effect |
|---|---|
| `width` / `height` | Size of the emitting rectangle, world units. The shape of the light is the shape of this. |
| `face` | Which way the entity emits. |
| `range` | How far the light carries, with a smooth cutoff at the end. |
| `intensity` | Brightness. 0 removes the light. |
| `colorR` / `colorG` / `colorB` | Emitter colour. |
| `offset` | Distance the rectangle sits proud of the entity's origin, along the face's normal. |
| `diffuse` / `specular` | Scales on the two terms — the wash across a surface, and the reflected shape of the emitter. |
Shape is the point. A wide panel puts a broad pool on the floor; a narrow strip
puts a narrow elongated one. That difference is not reachable with a point
light at any brightness.
At most 32 emitters are held at once, because the shader loops every light for
every pixel.
## How it renders
`areaLighting.renderFeature` runs one compute pass after lighting. Diffuse is
the exact irradiance of the quad — Lambert's formula over its four edges — which
matters where a point approximation fails hardest: a wide emitter close to a
wall. Specular uses the representative point on the rectangle nearest the
reflection ray, with the lobe widened to cover the emitter's solid angle and its
peak scaled down to match, so a large emitter reads broader rather than
brighter.
## Files
```
areaLights.package/
SurfaceLight.component/ -- an emitter on an entity's face
surfaceLight.module/ -- the emitter registry and scales
areaLighting.renderFeature/ -- the compute pass
```
# controller.editor
The viewport camera you author from. It is the behavior on the editor scene's `EditorCamera` — a world's players never meet it, which is why it reads the editor's controls rather than a game's.
Rotates the pivot child exposed by a sibling `Player` component if present, otherwise rotates self.
Public fields: `moveSpeed`, `sensitivity`, `speedMin`, `speedMax`.
Controls come from the map on `inputMap`, `@builtin::inputMaps.editor` by default: `move` to fly, `flyVertical` to rise and drop, `look` to aim while flying, `zoom` to adjust move speed. Rebind them by pointing `inputMap` at another map.
```luau
entity(id).component.add("controller.editor", { moveSpeed = 10 })
```
# 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.
```
# Cinematic Camera
Wide-FOV, slow-lerp follow with a configurable over-the-shoulder offset. Intended for cutscenes, dialogues, and walking moments where you want a more composed framing than `orbital_follow` and softer motion than `chase`.
The camera holds a right-shoulder offset relative to the target's facing and slow-lerps both position and look-at point so quick player movements feel cinematic rather than jittery.
## Vs. the other follow cams
| | `orbital_follow` | `chase` | `cinematic` |
|---|---|---|---|
| Orbit (mouse) | yes | no | no |
| Position lag | n/a | low (sticky) | high (cinematic) |
| Look-at lag | n/a | low | even higher than position |
| Composition | centered behind | locked behind | shoulder-framed |
| Best for | character action | vehicles, fast | cutscenes, walking, dialogue |
## Fields
| Field | Default | Notes |
|---|---|---|
| `target` | `""` | Entity ID. Falls back to `Camera.follow`. |
| `distance` | `4.5` | Trail distance behind target. |
| `height` | `1.8` | Vertical offset above target origin. |
| `shoulderX` | `0.6` | Right-shoulder offset (negative = left shoulder). |
| `fov` | `60.0` | Pushed once to the sibling `Camera` component on `awake`. |
| `positionSmoothing` | `0.35` | High = slow, cinematic. Per-second blend. |
| `lookAtSmoothing` | `0.45` | Higher than position so framing reads as "anticipating". |
## Use as a camera behavior
```lua
world.camera_default = asset.ref("@builtin::controller.cinematic")
-- Or per-scene
layers.active.camera.behavior = asset.ref("@builtin::controller.cinematic")
layers.active.camera.follow = layers.active.players.localPlayer
```
The component carries the `cameraBehavior` tag in `.metadata`.
## Notes
- No user-driven rotation, no scroll-zoom — `cinematic` is set-it-and-let-it-frame.
- Swap `shoulderX` for a left-shoulder shot. Sign-flip in script for an interactive switch.
- Pairs especially well with `humanoid_visual` + `standard_controller` for narrative gameplay.
# controller.side_scroller
Side-scroller camera controller. Fixed-offset camera that follows a target from the side (classic 2.5D perspective). Maintains a constant position relative to the target and looks slightly ahead.
Public fields: `target`, `centerX`, `centerY`, `centerZ` (fallback), `offsetX`, `offsetY`, `offsetZ` (camera offset from target), `lookAheadX`, `lookAheadY`, `lookAheadZ` (look-at offset from target).
Controls: none — follows the target automatically.
```luau
entity(id).component.add("controller.side_scroller", { target = "player", offsetX = -8, offsetY = 3 })
```
# controller.first_person
First-person camera controller. Positions the camera at an entity's location (+ eye-height offset) and rotates with mouse look.
It resolves its follow target in this order: its own `target` field, then `Camera.follow` when `target` is empty, then the static `posX/posY/posZ` when neither is set. `Camera.follow` is the standard slot every behavior reads, so a first-person camera selected through `Camera.behavior` tracks whatever `Camera.follow` points at — the correct source for a player camera, whose body id is minted fresh per spawned clone and cannot be authored onto the behavior ahead of time.
Public fields: `target` (entity id to follow, empty = use `Camera.follow`, then `posX/Y/Z`), `posX`, `posY`, `posZ`, `offsetX`, `offsetY` (eye height above target origin), `offsetZ`, `sensitivity`, `minPitch`, `maxPitch`.
Controls: pointer lock to rotate (yaw + pitch), which the standard scheme
takes while the world runs. With the cursor handed back, an RMB drag rotates
too — for as long as nothing else in the world binds the right button.
```luau
-- Track the Camera's follow target (set Camera.follow, no id authored here):
entity(id).component.add("controller.first_person", { offsetY = 1.6 })
-- Or a static camera at a fixed position:
entity(id).component.add("controller.first_person", { posX = 0, posY = 1.6, posZ = 3 })
```
# ParentConstraint
Copies the full transform (position + rotation + scale) from a target entity — like parenting but without hierarchy. The entity stays independent in the scene tree.
Public fields: `targetEntityId`, `axisX`, `axisY`, `axisZ`, `weight`.
Methods: `setTarget(entityId)`, `setWeight(w)`.
```luau
entity(id).component.add("ParentConstraint", {
targetEntityId = playerId,
weight = 1.0,
})
```
# oceans
A sea and the ground under it, as one package: the wave simulation, the surface
that renders it, the seabed it is seen through, and the answer to "how high is
the water here" that gameplay needs to float things on it.
```lua
local Ocean = require("@builtin::systems.oceans.modules.ocean")
Ocean.create({ preset = "gale" })
```
That is a whole ocean — waves out to the horizon, foam on the breaking crests, a
seabed below, and a surface that follows whoever is looking at it. To float
something on it:
```lua
local ocean = Ocean.forEntity(oceanEntityId)
boat.position = { x, ocean:sampleHeight(x, z), z }
```
It is a building block. The sun the water glitters under, the ambient its foam
sits in and the sky it reflects all come from the scene, through the same
directional light and reflection probes every other material in the engine
reads — so an ocean drops into a world that already has lighting instead of
bringing a second set, and changing the scene's light changes the water with it.
## What it does
The simulation is **spectral**: instead of animating individual waves, it builds
the sea's energy across every wavelength and direction from an oceanographic
spectrum, then inverse-transforms that into a height field. A real sea is what
comes out — wind speed alone decides wave height, and it rises with wind the way
a fully developed sea does, following the Pierson–Moskowitz law.
It runs in **cascades** — independent wave fields over different patch sizes,
each band-limited to its own range of wavelengths. A tile of roughly two
kilometres carries the long waves and one of a few hundred metres the short
ones; they sum into a surface with detail at every scale, and because the bands
are disjoint no wavelength is synthesised twice. Above them a **Gerstner bank**
adds the swell analytically, which no cascade can hold: a wave longer than its
own patch has nowhere to live, and swell arrives from a distant storm on its own
heading anyway.
**Foam** is not painted on. It comes from the Jacobian of the wave displacement:
where the surface folds over itself, the wave is breaking. Whitecaps appear
where and when the geometry actually breaks, accumulate into a persistent
world-space field, and streak behind the crest as they decay.
**Colour and transparency are the same mechanism.** Per-channel Beer–Lambert
extinction over the distance light travelled through water gives both — red dies
within a metre, blue survives tens. That is why shallow water over sand reads
turquoise and the open sea reads near-black blue without switching materials.
**The seabed** is one noise field read twice: scaled it is the terrain's relief,
unscaled it picks between sand and rock, so outcrops stand on the high ground
and sand fills the hollows. The sun's caustic web moves over both, bent by the
horizontal displacement of the water directly above it. Bodies land on it: the
pass that raises the ground writes the same field out as a height patch that
travels with the viewer, so what a body stands on is what the frame drew.
Nothing here ships an image. Every map the surface and the seabed sample — the
foam patterns, the sand, the rock, the caustics — is grown on the GPU at
startup.
## The pieces
| Asset | What it is |
|---|---|
| `components/Ocean` | Puts a sea on an entity. Its fields are the sea state; the simulation is rebuilt from them, so an ocean survives a scene reload. |
| `modules/ocean` | The substrate: `attach`, `create`, `forEntity`, `sampleHeight`, `sampleDisplacement`. |
| `components/Buoyancy` | Floats a rigid body on the sea. Its fields are the hull: how dense it is, how steady, and what box displaces water. |
| `modules/buoyancy` | The forces: `attach`, `forEntity`, `columns`, `submergedFraction`, `restingY`. |
| `modules/oceanPresets` | Nine named sea states, from `calmClear` through `gale`. |
| `modules/oceanQuality` | The four tiers — how finely a sea is resolved, as distinct from what sea it is. |
| `modules/oceanClipmap` | The camera-following ring geometry both the surface and the seabed are built on. |
| `modules/oceanFloor` | The seabed: terrain, ground materials, caustics and the ground physics collides with. |
| `modules/oceanScene` | The seam between the scene capture and the materials that sample it. |
| `shaders/oceanSurface` | The water: cascade normals, absorption, exact dielectric Fresnel, subsurface scattering, sun glitter, foam, and the view from below. |
| `shaders/oceanFloor` | The seabed: sand and rock blended by terrain noise, under a caustic web. |
| `materials/oceanWater`, `materials/oceanSeabed` | The default looks. |
| `renderFeatures/oceanScene` | Copies the frame's colour and depth so the surface can refract what is behind it and measure its own water column. |
| `computeShaders/ocean_*` | The ten-stage GPU pipeline. |
## Wind is the control
Significant wave height goes as the **square** of wind speed, so that one field
does most of the work:
| Wind | Sea | Wave height |
|---|---|---|
| 3 m/s | glassy, a mirror | ~0.2 m |
| 11 m/s | working sea, occasional whitecap | ~2.5 m |
| 24 m/s | gale, breaking and streaked | ~12 m |
Everything else shapes character rather than size: `choppiness` pinches crests
into peaks, `standingWaveRatio` runs waves both ways instead of only downwind,
`jonswapGamma` sharpens a fetch-limited sea, and the foam layers decide how
readily a breaking crest whitens and how long it streaks.
Read `documentation/oceans.guide` for the full walkthrough, including the
seabed, buoyancy, reflections and what each tier costs.
# effect
A set of visuals packaged as one thing: every impact a weapon makes, every stage
of an explosion, the muzzle flash, impact and tracer of a gun. Each visual is an
entry the effect plays by name. The graphs, the parts they are built from, and
the shaders, materials, compute shaders and textures they use all live inside
the effect.
## The shape on disk
<name>.effect/
effect.yaml what it plays: entry name -> one graph, or a list of graphs played together
graphs/ the graphs its entries play
parts/ graphs those graphs use as nodes, each one whole piece of the look
shaders/ shaders and shader modules its materials draw with
materials/
compute/ compute shaders its graphs run
textures/
README.md what this effect shows, what each entry plays, what each input changes
.metadata
settings.yaml its values for the settings below
preview.png its default entry at a representative moment
`effect.yaml`, `graphs/`, `README.md` and `.metadata` are required.
```yaml
plays:
flare: "~.graphs.flare"
impact:
- "~.graphs.impactFlash"
- "~.graphs.impactDebris"
default: flare
```
Inside an effect `~.x` names the effect's own `x`: `~.graphs.flare`,
`~.parts.crack`, `~.shaders.lava`, wherever the effect sits.
## Graphs and parts
A graph under `graphs/` composes what one entry shows. A piece worth building
and looking at on its own (the crack of a fissure, the spray, the smoke) is a
graph under `parts/`, called as a node:
```lua
local crack = g["~/parts/crack"]({ length = length })
```
A part is a whole graph, so it previews and plays alone: the crack without the
smoke in front of it. An operation several effects use (a streak look, a smoke
puff) is a `.procNode` outside the effect, used the way a module is required.
## Playing one
```lua
local beacons = asset.resolve("beacons", "effect")
local shown = beacons:play("flare", { root = mast, offset = { 0, 0.9, 0 }, inputs = { size = 0.4 } })
shown:set("size", 0.6)
shown:onFinished(function() print("done") end)
```
`root` is the entity it plays on and follows; `inputs` go to every graph of the
entry that declares them; the handle answers `set`, `pause`, `resume`, `seek`,
`getTime`, `stop`, `isFinished` and `onFinished`. A play finishes when every
graph of its entry reaches the end of its timeline; a graph that states
`realTime` alone has no end, so a play of it runs until `stop()`, and a
simulation that runs once states its frames
(`g:timeline({ realTime = true, frames = 120 })`). Plays are kept ready, built
ahead of time: `beacons:warm("flare", 6)` keeps six ready for a burst. An
`Effect` component, or a drop into the viewport, places an entry in a scene.
`entries()` and `entry(name)` read what it plays; `setEntry`, `removeEntry`,
`renameEntry`, `setDefault` and `newGraph` change it, keeping every line of
`effect.yaml` they do not touch.
## Settings
`pool`: how many plays of each entry are kept ready (default 2).
## In search
Each asset inside an effect is found on its own and names the effect it
belongs to. The effect itself is found by its preview, its README and its
entries.
# 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`
# Joint
Connects two bodies with a joint constraint. Each end is a rigid body (an entity with `Physics`) or a soft body (an entity with `SoftBody`).
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`, `breakForce`, `breakTorque`, `region`, `remoteRegion`.
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,
})
```
A soft body end is anchored on the body as a whole (its frame, the rigid motion that best fits its particles), or on a region of its particles: `region` for this entity, `remoteRegion` for the connected one, each `{ box = { min, max } }` (every particle between the corners) or `{ nearest = point }` (the one particle nearest the point), in the entity's unit box (-0.5 to 0.5 on each axis) unless `space = "world"`. A region end's anchor is measured from the centre of its particles. Motors, `breakForce` / `breakTorque` and `Physics.jointReaction` work the same at either kind of end, and the joint is removed with either body.
```luau
-- a soft punching bag hung by its top particle from a fixed hook
bag.component.add("Joint", { connected = hook.id, kind = "ball", region = { nearest = { 0, 0.5, 0 } } })
-- two jellies held face to face, breaking under 200 N
a.component.add("Joint", {
connected = b.id, kind = "fixed", breakForce = 200,
region = { box = { min = { 0.4, -0.5, -0.5 }, max = { 0.5, 0.5, 0.5 } } },
remoteRegion = { box = { min = { -0.5, -0.5, -0.5 }, max = { -0.4, 0.5, 0.5 } } },
})
```
# 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). |
# UiPanel
A UI panel component that renders a widget tree in one of three modes:
- `"2d"` — egui viewport overlay (HUDs, menus, debug panels).
- `"projected"` — screen-space UI at the entity's world position via `worldToScreen`. Always on top, no occlusion or lighting.
- `"3d"` — world-space billboard using egui render-to-texture. Respects depth/occlusion.
Public fields: `mode`, `layer`, `visible`, `source` (inline or `"lib::<path>"` / `"@..."` library module), `offsetX/Y/Z`, `maxDistance`, `fontSize`, `textColor`, `background`, `billboard3dScale`, `theme`, `rasterizeWidth`, `rasterizeHeight`.
Methods: `setTree(tree)`, `show()`, `hide()`, `toggle()`, `setMode(newMode)`, `getScreenName()`.
A library module named by `source` is mounted with `mount(ctx)`. When `mount` registers its own screen, the panel registers that screen again from its own code, so the screen is the panel's: its callbacks reach the panel's `onCallback`, which hands them to the `onCallback` the mount returned, and the screen is unregistered when the panel ends.
```luau
entity(id).component.add("UiPanel", {
mode = "2d",
layer = 10,
tree = { type = "label", props = { text = "HP: 100" } },
})
```
# Population
Draws a `.population` asset — one mesh at every transform the asset carries,
as ONE draw call per variant at any instance count.
```luau
entity.spawn("forest").component.add("Population", { source = "forest" })
```
The component owns what the draw costs: one GPU transform buffer and one
instanced-draw registration per variant. It builds them on awake, rebuilds
them when `source` changes or the asset's contents change on disk, and
releases them on destroy — so a scene reload replaces the draws instead of
stacking a second set, and despawning the entity takes them with it.
## Fields
| Field | Meaning |
|---|---|
| `source` | The `.population` asset to draw. |
| `castsShadows` | `false` renders the instances while blocking no light. |
## Reading what it draws
Both answers come back from the renderer, across every live registration:
```luau
local pop = entity.find("forest").component.get("Population")
pop:instanceCount() -- instances the engine reports drawing
pop:drawCalls() -- registrations — one per variant, at any instance count
```
## Where it comes from
`populationRef:instantiate(target?, opts?)` spawns exactly this: one entity
carrying this component. That is also what `Asset { source = <a population> }`
drives, which is how a baked procedural population reaches a scene — a root
record plus one child holding the reference, at any instance count.
The matrices a population carries are taken in this entity's space, so its
instances stand where the recipe placed them relative to the entity and move,
turn and scale with it.
# RenderFeatures
The scene's render features, on one entity: a list of `.renderFeature` assets,
each with the settings it runs under.
```lua
entity(id).component.add("RenderFeatures", {
features = {
{ feature = "@builtin::systems.reflections.ssr", settings = { intensity = 0.8, quality = "high" } },
{ feature = "@builtin::renderFeatures.outline" },
},
})
```
While the component is enabled every listed feature is live. A feature
something already runs — a script's `renderer.feature.create`, another entity's
list — is adopted and configured rather than stacked a second time. Each
feature runs its passes at the phase and order its own `render(ctx)` declares.
Disabling or destroying the component tears every one of them down.
Editing the list re-syncs it: an entry removed goes, one added comes up, and a
changed `settings` table reaches the live feature on its next frame.
`settings` are the knobs the feature's asset declares —
`renderer.feature.schema("@builtin::systems.reflections.ssr")` lists them with
their defaults. A knob the feature does not declare, or a value of the wrong
shape, is refused by name in the log.
Methods: `:getHandles()` — the live handles this component holds, keyed by the
feature asset's guid.
For code that tunes a feature at runtime — a quality slider, a cutscene —
`renderer.feature.find(ref)` and `renderer.feature.configure(handle, settings)`
reach the same knobs without an entity.
# 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
```
The attached behavior is an ordinary component on the camera's entity, so it is tuned the way any component is: read it with `component.get` and set its fields there. A `component.add` of the same type configures the instance the slot attached instead of adding a second one: the values it passes are written onto that instance and its proxy is returned. That add is the form a scene `build.luau` tunes the behavior with, because its values are saved with the scene and the loaded Camera's behavior takes them back. A key the component reads only as it is created (one of its `initKeys`, a private field) refuses the add and names the key.
```luau
cam.behavior = asset.ref("@builtin::controller.third_person_follow", "component")
local rig = entity(id).component.get("third_person_follow")
rig.cameraDistance = 6
-- the same, as an add of the type the slot attached
entity(id).component.add("third_person_follow", { cameraDistance = 6 })
```
`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.
# scene.toolbox shared module
Cross-tool sharing for the `scene.*` toolbox: the hierarchy snapshot core
(`snapshot`, `findAll`, `leaf`) that backs tree / findAll /
findByComponent / summary, and the last-scene-name state that save / load
share. Tools call these functions; the functions call engine APIs.
# CapsuleCollider (component)
A capsule collision shape: a cylinder along the entity's local Y axis capped by
hemispheres. This is the shape characters use, because a rounded bottom slides
over small steps instead of catching on them.
## Sizing
`radius` and `height` are in the entity's local space, multiplied by the
entity's world scale. `height` is the full height of the cylindrical section,
excluding the caps. Leave both unset and the capsule derives from the entity's
model bounds.
```lua
entity.find("player").component.add("CapsuleCollider", { radius = 0.3, height = 1.6 })
entity.find("pillar").component.add("CapsuleCollider", {})
```
## Fields
| Field | Meaning |
|-------|---------|
| `radius` | Capsule radius. Unset derives from model bounds. |
| `height` | Cylindrical section height. Unset derives from model bounds. |
| `isTrigger` | Report overlaps without blocking. |
| `friction` | Surface friction. |
| `restitution` | Bounciness. |
# Tessellation
Compute-shader mesh subdivision with material-driven displacement.
`Tessellation` sits next to `Model` on a renderable. The engine's
`tessellation` system reads the entity's current mesh geometry,
subdivides each triangle into `4^level` micro-triangles, displaces the
result along the interpolated normal from the entity material's
displacement map, and swaps the entity's `MeshInstance.mesh` for the
displaced derivative. The material is bound per-entity, so the
tessellated surface renders with the same PBR parameters and normal maps
as the source.
```luau
entity.find("floor").component.add("Tessellation", {
level = 4,
displacementScale = 0.05,
})
```
## Fields
| Field | Type | Purpose |
| --- | --- | --- |
| `level` | number | Subdivision level (0..=6). Clamped when out of range, and `getStatus()` answers `"clamped"`. `4^level` micro-tris per source tri. Under `mode = "adaptive"` this is the ceiling. |
| `displacementScale` | number | World-units of displacement per unit of sampled height. Sign matters — negative inverts inward. With no displacement map on the material, the surface is subdivided but left flat. |
| `mode` | string | `"static"` or `"adaptive"`; any other value makes `getStatus()` answer `"rejected_mode"` and leaves the entity rendering the mesh it already had. |
| `screenError` | number | Target micro-triangle edge in pixels, read under `mode = "adaptive"`. Smaller buys more subdivision at a given distance; zero or less asks for level 0. Default 8. |
| `stitch` | boolean | Place this surface's boundary on the coarsest lattice any patch sharing that boundary uses. Default `true`. |
## Methods
The `tessellation` system writes what each bake did onto the entity's
native `ecs.Tessellation` component, and these methods read it back.
`ecs.get(id, ecs.Tessellation)` reads the same values as its `status`,
`activeLevel`, `outputTriangles`, `outputVertices` and `stitchedEdges`
fields.
| Method | Purpose |
| --- | --- |
| `tess:getStatus()` | Read the latest status string: `""` before the first dispatch, then `"pending"`, `"ok"`, `"clamped"`, `"too_large"` or `"rejected_mode"`. |
| `tess:getActiveLevel()` | Read the level the installed mesh was baked at. |
| `tess:getTriangleCount()` | Read the installed mesh's triangle count: source triangles × `4^activeLevel`. |
| `tess:getVertexCount()` | Read the installed mesh's vertex count: source triangles × the lattice a patch carries at `activeLevel`. |
| `tess:getStitchedEdges()` | Read how many boundary edges the last bake placed on a coarser neighbour's lattice. |
## Adaptive level
```luau
entity.find("cliff").component.add("Tessellation", {
mode = "adaptive",
level = 5, -- ceiling, reached up close
screenError = 8, -- micro-triangles ~8 px on an edge
})
```
Under `mode = "adaptive"` the level is re-derived each frame from how
much screen the surface covers: the entity's bounding sphere is projected
under the viewport camera's own projection (perspective or orthographic),
and the level is the one whose `4^level` micro-triangles land near
`screenError` pixels on an edge — clamped to `level`. A surface therefore
gains detail as the camera approaches and sheds it on retreat. A sphere
the view frustum does not reach covers no pixels, so a surface behind or
outside the frame drops to level 0 until it comes back into view.
Sample `getTriangleCount()` across a dolly to watch it move. Two rules
bound the re-baking: a half-level dead band around the active level, so a
camera parked on a boundary holds one level, and a minimum spacing
between re-bakes, so a fast sweep through several levels re-bakes at a
bounded rate rather than every frame.
A whole entity holds one level, so the micro-triangles along every shared
edge inside it match by construction. Where two entities meet, see
*Shared boundaries* below.
## Shared boundaries
A surface's outline — the edges only one of its triangles uses — is the
only place another surface can meet it. Two surfaces whose outlines share
an edge and subdivide it to different levels place their points along it
on different curves: the finer one follows the displacement between the
coarser one's samples, and the space between the two curves is a crack
the background shows through.
Every frame, each tessellated surface's outline is placed in world space
and matched against the others'. Where a shared edge is found, the finer
surface bakes its points along that edge onto the coarser one's lattice,
so both trace one boundary. The rest of the finer surface keeps its own
level. `getStitchedEdges()` reports how many of its boundary edges were
placed this way, and a neighbour that changes level, moves away, or
appears re-bakes the surfaces that share an edge with it.
```luau
-- Two tiles meeting at x = 0, subdivided differently.
entity.find("tile_west").component.add("Tessellation", { level = 5, displacementScale = 1.5 })
entity.find("tile_east").component.add("Tessellation", { level = 2, displacementScale = 1.5 })
-- tile_west:getStitchedEdges() == 1 — its east edge is on tile_east's lattice.
```
Two outlines count as shared when both endpoints land on the same point
of a 0.1 mm world-space grid, which is what lets tiles authored to meet
match however each one is placed — the rounding each entity's own
transform leaves behind is orders of magnitude finer than one cell.
`stitch = false` leaves a surface subdivided uniformly to its own
level everywhere; its level is still what neighbours that do stitch clamp
themselves to.
## Output size
Each source triangle's micro-vertices are the grid points of one shared
barycentric lattice: `(n + 1)(n + 2) / 2` of them for `n = 2^activeLevel`,
carrying `n^2` micro-triangles. Micro-triangles meeting along an edge
inside a patch therefore name the same vertex, and the index buffer names
lattice addresses — three per micro-triangle. At level 6 a patch is 2145
vertices for 4096 triangles. Read the two counts with `getVertexCount()`
and `getTriangleCount()`.
## Displacement
Displacement height comes from the entity material's **displacement map**
(a grayscale height texture). A material without one still tessellates —
the geometry is subdivided for a smoother silhouette — but stays flat.
`displacementScale` scales the sampled height into world units along each
micro-vertex's interpolated normal.
## Limits
- Maximum subdivision level: 6 (= 4096 micro-triangles per source
triangle). Higher values are clamped, and `getStatus()` answers
`"clamped"`.
- Projected output cap: 64 MiB, counting the vertex buffer and the index
buffer together. A dispatch that exceeds this is skipped, and
`getStatus()` answers `"too_large"`: lower the level or use a
lower-poly source mesh.
# SpotLight
Adds a cone-shaped light source to an entity. Position comes from the entity's world transform; the cone axis is the entity's world rotation applied to the `face` direction, so a spot under a carrier turns with it. `face` defaults to `"Front"`, the entity-local -Z that `transform.forward` reports and that `entity:lookAt` aims, so aiming the carrier at something is what puts the cone on it. Shares the 64-light scene cap with `Light` (point lights), and shares its intensity scale: the same `intensity` at the same range puts the same light on a surface either kind faces from the same place, and the cone decides how much of the room that light reaches rather than how bright it is where it lands. `angle` and `innerAngle` are half-angles from the cone axis, so `angle = 30` opens a 60-degree cone.
Public fields: `colorR/G/B`, `brightness` (→ intensity), `range` (→ radius), `angle` (outer half-angle, deg), `innerAngle` (inner half-angle, deg), `face` (`Front`/`Back`/`Left`/`Right`/`Top`/`Bottom`), `shadows`, `cookieLayer`, `mobility`. `intensity` and `radius` are aliases of `brightness` and `range`, spelled the way the `Light` component spells them.
Methods: `:setColor(color)`, `:setBrightness(b)`, `:setRange(r)`, `:setAngle(deg)`, `:setInnerAngle(deg)`, `:setFace(face)`.
Falloff: 0..innerAngle = full bright, innerAngle..angle = smoothstep to 0, > angle = 0. Range falloff is UE4-style smooth windowed inverse-square clamped at `range`.
The cone is one further factor on that shared scale, and it is 1 inside `innerAngle`. So the reading that separates the two kinds is a room-wide or frame-wide average: a spot lifts it far less than a point light carrying the same number, because it reaches less of the room. Balance a mixed rig by the brightness of the surfaces each light is aimed at, not by how much of the frame each one lifts — and widen `angle` or `range` to cover more of the room where raising `brightness` would only deepen the spot's own pool.
A spot casts shadows by default; `shadows = false` keeps it purely additive. `cookieLayer` names a layer of the shared feature texture to project through the cone as a gobo, and `-1` (the default) leaves the cone uniform — fill the layer with `renderer.featureTexture.setLayer` before pointing a spot at it.
```luau
entity(id).component.add("SpotLight", {
color = {1, 0.95, 0.8},
brightness = 3.0,
range = 30,
angle = 30,
innerAngle = 20,
face = "Front",
shadows = true,
cookieLayer = 0,
})
```
# 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.
# 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`
# AudioListener
The "ears" of the scene. Spatial audio is heard relative to the entity carrying this component — its transform sets the listener position and orientation. Attach it to whatever should hear: a camera, a character, a dedicated listener rig.
Public fields: `active` (`boolean`, default `true`).
Methods: `setActive(on)`.
```luau
entity.find("camera").component.add("AudioListener")
entity.find("player").component.add("AudioListener", { active = true })
```
A scene needs an active listener for its `Audio` sources to be heard positionally. Sources with no active listener are reported at load and play centered until a listener is added.
`active` is live: writing it takes effect on the next frame, so clearing it on the listener holding the ears hands them to another active listener, and clearing it everywhere leaves the scene with no ears. When several are active one of them drives the ears — the same one for as long as those entities live, whatever else changes about them — and the engine warns once, naming it. Recreating the entities, as a scene reload or entering play does, chooses again. `audio.observe().listener` reports the winner as `entity`, alongside `present`, `count` (how many are active) and the pose. Which one it picks is not something to author against: deactivate or remove the extras when it matters.
# ambientOcclusion system
Screen-space ambient occlusion — the contact darkening in corners, creases and
where objects meet.
Without it, every concave region is lit exactly like a flat one: a box resting
on a floor has no seam, a corner is as bright as the wall, and an untextured
scene reads as plastic. Occlusion is the cheapest shading cue there is per
millisecond, because it needs no extra geometry pass — only the depth and normal
the frame already produced.
Three techniques are shipped. All three answer the same question — what fraction
of the hemisphere above this pixel is visible — and all three cost the same taps
to answer it.
## Using it
The scene's occlusion is a component — author it once and it persists with the
scene:
```lua
local id = entity.spawn("ambient_occlusion").id
entity(id).component.add("AmbientOcclusion", {
technique = "gtao", -- ssao | hbao | gtao
intensity = 0.9, -- how far the darkening goes, 0..1
radius = 1.5, -- world-space reach
quality = "high", -- low | medium | high | ultra
})
```
Every field is live: edit one in the inspector and the shading follows. Setting
`intensity` to 0 turns it off without losing the rest.
For code that adjusts occlusion at runtime — a quality slider, a cutscene —
reach for the module directly (same options, no component):
```lua
local ao = require("@builtin::systems.ambientOcclusion.ao")
ao.set({ technique = "hbao", intensity = 0.9 }) -- any subset; the rest is kept
ao.get() -- the settings in force
ao.techniques() -- the three, each with what it does
ao.qualityLevels() -- the sampling shape each level costs
ao.active() -- is the pass running
ao.clear() -- off, settings kept
```
## The three techniques
| technique | what it measures |
|---|---|
| `ssao` | Taps scattered over a disk, each weighed on its own: how far the neighbour rises above the tangent plane, over how far away it is. |
| `hbao` | One horizon per direction — a ray marched outward until it finds the steepest thing along it — averaged as sines. |
| `gtao` | The cosine-weighted arc between the two horizons of a slice, in closed form. This is the visibility the other two estimate. |
They are scaled to agree. A point in a right-angle corner sees exactly half the
hemisphere, and each technique is normalised so it reads that corner as half:
| technique | flat plane | right-angle corner | occlusion |
|---|---|---|---|
| `ssao` | 1.000 | 0.566 | 0.434 |
| `hbao` | 1.000 | 0.592 | 0.408 |
| `gtao` | 1.000 | 0.594 | 0.406 |
That is what lets `intensity` mean the same thing whichever technique is
selected, so switching one for another changes how the estimate is made rather
than how dark the scene gets.
## What the knobs do
| field | effect |
|---|---|
| `technique` | Which estimator runs: `ssao` \| `hbao` \| `gtao`. |
| `intensity` | How far the darkening goes, in [0, 1]. 0 turns it off and releases the pass. |
| `radius` | World-space reach — roughly "how big is a crease worth darkening". Constant in world space, so it does not swell as the camera approaches. |
| `power` | Contrast curve on the visibility term. Above 1 deepens the dark end. |
| `thickness` | World-space thickness assumed for an occluder, so a thin foreground object stops occluding everything behind it out to the full radius. |
| `bias` | How far a neighbour must rise above the surface before it counts as an occluder rather than as the surface itself, in [0, 1]. Raise it if a flat surface shades itself in patches; lower it if fine creases disappear. |
| `quality` | Sampling shape. See the table below. |
| quality | slices × steps | taps per pixel | resolution |
|---|---|---|---|
| `low` | 2 × 3 | 12 | half |
| `medium` | 3 × 4 | 24 | half |
| `high` | 4 × 6 | 48 | full |
| `ultra` | 6 × 8 | 96 | full |
Every technique spends the same budget: `slices × steps × 2` taps. `gtao` walks
each slice both ways, `hbao` treats each of the two directions as its own ray,
and `ssao` takes that many independent taps of the disk.
The lower two levels compute occlusion on a half-resolution grid — a quarter of
the pixels, each still costing its level's taps. Occlusion is low-frequency
almost everywhere, and the filter that removes the sampling noise lifts the
coarse grid back to the frame in the same pass, weighting each texel by the
depth where it sits so silhouettes survive the upsample. What a half-resolution
grid loses is the innermost pixel of a sharp crease; everything wider than a
couple of pixels comes back the same.
## How it renders
One compute pass after lighting writes the per-pixel visibility, a second
filters it and multiplies it into the frame, and a blit puts the result on
screen. The filter is depth-aware: a plain blur would bleed occlusion across
silhouettes, so each tap is weighted by how close its depth is to the centre
pixel's.
Every technique jitters its sampling by a per-pixel offset, which trades banding
for noise; the filter is the half of that trade that removes the noise.
Depth, normal and camera come from the render target being drawn (`@scene.depth`,
`@scene.normal`, `@frame.camera`), so the result is correct in offscreen
captures and render-to-texture cameras at their own resolutions, not only the
live viewport. Sky pixels are passed through — there is no surface to occlude.
The normal buffer is the deferred G-buffer, so this needs the deferred path
(the default).
## Seeing the occlusion on its own
While occlusion is running, `ambientOcclusion` is a capture view: the filtered
visibility with no scene colour, white where the hemisphere is open and dark
where it is closed.
```
capture { pass = "ambientOcclusion" }
```
It is the fastest way to tell a shading problem from an occlusion one, and to
compare two techniques or two `radius` values without the lighting in the way.
## Files
```
ambientOcclusion.package/
AmbientOcclusion.component/ -- the scene's occlusion, authored and persisted
ao.module/ -- ao.set / .get / .techniques / .qualityLevels / .active / .clear
aoShared.module/ -- the prelude, filter and passes the techniques share
ssao.renderFeature/ -- taps weighed on their own
hbao.renderFeature/ -- one horizon per direction
gtao.renderFeature/ -- the arc between two horizons
```
# 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 })
```
# third_person_follow
Over-the-shoulder camera. The rig hangs off a shoulder pivot beside the follow target and sits back along the heading it looks down, so framing is authored as offsets in space. The offset is measured in the camera's own frame, so the subject holds the same spot on screen whichever way it turns.
The rig owns that heading. Binding a follow target aims it along the subject's facing, 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 facing a bind reads comes from the body's own declaration of which way it faces (`Locomotion.forward`), so the rig lands behind a `+Z`-forward Synty body and a `-Z`-forward glTF body alike. A target that declares nothing falls back to its transform forward.
Public fields: `follow`, `shoulderOffset` (x across the frame, y up it, z along the heading), `verticalArmLength`, `cameraSide` (0 = left shoulder, 1 = right), `cameraDistance`, `minDistance`, `maxDistance`, `aimYaw` (the world heading the camera looks along, in radians about +Y from world +Z) and `aimPitch` (how far it tilts off level), `damping` (per-axis time constants in seconds), `cameraRadius` (radius of the pull-in sweep; `0` turns the pull-in off), `cameraIgnore` (entities whose colliders the pull-in passes through), `sensitivity`, `invertY` (off by default: pushing the mouse forward looks UP; on pitches the other way), `zoomSpeed`, `minPitch`, `maxPitch`.
Writing any framing field re-poses the rig immediately with damping bypassed, so edit mode shows the framing play mode will produce.
`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 look (play mode requests pointer lock, so no button is held), scroll to adjust `cameraDistance`.
## The control scheme
`inputMap` is the scheme the rig answers to. It reads two controls from it, each on its own:
- `look` — turns `aimYaw` and `aimPitch`.
- `zoom` — steps `cameraDistance`.
A map declaring only one of the two gives the rig that one and keeps the other where it is; the rig logs which control the map left out, and names the map. A field naming no map leaves the rig answering nothing, and logs that too. Point `inputMap` at a world's own map to rebind looking and zooming without touching this component.
**Repointing it on a live rig takes on the next frame.** The map the rig 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(rigId).component.get("third_person_follow").inputMap = asset.resolve("mygame.input.cinema", "inputMap")
```
Assigning the map the rig already holds changes nothing, so re-stating the scheme leaves the live set where it is. While gameplay is paused the rig holds no map at all — the editor's camera has the controls — and it takes whatever `inputMap` names when play resumes.
```luau
local cam = entity(id).component.get("Camera")
cam.behavior = asset.ref("@builtin::controller.third_person_follow", "component")
entity(id).component.get("controller.third_person_follow").cameraDistance = 3
```
## The camera pulls in when a collider is in the way
Each frame the rig sweeps a sphere of `cameraRadius` from the point it aims at out to where its framing puts the camera, and stands the camera where the sphere first meets a collider. Every collider in the world stops that sweep, whether or not anything draws it: an invisible walk bound (a `BoxCollider` with no `Model`), a trigger volume and a solid wall all pull the camera in alike. The follow subject is the one entity the sweep passes by default.
So a camera that snaps in close to the player, or collapses onto it, while nothing visible stands behind the player is meeting a collider nobody can see. Two fields settle it:
- `cameraIgnore` lists the entities whose colliders the camera sees through. Name the walk bounds and gameplay volumes there and the sweep passes them as it passes the subject, while walls the player can see still pull the camera in. Each entry is an entity (or its id), and it is the collider's own entity that is listed.
- `cameraRadius = 0` turns the pull-in off: the camera stands where its framing puts it, through every collider.
```luau
local rig = entity(camId).component.get("controller.third_person_follow")
rig.cameraIgnore = { entity.find("NorthBound"), entity.find("SouthBound") }
-- or drop the pull-in entirely
rig.cameraRadius = 0
```
## 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.
Assigning the slot attaches the rig to the camera's entity, so it is tuned on that instance: `component.get("third_person_follow")` and set its fields. A `component.add("third_person_follow", { ... })` after the assignment, in a scene `build.luau` or anywhere else, writes its values onto the instance the slot attached and returns it, so the Camera keeps the one rig it knows about. In a `build.luau` that add is the way to tune the rig: its values are saved with the scene and the loaded rig holds them.
## 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.
# ReflectionProbe
A reflection probe: bakes the scene into its own cube slot from its position, so reflective surfaces within its `radius` mirror the captured surroundings. Multiple probes in a scene are blended by proximity and rank (the renderer's per-fragment probe blend); a surface outside every probe's radius reflects the sky fallback, or the scene's ambient when no sky is captured. The probe renders a small editor-only gizmo marker (cyan octahedron) in edit mode and is invisible in play mode.
Every field is live: editing `radius`, `priority`, the proxy or `skyFallback` in the inspector re-pushes the probe, and `reflectionProbe.list()` reports what the component holds.
A probe is an **authoring-time** bake: it loads its persisted baked cube on awake (`reflectionProbe.loadBaked`) rather than re-baking on every load. A probe reflects once it holds a cube: until it is baked (or has a persisted bake to load) it adds nothing to the frame, arms no sky fallback, reads `baked = false` in `reflectionProbe.list()` and in `lighting.get`'s `environment.probes`, and the engine log names it with the call that bakes it. Set `probeId` on authored probes so the baked `probe_<probeId>.texture` asset keeps the same identity across reloads (the runtime entity id changes; the probeId does not).
Public fields:
- `radius` (number, default `12`) — influence radius in world units. A surface within it reflects this probe at a weight falling from full at the probe to zero at the radius.
- `bakeOnAwake` (bool, default `false`) — opt-in first-time bake on awake (deferred a few frames so geometry is GPU-resident; baking before the scene renders captures black). Only fires when no persisted baked cube exists yet.
- `probeId` (string, default `""`) — stable asset identity for the persisted baked cube. Empty → falls back to the entity id.
- `skyFallback` (bool, default `true`) - once the probe holds a cube, capture the scene's sky into the fallback slot so a surface outside every probe's radius reflects the sky.
Methods: `:bake()` — (re)bake this probe from its current position into its cube slot and persist it (runs off the calling frame).
```luau
-- One-liner (spawns an entity carrying this component):
reflectionProbe.add(0, 3, 0, { radius = 15, probeId = "lobby" })
-- Or attach directly + bake everything once the scene is built:
entity.find("zone").component.add("ReflectionProbe", { radius = 12 })
reflectionProbe.bakeAll()
```
Max 8 probes (cube slots). Registry policy lives in `@builtin::api.engine.reflectionProbe`; the generic per-slot capture primitive is the `environment` module.
# controller.rts
RTS camera controller. Top-down strategy camera. WASD to pan (aligned to camera yaw), scroll to zoom, right-click drag to rotate. Optionally lock to a target entity.
Public fields: `inputMap` (the camera-rig scheme, `@builtin::inputMaps.cameraRig`, by default; point it at a world's own map to rebind this camera), `target` (empty = free pan), `centerX`, `centerY`, `centerZ`, `distance`, `minDistance`, `maxDistance`, `moveSpeed`, `sensitivity`, `zoomSpeed`, `minPitch`, `maxPitch`.
Controls: WASD to pan, scroll to zoom, RMB drag to rotate.
```luau
entity(id).component.add("controller.rts", { distance = 15, moveSpeed = 12 })
```
# SceneModule
A placement of a `.sceneModule`: the module this entity is built from, and the
params this placement sets. In edit mode it runs the module's builder and
reconciles the result under this entity; what lands is permanent scene content —
saved in `scene.json`, replicated, selectable and inspectable like anything
placed by hand.
Public fields:
- `source` — the module to build. Gated by capability, so it accepts any asset
whose type defines `instantiate`. `module` is an input alias for
it.
- `params` — this placement's input overrides, keyed by the names the module
declares in its `inputs`. A name the module declares overrides that input's
default; a name it does not declare is left alone.
Those two are the whole record. A placement in `scene.json` reads:
```json
{
"id": "ent_ee2d09b7c4ee5a28",
"name": "orchard_0",
"components": [
{
"type": "@builtin::components.SceneModule",
"data": {
"enabled": true,
"params": { "rows": 2 },
"source": { "name": "Orchard", "type": "sceneModule", "guid": "49ba97ac-…" }
}
}
]
}
```
The entities the build placed sit beside it as records of their own, each
naming this entity as the build that placed it and its own chain of names from
the build root down to it:
```
buildOwner=ent_ee2d09b7c4ee5a28 buildKey=orchard
buildOwner=ent_ee2d09b7c4ee5a28 buildKey=orchard/tree_r1
buildOwner=ent_ee2d09b7c4ee5a28 buildKey=orchard/tree_r1/canopy
```
```luau
local pusher = entity.spawn("pusher_0")
pusher.component.add("SceneModule", {
source = asset.resolve("CoinPusher", "sceneModule"),
params = { seed = 0, coinCount = 40 },
})
pusher.localPosition = { 0, 0, 0 }
```
Placing the same module twice with different params yields two different
results from one asset:
```luau
for i = 0, 2 do
local p = entity.spawn("pusher_" .. i)
p.component.add("SceneModule", { source = coinPusher, params = { seed = i } })
p.localPosition = { i * 4, 0, 0 }
end
```
## Rebuilds
A write to the module's `init.luau`, or a change to `params`, re-runs the
builder and **reconciles** the result: an entity the previous build placed is
updated in place, one the build no longer emits is removed, and a new one is
spawned. Ids hold, so a reference to a built entity survives the rebuild.
A rebuild recognises its own entities by computing those ids again: the same
placement and the same place in the hierarchy give the same string. The scene
save writes them and the load brings them back, so a rebuild in a later session
lands on exactly the entities the first build placed with nothing carried
between the two.
A CHANGE is what rebuilds, and the changes are `source`, `params`, and the
module's code on disk. Loading and mode flips are not changes: the entities are
in the scene as authored content, the last bake is what they hold, and the
placement runs nothing. A rebuild whose module, params and module source are
all unchanged is skipped too. `:rebuild()` forces one anyway — for a change
nothing else reports, such as an asset the builder resolves changing underneath
it, or a module edited while the world was closed.
A build that raises leaves the previous one in the scene and says so, rather
than emptying the placement over a typo.
## Who states the params
A placement an author made — dragged in, spawned by a tool, written by a script
— holds its own params. Nothing else sets them.
A placement a **build** made is stated by that build. A scene's `build.luau`
that places one names its `source` and its `params`, and every run of that build
states them again — on load, on a write to the file, on `:build()`. So a param
set on such a placement is a preview: it rebuilds at once and holds until the
next run of the build, which states the params its own source gives.
Both moments say so. Setting a param on a build-placed placement names the file
that states it:
```
[warn] a build placed this entity and states its SceneModule params — a param
set on the placement holds until that build runs again and states them from its
own source { placement="tent_ring_placement", set="count = 9",
statedIn="/zero/source/scenes/main.scene/build.luau (content)" }
```
and the run that states them again names what it replaced:
```
[warn] a build ran and stated this placement's SceneModule params over the ones
set on the placement — the file the build is written in is where they are stated
{ placement="tent_ring_placement", replaced="count: 9 → 7",
statedIn="/zero/source/scenes/main.scene/build.luau (content)" }
```
A run that states what the placement already holds says nothing. To change the
value for good, change it where the build states it.
A build that stops stating a placement takes what the placement built with it.
When a run no longer adds the `SceneModule` to an entity it places, because it
dropped the module or now places it there through `instantiate`, the entities
the placement built under that entity go with the component, so the entity
holds only what the build states now. A placement an author
added to an entity a build places is the author's: a run of that build leaves
it and what it built in place.
## Play
Nothing runs and nothing instantiates. The entities are already in `scene.json`
and load as authored content, so play is deterministic, costs no Luau
construction, works on a runtime-profile boot with no edit session, and every
multiplayer peer loads the same records. This component is inert there.
## Methods
- `:rebuild()` — re-run the builder and reconcile, ignoring the signature.
Returns false in play.
- `:declaredInputs()` — the bound module's declared inputs, sorted:
`{ name, kind, default, values? }`. The set `params` can address.
# PositionConstraint
Constrains an entity's transform (position, optionally rotation and scale) to follow a target entity, with optional offset. Use `weight` to control smoothing.
Public fields: `targetEntityId`, `targetPositionX/Y/Z`, `offsetX/Y/Z`, `axisX`, `axisY`, `axisZ`, `weight`, `constrainRotation`, `constrainScale`.
Methods: `setTarget(entityId)`, `setOffset(x, y, z)`, `setWeight(w)`.
```luau
entity(id).component.add("PositionConstraint", {
targetEntityId = carId,
offset = {0, 5, 10},
weight = 0.15,
})
```
# 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`
# components.FreeMover
WASD + mouse free-fly mover. Attach to an entity to make it move under
WASD input with mouse look, independent of any character controller or
physics rig. Used by the editor's free camera and any debug "fly-around"
viewpoint.
Public fields: `speed`, `sensitivity`, `sprintMultiplier`.
# Mover
`Mover.component` — see `init.luau` for the component surface.
Location: `src/lua/lib/components/Mover.component`
# HeightfieldCollider (component)
A regular grid of heights, which is the shape terrain collides with. Testing
against it costs a cell lookup rather than a walk down a triangle tree, so a
large landscape collides at a cost that stays flat as it grows.
## The grid
`rows` and `cols` are the grid's dimensions, `extent` its world span as
`{ x = width, y = heightScale, z = depth }`, and `heights` the row-major grid
itself, indexed `heights[r * cols + c + 1]`. The grid is centred on the entity.
`rows` maps to the entity's local Z and `cols` to its local X.
```lua
entity.find("terrain").component.add("HeightfieldCollider", {
rows = 64, cols = 64,
extent = { x = 300, y = 1, z = 300 },
heights = grid,
})
```
Use `setGrid(rows, cols, heights)` to replace the grid and the dimensions
describing it together, so the two are never out of step.
## Replication
`heights` is `NoSync`. It is large, and every peer can rebuild it from its own
copy of the terrain, so replicating it would send a whole landscape over the
wire to reproduce something each peer already has.
## Fields
| Field | Meaning |
|-------|---------|
| `rows` / `cols` | Grid dimensions. |
| `extent` | World span, with `y` scaling the heights. |
| `heights` | Row-major height grid. Not replicated. |
| `isTrigger` | Report overlaps without blocking. |
| `friction` | Surface friction. |
| `restitution` | Bounciness. |
# 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.
# Humanoid
Marker component for entities carrying a skeleton conforming to the
standard humanoid bone layout (Synty / Unreal-Mannequin convention).
Locomotion, LocomotionFull, future IK, equipment slots, and ragdoll all
read bones through this component instead of re-walking the descendant
tree.
## Bone naming convention
```
Hips
Spine_01 → Spine_02 → Spine_03 → Neck → Head
└ Eyes, Eyebrows, Jaw
UpperLeg_R / UpperLeg_L
LowerLeg_R / LowerLeg_L
Ankle_R / Ankle_L
Ball_R / Ball_L → Toes_R / Toes_L
Clavicle_R / Clavicle_L
Shoulder_R / Shoulder_L
Elbow_R / Elbow_L
Hand_R / Hand_L
IndexFinger_01..04 / Thumb_01..04 / Finger_01..04
```
Mixamo / Unity-Humanoid imports go through a bone-rename pass at import
time (tracked separately).
## Fields
| Field | Type | Notes |
|---|---|---|
| `height` | number | Foot-to-head height in T-pose, metres. Default 1.8. |
| `eye_height` | number | Foot-to-eyes height, metres. Default 1.65. |
| `bones` | `{ [string]: entity }` | Computed; bone-name → entity-proxy map. Re-walks on read. |
## Methods
| Method | Returns | Notes |
|---|---|---|
| `:bone(name)` | `entity \| nil` | Nil-safe single-bone lookup; same as `self.bones[name]`. |
## Usage
```luau
-- Read a specific bone
local h = entity(playerId).component.get("Humanoid")
local head = h.bones.Head -- entity proxy
local handR = h:bone("Hand_R") -- nil-safe lookup
-- Mount something on the head
local hat = entity.spawn("hat", { parent = head.id })
-- Read height for camera mounting
local cam = entity.spawn("eye_cam", { parent = playerId })
cam.localPosition = { 0, h.eye_height, 0 }
```
## Tag convention
Bundles carrying a humanoid skeleton tag themselves with both
`playerVisual` AND `humanoid` in their `.metadata`. Tooling can filter
to humanoid-conforming visuals via
`asset.list("bundle", nil, { fields = { tags = { "playerVisual", "humanoid" } } })`.
# controller.isometric
Isometric camera controller. Fixed-angle overhead camera (classic isometric view). WASD to pan on the XZ plane, scroll to zoom. The viewing angle is fixed (no rotation).
Public fields: `inputMap` (the camera-rig scheme, `@builtin::inputMaps.cameraRig`, by default; point it at a world's own map to rebind this camera), `target` (empty = free pan), `centerX`, `centerY`, `centerZ`, `distance`, `minDistance`, `maxDistance`, `moveSpeed`, `zoomSpeed`, `yaw` (default pi/4), `pitch` (default ~35.26 degrees).
Controls: WASD to pan (world-axis-aligned), scroll to zoom.
```luau
entity(id).component.add("controller.isometric", { distance = 12, moveSpeed = 8 })
```
# LookAtConstraint
Rotates an entity to face a target entity or a fixed world position. Use `weight` for partial/smoothed orientation.
Public fields: `targetEntityId`, `targetPositionX/Y/Z`, `axisX`, `axisY`, `axisZ`, `weight`.
Methods: `setTarget(entityId)`, `setWeight(w)`.
```luau
entity(id).component.add("LookAtConstraint", {
targetEntityId = playerId,
weight = 0.5,
})
```
# Audio
Adds audio playback to an entity, driven by a `.soundClip` asset. Supports sound effects, music, and spatial/3D audio tied to entity position.
Public fields: `clip` (`AssetRef<soundClip>`), `sourceType` (`"SoundEffect"` / `"Music"` / `"Ambient"`), `volume`, `pitch`, `looping`, `spatial`, `maxDistance`, `rolloff`, `autoplay`, `channel`.
A spatial source plays at full volume within one unit of the listener and reaches silence at `maxDistance` (default 50). Across that window its amplitude is `(1 - t)^rolloff` for a source `t` of the way through, so the default `rolloff` of `1` is a linear fade — half the distance, half the amplitude — a larger exponent drops away sooner, and `0` holds full volume to the edge.
Methods: `play()`, `pause()`, `stop()`, `setVolume(v)`, `setPitch(p)`.
```luau
entity(id).component.add("Audio", { clip = explosionClip })
entity(id).component.add("Audio", { clip = fireClip, spatial = true, looping = true })
```
A loose audio file (ogg/mp3/wav/flac) needs to be imported into a `.soundClip` asset before it can be referenced here — the importer creates the container and encodes its playable payload.
Whether the clip streams its compressed payload or decodes fully on load follows the clip's own `loadType` setting: `"decompressOnLoad"` and `"streaming"` apply directly, and `"auto"` (the clip's default) picks by the clip's duration — short one-shots decode up front, longer tracks stream. The clip decides: each source this component builds asks `clipRef:streams(bytes)`.
# CutsceneTrigger
Plays a cutscene when something happens, without writing the wiring: the scene
entering play, the player reaching this entity, or a call from your own code.
```luau
-- the game opens on a cutscene
entity.spawn("Intro").component.add("CutsceneTrigger", {
cutscene = "mygame.scenes.intro",
trigger = "onPlay",
})
-- a cutscene when the player reaches the gate
entity.find("GateArea").component.add("CutsceneTrigger", {
cutscene = "mygame.scenes.gate_opens",
trigger = "onEnter",
radius = 4,
})
```
The cutscene itself is a module returning a definition — see the `cutscene`
module for its shape, and the `topics/cutscenes` guide for the whole picture.
## Fields
| Field | Default | Notes |
|---|---|---|
| `cutscene` | `""` | The module path a cutscene definition is returned from. |
| `trigger` | `"onPlay"` | `onPlay` as the scene enters play · `onEnter` when the local player comes within `radius` · `manual` only on a `play()` call. |
| `radius` | `3` | How close the local player has to get, for `onEnter`. |
| `delay` | `0` | Seconds to wait after the trigger before it starts. |
| `once` | `true` | Whether it plays only the first time it is triggered. |
| `skippable` | `true` | Whether the player can skip it, overriding the definition. |
| `played` | `false` | Whether it has already played. Local to this peer. |
## Methods
- `play()` — start the cutscene now, whatever `trigger` says. This is what
`trigger = "manual"` is for.
- `isPlaying()` — whether the cutscene this trigger started is still running.
## Notes
- `played` is `NoSync` on purpose: a cutscene is something each player watches for
themselves, so one player having seen it is not a reason another player does
not.
- Playing goes through `cutscene.play`, so only one cutscene is ever on screen —
a trigger that fires while another cutscene runs ends that one first. The
`onEnter` trigger holds off while any cutscene is playing rather than cutting
across it.
- A trigger destroyed while its own cutscene is on screen ends the cutscene, so
the player is never left looking through a camera nothing moves.
# SoftBody
Makes an entity a deformable body: a jelly, a cushion, a balloon, a cloth, a soft version of the entity's own model, or a shell that crumples and keeps the dent, like a car body after a crash. The body is a set of particles held together by elastic elements, simulated in the same step as rigid bodies, so it rests on and pushes rigid bodies, rigid bodies rest on, bounce off and dent it, and soft bodies collide with each other.
A soft body is its own body and its own collision surface: it goes on an entity with no `Physics` and no collider component, and whichever is added second is refused with an error naming the conflict (logged, and readable as the refused component's `disabledReason`). The body fills the entity's unit box (-0.5 to 0.5 on each axis) times its world scale, so it sits where a default cube or sphere primitive of that scale would. `shape = "mesh"` instead fills every closed part of the entity's model with tetrahedra of `cellSize`, and the model is drawn carried by them; a model with no closed part, or a cell size past the 60000-cell cap, is refused with the reason and the fields that change it. While the simulation runs, the entity's position and rotation follow the body as a whole, and a `Model` on the entity moves with it; the deformed surface is drawn by the physics view (`capture` with `pass = "physics"`) and read by `Physics.softBodyState`.
Public fields: `shape` (`"box"`, `"sphere"`, `"balloon"`, `"cloth"`, `"mesh"`), `cellSize` (for `mesh`: the cells' edge in metres, 0 takes the model's longest extent divided by `resolution`), `collideWith` (for `mesh`: `"cage"`, the cells' boundary, or `"surface"`, the model's own surface), `resolution` (particles along the longest edge, 2 to 24), `mass` (kg; the default 0 weighs it at 1 kg per cubic metre of its shape, like a rigid body), `stiffness` (Young's modulus in pascals for a box or sphere, default 300; spring stiffness for a balloon or cloth), `damping` (0 rings on, 1 settles without overshoot), `model` (a solid's cells: `"corotational"`, the default, `"neoHookean"` or the cheap `"volume"`), `poisson` (Poisson's ratio, 0 up to 0.5), `plasticYield` (the strain past which a dent stays; 0, the default, is fully elastic), `plasticCreep` (how fast per second the strain past the yield sets, default 10), `plasticMax` (the most permanent strain a part takes, default 0.5), `inflation` (the share of its built volume a closed shape is held at), `shapeMatching` (hertz of the pull back toward the body's rest shape as a whole, 0 for none), `selfContact` (whether the surface collides with itself), `repairs`, `preserveVolume`, `friction`, `restitution`, `collisionMemberships`, `collisionFilter`, `canSleep`, `attachments`. The material (`stiffness`, `damping`, `poisson`, the plastic fields, `inflation`, `shapeMatching`, `preserveVolume`, `friction`, `restitution`, the collision groups) and `repairs` take effect on the running body, waking it. Writing `shape`, `resolution`, `cellSize`, `collideWith`, `mass`, `model`, `selfContact` or `canSleep` rebuilds the body; in play mode a rebuild of the same particles (a new `model`, `mass` or `selfContact`, or the entity moved while the clock is paused) keeps the deformation that stayed, and one while the simulation runs keeps every particle where it is and how it moves. A reload of this component's code keeps the body as it stands: the reloaded component takes it over, dents, motion and pins included.
**Permanent deformation.** With `plasticYield` above 0, a part of the body strained further than the yield flows toward the shape it is held in, so a dent, a crumple or a crease stays once the load is gone: a car shell that keeps its crash, clay that keeps a print, a sheet that keeps a fold. `Physics.softBodyState(id).plastic` reads how deep it is, in metres; `:repair()` (or `Physics.repairSoftBody(id)`) restores the shape the body was built in, the body put back in it where it stands and moving on as it moved. Leaving play for edit mode puts the body back in that shape too.
`attachments` holds regions of the body to rigid bodies, two-way: a list of `{ target, region }`, `target` the entity id of a body with `Physics`, `region` `{ box = { min, max } }` (every particle between the corners) or `{ nearest = point }` (the one particle nearest the point) in the entity's unit box, `space = "world"` for world coordinates, empty for the whole body. Each region follows its rigid body and the rigid body carries its weight and pull. Changing the list re-ties only the entries that changed; a rebuilt body is tied again, and removing either side releases the hold. `Physics.softBodyState(id).attachments` reads each entry back with the force it pulls with, or why it holds nothing. To join soft bodies to each other, or to anchor a joint on a soft body, use a `Joint`, whose ends each take a region.
Methods: `:state(opts?)` returns the same report as `Physics.softBodyState(entityId, opts)`. `:repair()` restores the shape the body was built in and returns the new `repairs` count. `:attach(target, region?)` adds an attachment and returns its index (the same as `Physics.attachSoftRegion`); `:detach(target?)` releases the regions held to `target`, or all of them, and returns how many it removed.
The body is built at the start of the frame after the component is added. Until then `Physics.softBodyState(id).status` is `"pending"` (with `exists = false`, and `waitingFor` naming what the build waits on); once built it is `"built"`, and a component that was not built reads `"refused"` with the reason in `refusal`.
`Physics.softBodyState(id).cage` reports a `mesh` body's fill: `cellSize`, `cached` (taken from the cage cache, as a second instance of a model is), `buildMs`, `generateMs`, `filledParts`, `strayParts`, `openParts`, `seams` (springs sewing touching parts together), `pieces`, `enclosedVolume` and `digest`.
```luau
local jelly = entity.spawn("jelly")
jelly.position = { 0, 2, 0 }
jelly.component.add("SoftBody", { shape = "box", resolution = 6 })
print(Physics.softBodyState(jelly).status) -- "pending" in the call that added it
-- on a later frame:
local s = Physics.softBodyState(jelly)
print(s.status, s.particleCount, s.volumeRatio, s.sleeping) -- "built", 216, ...
```
```luau
-- a cloth whose left edge hangs from a pole
flag.component.add("SoftBody", { shape = "cloth" })
flag.component.get("SoftBody"):attach(pole, { box = { min = { -0.5, -1, -0.5 }, max = { -0.45, 1, 0.5 } } })
print(Physics.softBodyState(flag).attachments[1].forceMagnitude) -- on a later frame
```
```luau
-- a slab of clay that keeps the print of whatever lands on it
clay.component.add("SoftBody", { shape = "box", stiffness = 3000, plasticYield = 0.02 })
-- on a later frame, after something landed on it and left:
print(Physics.softBodyState(clay).plastic) -- metres of dent that stayed
clay.component.get("SoftBody"):repair() -- and gone again
```
# ClipPlayer
Plays one `.animation` clip on the skinned body it is attached to, through the
canonical AnimGraph. The graph retargets the clip from its source rig onto the
body's rig and drives the body's bone entities each frame, so any humanoid clip
plays on any humanoid body. It is the single-clip case of the animation
contract: build a one-clip graph on start, then advance and apply it each frame.
Attach it to the same entity as the skinned mesh. The body's rig comes from its
`Skeleton`; the clip's source rig from the clip itself (or `sourceRig`). All the
retarget and pose machinery lives in the graph — this component only configures
the clip and ticks it.
```luau
entity.find("hero").component.add("ClipPlayer", { clip = "A_Walk_F_Masc" })
```
Public fields: `clip` (the `.animation` to play), `sourceRig` (the clip's source
rig; empty resolves from the clip), `looping`, `speed` and `playing`.
# PostProcess Component
Applies a **stack of post-process effects** to the scene, declaratively, from a
single entity. Each effect references a `.shader` asset (`@domain: post-process`)
and carries its own **configurable property values** — tuned like material
properties, with no shader edit required.
This is the load-time **reproducer** for post-processing: where an agent's live
`postprocess.add(...)` + `postprocess.setProperty(...)` calls produce effects
that vanish on reload, a `PostProcess` component re-registers the exact same
stack — with the exact same tuning — every time the scene loads. The persist
freeze converts live execute-origin effects into `.shader` assets + one entity
carrying this component, so **what you see is what you get** across save/reload.
## Properties
| Field | Type | Description |
|---|---|---|
| `effects` | array | The effect stack. Each entry: `{ shader, priority?, enabled?, layer?, values?, textures? }`. |
| `shape` | `"global"` \| `"box"` \| `"sphere"` | Where the stack applies. Default `"global"` (unbounded). |
| `extents` | vec3 | Box region half-extents, world units. Default `{5, 5, 5}`. |
| `radius` | number | Sphere region radius, world units. Default `5`. |
| `falloff` | number | Soft-edge distance outside the volume the stack fades over. Default `0`. |
| `priority` | number | Order of this region against other `PostProcess` regions, added to each effect's own priority. Default `0`. |
| `display` | struct `{ curve, exposure }` | The display transform this region sets while it is live — `curve` (0 linear, 1 Reinhard, 2 ACES, 3 Khronos PBR Neutral, default `3`) and `exposure` (multiplier on the scene's light before the curve, default `1`). Takes effect only when `setsDisplay` is `true` and `shape` is `"global"`. |
| `setsDisplay` | boolean | Whether this region writes `display` to the chain's `__display__` transform while it is live. Default `false`. |
Each effect entry:
| Key | Type | Description |
|---|---|---|
| `shader` | asset reference | The `.shader` asset (a `@domain: post-process` shader) this effect runs. Builtin shaders (`@builtin::shaders.post.vignette`, …) or scene-authored custom shaders both work. |
| `priority` | number | Chain order within this region — lower runs earlier. Default `100`. |
| `enabled` | boolean | Per-effect toggle. Default `true`. |
| `layer` | `"all"` \| `"scene"` | Which composited image this effect grades: `"scene"` is the rendered picture before the UI reaches it, in the scene's own light; `"all"` is the picture fitted to the screen with every widget on it. Left empty, the entry follows its shader's `space` — a `light` shader grades `"scene"`, a `display` shader grades `"all"`. |
| `values` | table | Named property overrides `{ [prop] = value }`, applied via `postprocess.setProperty` after the effect is added — the configurable tuning. |
| `textures` | table | Texture bindings `{ [prop] = path }`, applied via `postprocess.setTexture` after the effect is added. |
The component-level `enabled` flag (engine-managed, not declared here) toggles the
**whole stack**: disabling the component removes every effect (and restores the
chain's own display transform if this region set one); enabling it re-registers
them (`onEnable` / `onDisable`).
## Usage
```lua
entity.find("fx").component.add("PostProcess", {
effects = {
{ shader = "@builtin::shaders.post.vignette", priority = 0,
values = { intensity = 0.6 } },
{ shader = "@builtin::shaders.post.grayscale", priority = 10,
values = { intensity = 1.0 } },
},
})
```
Setting the display transform from a `global` region:
```lua
entity.find("look").component.add("PostProcess", {
setsDisplay = true,
display = { curve = 2, exposure = 1.5 }, -- ACES
})
```
Two `global` regions with `setsDisplay = true` both write `__display__` — whichever
registers last wins the frame. The regions setting it share one ordered record of
who is live, so they come off in any order: removing the newer of two leaves the
older one's display standing, removing the older leaves the newer one's, and
removing the last leaves whatever stood before any of them wrote.
The exposure package drives `__display__.exposure` on its own, from a GPU readback
each tick it meters. A region arriving or leaving sets the exposure and the
package's next readback sets it again, so with auto-exposure running the exposure
a region asks for holds for a frame while its curve holds for as long as the region
does.
## Methods
- `pp:getEffectNames()` — the live effect names currently registered, in stack order.
- `pp:setProperty(index, prop, value)` — tune a property of one effect at runtime
(forwards to `postprocess.setProperty`), like editing a material property.
# 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` (radius of the pull-in sweep; `0` turns the pull-in off), `cameraIgnore` (entities whose colliders the pull-in passes through), `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 }
```
## The camera pulls in when a collider is in the way
Each frame the rig sweeps a sphere of `cameraRadius` from the point it aims at out to where its framing puts the camera, and stands the camera where the sphere first meets a collider. Every collider in the world stops that sweep, whether or not anything draws it: an invisible walk bound (a `BoxCollider` with no `Model`), a trigger volume and a solid wall all pull the camera in alike. The follow subject is the one entity the sweep passes by default.
So a camera that snaps in close to the player, or collapses onto it, while nothing visible stands behind the player is meeting a collider nobody can see. Two fields settle it:
- `cameraIgnore` lists the entities whose colliders the camera sees through. Name the walk bounds and gameplay volumes there and the sweep passes them as it passes the subject, while walls the player can see still pull the camera in. Each entry is an entity (or its id), and it is the collider's own entity that is listed.
- `cameraRadius = 0` turns the pull-in off: the camera stands where its framing puts it, through every collider.
```luau
local rig = entity(camId).component.get("controller.orbital_follow")
rig.cameraIgnore = { entity.find("NorthBound"), entity.find("SouthBound") }
-- or drop the pull-in entirely
rig.cameraRadius = 0
```
## 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.
# SkinnedModel
Component that drives skinned model playback and animation graph integration.
Like `Model`, it carries the `material` field (`AssetRef<material>`) — material lives on the model that renders it, not as a standalone component. Set it at add time or assign `entity(id).component.get("SkinnedModel").material = "..."` later; `:setMaterialProperty` / `:getMaterialProperty` / `:getMaterialPropertyNames` operate on the assigned material (registry-wide).
## Blend model
`skinning` chooses how the pass combines a vertex's joint influences.
- `"linear"` (default) averages the joint matrices themselves. The average of
two rotations is not a rotation, so a strongly twisted joint loses volume
toward its axis — at a 180° twist a limb collapses onto the axis entirely.
- `"dualQuaternion"` averages the joints' rigid motions as unit dual
quaternions and renormalises, so the blend is a rotation at every weight and
the cross-section is carried through the twist. Non-uniform joint scale rides
along as a separately blended stretch. It costs a quaternion extraction per
influence, so it is worth setting on characters whose wrists, shoulders or
spine twist far, and leaving off elsewhere.
```lua
entity.find("hero").component.get("SkinnedModel").skinning = "dualQuaternion"
```
A rig at rest renders identically under either model.
## Render self-check
A few frames after its mesh attaches, a SkinnedModel checks that the mesh
names something the engine can draw (on the GPU, or held off the device until
this display puts it back) and that the pose it is drawn in has not collapsed it: the
posed vertices the skinning pass wrote are measured against the mesh's own
bind-pose extent, so a 1 cm part of a prop is judged by its own size. A mesh
that fails is swapped for the builtin ERROR marker, drawn un-skinned with the
error material, and the reason is logged. Setting `model` again takes the
marker down and checks the new mesh.
`renderCheck()` answers the verdict: `{ state = "pending" | "passed" |
"failed" | "skipped", reason }`.
```lua
local v = entity.find("prop_part").component.get("SkinnedModel"):renderCheck()
if v.state == "failed" then print(v.reason) end
```
# deprecatedParticles (deprecated)
> **Deprecated.** This belongs to the earlier particle substrate. It stays loadable by identity and guid for content that already uses it; nothing new should use it, and search does not offer it. Particles are procgen sim nodes (`sim.spawn`, `sim.particles`, `sim.shader`) played through an effect: use `@builtin::assetTypes.effect`.
The package holds the GPU particle engine module (`engine`), the `deprecatedParticles` module over it, the `DeprecatedParticleEmitter` and `DeprecatedSimpleParticleEmitter` components, the shaders, compute shaders and materials they draw with, and a demo scene. The package's `.metadata` carries the deprecation marker, so every asset inside is deprecated.
# WheelCollider
A wheel of a ray-cast vehicle (Unity-style). Add it to a child entity of a rigid body: every wheel under one body is one wheel of that body's vehicle, simulated by rapier's ray-cast vehicle controller (`DynamicRayCastVehicleController`). The system finds the nearest ancestor with a `Physics` component and drives it.
Each physics tick a wheel casts its suspension ray straight down the chassis' local **-Y** from its own position, which is the suspension **mount**. The ray passes through triggers and through the chassis' own colliders, so a chassis `BoxCollider` around the mounts is fine. The controller then applies the suspension, drive, brake and tyre grip to the chassis as impulses.
Drive conventions: positive `motorTorque` drives toward the parent body's local **-Z** (the engine's forward); positive `steerAngle` steers right.
## Fields
| Field | Meaning |
|---|---|
| `suspensionDistance` | Suspension travel (m): the wheel hangs at most this far below the mount, and the spring compresses by at most this much from its rest, where the bump stop holds the wheel. |
| `targetPosition` | Where the spring rests, as a share of `suspensionDistance` below the mount. The spring's rest length is `suspensionDistance * targetPosition`, and the wheel reaches the ground within that length plus its radius. 1 rests at full extension. The spring compresses from its rest by up to `suspensionDistance`, where the bump stop is. |
| `springRate`, `damperRate` | Spring stiffness (N/m) and damping (N·s/m) per wheel. |
| `maxSuspensionForce` | The most force (N) one wheel's suspension pushes with (default 6000). Raise it for a vehicle heavier than about 2 t on four wheels. |
| `radius`, `width` | Wheel size (m). An unset radius comes from the wheel entity's model bounds, or else from its visual's. The ray is cast from the point across the tread where the ground is nearest, so a kerb under one shoulder holds the wheel up. |
| `motorTorque` | Drive torque (N·m). The wheel drives with `motorTorque / radius` newtons, up to `forwardFriction` times the load it carries. |
| `brakeTorque` | Brake torque (N·m), braking with up to `forwardFriction` times the load. It acts while `motorTorque` is 0, and so does `rollingResistance`. A braked car stands still on any slope its brakes' grip holds, gravity included. |
| `steerAngle` | Steering angle (degrees), positive steers right. At light throttle the car turns on the radius its geometry gives, the wheelbase over `tan(steerAngle)`; driving the steered wheels hard tightens the turn and driving only the others widens it. |
| `frictionSlip` | The tyre's combined grip limit (default 10.5): the drive, brake and side grip together reach at most the suspension load times this before the tyre slides sideways. Lower it (1 or less) for ice, a drift car or skids. |
| `sideFrictionStiffness` | How strongly the tyre resists sliding sideways below its grip limit (default 1). |
| `forwardFriction` | Longitudinal grip coefficient (default 1): drive and brake force are held to this times the load the wheel carries. |
| `sidewaysFriction` | Multiplier on `sideFrictionStiffness`. |
| `rollingResistance` | Drag on a freely rolling wheel, times the load it carries. |
| `is2D` | The wheel applies no sideways grip. |
| `visual` | The wheel's visual: an entity, or its id (see below). |
The suspension ends at a **bump stop**: once the spring has compressed by `suspensionDistance` from its rest, the wheel holds the chassis off the ground there like a solid contact. It stops the chassis closing on the ground within the tick, so a landing faster than the springs can take, or a chassis heavier than its springs carry, ends on the stops with the tyre on the ground rather than in it, and the chassis is not thrown back up. A chassis that stands past its stops (put there by a script, say) is eased back out at no more than 1 m/s.
The spring is applied as an impulse worked out from the state at the start of each tick. So that it cannot feed on itself, its rate is held, per tick, to what the chassis' mass **and its rotational inertia** integrate stably: a light chassis, or one whose pitch or roll inertia is small next to its wheelbase, gets less of a very stiff spring than authored. The damper is applied to the velocity the chassis ends the tick with, which is stable at any rate, so the authored `damperRate` always acts whole. A damper pulls a wheel down no harder than its spring pushes it.
## Runtime state
Read-only, updated every physics tick, and readable through `Physics.getWheelState(wheelId)`:
- `isGrounded`: the tyre touches the ground anywhere within the suspension's full travel, `suspensionDistance` below the mount. Past the spring's rest it carries no load.
- `compression` (0 with the spring at or past its rest length, up to `suspensionDistance`, where the bump stop is).
- `angularVelocity` (rad/s) and `rotation` (rad): the wheel's spin and its angle about the axle, which grow as it rolls forward by the distance rolled over the radius.
- `skid` (m/s): how fast the tyre slides sideways while its grip is used up; 0 while the tyre holds.
- `sideImpulse`, `forwardImpulse` (N·s): what the tyre applied to the chassis this tick. `suspensionForce` (N). `stopImpulse` (N·s): what the bump stop applied this tick, the part of a landing or a load the suspension could not take.
- `center`, `hitPoint`, `hitNormal`: the wheel centre and the ground contact, in world space. A wheel with no ground within its travel hangs at full travel.
A driver for the wheels is built in: `VehicleController` on the chassis reads the vehicle input map and writes `motorTorque`, `brakeTorque` and `steerAngle`.
`Physics.vehicleState(chassisId)` reads the whole vehicle: the chassis' forward speed (m/s, negative in reverse), how many wheels are grounded, and each wheel's state.
## Seeing the suspension
`capture { pass = "physics" }` and the edit-mode collider overlay draw every wheel over the colliders: its suspension ray over its whole reach, `suspensionDistance` and the radius below the mount (green on ground, red in the air, as `isGrounded` reads), the travel from the mount to the tyre's centre coloured by how much of it is taken up (blue extended, yellow half way, red bottomed out), the tyre's radius as a white circle across its steered axle, and where the tyre meets the ground (yellow) with the ground's normal (cyan). `Physics.debugLines({ layers = { "wheels" }, entities = { chassis } })` returns those lines with each wheel's `grounded` and `compression` (the same values as `isGrounded` and `compression` here), `travel`, `suspensionLength`, `restLength`, `radius`, `mount` and `contactPoint` / `contactNormal`. In edit mode the vehicle stands at rest, so its wheels draw where they are mounted and follow their authored fields as they change.
## Visuals
The wheel entity's transform is the suspension mount, so it stays where it is authored. Put the tyre mesh on another entity under the chassis (a child of the wheel is the usual place) and name it in `visual`, as the entity or its id (a prefab or player clone of the car points it at its own copy of the tyre): every tick the wheel places that entity at the wheel centre, turns it by the steering angle and spins it about the axle, starting from the rotation it had when the wheel first posed it.
Methods: `setMotor(torque)`, `setBrake(torque)`, `setSteer(angle)`.
```luau
local car = entity.spawn("car")
car.position = { 0, 1, 0 }
car.component.add("Physics", { kind = "dynamic", mass = 600 })
car.component.add("BoxCollider", { half = { 0.9, 0.3, 2.0 } })
for _, at in { { -0.85, -1.3 }, { 0.85, -1.3 }, { -0.85, 1.3 }, { 0.85, 1.3 } } do
local wheel = entity.spawn("wheel", { parent = car.id })
wheel.localPosition = { at[1], -0.3, at[2] }
local tyre = entity.spawn("tyre", { parent = wheel.id })
tyre.component.add("Model", { model = "cylinder" })
tyre.localRotation = { 0, 0, 0.7071068, 0.7071068 } -- the cylinder's axis along X
tyre.localScale = { 0.7, 0.25, 0.7 }
wheel.component.add("WheelCollider", {
suspensionDistance = 0.3,
springRate = 20000,
damperRate = 2000,
visual = tyre,
})
end
```
# RenderFeature
Runs a `.renderFeature` render pass for the lifetime of the entity carrying it.
A scene's features are listed together, each with its settings, on
`RenderFeatures`; this is the one-feature form. A feature already live on the
same asset is adopted rather than stacked a second time.
A render feature is authored content — a `.renderFeature` asset whose `init.luau`
returns `{ setup?, render, teardown? }` and enqueues GPU passes every frame. This
component is the declarative way to keep one running: on awake / enable it calls
`renderer.feature.create(feature)`, and on disable / destroy it tears the feature
down. A render feature is a **global** pass, so a single component on a single
entity drives it for the whole scene.
## Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `feature` | `assetRef("renderFeature")` | none | The render feature asset to run. |
## Methods
- `:getHandle()` — the live `RenderFeatureHandle` this component created (its
`guid` is the feature's stable id), or `nil` when disabled / before awake.
## Persist
This component is the persist-side mirror of a live `renderer.feature.create(…)`
call. When you enable a render feature ad-hoc during play, the scene-authoring
freeze (`tools.sceneAuthoring.acceptChanges`) promotes the feature's source into a
`.renderFeature` asset and records it on one entity carrying this component — so
loading the scene re-runs the exact same render pass.
## Example
```lua
local e = entity.spawn("acrylic_pass", {})
e.component.add("@builtin::components.RenderFeature", {
feature = asset.resolve("acrylic", "renderFeature"),
})
```
# 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.
# ik
Look at a rig's bones and at what an IK solve is actually doing.
Debugging IK means answering three questions, and none of them can be answered
from a screenshot: what are this rig's bones called, where are they right now,
and is the chain reaching its target or merely near it.
`bones` lists a body's bones with their world positions and parents — the names
you need before naming a chain, and the positions you measure against when
checking a solve from outside the component. Rigs rarely use the word you would
guess: the standard humanoid here calls its feet `Ankle_L` and `Ankle_R`, with no
bone containing "foot" anywhere.
`reach` reports how far a limb can extend and how far away a point is, both in
the same units. A target beyond a limb's reach has no solution — the chain
extends straight toward it and stops, which is correct and looks exactly like a
broken solver. This is the first thing to check when a solve looks inert.
`state` gathers every IK component on an entity and reports what each one is
doing, so "is anything solving at all" is one call rather than five.
# 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, asked)` hook (a DEFAULT the schema can't express — a name-derived convention, a fact read off a payload the caller handed it, a value following a field `asked` shows a higher layer naming; 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. `about` / `aboutAt` read a type's `about.yaml` (`intent`, `function`, `usage`), leaving out any key that is empty or still starts with `[[describe]]`; `aboutRows` and `aboutMarkdown` lay the texts out for the Inspector and the `types/<name>` guide. |
| `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.