Log inGet started

Components

Updated 6 September 2026

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, description) constructor — not a bare value:

public = {
  speed = Field.number(60, NoSync, "Metres per second at full throttle."),
  label = Field.string("hi", NoSync, "Text drawn above the entity."),
  target = Field.entityRef(nil, Sync, "The entity this one is tracking."),
  material = Field.assetRef("material", nil, Sync, "Material painted on the mesh."),
}
  • 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: Sync replicates writes to other players; NoSync keeps the field client-local.
  • The description is the trailing argument, and it is what the inspector shows and man prints. Write what the value MEANS and its unit or frame of reference — "Metres per second at full throttle.", not "The speed.". A field with a default and no description states a number and hides its meaning. Where a field also needs the Serialized marker, the trailing argument takes both: Field.bool(false, Sync, { serialized = Serialized, description = "…" }).
  • An entityRef field accepts a live entity proxy OR a raw id string on write (comp.target = otherEntity or comp.target = "ent_…"), and reads back a live proxy — comp.target.id, comp.target.position, etc. — or nil when unset. The stored/serialised value is the plain id.

Restricting what a reference field accepts

A reference field can state what belongs in it, and the write is refused if something else arrives. Which constructor you reach for depends on what decides membership:

public = {
  -- by asset category: only a material
  material = Field.assetRef("material", nil, Sync),

  -- by tag: any asset tagged `cameraBehavior`, whatever its type. Tag a new
  -- asset and it becomes assignable with no edit here.
  behavior = Field.taggedRef("cameraBehavior", nil, Sync),

  -- by data contract: only a `.data` instance whose contract chain includes it
  weapon = Field.dataRef("tdWeapon", nil, Sync),

  -- by capability: any asset its type can instantiate into a scene
  spawnable = Field.instantiableRef(nil, Sync),

  -- by component type
  aimCam = Field.componentRef("Camera", nil, NoSync),
}

Reach for taggedRef when several unrelated asset types can all qualify and the set grows over time — tagging is how the set is edited, rather than the field. asset.add_tag(name, tag) marks an asset, and asset.list({ fields = { tags = tag } }) lists what fits a slot.

A refused write names the constraint and what the value was instead, so the message says which asset to reach for next. Reading a component with zero entityOps component <Type> (or use_tool { toolbox = "entityOps", tool = "component" }) reports each field's accepts (what the slot takes) and acceptsBy (whether that is a category, a tag, a contract, or a component type), so the constraint is readable without opening the source.

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 public from private.
  • Sync: Sync or NoSync, the same replication mode public fields use. A Sync private 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. public fields always persist, so there's nothing to opt into. A private field persists only when its constructor takes a trailing Serialized marker; leaving it off is the more common case, since most private state is a runtime detail, not saved data. Writing Serialized on a public field 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 field name the component doesn't declare raises — getField('x'): 'Health' declares no such field — and so does a value the declared kind can't hold. When the name is a literal and the ref came from a component.get("Health") naming its type, the checker reports both before the line runs, with a did-you-mean on a close miss. A name built at runtime, or a ref whose type isn't pinned in that file, is checked when it executes.

A few things carry over from public fields but are worth calling out for private specifically:

  • fields, getField, and setField are reserved on every component: neither public nor private can declare a member with those names.
  • Field.alias and computed fields (computed(function(self) ... end)) are public-only.
  • A name can only exist on one surface: declaring the same field name in both public and private is a registration error.
  • A private entityRef or assetRef field stores the raw identity string exactly as written. Unlike the same field kind on public, 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:

HookWhen it runs
awakethe moment the component initialises
startthe next frame, after every component on the entity has awaked (safe place for cross-component setup)
update(dt) / fixedUpdate(dt)every frame while the gameplay clock runs (engine.paused == false), in either mode
editorUpdate(dt)every frame while the clock is held (engine.mode == "edit" OR engine.paused == true)
onEnable / onDisablewhen the component (or its entity) is toggled enabled/disabled
onDestroywhen 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.

The per-frame hooks read two axes, not one

engine.mode ("edit" / "play") and engine.paused (the gameplay clock) are independent, and each per-frame hook reads one of them:

  • update / fixedUpdate tick while the clock runs, whichever mode the engine is in.
  • editorUpdate ticks while the clock is held — because the mode is edit, or because play is paused.
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. A session that never writes engine.paused therefore 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 names the two default rows of a four-row table.

Row 2 costs people afternoons. With engine.paused = false in edit — written 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 per frame. So a component that declares both and delegates to one body runs that body twice a 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 surfaces as an animation at double speed, or a per-frame rebuild uploading its geometry twice. For exactly one tick per frame in every row, name the axis you meant:

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

So a behaviour you want only while the game runs goes in update; live editor tooling goes in editorUpdate; a behaviour you want in both goes in both, with the guard above.

A scene entrypoint.luau uses the same two hook names under a different dispatcher — the scene loader partitions strictly on mode, so an entrypoint's update needs play AND running, its editorUpdate needs edit, and play-paused ticks neither. The scenes guide covers it; don't carry this table across.

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 public field changedonPropertyChanged(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.assetRef target is edited/reloaded, the component's onAssetReload(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.

  • A module the component requires changed → the module's table is refreshed in place and the component's onModuleReload(path) fires, naming the module that re-ran. A module the component requires reloads below is the whole rule.

  • The component's own source changed → editing its init.luau hot-reloads the definition and reconciles live instances in place; your public and private values are preserved. You're iterating on behaviour without restarting anything.

    A live instance crosses one full lifecycle boundary per edit that changes its code, and this is the whole rule:

    onDestroy()  →  awake()  →  start()   -- update() resumes on the next frame
    

    The reload ends the running life with onDestroy, then constructs the replacement with awake and start, in that order — the same construction a fresh attach runs. So the hooks a reload runs are exactly the hooks it undid, and it does not matter which of the two an instance builds its state in. onEnable/onDisable are untouched: the reload runs neither, so an instance carries the onEnable it already had through to whichever teardown ends it. An edit that leaves the source byte-identical is not a change, and such an instance crosses no boundary at all — its life continues.

    The declared-field set moves with the source, and every reader of it moves at the same time. A public field the edit adds is a field the component has from that moment: component.add(name, { newField = ... }), entity.spawn { components = { [name] = { newField = ... } } } and a scene that names it all take it, and reading or writing it on a live instance works. A field the edit removes stops being one just as promptly — a call that still names it is reported at the callsite against the fields the reloaded source declares, and the key is skipped. Neither direction waits on a restart.

    What the reload does not carry over is module-level state — a local declared at the top of the chunk (a counter, a table of ids, a cache). The reload re-executes the chunk, so the new life starts with those locals at their initial values, and awake/start are what fill them in again. Two things follow. State a hook builds is rebuilt, so put it in awake or start rather than in a bare initialiser at the top of the file. And anything the old life handed to somebody else — a subscription on another component's event, a registration in a shared module, an entity it spawned — is still out there holding the old chunk's locals, so release it in onDestroy, which is the call that pairs with the construction about to run.

A module the component requires reloads

local Bus = require("game.Bus") at the top of a component captures that module's table once, when the component loads. Editing the module hot-reloads it, and the reload keeps the table's identity and replaces its members — so the captured reference is running the new code immediately, without the component reloading and without a second require. Bus.emit after the edit is the edited emit.

What the reload does not keep is the module's own local state. It re-executes the module's chunk, so local listeners = {} at the top of that file is a fresh, empty table. Whatever the component put there — a subscription, an owner id, a hook slot it filled in during its own awake — is gone on the far side, while the component goes on ticking: the component was not reloaded, so its awake does not run again, and every reading it takes of itself is healthy.

onModuleReload(path) is the hook for that. It fires once per reloaded module the component's script requires, after that module's new body has run, so re-registering from it lands on current code:

local Bus = require("game.Bus")

local function subscribe()
  Bus.on("hop", function() public.hops += 1 end)
end

function awake()
  subscribe()
end

function onModuleReload(path)   -- `path` names the module that re-ran
  subscribe()
end

A module that re-ran because a module it requires was edited is reported the same way, so a component two edges from the edit hears about the dependency it actually holds.

On the module's own side the answer is modules.state and modules.onUnload, and a module that uses them costs its dependents nothing — there is no state to lose, so nothing needs re-registering:

-- game/Bus.module/init.luau
local M = {}
local durable = modules.state()          -- the engine holds this table, not the chunk
durable.listeners = durable.listeners or {}

function M.on(name, fn)
  local list = durable.listeners[name] or {}
  durable.listeners[name] = list
  table.insert(list, fn)
end

function M.emit(name, ...)
  for _, fn in ipairs(durable.listeners[name] or {}) do fn(...) end
end

modules.onUnload(function()
  -- this run's last moment: its locals still name what it made
end)

return M

modules.state() is a table the engine holds rather than the chunk, so it is the same table on both sides of every reload — a listener list kept there survives, and the subscribers never notice the edit. modules.onUnload(fn) registers the outgoing run's teardown; it runs once, at the moment that run ends, while that run's locals are still in scope. core/scripting-and-tasks covers both in full.

Which side to reach for follows from who owns the module. Own it, and put what an edit must not undo in modules.state and release the rest in modules.onUnload. Require somebody else's — a shared module another slice authors — and onModuleReload is the component's own defence, needing nothing from the module at all.

Owning entities a component spawns

A component that builds world content in awake (floors, props, foes) needs a way to remove that content before it rebuilds — on a hot-reload, a remove-and-re-add, or an edit↔play flip. The obvious approach, tracking spawned ids in a module-level list, does not survive any of those: the reloaded (or re-initialised) component gets a fresh, empty list while the previous build's entities are still live in the world. Cleanup then iterates an empty list, removes nothing, and the next build stacks a second copy — duplicated geometry, duplicated colliders, and entity.find(name) returning the oldest stale copy.

Re-derive ownership from the world instead of trusting a local:

  • Parent everything under one root entity the component spawns, and despawn that root to reclaim the whole subtree — despawning a parent cascades to its children:

    function awake()
      local existing = entity.find("myLevelRoot")
      if existing then existing:despawn() end   -- clears the previous build
      local root = entity.spawn("myLevelRoot")
      -- spawn floors/props/foes as children of root …
    end
    
  • Or sweep by a name prefix / tag the component owns, when a single root doesn't fit. findAll takes a glob, so the engine filters by name and hands back only the matches:

    for _, e in ipairs(entity.findAll("lvl_*")) do
      e:despawn()
    end
    

Either way the cleanup reads its targets from the live world, so it works no matter how many times the component reloaded in between.

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 selfself.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 execute response's logs field 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 through the logs toolbox, whose search and errors tools hold every line individually. A line logged from a lifecycle hook carries the component and the entity it ran on, so search can narrow to one instance.
  • Identical lines condense in the execute response. Repeated identical log lines collapse into one entry with a <repeated n times> marker, so seven instances warning the same string read as a single line — check the marker's count before concluding only one instance ran. The log ring itself holds every line separately, and each carries the entity that logged it, so the logs toolbox's search tool separates the instances without you having to write the entity id into the message yourself.
  • 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: tools.use("profiler", "hotspots") / tools.use("profiler", "scripts") attribute frame time down to the component + entity, and profiler regions bracket a specific span.
  • The probes that stay truthful across VM contexts: public component fields (readable from any execute via entity(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. A method is declared on public with a colon, and receives the component ref as self:

typed function public:setSpeed(v: number)
  self.speed = v
end

The colon is the whole calling convention: a caller spells it the same way the declaration does, and so does every method the ref carries by itself — ref:fields(), ref:getField(name), ref:setField(name, value).

entity(id).component.get("Spinner"):setSpeed(20)

A dot-call passes the first argument where self belongs and shifts every later one, so ref.setSpeed(20) puts 20 in self and leaves v nil. The checker reports it (engine-component-method-dot-call) wherever the receiver's component type is named at the callsite.

A function the chunk declares at file scope — function helper(x) ... end — is the component's own: its hooks and its methods call it under its bare name, and it reads self and public from the chunk's environment. public is the surface a component ref answers for, so naming a file-scope function through a ref raises and says which declaration would expose it:

entity(id).component.get("Spinner"):helper(1)
-- Component<Spinner:sc_…> declares 'helper' at file scope, so it is not a
-- method on the component ref — declare it as `function public:helper(...)`
-- and call it as `ref:helper(...)`

Methods listed in declare.syncedFunctions are broadcast to other players as RPCs. Broadcasting belongs to the entity's owner: a call made on an entity this peer does not own runs locally, stays off the wire, and says so at WARN. Every peer runs the body under the authority of the peer that sent it, so a write the body makes to a different synced entity lands on all of them or on none — claim that entity first if the body is meant to change it.

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. payloadSchema is an optional table of Field.<kind>() descriptors, the same constructors public fields use, describing the arguments a fire carries. Omit it for a payloadless event.
  • syncMode defaults to local (NoSync); pass Sync to 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 subscriptions API: subscriptions.list(filter?), subscriptions.get(id), subscriptions.publishers(), and subscriptions.cancel(id). A Connection returned by :connect / :once carries its inspection id as conn.id; subscriptions.cancel(id) disconnects a subscription immediately without needing the Connection object, so code that no longer holds it (a later execute(), 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.

  • documentation
  • guide