Log inGet started
·
assettype · drop-in viewer
asset⌬ assettypeassetTypeprimary: type.yaml·originates fromworld 07158574-5…

component.assetType

A component is a reusable behavior + data unit attached to an entity. The component file declares public fields, methods, and lifecycle callbacks; the engine instantiates one per attachment and drives its hooks every frame. Components are the primary unit of gameplay code — movem…

byzero-proxy @ DESKTOP-DB3UJOJ·posted 2mo ago
What it does

Component (asset type)

A component is a reusable behavior + data unit attached to an entity. The component file declares public fields, methods, and lifecycle callbacks; the engine instantiates one per attachment and drives its hooks every frame. Components are the primary unit of gameplay code — movement, AI, animations, UI panels, network sync, and per-entity state all live here.

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.

Related types

  • .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.

Interface

What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.

conforms to

zero/asset-type/v1
⌬ Spec
suffix.componentcontainernoprimary aliasesinit.luau, init.luaplural dircomponentsrequired filesinit.luau, README.md, .metadataoptional filesinit.lua
Exposed API
⌬ Instance methods

getInitScript(self: ?) → string

Read the component's entry script (`init.luau` / `init.lua`) as raw text.

argtypedescription
self?

examples

local src = compRef:getInitScript()

getReadme(self: ?) → string

Read the component's `README.md` body.

argtypedescription
self?

examples

print(compRef:getReadme())

listPublicFields(self: ?)

Best-effort list of the component's `public` field names by parsing the entry script. Recognises `public.<name> = ...` assignments at module scope; doesn't expand metatable declarations. Comments and string literals are not read. Use as a discovery hint, not a strict schema.

argtypedescription
self?

examples

for _, f in ipairs(compRef:listPublicFields()) do print(f) end

attachTo(self: ?, entityId: string, fields: { [string]: any }?) → any

Attach this component to an entity, equivalent to `entity(entityId).component.add(compRef.name, fields)`. The component's type name comes from the ref's `name` (the leaf folder stem with `.component` stripped) — never guessed. `component.add` returns) or nil on failure.

argtypedescription
self?
entityIdstringTarget entity ID.
fields{ [string]: any }?Optional `public` field overrides.

examples

compRef:attachTo(playerId, { speed = 5 })

listInstances(self: ?)

List every entity in the live scene that currently has a component of this type. Useful for inspector tooling.

argtypedescription
self?

examples

for _, id in ipairs(compRef:listInstances()) do print(id) end

getSource(self: ?) → string

The component type's registered source text — the live definition in the runtime registry. `getInitScript` reads the on-disk entry file; this reads what the engine actually registered (and resolves builtin components by identity).

argtypedescription
self?

examples

local src = compRef:getSource()

isRegistered(self: ?) → boolean

Whether the component type is registered with the ECS, so that `component.add(name)` takes it. The source is stored the moment the asset is written; the type registers when that registration drains, on a later frame, and this reads the registration rather than the source.

argtypedescription
self?

examples

Test.waitUntil(function() return compRef:isRegistered() end, 120)

getInfo(self: ?) → any

Metadata for the component type: `{ name, builtin, executionOrder, hooks }`, where `hooks` is a `{ <hookName> = true }` map scanned from the source.

argtypedescription
self?

examples

local info = compRef:getInfo() print(info.executionOrder)

getAssetFields(self: ?) → any

The component type's asset-field declarations — a map of field name to asset category (e.g. `{ material = "material" }`), or nil when the component declares no `Field.assetRef` fields.

argtypedescription
self?

examples

for field, cat in pairs(compRef:getAssetFields() or {}) do end

inspect(self: ?) → any

`asset.inspect` type-specific detail: `{ fields, methods, events, hooks, assetFields, executionOrder }`, parsed from the component's own entry script (`getInitScript`) via `luau_introspect`. Cached on the asset's content checksum, so re-inspecting unchanged source is free. A component with no readable entry script returns an empty detail rather than erroring.

argtypedescription
self?

examples

local events = asset.inspect(compRef).detail.events
⌬ Hooks

onRegister(self: ?) → void

Initial-registration callback: register this component's type from its entry script the moment the instance first registers. Fired by the world-ready `onRegister` sweep BEFORE the world entrypoint (and its scene load) runs, so `component.add`-by-name resolves for entities the same load materialized. Idempotent: the registry upsert is a no-op when the source is unchanged, and a later `onChange` re-registration converges on the same definition.

argtypedescription
self?

onChange(ref: ?, change: ?) → void

Asset-type change callback: (re)register + hot-reload this component type whenever its `.component` is seeded (a world seed) or its entry script (`init.luau` / `init.lua`) is edited. This is what registers and live-reloads USER components — library components register through the VFS write hook (author-immutable content does not dispatch `onChange`). Mirrors the `.material` / `.shader` assetTypes owning their own registration. Convergent + idempotent: registration never writes back into the asset folder, and the underlying registry upsert is a no-op when the source is unchanged.

argtypedescription
ref?
change?

onDelete(ref: ?) → void

Asset-type delete callback: unregister this component type when its `.component` folder is removed. The teardown counterpart of `onChange`'s registration — the type owns both halves of its runtime lifecycle, the way the `.material` / `.shader` types do. `__components.unregister` drops the type from the runtime registry, the FFI type-info / schema mirrors, and the `/registered/components/` projection; instances already attached to live entities keep running (their closures are already bound).

argtypedescription
ref?

Sub-parts

Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.

3items
This part has no composite children. See the Files segment for its leaf payloads.
backing path · assetTypes/component.assetType

Problems

Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.

0problems
No problems reported. This asset, its contents, and its direct deps are clean as of the latest commit.
ZeroMind agent review · awaiting first pass
Findings
Reviewer findings (handle · model · tag · quoted note) appear here once the per-pass review log lands. Today only the rolled-up agent_score is exposed.
usability
did it work as advertised
quality
authoring polish + cohesion
performance
frame & memory budget held
agent review score
/ 100
awaiting first pass
usability × 0.40
+ quality × 0.35
+ performance × 0.25
± compat factor

Usability ratings

Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".

%no reports yet
Sign in to report whether this part worked for you.
Discussion

Scoped to this part · feeds back into the world's score.

0comments
Sign in to post.sign in
No comments yet. Be the first.