Skip to content

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

  1. Unity-style composition — Behavior is split into attachable types; Things stay thin.
  2. Single schema, many surfaces — One ComponentDefinition drives inspector UI, flow catalog, coercion, and serialization.
  3. Visual + code — Built-ins are JavaScript modules; authors can use project components with Steps or Script.
  4. Graph-first runtime — The rule engine calls into ComponentHost; components do not replace Object Flow.
  5. Safe user expressions= expressions and script sandboxes limit blast radius of author typos.

Layer diagram

mermaid
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 --> Renderer

Core types

ComponentDefinition (define.js)

Frozen object with:

PropertyRole
id, label, category, icon, description, versionIdentity & UI
requiresAuto-attach dependencies
fieldsTyped instance data
eventsDeclarative event metadata (+ payload schema hints)
actionsNamed operations with params and run
conditionsNamed predicates with run
handlerscomponentId.eventName → fn for sibling reactions
onAttach, onDetach, onPlayStart, onPlayEnd, onTickLifecycle
migrateOptional version → version field migrators

defineComponent normalizes field specs through field-types.js and rejects invalid ids.

Component instance (host.js)

Per attachment:

javascript
{
  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.

MethodPurpose
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 / checkDispatch action/condition
tick(dt)Frame hooks
onPlayStart / onPlayEndPlay boundary + restore initial fields
serialize / hydrateScene / blueprint IO

Registry (registry.js)

Global map id → ComponentDefinition.

  • registerComponent / unregisterComponent
  • getComponentDefinition, listComponentDefinitions, listComponentCategories
  • onRegistryChanged(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:

javascript
{
  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:

  1. Sibling handlers on the same host run first (handlers["health.damaged"]).
  2. The event may propagate to the wider rule/event system (implementation in host.js emitComponentEvent).

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 params from 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)

text
record.actions[name]
  ├─ kind: "script"  → compileScript(body) → run(ctx)
  └─ kind: "steps"   → () => runSteps(steps, ctx)

record.conditions[name]
  └─ compileScript(body) → run(ctx) returning boolean

Compiled definitions are registered like built-ins; isProjectComponent(id) guards Studio UX.


Persistence

Scene serialization (scene-serialization.js)

  • Scene version: 14 includes projectComponents[].
  • Each object stores components: [{ type, version, fields }].
  • Load order: syncProjectComponents before 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

SystemIntegration
rules.jscomponentAction, componentCondition, script, expression conditions; tick calls components.tick
renderer.jsdrawComponentOverlays (e.g. health bar)
interaction-graph-*Catalog groups, labels, inspectors
scene-editor-inspector.jsrenderComponentsInspector, registry refresh
editor-workspace-ui.jseditor-components body class, studio visibility
sprite-hit-mask.jsAlpha hit testing (orthogonal but used by combat targeting)

Testing

tests/components-smoke.mjs covers:

  • attach / run / conditions
  • project script & step bodies
  • requires auto-attach
  • expression evaluation edge cases

Run:

bash
node tests/components-smoke.mjs

Future-facing hooks (not fully implemented)

The schema includes placeholders the architecture anticipates:

FeatureStatus
replicate on fieldsDeclared; multiplayer sync deferred
graph on actionsString reference for per-action flow graphs
Per-instance blueprint overridesPartially implemented — scalar override dots + component override panel + revert/update prefab

File map

text
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 components

See Code authoring for how to add a new built-in module.

Opal Engine — MIT licensed