Authoring components in code
Use this guide when adding built-in components to the engine or shipping a plugin that calls registerComponent.
For scene-only custom types without touching the repo, use Project components & Studio instead.
Minimal component
Create src/game/components/built-in/my-feature.js:
import { defineComponent } from "../define.js";
export default defineComponent({
id: "myFeature",
label: "My Feature",
category: "General",
description: "One-line summary for the Add Component menu.",
fields: {
power: { type: "number", default: 1, min: 0, label: "Power" },
},
events: {
triggered: { label: "Triggered", payload: { value: "number" } },
},
actions: {
boost: {
label: "Boost",
params: { amount: { type: "number", default: 1 } },
run({ self, params, emit }) {
self.power += params.amount;
emit("triggered", { value: self.power });
},
},
},
conditions: {
isPowered: {
label: "Is Powered",
run: ({ self }) => self.power > 0,
},
},
});Register in src/game/components/index.js:
import myFeature from "./built-in/my-feature.js";
const BUILT_INS = [
// …existing
myFeature,
];Restart or hot-reload the editor; the type appears everywhere automatically.
ID rules
- Required, unique across registry.
- Pattern: letters, digits, underscore; should start with a letter (
health,turnQueue,abilityCooldown). defineComponentthrows if invalid.
Fields
Pass a spec object per key:
fields: {
hp: {
type: "number",
default: 30,
min: 0,
max: 999,
step: 1,
label: "HP",
description: "Shown in inspector tooltip",
replicate: "owner", // multiplayer hint (optional)
kind: "config", // see below — drives the flags for you
},
}Shorthand: { type: "number", default: 0 } is enough.
Field kind — reach for this, not the raw flags
kind is the single source of truth for how a field behaves. Set it and the inspector/persistence/Flow flags are derived consistently; don't hand-assemble readOnly + persist + flow:{...} yourself (that's how they drift apart).
kind | Meaning | Inspector | Persisted? | Flow |
|---|---|---|---|---|
config (default) | Authored settings (speed, color, HP) | editable | yes | get (set is opt-in) |
output | Runtime-computed readouts (grounded, speed, remaining) | read-only | no (recomputed) | get only |
internal | Machinery the user never edits (cached images, scratch ids) | hidden | yes (it's data) | none |
Notes:
outputfields are readable in Object Flow (a getValue node) but never settable — perfect for "is grounded?", "current speed", "cooldown remaining".internalpersists by default because it's usually stored data (stateSprites,items). For pure per-frame scratch, addpersist: false.- Explicit
readOnly/hidden/persiststill override the kind defaults when you really need to.
Actions
actions: {
damage: {
label: "Damage",
description: "Optional longer help text",
params: {
amount: { type: "number", default: 1, min: 0, label: "Amount" },
},
run(ctx) {
const { self, params, emit, thing, runtime } = ctx;
// self === instance.fields (mutate in place)
},
},
}run may return a value (e.g. use on cooldown returns false when blocked). Graphs generally ignore return values today— prefer emitting events.
Conditions
Function shorthand:
conditions: {
isAlive: ({ self }) => self.hp > 0,
}Or full spec with params:
conditions: {
hpAtLeast: {
label: "HP At Least",
params: { value: { type: "number", default: 1 } },
run: ({ self, params }) => self.hp >= params.value,
},
}Lifecycle hooks
| Hook | When |
|---|---|
onAttach | After instance created and fields filled |
onDetach | Before removal |
onPlayStart | Enter play mode (after snapshotInitial) |
onPlayEnd | Exit play (before restoreInitial) |
onTick | Each frame; ctx.dt in seconds |
Requires
requires: ["health"],attach("healthBar") auto-attaches health first. Inspector enforces detach order via locked remove buttons.
Sibling handlers
React to events emitted by other components on the same Thing:
handlers: {
"health.damaged"({ self, state }) {
state.flashUntil = performance.now() + 200;
},
"health.died"({ self }) {
self.visible = false;
},
},Key format: otherComponentId.eventName. Handler runs before global event broadcast.
Custom rendering
Built-ins can hook renderer.js drawComponentOverlays(thing):
const bar = thing.components?.get?.("healthBar");
if (bar?.fields.visible) { /* draw bar from health sibling */ }Keep drawing in the renderer; keep state in the component fields.
Version migrations
version: 2,
migrate: {
2: (fields) => {
if (fields.oldKey != null) {
fields.newKey = fields.oldKey;
delete fields.oldKey;
}
return fields;
},
},Saved instances store version at attach time; hydrate upgrades stale data.
Runtime access
The owning rule system binds a runtime provider directly to each Thing's ComponentHost. Component definitions consume that injected context; they should not import rules.js (which would create a circular dependency) or set a module-global provider.
In actions/conditions:
run({ runtime }) {
const objects = runtime?.getObjects?.() || [];
}emit in context forwards to the engine event bus when wired.
unregister / hot reload
Project components unregister when removed from the scene. Built-ins typically stay for the session. If you build a dev plugin:
import { registerComponent, unregisterComponent } from "./components/index.js";Listen to onRegistryChanged to refresh UI.
Checklist for a new built-in
- [ ]
defineComponentwith id, category, description - [ ] Fields with defaults and labels
- [ ] At least one action or condition if authors need graph nodes
- [ ] Events documented if other systems should subscribe
- [ ]
requiresif dependent on another type - [ ] Added to
BUILT_INSinindex.js - [ ] Smoke test or manual play test
- [ ] Optional overlay in
renderer.js - [ ] Entry in Built-in reference
Copy-paste template
See src/game/components/built-in/health.js as the canonical full example (events, actions, conditions, lifecycle, edge cases).
For visual authoring without deploys, point designers to Project components & Studio.