Component system architecture
This document describes how components are defined, registered, attached, executed, and persisted. It targets anyone modifying src/game/components/ or integrating components into new engine features.
Design goals
- Unity-style composition — Behavior is split into attachable types; Things stay thin.
- Single schema, many surfaces — One
ComponentDefinitiondrives inspector UI, flow catalog, coercion, and serialization. - Visual + code — Built-ins are JavaScript modules; authors can use project components with Steps or Script.
- Graph-first runtime — The rule engine calls into
ComponentHost; components do not replace Object Flow. - Safe user expressions —
=expressions and script sandboxes limit blast radius of author typos.
Layer diagram
flowchart TB
subgraph authorTime [Author time]
Studio[Component Studio]
Inspector[Components inspector card]
FlowUI[Object Flow inspector]
end
subgraph definitions [Definitions]
Define[defineComponent]
BuiltIn[built-in/*.js]
Project[projectComponents JSON]
Registry[registry.js]
end
subgraph runtime [Play mode]
Thing[Thing]
Host[ComponentHost]
Rules[rules.js]
Renderer[renderer overlays]
end
Studio --> Project
BuiltIn --> Define
Project --> Define
Define --> Registry
Inspector --> Host
FlowUI --> Rules
Registry --> Host
Thing --> Host
Rules --> Host
Host --> RendererCore types
ComponentDefinition (define.js)
Frozen object with:
| Property | Role |
|---|---|
id, label, category, icon, description, version | Identity & UI |
requires | Auto-attach dependencies |
fields | Typed instance data |
events | Declarative event metadata (+ payload schema hints) |
actions | Named operations with params and run |
conditions | Named predicates with run |
handlers | componentId.eventName → fn for sibling reactions |
onAttach, onDetach, onPlayStart, onPlayEnd, onTick | Lifecycle |
migrate | Optional version → version field migrators |
defineComponent normalizes field specs through field-types.js and rejects invalid ids.
Component instance (host.js)
Per attachment:
{
typeId: "health",
definition: ComponentDefinition,
fields: { hp: 30, maxHp: 30, ... },
initial: { ... }, // snapshot at play start
thing: Thing,
enabled: true,
}self in action/condition/handler code is instance.fields (mutated in place).
ComponentHost
Owned by each Thing as thing.components.
| Method | Purpose |
|---|---|
attach(typeId, fields?) | Attach; satisfy requires; run onAttach |
detach(typeId, opts?) | Run onDetach; remove. Blocked when dependents exist unless { cascade: true } or { force: true } |
dependentsOf(typeId) | List component types on this host that require the given type |
setEnabled(typeId, bool) | Enable/disable; disabled instances skip run, check, and tick |
update(typeId, patch) | Coerce and assign fields |
run / check | Dispatch action/condition |
tick(dt) | Frame hooks |
onPlayStart / onPlayEnd | Play boundary + restore initial fields |
serialize / hydrate | Scene / blueprint IO |
Registry (registry.js)
Global map id → ComponentDefinition.
registerComponent/unregisterComponentgetComponentDefinition,listComponentDefinitions,listComponentCategoriesonRegistryChanged(callback)— inspector & studio refresh
Built-ins register once at module load (index.js). Project components register on scene load and unregister when removed from the scene list.
World-scoped runtime bridge
Each rule system binds its provider to the ComponentHost instances in its own world. The provider supplies { runEvent, getObjects, vars, ... }, so component code can:
- Emit into that world's event bus
- Query scene objects (Team, Turn Queue)
- Read game variables
Newly spawned Things are bound before their component play lifecycle runs. There is no ambient or module-level runtime provider. Engine composition roots bind each host with setRuntimeContextProvider; isolated tests use an explicit test harness so two simultaneous worlds cannot overwrite one another's runtime.
Action context (_buildContext) includes:
{
thing, self, definition, instance,
params, event, source, target,
get, getInstance, run, check, emit,
runtime, dt?, components: host,
}Event emission & sibling handlers
When a component emits an event:
- Sibling handlers on the same host run first (
handlers["health.damaged"]). - The event may propagate to the wider rule/event system (implementation in
host.jsemitComponentEvent).
This lets Health Bar react to Health without graph edges between them.
Field type system (field-types.js)
Each type provides defaultValue, coerce, widget, portType.
Coercion runs on:
- Inspector edits
host.update- Action
paramsfrom graphs - Deserialize / hydrate
hidden fields still persist but skip inspector rows. readOnly fields display but don’t invite edits in generated UI.
Project component compilation (project.js)
record.actions[name]
├─ kind: "script" → compileScript(body) → run(ctx)
└─ kind: "steps" → () => runSteps(steps, ctx)
record.conditions[name]
└─ compileScript(body) → run(ctx) returning booleanCompiled definitions are registered like built-ins; isProjectComponent(id) guards Studio UX.
Persistence
Scene serialization (scene-serialization.js)
- Scene
version: 14includesprojectComponents[]. - Each object stores
components: [{ type, version, fields }]. - Load order:
syncProjectComponentsbefore instantiating Things so types exist at hydrate time.
Blueprints (scene-editor-object-actions.js)
saveSelectedAsPrefab copies components.serialize(). spawnBlueprint passes components into createSpriteThing.
Migration
If record.version < definition.version, host.hydrate runs definition.migrate[v] steps per version jump.
Integration points
| System | Integration |
|---|---|
| rules.js | componentAction, componentCondition, script, expression conditions; tick calls components.tick |
| renderer.js | drawComponentOverlays (e.g. health bar) |
| interaction-graph-* | Catalog groups, labels, inspectors |
| scene-editor-inspector.js | renderComponentsInspector, registry refresh |
| editor-workspace-ui.js | editor-components body class, studio visibility |
| sprite-hit-mask.js | Alpha hit testing (orthogonal but used by combat targeting) |
Testing
tests/components-smoke.mjs covers:
- attach / run / conditions
- project script & step bodies
requiresauto-attach- expression evaluation edge cases
Run:
node tests/components-smoke.mjsFuture-facing hooks (not fully implemented)
The schema includes placeholders the architecture anticipates:
| Feature | Status |
|---|---|
replicate on fields | Declared; multiplayer sync deferred |
graph on actions | String reference for per-action flow graphs |
| Per-instance blueprint overrides | Partially implemented — scalar override dots + component override panel + revert/update prefab |
File map
src/game/components/
index.js Public exports + built-in registration
define.js defineComponent
registry.js Global registry
host.js ComponentHost
field-types.js Typed fields
expressions.js =expr and compileScript
steps.js Step interpreter
project.js Project compile/register
catalog.js Flow node catalog
inspector.js Object inspector UI
studio.js Component Studio
studio-steps.js Step editor UI
built-in/*.js Shipped componentsSee Code authoring for how to add a new built-in module.