---
name: incanto-scene-json-authoring
description: Read and write Incanto *.scene.json files — the JSON source of truth for every Incanto game. Use when creating, inspecting, or modifying scenes, nodes, signal wiring, or sub-scene instances.
---

# Authoring Incanto Scene JSON

> Shipped inside the `incanto` npm package — this document always matches the
> installed engine version. Sibling skills live in `node_modules/incanto/skills/`.

An Incanto game's structure lives entirely in scene JSON. You can understand and modify a
game without reading any TypeScript: nodes, their properties, group/tag identity, signal
wiring, and composition are all in the file. TS is for *behavior only* (attached via
`script` — see `incanto-behaviors-and-scripts.md`).

## File shape

```json
{
  "format": 1,
  "type": "scene",
  "name": "Level1",
  "dimension": "2d",
  "viewport": { "design": [960, 540], "fit": "expand" },
  "assets": { "<key>": { "type": "...", "url": "..." } },
  "constants": { "UI": 1000, "Background": -100 },
  "input": { "<action>": { "...": "...", "touch": "joystick|button (optional — mobile on-screen controls)" } },
  "multiplayer": { "room": "auto" },
  "fragment": false,
  "root": { "name": "Level1", "type": "Node", "children": [] },
  "connections": []
}
```

- `format` MUST be `1`. `type` MUST be `"scene"`. `name` non-empty.
- `dimension` is optional: `"2d"` or `"3d"` only. Left out, it is INFERRED from
  the node types in the tree (anything ending `3D` makes the scene 3D) — write
  it anyway, because it is what the environment validator, the audit's
  no-camera/no-light warnings and `physics: "auto"` all read.
- `fragment` (optional, default `false`) says this file is a PIECE of another
  scene — a spawner's prefab, a sub-scene something instances — and is never
  rendered on its own. It changes nothing at runtime; it tells the checker to
  stop asking a prefab where its camera and its sun are, and to stop answering
  the two questions only the HOST can answer: a node path that leaves the file
  (`/root/Bolts` is the fragment's own root only while the fragment is alone)
  and a `DamageOnContact.targetGroup` whose victims live in the host scene.
  Those two are still NAMED, with a reworded verdict — a typo is most likely in
  exactly this file, written away from the scene that gives it meaning. Set it
  on any scene you load from TypeScript and hand to a spawner. (A scene another
  scene embeds with `instance` needs no key — `incanto-check` walks the project
  and can see that for itself.)
- `viewport` (optional) makes scene JSON own responsive layout: author the world
  in fixed `design` pixels (`[width, height]`, positive numbers) and the renderer
  maps them onto any canvas size. `fit`: `"expand"` (design rect always fully
  visible, extra world beyond — default) · `"letterbox"` (exactly the design
  rect, centered bars) · `"integer"` (whole-number pixel-art scaling). Bad
  values are hard `BAD_FORMAT` errors at load. Place world geometry in design px
  in the JSON — not `window.innerWidth` math in TS. 2D HUDs pair this with
  `UILayer`'s `anchor` prop, which pins the layer origin to a screen
  corner/edge/center (see `incanto-building-2d-games.md`).
- `assets` keys are stable human-readable handles; node props reference them as `"$key"`.
- `constants` are named, reusable TYPED values; a node prop references one as
  `{ "@const": "NAME" }` and the loader replaces it with the literal AT LOAD (see
  "Named constants" below). Distinct from `$key` assets (which stay references).
- `environment.rendering` carries renderer settings IN the scene:
  `{ "antialias": false, "pixelRatio": "device" }` (pixelRatio: a number or
  `"device"`; defaults antialias true / 2D pixelRatio 1 (Phaser parity) /
  3D device-capped-2). Explicit `Renderer2D/3D` constructor options outrank it.
- 3D `environment` also carries the whole atmosphere — `sky` (physical
  atmosphere: `type: "atmosphere"`, sun via `elevationDeg`/`azimuthDeg` or
  `sunPosition`, `turbidity`, `rayleigh`), `fog` (`{color?, near?, far?}`
  linear fog), `clouds` (volumetric raymarched cloud deck —
  `{coverage?, density?, base?, top?, color?, shadeColor?, speed?, scale?}`;
  see `incanto-building-3d-games.md`), `shadows`
  (`true`/`false`/`{mapSize: 1024|2048, radius}`), `exposure` (ACES
  tone-mapping dial, default 1). All renderer-interpreted, hard-validated with
  errors listing valid keys/options; scenes without them render unchanged.
  Full recipe: `incanto-building-3d-games.md`.
- `assets` / `input` / `multiplayer` / `environment` / `viewport` are preserved verbatim by load → export
  round-trips (`environment` carries renderer settings — see `incanto-building-3d-games.md`).
- Fixed-length numeric array props (`position`, `rotation`, `scale`, `size`…) are validated
  element-wise: wrong length, non-number elements, or NaN/Infinity → `PROP_TYPE_MISMATCH`.

## Nodes

```json
{
  "name": "Player",
  "type": "Node",
  "groups": ["player"],
  "tags": { "kind": "LOCAL_PLAYER" },
  "props": { },
  "script": { "name": "PlayerController", "props": { "maxSpeed": 220 } },
  "network": { "mode": "owner", "sync": ["position"] },
  "children": []
}
```

Rules the engine enforces with **hard errors** (it never warns silently):

1. Every node needs `type` (a registered node type) **or** `instance` — never both, never neither.
2. `props` are **delta-only**: write a prop only when it differs from the type's default.
   On export, default-equal values are omitted automatically.
3. Prop keys and value kinds are validated against the type's schema:
   unknown key → `UNKNOWN_PROP` (message lists valid keys); wrong JSON kind
   (e.g. string where the default is boolean) → `PROP_TYPE_MISMATCH`. A prop
   whose default is `null` means AUTO — the engine decides — and validates the
   other kinds it declares (`drape`/`collide`/`islandEdge`/`Chase.ground` take
   a boolean, `snapToGround` a boolean or a lift in metres, `reflectivity` a
   number); the editor offers the boolean ones as auto / on / off.
   A few props accept TWO kinds — `Flowers3D.density` takes a preset name or
   plants per m², `Water3D.underwater` takes `true`/`false` or a settings
   object — and `incanto-node-reference.md` names both in its Kind column
   (`one of: lush sparse none, or number`). The error names them too, so a
   value the loader refuses tells you every shape it would have taken.
4. Sibling names must be unique. Colliding names are auto-renamed by incrementing a
   trailing number (`Enemy` → `Enemy2` → `Enemy3`) — **write unique names yourself** so your
   connection paths stay valid (connections resolve after renaming).
5. Node names must not contain `/` or `%` and must be non-empty.
6. `groups` are string tags for queries (`getNodesInGroup`, connection filters).
   `tags` is free-form JSON for game-logic identity (`{"kind": "ITEM", "value": 10}`).
   **`groups` is a LIST and `tags` is an OBJECT**, and the loader now says so:
   `"groups": "player"` used to load clean and spread the string one CHARACTER
   at a time (`["p","l","a","y","e","r"]`), so `player` was in no group and
   every query and `filter: {group: "player"}` silently matched nothing. Every
   node key is checked for its KIND now — `props`/`overrides` an object,
   `children` a list, `uid` a non-empty string from `newUid()` — as are the
   header's `constants`, `multiplayer` (objects) and `connections` (a list).
7. `script` resolves to a registered Behavior at load (`incanto-behaviors-and-scripts.md`);
   `network` drives multiplayer replication (`incanto-multiplayer.md`). Both round-trip
   losslessly through export.

## Why a NESTED tree (not flat nodes+parent)

The file you are editing is the primary surface agents read: nesting makes
orphans/cycles/dangling-parent errors UNREPRESENTABLE, keeps a node's context
physically adjacent, and makes subtree copy/move one-object operations. Flat
addressing needs are covered by uid (`getNodeByUid`). Full reasoning:
docs/adr/0001 in the repo.

## Draw order: orderGroup + renderOrder

Every visual node has TWO ordering props: `orderGroup` (a named band —
`background < terrain < default < characters < effects < overlay`) and
`renderOrder` (fine offset INSIDE the band). Effective order =
band base + renderOrder. Type defaults: particles ship in `effects`,
Terrain3D in `terrain`, everything else `default`.

```json
{ "name": "Hero", "type": "Sprite2D",
  "props": { "orderGroup": "characters" } },
{ "name": "Smoke", "type": "Particles2D",
  "props": { "renderOrder": 5 } }          // effects band, +5 within it
```

In 3D the bands order the TRANSPARENT pass (opaque geometry is depth-
tested regardless); in 2D they are the layering system. 2D `zIndex` is an
accepted alias for `renderOrder`.

The six built-in bases: `background -2000`, `terrain -1000`, `default 0`,
`characters 1000`, `effects 2000`, `overlay 3000`.

### Your own bands

A band is a **number**, not a name — `base(orderGroup) + renderOrder` is the
whole sort — so a scene declares what its band is WORTH:

```json
{
  "format": 1, "type": "scene", "name": "Level",
  "orderGroups": { "ui-back": 2500, "ui-front": 3500 },
  "root": { "...": "..." }
}
```
```json
{ "name": "Panel", "type": "Sprite2D", "props": { "orderGroup": "ui-back" } }
```

Re-declaring a built-in re-bases it for this scene (`{"terrain": 500}`) — a 2D
game with no terrain can reclaim that band.

**Typos still hard-fail.** The valid set is the six built-ins plus what THIS
scene declares, so `"ui-bakc"` is a load error naming the valid options, exactly
as `"charcters"` always was. A band declared without a finite number is also a
load error: a bare name would sort identically to `default` and mean nothing.

Bands are per-scene and are REPLACED on a scene swap — level 2 does not inherit
level 1's layers.

## Parents and children

EVERY node type can hold children — `children` is universal, so any node works
as a grouping container. **A child's `position` is relative to its parent**, so
nesting is also how you move a group: shift the parent and everything under it
follows. From code, `worldPosition(node)` (`incanto/gameplay`) is where a nested
node actually is; `node.position` is the local offset. The inverse is not true: some types demand a SPECIFIC
parent and are therefore also invalid as the root. Today's rule:

- `CharacterController2D` must be a direct child of a `CharacterBody2D` —
  anywhere else (including root) is a hard `loadScene` error naming both nodes.

These constraints are loader-enforced, so you discover them at authoring time;
the editor's add-node list greys out types its engine probe rejects at the
current position.

## Stable identity: `uid`

Sibling names must be unique, but the SAME name may repeat across the tree — so name
lookups return lists. For a stable, scene-wide-unique handle give a node a `uid`:

```json
{ "name": "Player", "type": "CharacterBody2D", "uid": "n_sqb84lj9q8pvemmx", … }
```

- **NEVER hand-write a uid.** Generate every uid with the engine's `newUid()`
  (crypto-strength `n_` + 16 base36 chars) — hand-made readable strings break
  the collision contract and stand out as fakes:
  - in game/tool code: `import { newUid } from 'incanto'` (e.g. when spawning
    nodes at runtime that you'll serialize)
  - while authoring JSON by hand:
    `node -e "import('incanto').then(m => console.log(m.newUid()))"`
  - the editor assigns uids automatically to everything it creates
- `root.getNodeByUid("n_p1gakz144ktj7eta")` → the node (or null) — survives moves and renames
- `root.getNodesByName("Enemy")` → EVERY node named Enemy, in document order
- Duplicate uids are a hard load error (`DUPLICATE_UID`)
- The editor treats uid as IDENTITY: every node gets one at creation (legacy scenes
  are backfilled on open), it is shown read-only, and deleting a node whose uid is
  still referenced anywhere opens an unlink-or-cancel confirmation

## Assets

Every entry needs **`type` and `url` — both required**. Everything else on an
entry is FREE METADATA: arbitrary key-values the engine ignores but preserves,
readable from `scene.assets` — use it to carry information for yourself or
other agents (license, palette, source prompt, frame counts…).

```json
"assets": {
  "characters/knight": {
    "type": "texture", "url": "/sprites/knight.png",
    "filter": "nearest", "license": "CC0", "palette": ["#2d3250", "#ff6b6b"]
  },
  "avatar": { "type": "model", "url": "/models/hero.vrm" },
  "run":    { "type": "animation", "url": "/anims/run.glb" }
}
```

Supported `type` values:

| type | what it loads | consumed by |
|---|---|---|
| `texture` | an image | `Sprite2D.texture` |
| `spritesheet` | a spritesheet image (needs numeric `frameWidth`/`frameHeight`) | `AnimatedSprite2D.sheet` |
| `model` | a **GLB / glTF / VRM** 3D model | `ModelInstance3D.model` |
| `animation` | a GLB's animation clips (memory only — drawn nowhere) | `ModelInstance3D.animation` |

Nodes reference entries as `"$key"` (`"$characters/knight"`). `model`/`animation`
details — sizing, animation retargeting, the `incanto-model` CLI — live in
`incanto-3d-models.md`.

## Asset groups

Asset keys may carry a `group/` prefix — pure naming convention, zero engine
machinery: `"characters/knight"` is one flat key, referenced as
`"$characters/knight"`. The editor renders groups as collapsible folders in
its ASSETS explorer section. Group related assets (`characters/`, `ui/`,
`fx/`) so scenes stay navigable as they grow.

## Named constants

A scene-level table of reusable TYPED values. Define once, reference from any
node prop — change the value in one place and every reference updates.

```json
{
  "constants": { "UI": 1000, "Background": -100, "Brand": "#ff3366", "Spawn": [0, 0, 0] },
  "root": { "name": "World", "type": "Node3D", "children": [
    { "name": "Hud",  "type": "Sprite2D",   "props": { "renderOrder": { "@const": "UI" } } },
    { "name": "Pond", "type": "Water3D",    "props": { "renderOrder": { "@const": "Background" } } },
    { "name": "Logo", "type": "MeshInstance3D", "props": { "material": { "color": { "@const": "Brand" } } } }
  ] }
}
```

- A prop value of `{ "@const": "NAME" }` is replaced with the constant's literal
  value AT LOAD — the node only ever sees the resolved value, so the constant's
  type MUST match the prop's type (a number constant for a number prop, etc.) or
  you get a `PROP_TYPE_MISMATCH`. Works at any depth (inside `material`, arrays…).
- Unknown name → `UNKNOWN_CONSTANT` (lists the declared names). Constants are
  per-scene and FLAT — a constant's value is a final literal, never another `@const`.
- The editor manages these in a CONSTANTS panel (like ASSETS); the Inspector lets
  you pick a matching-typed constant or type a raw value for any prop.
- THE use case: render-order / sorting tiers. Define `Background`/`World`/`UI`
  once, then point every node's `renderOrder` at them.

## Render order (draw priority)

ONE prop name across 2D and 3D: **`renderOrder`** (default 0, higher = drawn
later / on top), on every `Node2D` and `Node3D`. (Legacy 2D scenes that used
`zIndex` still load — it's accepted as an alias — but author new scenes with
`renderOrder`.)

- **2D**: every drawable (Sprite2D/ColorRect2D/Label/Particles2D) honors it —
  see `incanto-building-2d-games.md`.
- **3D**: only affects TRANSPARENT materials (three sorts the transparent pass
  by `renderOrder`, then depth; opaque meshes always sort by depth). Built-ins set
  sensible defaults — `Water3D` 1, `Foliage3D` blades 2 — which you can override.
- Name the tiers with constants (above) instead of scattering magic numbers.

## Node paths

Used by `connections[].from/to` and APIs like `getNode`:

| Path | Meaning |
|---|---|
| `"Child/Grand"` | descend from the current node |
| `"."` | the current node (in connections: the scene root) |
| `".."`, `"../Sibling"` | parent hops (relative paths only) |
| `"/Level1/Player"` | absolute — first segment must equal the root node's name |
| `"/root/Player"` | `/root/` is an alias for the tree root regardless of its actual name |
| `"%Player"` | unique-name lookup across the whole tree (error if 0 or ≥2 matches) |

Bad grammar → `BAD_NODE_PATH`. Unresolvable → `NODE_NOT_FOUND` (message lists the children
that do exist at the failing spot).

## Connections (serialized signal wiring)

```json
{ "signal": "triggerEnter", "from": "Coin", "to": ".", "handler": "onCoinCollected",
  "once": true, "filter": { "group": "player" } }
```

- `from`/`to` are node paths **relative to the scene root** (`"."` = the root).
- `signal` must be DECLARED on the from node (the class's or its behavior's
  `static signals`) — validated at load → `UNKNOWN_SIGNAL` otherwise.
- `handler` must be a method on the target node OR its behavior (script) — both are
  validated hard at load. Otherwise → `UNKNOWN_HANDLER`.
- **The ARGUMENTS are checked when the wire fires**, not at load — nothing declares
  how many a signal carries, so the pair can only be compared at the moment both
  are known. A handler given fewer than it needs is reported once, by name:

  ```
  [incanto] 'died' from 'Enemy' carries 0 argument(s) and 'Keeper.addScore' needs 1.
            The missing one(s) arrive as undefined — a number handler gets NaN and
            never recovers.
  ```

  `died → ScoreKeeper.addScore` is the natural way to score a kill and it used to
  set the score to `NaN` on the first one, with the win condition then
  permanently out of reach. The numeric handlers REFUSE a non-number now, so the
  same wire fails loudly instead — and so does the one the arity check cannot
  see: `clicked → addScore` hands the NODE, which is one argument into one
  parameter, and `score + node` is a STRING that grows forever. A handler that DEFAULTS what it is not given is correct and stays silent:
  `won → GameFlow.win` works, because `win(text = 'YOU WIN')` needs nothing.

  Writing your OWN handler, the same rule applies to you: an optional parameter
  is a **default**, not a `?`. `hurt(amount: number, from?: Node)` counts as
  needing 2, because `?` is a TypeScript annotation that does not survive to
  runtime — write `from: Node | undefined = undefined` and the wire is judged on
  what it actually needs. Every engine method a wire can name has been corrected
  to this; before that, four of five documented HUD wires were reported as
  broken while working perfectly. On **both**, it is
  `AMBIGUOUS_HANDLER`: the node's method wins, so the script's would never run
  and nothing would say so. A core node answers to 62 public methods before any
  adapter adds more (`stop` `play` `show` `clear` `say` `start` `free` …), which
  is why the collision is easy to write — rename the script's method. Script names themselves must be
  registered (`registerBehavior`) before `loadScene` → `UNKNOWN_BEHAVIOR` otherwise.
- Unresolvable `from`/`to` → `DANGLING_CONNECTION` at load. Renaming a node breaks its
  connections **loudly** — update paths in the same edit.
- `args` calls the handler with THOSE values instead of whatever the signal
  emitted — `Function.bind` for a wire:

  ```json
  { "signal": "phase2", "from": "Boss/Mood", "to": "HUD/Banner", "handler": "show",
    "args": ["THE CINDERS CATCH", { "color": "#ffb454", "seconds": 1.6 }] }
  ```

  Without it, "when this happens, say that" needed a two-line behaviour whose
  whole job was calling one method with one constant. Bound arguments REPLACE
  the emitted ones — a wire that half-carried would be ambiguous, and the
  emitted values are still one connection away (the same wire without `args`).
  It must be a LIST: `"args": "ENRAGED"` is a load error.
- `filter` gates firing on the first emitted argument: it must be a node in `filter.group`
  and/or match every `filter.tag` entry. **A filter's keys are `group` and `tag`, and a
  connection's are `[signal, from, to, handler, once, filter]`** — anything else is a load
  error, because a filter the matcher cannot read (`{"gruop": "player"}`, or the bare
  string `"player"`) passes EVERYTHING, which is the opposite of what a filter is for, and
  `"once": "no"` is truthy so it fires exactly once. With `once: true`, the connection is consumed only
  when the filter matches.

## Sub-scene composition

```json
{ "name": "Ruby", "instance": "scenes/item.scene.json", "overrides": { "value": 99 } }
```

- `instance` embeds another scene's tree; `overrides` deep-merge onto the sub-scene root's
  props. At the instancing site, `groups` and `children` COMPOSE (union/append), while
  `tags`/`script`/`network` REPLACE the sub-scene root's values when declared (omitted = kept).
- The sub-scene's own `connections` are wired inside its subtree automatically,
  and so are its `assets`, `input`, `strings`, `constants` and `orderGroups` —
  **a prefab brings everything it declares**. (Its `orderGroups` used to come
  through as NAMES only, so `"orderGroup": "loot"` validated and the band's
  number was dropped: the sprite sorted in the default band and the scene loaded
  clean.) A `coin.scene.json` with its own
  spritesheet, or a `player.scene.json` with its own `move`/`jump` bindings, is
  a complete, reusable thing.
- The HOST wins any asset key, action name or string it declares itself, so a
  level can re-point a prefab's art or rebind its controls without editing the
  prefab. Two INSTANCED scenes declaring one asset key with different urls — or
  one action name with different keys — is a hard error naming both:
  first-writer-wins would hand the second prefab the first one's art in silence.
  The same key with the same value is just two prefabs agreeing.
- There is **no** scene inheritance — composition only.
- Current limitation: exporting expands instances into full trees (the `instance` reference is
  not preserved on export yet).
- Cycles (`a` instances `b` instances `a`) and missing resolvers → `UNRESOLVED_INSTANCE`.
- A placement's props go in `overrides`. `props` on an `instance:` node is a
  hard `BAD_FORMAT` error — it used to be accepted and read by nothing, so a
  position written there was silently the sub-scene's own.

### Giving the loader the sub-scenes (the half that is not JSON)

`loadScene` never reads files: it asks `resolveScene(path)` for the JSON behind
an `instance:` string, synchronously, and without one a scene with a prefab in
it does not load. Every CLI (`incanto-check`, `-playtest`, `-verify`, `-feel`,
`-play`) passes one already, resolved against the SCENE's own folder — your
game and your harness are the two places you write it yourself:

```ts
// App.tsx — vite. Every *.scene.json beside this file, keyed by the path an
// `instance:` names.
const prefabs = import.meta.glob('./*.scene.json', { eager: true, import: 'default' });
const resolveScene = (path: string): unknown => prefabs[`./${path}`] ?? null;
await createGame2D({ canvas, scene: sceneJson, resolveScene });
```

```ts
// verify.ts — bun, no bundler: import the prefab and hand it over by path.
import crateJson from './src/crate.scene.json';
const resolveScene = (path: string) => (path === 'crate.scene.json' ? crateJson : null);
await runScript(sceneJson, { durationMs: 4000, resolveScene, steps: [...] });
```

`examples/tiles-2d` is the worked example in 2D: two crates that ARE
`crate.scene.json`, one of them adding a rope child of its own. `examples/tps-3d`
is the 3D one: the arena's cover blocks and pillars are `cover.scene.json` and
`pillar.scene.json`, placed with a `position` override each.

## Error codes you will see (all hard failures at load)

| Code | Cause | Fix |
|---|---|---|
| `BAD_FORMAT` | wrong `format`/`type`/`name`/`dimension`, node without `type`/`instance`, or both | match the file shape above |
| `UNKNOWN_NODE_TYPE` | `type` not registered | use a type from the message's registered list |
| `UNKNOWN_PROP` | prop key not in the type schema | use a key from the message's list |
| `PROP_TYPE_MISMATCH` | prop JSON kind differs from the default's kind (incl. a `@const` whose value is the wrong type) | match the default's kind |
| `UNKNOWN_CONSTANT` | `{"@const":"X"}` names a constant not in the scene's `constants` | declare it, or use a listed name |
| `BAD_NODE_PATH` | malformed path grammar | see path table |
| `NODE_NOT_FOUND` | path resolves nowhere | message lists existing children |
| `DUPLICATE_UNIQUE_NAME` | `%Name` matches ≥2 nodes | rename one, or use an explicit path |
| `DANGLING_CONNECTION` | connection `from`/`to` unresolvable | fix the path after renames |
| `UNKNOWN_HANDLER` | handler missing on the node AND its behavior | fix the method name |
| `AMBIGUOUS_HANDLER` | handler is a method on the node AND on its behavior — the node's wins silently | rename the behavior's method |
| `UNKNOWN_SIGNAL` | connection (or emit/on) names a signal the from node never declares | use a declared signal, or declare it (`static signals` / `declareSignal`) |
| `UNKNOWN_BEHAVIOR` | `script.name` not registered | `registerBehavior(name, Class)` before `loadScene` |
| `UNRESOLVED_INSTANCE` | no resolver, unknown path, or instance cycle | provide/fix `resolveScene`, break the cycle |
| `WRONG_DIMENSION` | a `*3D` node in a 2D scene (or the reverse), or `createGame2D` given a 3D scene | use the matching node type / the matching `createGame*` |
| `DUPLICATE_NODE_TYPE` | two classes registered under one type name | rename one type, or `{ replace: true }` for hot reload |
| `DUPLICATE_BEHAVIOR` | two classes registered under one behavior name | rename one, or `{ replace: true }` for hot reload |
| `TREE_VIOLATION` | invalid name, re-parenting without detach, cycles, double root | follow the rule in the message |

Every one of these lists what WOULD have worked, and names the nearest
candidate when there is one — `Unknown node type 'Label2D'. Did you mean
"Label"?`, `Unknown prop 'size' on 'Label'. Did you mean "fontSize"?`. A name
that resembles nothing gets the list alone, because a suggestion that is not the
answer is worse than none.

Scene-load and registry errors carry structured `details` (`path`, `uid`, `nodeType`,
`prop`, `signal`, `validOptions`) mirroring the prose — prefer those over regexing the
message (a few runtime errors still carry prose only). Scene-load
errors append the offending node's path: `… (at '/Level/Enemies/Slime3')`.

## Programmatic API (current milestone)

```ts
import { loadScene, registerCoreNodes, duplicateNode } from 'incanto';

registerCoreNodes();                            // explicit — never an import side effect
// `{ engine }` on a manual boot: `onReady` runs during the LOAD, so a behaviour
// that reads `this.engine` or `this.rng` there throws without it. `createGame2D`
// /`3D` and `runScript` pass it for you.
const scene = loadScene(json, { engine, resolveScene }); // throws IncantoError on any problem
scene.root.getNode('Player').emit('hit', 10);
const copy = duplicateNode(scene.root.getNode('Coin'));
const exported = scene.toJSON();                 // lossless (delta-only props)
```

Registration semantics: `registerNodes2D()` / `registerNodes3D()` (from
`incanto/2d` / `incanto/3d`) and `registerNodesNet()` (from `incanto/net`)
each INCLUDE `registerCoreNodes()` — you never call it separately alongside
them, and calling several registrars together (e.g. 2D + Net for a
multiplayer game) is safe: re-registering the SAME class under a name is
idempotent (only a DIFFERENT class under an existing name is a
`DUPLICATE_NODE_TYPE` error, or needs `{ replace: true }` for hot reload).
`createGame2D`/`createGame3D` call the right registrar for you.

Lifecycle once loaded: `onEnterTree` parent-first → `onReady` children-first (once per
instance) → per-frame `update(dt)` / fixed-rate `fixedUpdate(dt)` parent-first →
`queueFree()` defers destruction to end of the update pass.
