Skip to content

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:

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

javascript
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).
  • defineComponent throws if invalid.

Fields

Pass a spec object per key:

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

kindMeaningInspectorPersisted?Flow
config (default)Authored settings (speed, color, HP)editableyesget (set is opt-in)
outputRuntime-computed readouts (grounded, speed, remaining)read-onlyno (recomputed)get only
internalMachinery the user never edits (cached images, scratch ids)hiddenyes (it's data)none

Notes:

  • output fields are readable in Object Flow (a getValue node) but never settable — perfect for "is grounded?", "current speed", "cooldown remaining".
  • internal persists by default because it's usually stored data (stateSprites, items). For pure per-frame scratch, add persist: false.
  • Explicit readOnly / hidden / persist still override the kind defaults when you really need to.

Actions

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

javascript
conditions: {
  isAlive: ({ self }) => self.hp > 0,
}

Or full spec with params:

javascript
conditions: {
  hpAtLeast: {
    label: "HP At Least",
    params: { value: { type: "number", default: 1 } },
    run: ({ self, params }) => self.hp >= params.value,
  },
}

Lifecycle hooks

HookWhen
onAttachAfter instance created and fields filled
onDetachBefore removal
onPlayStartEnter play mode (after snapshotInitial)
onPlayEndExit play (before restoreInitial)
onTickEach frame; ctx.dt in seconds

Requires

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

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

javascript
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

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

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

javascript
import { registerComponent, unregisterComponent } from "./components/index.js";

Listen to onRegistryChanged to refresh UI.


Checklist for a new built-in

  • [ ] defineComponent with 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
  • [ ] requires if dependent on another type
  • [ ] Added to BUILT_INS in index.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.

Opal Engine — MIT licensed