tosijs-3d-ensemble

The tosijs-3d ensemble format, and a graphical editor for authoring it.

An ensemble is a reusable, JSON-described arrangement with declared capabilities and relationships. No code, no engine types: plain data a game loads, a tool authors, and a generator can emit.

It is not a combat format, and that is the whole win. An ensemble can be a floating fortress of shields and turrets — or tosijs-3d's own standard scene: sun, shadow rig, sky, ground plane, fog. Load that stack as data and a scene reads as "the standard setup, plus the thing I am actually showing you", instead of burying the two interesting lines in boilerplate.

<tosi-b3d>
  <tosi-ensemble src="/ensembles/standard-scene.json"></tosi-ensemble>
  <tosi-b3d-box size="1.5" y="0.75" color="#2f9e8f"></tosi-b3d-box>
</tosi-b3d>

Hit points, turrets and shields are an opt-in preset, because most things in most scenes can never be shot — and terrain that quietly accumulates damage and disappears at 100 000 is a worse outcome than terrain that was never a combatant:

// sun, sky, ground, terrain, water…
import { registerSceneFeatures, registeredFeatures } from 'tosijs-3d-ensemble';
// …and the fortification vocabulary, only if you want it
import { registerCombatPreset } from 'tosijs-3d-ensemble/presets/combat';

registerSceneFeatures();
registerCombatPreset();

/*
  Both calls are idempotent, and this page has already made them — which is
  why the list below shows the combat vocabulary too. A game that never
  imports the second line simply does not have `destroyable`, `turret` or
  `protector` in this list, and `validate` does not know what a shield is.
*/
preview.append(
  Object.assign(document.createElement('p'), {
    textContent: registeredFeatures()
      .map((f) => f.name)
      .join(', '),
  })
);

Status: the format, validation, the registry and the instantiator are built and tested; the editor is a working scaffold on the doc site. See PLAN.md for what is done and what is next.

One package, tree-shakeable

The format, the instantiator and the editor ship as one package. A game imports the first two; the editor is never reached and tree-shakes away.

// a game
import { buildEnsemble, validate, placeMesh } from 'tosijs-3d-ensemble';

// an author
import { ensembleEditor } from 'tosijs-3d-ensemble';

// All four are real exports, which is not something this block could always
// claim: it named `registerBuiltInFeatures` — a function this package has
// never had — for as long as the example was unrunnable, and nothing noticed
// because nothing ran it.
preview.append(
  Object.assign(document.createElement('p'), {
    textContent: [
      ['buildEnsemble', buildEnsemble],
      ['validate', validate],
      ['placeMesh', placeMesh],
      ['ensembleEditor', ensembleEditor],
    ]
      .map(([name, value]) => `${name}: ${typeof value}`)
      .join(' · '),
  })
);

One package, because that is what makes the editor and the game call the same instantiator — "what you author is what you get" holds by construction rather than by discipline. A shipped game still carries no editor, but that is guaranteed by a test that bundles the game's imports and fails if any editor module survives (src/tree-shaking.test.ts), not by npm packaging. A stray import from the format layer into the editor is exactly how this rots, and nothing else would notice.

The editor's UI is built on tosijs-3d's SVG UI, not DOM widgets, so it runs in a headset as well as a browser: editing a 3D arrangement is a spatial task. See SPEC.md open question 5 for the argument and its costs.

Running it

bun install
bun start        # doc site + the editor at /editor/, on :8032
bun test
bun run build

Prior art

A working prototype lives in ../manta-recon, where this grew inside a game before being extracted. PLAN.md §"Prior art" lists what to lift.