Log inGet started

Component (asset type)

Updated 6 September 2026

When to use one

  • You need per-entity state that survives across frames.
  • You need per-entity behavior driven by engine lifecycle hooks (start, update, destroy).
  • You want to expose configurable fields to designers / entity.spawn callers / scenes without code changes.

If you need a global service that outlives any entity, use a .service instead. If you need a stateless pure-function library, use a .module.

Where it lives

  • Source: /zero/source/.../<Name>.component/
  • Identity: <Name> (the .component suffix strips from the identity, but the folder retains the suffix on disk).
  • Folder shape:
    • init.luau (or init.lua) — declares the component. Required.
    • README.md — instance-level documentation. Required.
    • .metadata — agent-editable tags + free-form fields. Required.

The structural contract is enforced by asset.validate against this type's type.yaml.

How to create one

Always start with the scaffold — it guarantees the folder shape matches type.yaml from the moment it's on disk:

asset.create("component", "<Name>")
-- Creates: /zero/source/<Name>.component/
--   init.luau   (canonical declare + public block + hooks stubbed)
--   README.md   (instance README template)

After scaffolding, edit init.luau to fill in the actual fields and behaviour. The component registers as soon as the file is written — hot-reload is automatic; no engine restart required.

How it operates

  1. Registration. Writing init.luau into a .component/ folder indexes the asset, mints a guid (in the .meta sidecar), and surfaces the type to the engine's component registry. Same-frame visible to entity.component.add.

  2. Attachment. entity(id).component.add(name, data) (or the batch form tools.use("entityOps", "addComponents", targets, { [name] = data })) attaches an instance. The data table's fields override the component's declared defaults; omitted fields take the declared default. The data values are copied into public.* BEFORE awake() runs.

  3. Lifecycle. The hooks run in this order:

    • awake() — same frame as attach, immediately after the data copy. Initialise state, set up native bridges, register with other systems. Other components on the same entity may not have awoken yet.
    • start() — the next frame. Every component on the entity has completed awake() by then, so cross-component setup is safe here.
    • update(dt) — same frame as start(), then every frame after, for as long as the gameplay clock is running. fixedUpdate(dt) runs beside it on the fixed timestep; editorUpdate(dt) runs while the clock is held. Which of them ticks is decided by engine.mode AND engine.paused together — see Per-frame hooks and the two axes below, and man modes.
    • onDestroy() — when the component is removed via component.remove or when the owning entity is despawned. Release resources, drop native ECS components.

    For the full list (onEnable, onDisable, fixedUpdate, editorUpdate, onPropertyChanged, onAssetReload, onModuleReload, onBeforeSave, …) and the timing details, read man components — that is the canonical lifecycle reference. Generic component patterns (declare blocks, public fields, sync, ECS bindings, typed methods) all live there.

  4. Public surface. Fields declared in public are inspectable, editable in the inspector, and persist with the entity. Methods are declared on that same table with a colon — typed function public:method(args) — and every one of them takes the component ref as self, so a caller invokes it with a colon too: entity(id).component.get(name):method(args). That is the one calling convention a component ref answers to: the reflection methods ref:fields() / ref:getField(name) / ref:setField(name, value) take the same spelling. A function the chunk declares at file scope (function helper()) is the component's own, reachable from its hooks under its bare name; public is what the ref carries.

  5. Events. An events block declares typed moments the component fires from inside — events = { onHit = Event({ dmg = Field.number(0, NoSync) }) }. The owner fires with events.onHit:fire({ dmg = 10 }); outside code subscribes through the proxy's subscribe-only facade, entity(id).component.get(name).events.onHit:connect(fn) (:once / :wait too). Pass Sync as the event's second argument to replicate a fire to other peers. Events let one component react to another's moments without polling. Full reference: man components, Events section.

  6. Hot reload. Editing init.luau reloads the component definition; live instances are reconciled with the new declaration in-place, and each crosses one lifecycle boundary: onDestroy() ends the running life, then awake() and start() construct the one that replaces it, and update() resumes on the next frame. public and private values carry across; module-level locals belong to the chunk, so the new life starts with them at their initial values and its awake/start are what fill them in. Whatever the old life handed elsewhere — a subscription, a shared registration, a spawned entity — is released in onDestroy, the call that pairs with the construction about to run. An edit that leaves the source byte-identical crosses no boundary at all.

Discovery

  • asset.list("component") — every registered component (engine + world + libraries).
  • asset.inspect("<name>") — identity, scope, public fields, lifecycle methods, source path, and this type README. detail carries the component's declared fields, methods, and events, parsed from its source — tools.use("assets","describe", "<name>") renders it as markdown, so a component's events are visible without opening its source.
  • cat /zero/source/<Name>.component — the raw filesystem view (a different projection than asset.inspect).
  • entity(id).component.list() — components attached to an entity.

Per-frame hooks and the two axes

engine.mode ("edit" / "play") and engine.paused (the gameplay clock) are independent, and the per-frame hooks read them separately:

  • update(dt) / fixedUpdate(dt) tick while the gameplay clock is running — whichever mode the engine is in.
  • editorUpdate(dt) ticks while the clock is held — because the mode is edit, or because play is paused — and also alongside update whenever edit is running.

Which gives four states, and this table is the contract:

engine.modeengine.pausedhooks that tick
edittrueeditorUpdate
editfalseupdate, fixedUpdate, editorUpdate
playfalseupdate, fixedUpdate
playtrueeditorUpdate

Entering edit sets paused = true and entering play sets it back to false, so a session that never writes engine.paused only ever sees rows 1 and 3 — which is where the shorthand "update is the play tick, editorUpdate is the edit tick" comes from. It is the two default rows of a four-row table, not the rule.

Row 2 is the one that surprises. With engine.paused = false in edit — set by hand, or by a tool that advances the gameplay clock to sample it, such as the capture filmstrip — update and editorUpdate both fire, once each, every frame. A component that declares both and routes them to one body therefore runs that body twice per frame:

local function drive(dt) ... end
function update(dt)       drive(dt) end
function editorUpdate(dt) drive(dt) end   -- twice per frame in row 2

Nothing reports the doubling; it reads as an animation running at twice its intended speed. To get exactly one tick per frame in every row, ask which axis you meant and answer it in the body:

function update(dt)       drive(dt) end
function editorUpdate(dt) if engine.paused then drive(dt) end end

A scene entrypoint.luau declaring the same two hook names is dispatched by the scene loader, not by this table: there update needs play AND running, editorUpdate needs edit, and play-paused ticks neither. Read man scenes before carrying a component's rule to an entrypoint.

Authoring conventions

  • Annotate public fields with --!desc so the inspector + LSP show documentation alongside the field name.
  • Declare lifecycle hooks (start, update, destroy) explicitly rather than relying on engine defaults — readers can see at a glance what runs when.
  • Keep update(dt) cheap — it runs every frame per instance. Move heavy work to start, to a service, or behind a job (man jobs).
  • For multi-entity batching, prefer tools.use("entityOps", "addComponents", targets, components) over a loop of entity(id).component.add — it takes many targets and many components in one call.

Common pitfalls

  • Naming. The component identity is the folder stem with .component stripped (e.g. <Spinner>.component/ → identity Spinner). Don't repeat the suffix in the identity passed to component.add.
  • init.lua vs init.luau. Either is accepted (it's a one_of_group: "entrypoint"), but .luau is canonical. Mixing both in one folder is invalid.
  • update in edit mode. It runs whenever the gameplay clock is running, and entering edit stops that clock — so update is idle in a freshly-entered edit session and ticks again the moment anything sets engine.paused = false. Use tools.use("wld", "play") to test update logic under the mode it ships in; tools.use("wld", "edit") to return. See Per-frame hooks and the two axes above.
  • Renaming. Renaming the folder changes the identity. Update every component.add(<oldname>, ...) call and the scene JSON entries that reference it.
  • .module — pure-function library, no per-entity state.
  • .service — globally-scoped behavior, no per-entity state.
  • .preset — a captured component configuration, replayable into component.add without re-typing fields.
  • .bundle — entity hierarchies that may carry component attachments as part of the bundle.
  • asset-type
  • reference