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…
When to use one
- You need per-entity state that survives across frames.
- You need per-entity behavior driven by engine lifecycle hooks
(
start,update,destroy). - You want to expose configurable fields to designers /
entity.spawncallers / scenes without code changes.
If you need a global service that outlives any entity, use a .service
instead. If you need a stateless pure-function library, use a .module.
Where it lives
- Source:
/zero/source/.../<Name>.component/ - Identity:
<Name>(the.componentsuffix strips from the identity, but the folder retains the suffix on disk). - Folder shape:
init.luau(orinit.lua) — declares the component. Required.README.md— instance-level documentation. Required.
The structural contract is enforced by asset.validate against this
type's type.yaml.
How to create one
Always start with the scaffold — it guarantees the folder shape
matches type.yaml from the moment it's on disk:
asset.create("component", "<Name>")
-- Creates: /zero/source/<Name>.component/
-- init.luau (canonical declare + public block + hooks stubbed)
-- README.md (instance README template)
After scaffolding, edit init.luau to fill in the actual fields and
behaviour. The component registers as soon as the file is written —
hot-reload is automatic; no engine restart required.
How it operates
-
Registration. Writing
init.luauinto a.component/folder indexes the asset, mints a guid (in the.metasidecar), and surfaces the type to the engine's component registry. Same-frame visible toentity.component.add. -
Attachment.
entity(id).component.add(name, data)(or the batch formsc.addComponent(ids, name, data)) attaches an instance. Thedatatable's fields override the component's declared defaults; omitted fields take the declared default. Thedatavalues are copied intopublic.*BEFOREawake()runs. -
Lifecycle. The hooks run in this order:
awake()— same frame as attach, immediately after the data copy. Initialise state, set up native bridges, register with other systems. Other components on the same entity may not have awoken yet.start()— the next frame. Every component on the entity has completedawake()by then, so cross-component setup is safe here.update(dt)— same frame asstart(), then every frame after. Play mode only — edit mode does not callupdate/fixedUpdate/editorUpdate. Seeman modes.onDestroy()— when the component is removed viacomponent.removeor when the owning entity is despawned. Release resources, drop native ECS components.
For the full list (
onEnable,onDisable,fixedUpdate,editorUpdate,onPropertyChanged,onAssetReload,onBeforeSave, …) and the timing details, readman components— that is the canonical lifecycle reference. Generic component patterns (declare blocks, public fields, sync, ECS bindings, typed methods) all live there. -
Public surface. Fields declared in
publicare inspectable, editable in the inspector, and persist with the entity. Methods declared on the table become callable viaentity(id).component.get(name):method(args). -
Events. An
eventsblock declares typed moments the component fires from inside —events = { onHit = Event({ dmg = Field.number(0, NoSync) }) }. The owner fires withevents.onHit:fire({ dmg = 10 }); outside code subscribes through the proxy's subscribe-only facade,entity(id).component.get(name).events.onHit:connect(fn)(:once/:waittoo). PassSyncas the event's second argument to replicate a fire to other peers. Events let one component react to another's moments without polling. Full reference:man components, Events section. -
Hot reload. Editing
init.luaureloads the component definition; live instances are reconciled with the new declaration in-place.
Discovery
asset.list("component")— every registered component (engine + world + libraries).asset.inspect("<name>")— identity, scope, public fields, lifecycle methods, source path, and this type README.detailcarries the component's declaredfields,methods, andevents, parsed from its source —tools.use("assets","describe", "<name>")renders it as markdown, so a component's events are visible without opening its source.cat /zero/source/<Name>.component— the raw filesystem view (a different projection thanasset.inspect).entity(id).component.list()— components attached to an entity.
Authoring conventions
- Annotate public fields with
--!descso the inspector + LSP show documentation alongside the field name. - Declare lifecycle hooks (
start,update,destroy) explicitly rather than relying on engine defaults — readers can see at a glance what runs when. - Keep
update(dt)cheap — it runs every frame per instance. Move heavy work tostart, to a service, or behind a job (man jobs). - For multi-entity batching, prefer
sc.addComponent(ids, ...)over a loop ofentity(id).component.add— same hot-path, one mutation enqueue per batch.
Common pitfalls
- Naming. The component identity is the folder stem with
.componentstripped (e.g.<Spinner>.component/→ identitySpinner). Don't repeat the suffix in the identity passed tocomponent.add. init.luavsinit.luau. Either is accepted (it's aone_of_group: "entrypoint"), but.luauis canonical. Mixing both in one folder is invalid.updatein edit mode. It doesn't run — by design. Usewld.play()to test update logic;wld.edit()to return.- Renaming. Renaming the folder changes the identity. Update
every
component.add(<oldname>, ...)call and the scene JSON entries that reference it.
Related types
.module— pure-function library, no per-entity state..service— globally-scoped behavior, no per-entity state..preset— a captured component configuration, replayable intocomponent.addwithout re-typing fields..bundle— entity hierarchies that may carry component attachments as part of the bundle.