Components
A component is the primary unit of gameplay code: a piece of behaviour + data attached to an entity. Movement, AI, animation drivers, UI panels, network sync, per-entity state — all of it lives in…
A component is an asset (of type component), so everything in the assets guide applies — it has an identity, you find it with asset.list("component"), and you author it with asset.create. This guide is about the part that's specific to components: their public data, their lifecycle, and how they run in edit vs play.
The public surface — declared data
A component declares its configurable data in a public table. Each field is built with a Field.<kind>(default, mode) constructor — not a bare value:
public = {
speed = Field.number(60, NoSync),
label = Field.string("hi", NoSync),
target = Field.entityRef(nil, Sync),
material = Field.assetRef("material", nil, Sync),
}
- The kind (
number,string,bool,vec3,color,entityRef,assetRef,componentRef,table, …) makes the field typed, inspector-editable, and serialised with the entity. - The mode is required:
Syncreplicates writes to other players;NoSynckeeps the field client-local. - An
entityReffield accepts a live entity proxy OR a raw id string on write (comp.target = otherEntityorcomp.target = "ent_…"), and reads back a live proxy —comp.target.id,comp.target.position, etc. — ornilwhen unset. The stored/serialised value is the plain id.
When a component is attached, the caller can override those defaults, and the values are in place before the first lifecycle hook runs:
entity(id).component.add("Spinner", { speed = 10 }) -- speed starts at 10, not 60
Inside the component you read and write fields through public:
public.speed = public.speed + 1
Every component also gets framework fields the engine injects (read-only): public.entity (the owning entity's proxy — public.entity.position, public.entity.component, …), public.entityId, public.instanceId, and public.type. So a component always knows which entity it's on without being told.
The private surface (hidden data)
A component can declare a second table beside public, called private. It uses the same Field.<kind>(default, mode) constructors, so the declaration reads the same way:
private = {
secretSeed = Field.number(0, NoSync, Serialized),
scratch = Field.number(0, NoSync),
}
The difference from public is reach, not mechanism. A public field is editor-visible and set on component.add. A private field is closed to everyone but the component that declares it: it's invisible to the inspector, and there's no comp.secretSeed on the outside.
Every field, public or private, is really three independent choices:
- Access: which table it's declared in. This one decision splits
publicfromprivate. - Sync:
SyncorNoSync, the same replication modepublicfields use. ASyncprivate field still replicates to other peers; it's just not visible to their inspector either. - Persistence: whether the field is part of what gets saved with the entity.
publicfields always persist, so there's nothing to opt into. Aprivatefield persists only when its constructor takes a trailingSerializedmarker; leaving it off is the more common case, since most private state is a runtime detail, not saved data. WritingSerializedon apublicfield is a registration error: public fields already always serialize, so the marker has nothing to add.
A private field without Serialized resets to its declared default every time the scene loads, the same as it would on a fresh awake(). secretSeed above survives a save/reload with whatever value was last set; scratch comes back at 0 regardless of what it held before.
Inside the component's own chunk, private fields read and write exactly like public ones:
private.scratch = private.scratch + 1
From outside, the only way to reach a private field is the reflection triad on the component's ref, and every call on it takes :, not ., since these are instance methods:
local comp = entity(id).component.get("Health")
comp:setField("secretSeed", 42)
print(comp:getField("secretSeed"))
comp:fields() returns the schema for both surfaces together, one entry per field: {name, access, kind, sync, serialized}. access is "public" or "private", so code that walks a component's shape doesn't need to special-case which table a field came from.
A few things carry over from public fields but are worth calling out for private specifically:
fields,getField, andsetFieldare reserved on every component: neitherpublicnorprivatecan declare a member with those names.Field.aliasand computed fields (computed(function(self) ... end)) are public-only.- A name can only exist on one surface: declaring the same field name in both
publicandprivateis a registration error. - A private
entityReforassetReffield stores the raw identity string exactly as written. Unlike the same field kind onpublic, it never rehydrates into a live entity proxy or resolved asset handle on read.
component.add(type, data) accepts a Serialized private field's key in its init table, the same way it accepts public keys, so restoring a saved entity restores private state too. A non-serialized private key in that table is dropped with a warning pointing at comp:setField instead, since there's nothing to persist to and the runtime-only field is initialized to its default anyway.
None of this changes where transient, per-frame scratch belongs: a value that never needs a name in the schema, like a cached handle or a subscription token, still lives in a module-local outside both public and private (see "Internal state" in the component template).
The lifecycle — and when each hook runs
The engine drives a component through named hooks. Declare the ones you need as top-level functions. Inside a hook there is no self — you read and write state through public.<field>.
function awake() end -- once, the moment the component is attached
function start() end -- once, the frame after every component on the entity has awoken
function update(dt) end -- every frame
function onDestroy() end -- when the component is removed or its entity despawns
What runs when is the part that trips people up, so here it is from the engine's actual behaviour:
| Hook | When it runs |
|---|---|
awake | the moment the component initialises |
start | the next frame, after every component on the entity has awaked (safe place for cross-component setup) |
update(dt) / fixedUpdate(dt) | every frame — play mode only |
editorUpdate(dt) | every frame — edit mode only |
onEnable / onDisable | when the component (or its entity) is toggled enabled/disabled |
onDestroy | when the component is removed or the entity despawns |
onPropertyChanged(key, value, oldValue) | after any write to a public field |
awake and start run in both edit and play — they fire whenever a component initialises. update/fixedUpdate are the play-only ticks; editorUpdate is the edit-only tick. So a behaviour you want only while the game runs goes in update; live editor tooling goes in editorUpdate.
Entering play re-initialises everything
Switching to play doesn't just "start ticking." The engine snapshots the edit state, tears the edit instances down (onDestroy fires), and re-initialises a fresh play world — so awake and start run again in play, and update begins. Leaving play discards that play world and restores the edit snapshot. This is why play-mode changes never affect your edit content, and why you can't rely on a value an edit-mode awake computed surviving into play — play gets its own fresh awake. (The engine guide covers the edit/play model in full.)
If something should happen only in one mode, check engine.mode ("edit" or "play") inside the hook.
Reacting to changes
- A
publicfield changed →onPropertyChanged(key, value, oldValue)fires; use it to push the new value somewhere or invalidate a cache. - An asset the component references changed → if a
Field.assetReftarget is edited/reloaded, the component'sonAssetReload(field)fires with the field name, so it can re-read and react. This is how a component live-updates when the material/mesh/etc. it points at is changed. - The component's own source changed → editing its
init.luauhot-reloads the definition and reconciles live instances in place; yourpublicvalues are preserved. You're iterating on behaviour without restarting anything.
Errors
A component can surface its own non-fatal errors, and the engine surfaces errors it hits on the component — both show up on the entity (e.g. in the inspector):
public.reportError(message, reference)— record a component error. Reporting again from the same origin replaces the previous one.public.clearError()— clear reported errors (a no-op if there were none).public.errors()— read the component's current errors.onError— a hook that fires whenever anything inside the component errors (any hook or method throws, or the engine raises an error against it), so you can handle or surface the failure instead of it failing silently.
(Inside a method these are on self — self.reportError(...) — the same surface under the method's scope name.)
Observing instances — logs, timing, and truthful probes
Component instances run in their own VM contexts, and the observability surfaces behave differently there than a first read suggests. Know these before debugging a live component:
- The
executeresponse'slogsfield is stack-scoped. It carries output from the code the call itself ran (plus engine-level errors from the call's window).log.*lines emitted by component lifecycle hooks do not attach to it — query them explicitly withlogs.warnings()/logs.errors(), which hold every line individually. - Identical lines condense. Repeated identical log lines collapse into one entry with a
<repeated n times>marker. Seven instances warning the same string read as a single line — check the marker's count before concluding only one instance ran, or include the entity id in the message so each instance's line is distinct. os.clock()is process CPU time. A delta across a section measures how much CPU the whole process burned, not how long your section took — in a busy engine the two diverge freely (and on wasm, wildly). For section timing use the attributed profiler:profiler.hotspots/profiler.scriptsattribute frame time down to the component + entity, and profiler regions bracket a specific span.- The probes that stay truthful across VM contexts:
publiccomponent fields (readable from anyexecuteviaentity(id).component.get(...)) and the attributed profiler. When logs and timers disagree with each other, trust field state and profiler attribution.
Methods
Beyond lifecycle hooks, a component exposes methods callers invoke. Methods use self (unlike hooks):
typed function public:setSpeed(v: number)
self.speed = v
end
entity(id).component.get("Spinner"):setSpeed(20)
(Methods listed in declare.syncedFunctions are broadcast to other players as RPCs.)
Events
A component's public fields are state, and its methods are things you can call on it. An event is neither: it's a moment. A component declares typed events beside public, fires them from inside when something happens, and any code holding a reference to that instance can subscribe without the component knowing or caring who's listening.
public = {
health = Field.number(100, NoSync),
}
events = {
onFire = Event(),
onHit = Event({ dmg = Field.number(0, NoSync) }),
}
Event(payloadSchema?, syncMode?)declares one event.payloadSchemais an optional table ofField.<kind>()descriptors, the same constructorspublicfields use, describing the arguments a fire carries. Omit it for a payloadless event.syncModedefaults to local (NoSync); passSyncto replicate the event to other peers (see Replication below).
Firing (owner only)
Inside the declaring component, fire an event through the events global, bound only into that component's own environment (lowercase :fire, separate from public):
typed function public:takeDamage(dmg: number)
events.onHit:fire({ dmg = dmg })
end
Because events only exists inside the component that declared it, only that component can fire its own events. The fired payload is validated against the schema: a missing member, an extra member, or a member of the wrong type is an error at the :fire call, not a silent drop.
Subscribing (from outside)
Outside code reaches a component's events through its proxy's .events, which is subscribe-only:
local weapon = entity(id).component.get("Weapon")
local conn = weapon.events.onHit:connect(function(payload)
print(payload.dmg)
end)
:connect(fn) returns a Connection (conn.Connected, conn:Disconnect()). :once(fn) behaves the same but self-disconnects after its first fire. :wait() yields the calling code until the next fire and returns the payload. There's no :fire on this side, .events itself can't be reassigned, and subscribing to a name the component doesn't declare is an error.
Lifecycle
- Connecting from inside a component ties the connection to that component's own entity: destroying the subscriber's entity disconnects it automatically.
- Destroying the publisher drops its events: every connection on them reports
Connected == false. - Editing the publisher's source hot-reloads it in place: subscriptions to events the new source still declares survive with their connections intact and keep firing; an event the new source no longer declares has its subscribers disconnected.
None of the above needs a manual onDestroy handler to clean up after itself. :Disconnect() is still there for detaching a subscription early, on purpose, before either side goes away.
Inspection
Every subscription made through .events is tracked while its publisher lives: who subscribed (the subscribing component, or an execute() call, down to the source line), how many times its handler has run, and whether it is still connected. Fires are counted per event on the publisher too, including events nobody subscribes to, so "has this ever fired?" is always answerable.
Two surfaces expose the same data:
- The
subscriptionsAPI:subscriptions.list(filter?),subscriptions.get(id),subscriptions.publishers(), andsubscriptions.cancel(id). AConnectionreturned by:connect/:oncecarries its inspection id asconn.id;subscriptions.cancel(id)disconnects a subscription immediately without needing theConnectionobject, so code that no longer holds it (a laterexecute(), another tool) can still end it. - The VFS at
/zero/runtime/events/:STATE(summary counts),publishers/<instance>/meta.json(per-event fire stats plus subscriber ids),subscriptions/<id>/meta.json(one subscription with its origin and delivery counters). Read-only; browse it with the shell like any other runtime state.
Rows for disconnected subscriptions (a :once that ran, a cancelled connection) are retained, bounded, until the publisher instance is destroyed, so recent history stays inspectable after the fact.
Replication
A fire is local by default: only same-world subscribers see it, nothing crosses the network. Pass Sync to opt an event into replication:
events = {
onHit = Event({ dmg = Field.number(0, NoSync) }, Sync),
}
When the owning peer fires a Sync event, local subscribers receive it exactly like a NoSync fire, and the fire is also delivered once to subscribers on other peers. Reach for Sync whenever a fire needs to be seen across the network: a hit that should register on a remote spectator's UI, a state change other players need to react to. A Sync event's payload members must all be serialisable; declaring one with a resource (GPU handle) member fails to register at load time.
Static checking
The LSP resolves component.get(...).events.<name> against the component's declared events: a name it doesn't declare is a diagnostic. Selecting an event with a computed expression (events[someExpr]) instead of a literal name is flagged too, since event names are meant to be pinned statically so a subscription can be checked ahead of time.
declare — engine-facing metadata
A declare { ... } block at the top configures how the engine treats the component:
declare {
executionOrder = 0, -- per-frame ordering vs other components (lower runs earlier)
-- bindings = { "__native" }, -- native tables the component needs
-- syncedFunctions = { "takeDamage" }, -- methods auto-broadcast to peers
}
Authoring one
asset.create("component", "Spinner") -- → /zero/source/Spinner.component/ (init.luau + README)
Fill in init.luau (declare, public, hooks, methods) — it registers and hot-reloads as you write it. The scaffold's init.luau ships the full catalogue of fields and hooks as commented stubs, so it's also a reference. After editing, asset.validate("/zero/source/Spinner.component").
Finding the rest
asset.list("component") lists every component (engine + world + libraries); asset.inspect("<name>") shows its public fields, methods, and docs. The built-in components under /zero/source/libs/@builtin/components/ are the best worked examples — open one and read it. For the full hook catalogue, the scaffold's commented init.luau is the canonical list.