Component (asset type)
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.spawncallers / 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.componentsuffix strips from the identity, but the folder retains the suffix on disk). - Folder shape:
init.luau(orinit.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
-
Registration. Writing
init.luauinto a.component/folder indexes the asset, mints a guid (in the.metasidecar), and surfaces the type to the engine's component registry. Same-frame visible toentity.component.add. -
Attachment.
entity(id).component.add(name, data)(or the batch formtools.use("entityOps", "addComponents", targets, { [name] = data })) attaches an instance. Thedatatable's fields override the component's declared defaults; omitted fields take the declared default. Thedatavalues are copied intopublic.*BEFOREawake()runs. -
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 completedawake()by then, so cross-component setup is safe here.update(dt)— same frame asstart(), 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 byengine.modeANDengine.pausedtogether — see Per-frame hooks and the two axes below, andman modes.onDestroy()— when the component is removed viacomponent.removeor 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, readman components— that is the canonical lifecycle reference. Generic component patterns (declare blocks, public fields, sync, ECS bindings, typed methods) all live there. -
Public surface. Fields declared in
publicare 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 asself, 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 methodsref: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;publicis what the ref carries. -
Events. An
eventsblock declares typed moments the component fires from inside —events = { onHit = Event({ dmg = Field.number(0, NoSync) }) }. The owner fires withevents.onHit:fire({ dmg = 10 }); outside code subscribes through the proxy's subscribe-only facade,entity(id).component.get(name).events.onHit:connect(fn)(:once/:waittoo). PassSyncas 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. -
Hot reload. Editing
init.luaureloads the component definition; live instances are reconciled with the new declaration in-place, and each crosses one lifecycle boundary:onDestroy()ends the running life, thenawake()andstart()construct the one that replaces it, andupdate()resumes on the next frame.publicandprivatevalues carry across; module-levellocals belong to the chunk, so the new life starts with them at their initial values and itsawake/startare what fill them in. Whatever the old life handed elsewhere — a subscription, a shared registration, a spawned entity — is released inonDestroy, 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.detailcarries the component's declaredfields,methods, andevents, 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 thanasset.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 isedit, or because play is paused — and also alongsideupdatewhenever edit is running.
Which gives four states, and this table is the contract:
engine.mode | engine.paused | hooks that tick |
|---|---|---|
edit | true | editorUpdate |
edit | false | update, fixedUpdate, editorUpdate |
play | false | update, fixedUpdate |
play | true | editorUpdate |
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
--!descso 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 tostart, to a service, or behind a job (man jobs). - For multi-entity batching, prefer
tools.use("entityOps", "addComponents", targets, components)over a loop ofentity(id).component.add— it takes many targets and many components in one call.
Common pitfalls
- Naming. The component identity is the folder stem with
.componentstripped (e.g.<Spinner>.component/→ identitySpinner). Don't repeat the suffix in the identity passed tocomponent.add. init.luavsinit.luau. Either is accepted (it's aone_of_group: "entrypoint"), but.luauis canonical. Mixing both in one folder is invalid.updatein edit mode. It runs whenever the gameplay clock is running, and entering edit stops that clock — soupdateis idle in a freshly-entered edit session and ticks again the moment anything setsengine.paused = false. Usetools.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.
Related types
.module— pure-function library, no per-entity state..service— globally-scoped behavior, no per-entity state..preset— a captured component configuration, replayable intocomponent.addwithout re-typing fields..bundle— entity hierarchies that may carry component attachments as part of the bundle.