The feature registry

A feature is a registration, not a case in a switch. That is what makes the format open: a consumer's feature is indistinguishable from a shipped one in the file, in the editor's palette, in ref pick lists, and in save/load.

import { registerFeature } from 'tosijs-3d-ensemble'

registerFeature({
  name: 'turret',
  schema: {
    type: 'object',
    title: 'Turret',
    properties: {
      range: { type: 'number', minimum: 20, maximum: 2000, default: 260, 'x-unit': 'm' },
      smart: { type: 'boolean', default: false, description: 'leads its target' },
    },
  },
  bind(piece, cfg, ctx) {
    const el = b3dTurret({ ...cfg, x: ctx.at[0], y: ctx.at[1], z: ctx.at[2] })
    ctx.scene.appendChild(el)
    ctx.onDispose(() => el.remove())
    return el
  },
})

bind and link are two phases, and that is not a style preference

A protector that resolves its power source during bind works only if the reactor happened to bind first — so the same ensemble behaves differently depending on the order pieces appear in the file, and reordering them in the editor silently changes behaviour. That is a nasty bug class: it looks like an intermittent content problem, not a lifecycle one.

So bind creates and returns a handle, touching nothing else, and link runs after every piece has bound and is the only place ctx.handle, ctx.piecesByRole and zone lookups are legal. Same shape as tosijs-3d's scene-listener contract, for the same reason: a thing that reaches for its neighbours cannot run while the neighbours are still arriving.

Rebuilding must be idempotent

The editor rebuilds an ensemble on every edit — hundreds of times a session where a game does it once. onDispose is necessary and not sufficient; the test worth writing is build → dispose → build, asserting the scene's mesh, observer and material counts return to where they started. A leak a game never notices will eat an editing session.

Guard your per-frame work

A throw inside a Babylon render observer kills the render loop permanentlynotifyObservers has no isolation and the loop does not re-queue, so the page goes black with no error where anyone would look. A bind/link that registers per-frame work must guard itself.

Links are a registry too

buildEnsemble documented a link phase that wires ensemble.links, and the runtime never read them: an ensemble with chain reactions built cleanly, reported no problems, and did nothing. Found by the first consumer with links, which is exactly who would find it.

The fix cannot be "implement chain reactions", because the format has no domain. A delay that kills one piece after another is a combat rule; a beam that draws a conduit is a visual one. Neither belongs in an instantiator that also has to load a botanical garden.

So links get the same treatment features already have: a registry keyed by the payload key. A piece's features is a map of name → config; a link's payload is read the same way, so { from, to, delay: 0.4, beam: true } invokes whatever is registered for delay and for beam, and a consumer's own link kind is indistinguishable from a built-in.

⚠️ The payload is every key except from, to and kind, merged over values. The type documents values, and the files in the wild put delay and beam at the top level — so both are read rather than one being declared wrong after the fact. values wins on a collision, being the explicit spelling.

It also settles an ownership question a feature hook could not. A chain is a property of the LINK, not of either endpoint: whichever end wired it would have to reach across, and two endpoints both trying leaves it ambiguous who disposes it. A link handler owns the link, gets both ends, and disposes once.