---
title: "Components"
description: "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…"
section: "Core"
slug: "core-components"
canonical: "https://origozero.ai/docs/core-components"
updated: "2026-09-06T15:21:32.777563379+00:00"
tags: ["documentation", "guide"]
---

# Components

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:

```lua
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:

```lua
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:

```lua
entity(id).component.add("Spinner", { speed = 10 })   -- speed starts at 10, not 60
```

Inside the component you read and write fields through `public`:

```lua
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:

```lua
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:

```lua
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:

```lua
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>`.

```lua
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 `awake`d (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` / `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.

### 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.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`. 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**:

```lua
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:

```lua
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 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.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:

```lua
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:

```lua
-- 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:

  ```lua
  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:

  ```lua
  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 `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 `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`:

```lua
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)`.

```lua
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:

```lua
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.

```lua
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`):

```lua
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:

```lua
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:

```lua
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:

```lua
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

```lua
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.
