---
name: incanto-editor
description: The visual scene composer shipped in the incanto package — bunx incanto-editor serves a page that renders a scene with the installed engine, lets you compose nodes and edit every prop, saves back to the file, and speaks a postMessage protocol for iframe embedding. Use when the user wants to visually arrange or tweak a scene, or when integrating the editor into a host service.
---

# Scene Editor

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

## Launch

```bash
bunx incanto-editor                          # PROJECT mode: discovers every *.scene.json
bunx incanto-editor src/game.scene.json      # SINGLE mode: one fixed file
bunx incanto-editor level.scene.json --output build/level.scene.json --port 5179 --host 0.0.0.0
```

**Project mode** (no file argument — the usual way) scans the current directory for
`*.scene.json` (skipping node_modules/dist/hidden dirs): one scene auto-opens; several
open the scene browser.

**What the server will and will not touch**, since the browser lists every
`.json` and the answer comes from the server:

| | |
| --- | --- |
| reads | any `.json` **under the launch directory** — but only sends it if it IS a scene; anything else comes back with the reason and its contents never leave |
| writes | a scene, over a file that is already a scene, or to a file that does not exist yet |
| refuses | a path outside the project, a non-scene body, and **overwriting a file that is not a scene** — `?file=package.json` gets a 409 naming what it found |

That last row used to be missing: the write path checked the body and never what
it was about to land on, so one query parameter could replace a manifest with a
scene. The GET on the same endpoint already refused it.

### The scene browser (`scenes`)

The `scenes` button opens the project as a tree, not a list of paths:

- **Every `.json` in the project is listed** — a scene is JSON by definition, so
  nothing else could be one — and each row says whether it IS a scene, read from
  the file rather than guessed from its name. A scene declares itself:
  `{"format": 1, "type": "scene", "name", "root"}`, the same four keys the loader
  demands. Scene rows show `3d · 24 nodes`; anything else is dimmed and marked
  `not a scene`, and opening it fails with the reason (`"type" must be "scene",
  and this file has no "type"`). So a scene you saved as `level.json` is right
  there and opens; `package.json` is right there and tells you why it will not.
  (Non-scene contents never leave the server — only the reason does.)
- **Folders**, sorted, with a scene count each; a chain that holds nothing else
  collapses into one row (`src / scenes`). Small projects open fully expanded.
- **The scene you are editing** is marked and revealed, and the cursor starts on it.
- **A filter box** — type any part of a path; matches show wherever they are hiding.
  Keyboard from that box: `↑↓` move · `→` open a folder · `←` close it · `⏎` load ·
  `Esc` close (backdrop and ✕ work too). The cursor always sits on a SCENE —
  the first match while you filter, the scene you are editing when you open the
  panel — so `⏎` loads something without arrowing first.
- Each row carries **when it was last written and how big it is**, which is usually
  how you recognise the file you were just in.
- **create** makes a new scene at the path in the box — **matching the project**:
a 3D game gets `dimension: "3d"` with a `Node3D` root, a 2D game gets 2D, read
from the scenes already on disk. It used to hardcode 2D for everyone, and node
types are immutable by design, so a 3D author's new scene could only be deleted
and re-made by hand — left empty it uses the
  placeholder, which tracks the folder you are standing in, so a scene lands beside
  its siblings rather than at the project root. Parent dirs are created.
- Loading another scene while you have unsaved EDITS asks first, and the answer
  is not only *discard*: **save & open** writes the scene you are leaving and
  then opens the other one.

**The same browser opens inside a running game.** When the game's dev server serves
the project's scenes, `☰ debug ▸ ✎ edit this scene` gives you the whole project: the
editor opens on the scene the game is running, and `scenes` switches to any other one,
saves back to ITS file, and `play ▸ run as the game` boots whichever scene you loaded.
That needs one plugin in `vite.config.ts` (already there in every `incanto-new`
project):

```ts
import { incantoScenes } from 'incanto/vite';
export default defineConfig({ plugins: [incantoScenes()] });
```

Without it the `scenes` button is still there and says what to add — a missing
one-line plugin should be a sentence you can act on, not a button that quietly
is not there. The editor still edits the scene the game booted with meanwhile.

Opens a local page (default `http://127.0.0.1:5179/`) with three panes:

- **Explorer** — two collapsible sections: **ASSETS** on top (icon rows by
  type; **an asset the renderer could not FETCH turns red**, with the url in its
  tooltip and the consequence spelled out when you select it — *"failed to
  load — every node using $fx/coin draws nothing"* — plus a banner naming all of
  them. A 404'd texture is otherwise the one failure with no symptom: the scene
  is structurally perfect, the tree is full, and the viewport draws nothing where
  the art should be; keys with a `group/` prefix nest under collapsible folders (any depth),
  each showing its recursive asset count — refs are `$group/key`; click the
  icon for a blurb, the row to edit in the inspector; DRAG asset rows onto a
  folder (or the section background = root) to move them — references rewrite
  automatically, as they do when a group is renamed; the folder button creates
  a group, and deleting a non-empty group asks first) and the
  **SCENE tree** below — type icons left of each name (click one for a balloon
  explaining the type), chevron collapse/expand with depth guide lines,
  ctrl/cmd-click **multi-select**, and **drag-and-drop restructuring**: drop ONTO
  a row to make the dragged node(s) its children, drop on a row's top/bottom edge
  to reorder before/after. Dragging a selected row moves the whole selection.
  Illegal drops (engine rules — e.g. a CharacterController2D outside a
  CharacterBody2D) are ROLLED BACK entirely with the error in the banner; the
  tree never shows a state the engine would reject.
  **A reparent does not MOVE anything**: the node keeps its world transform and
  the editor rewrites the local `position`/`rotation`/`scale` to match (drop a
  ball at `[3, 0.6, 0]` onto a crate at `[-3, 0.5, 0]` and the ball stays put,
  holding `[6, 0.1, 0]`). Rotated and scaled parents are handled the same way,
  and the values that are no longer needed disappear rather than being written
  as defaults. This is the Godot/Unity/Blender behaviour, and it is what keeps a
  tree edit from changing the picture. Right-click for
  duplicate / rename (or double-click the name) / cut / copy / paste-as-child /
  delete — all act on the multi-selection. **Rename REPAIRS references**: every
  `connections[].from/to` and every node-path prop pointing at the node (or into
  its subtree) is rewritten in the same edit, across all three path forms
  (`%Unique`, `/root/…`, relative `../Skin`), and the editor reports what it
  changed. A prop is rewritten only if its type declares `nodePath: true`, so a
  text prop that merely reads "Player" is never touched. `+` adds a child of any registered
  type, `✕` deletes the selection. Selecting the ⚙ scene row edits the header
  (dimension, gravity with real inputs, environment/input/assets/multiplayer),
  including a **STRINGS table** — one row per `@t:` key, one column per
  language. It shows the two things raw JSON cannot: an untranslated cell shows
  the English it will silently fall back to (greyed, because that fallback is
  the design), and a key the BASE locale is missing is marked in red, because
  that one has nothing to fall back to and paints the raw key on screen.
  `+ key`, `+ language`, and deleting a key that a prop still says asks first.
- **CONNECTIONS on the selected node** — the inspector's last section shows what
  the node **emits** and what it **receives**, each row being signal → target →
  handler with a ✕ to remove and `+ connect` to add. Signals come from the node
  type merged with its behavior's `static signals`; the target picker lists every
  node in the scene; the handler is a dropdown of real methods when the target
  has no script, and free text when it does (those handlers live in your
  TypeScript, which the editor never loads). An end that no longer resolves is
  marked red with the reason. For what a row cannot express — `filter`, `once`
  and `args` — the scene header still has `connections` as raw JSON. A
  connection's keys are exactly `[signal, from, to, handler, once, filter,
  args]`, a closed set, so an invented one is a load error rather than a key
  that quietly does nothing. **`args` survives editing but is not shown**: a row
  whose wire carries bound arguments reads as `phase2 → HUD/Banner.show` with no
  hint of the words it says.

  **Re-pointing a target keeps your handler.** A handler the new target does not
  have is replaced (the row would otherwise stay green here and hard-fail at
  load), and the row remembers what it started with — so changing your mind and
  putting the target back puts the handler back. Before that, a round trip left
  `phase2 → HUD/Banner.show` as `HUD/Banner.childNames`: the replacement took
  the target's first offer, and the offers included the tree plumbing every node
  inherits. They do not any more — the node base class is read the way the
  behavior base class always was, so `queueFree` stays and `childNames`,
  `wiringPath` and the rest are gone.
  **Validation now runs WITH connections**: the editor used to strip them before
  loading, so it reported green for a scene the game hard-fails to open.
- **Viewport with DIRECT MANIPULATION** — a mode toolbar (top left) + the
  Unity/Godot keys: **W move · E rotate · R scale** (active mode highlighted).
  A collider-visibility toggle sits beside the mode tools, and it shows the
  colliders the ENGINE builds, not a drawing of the `collider` prop: it attaches
  Rapier to the edit view with `simulate: false` and turns on the same
  `debugDraw` the play view uses. So a mesh-fitted body, an InstancedMesh3D
  scatter's hulls, a terrain and a character capsule all appear — none of them
  are in the scene JSON — and rotation, scale and offset are wherever physics
  actually puts them. Nothing is simulated: bodies are posed from the tree each
  tick (an outline follows a gizmo drag) and the solver never runs, so the scene
  does not fall over while you edit it. Persisted, and carried into play mode
  unchanged.
  Move: axis arrows (X red, Y green, Z blue in 3D) + center square for free
  movement. Rotate: drag the ring(s) — one ring in 2D, three world-axis rings
  in 3D — with a live degree readout at the cursor. Scale: the same arrows with
  cube tips (center = uniform); right-dragging a handle scales in any mode.
  **Esc cancels the drag in progress** and reverts the value; **hold Shift to
  SNAP** — rotations to 15°, positions to the grid (10 px in 2D, 0.5 u in 3D),
  scales to 0.25 steps (the readout shows the snapped value).
  2D additionally supports click-pick, body drag, wheel zoom-at-cursor,
  right/middle/Shift-drag pan, Alt+wheel scale, and collider wireframes.
  **HUD nodes are editable like anything else**: a `UILayer` subtree is posed in
  screen space rather than world space, and the viewport now picks, outlines and
  drags it there — click the widget where you SEE it, and its position moves 1:1
  with the cursor whatever the world zoom is. (Picking used to look only at the
  world pass, so a HUD could not be selected in the viewport at all, and its
  outline was drawn wherever the game camera happened to be looking.)
  A `HudLayer`'s DOM is drawn over the edit view but never takes the pointer
  there — a centred pause menu used to sit exactly where the selected node's
  gizmo is and eat the drag (a rotate ring under it never saw a pointerdown; a
  move drag toggled Mute). Its widgets are live again in play.
  **`F` frames the SELECTED node** (its whole subtree; a light or empty node has
  no bounds, so the camera goes to it at a readable distance) — and the whole
  scene when nothing is selected, which is the Maya/Unity/Unreal meaning of the
  key. Same as the ⌖ frame button, and as `frame in view` in the tree's
  right-click menu. This is how you find a node you selected in the tree that is
  nowhere near the camera; double-click in the tree stays RENAME. The add-node dropdown is grouped by
  category (Core / 2D / 2D Physics / 3D / 3D Physics / Network, plus a trailing
  Other catch-all so an unclaimed type can never vanish from the list). The
  dropdown, inspector and in-editor docs are registry/schema-driven — newly
  registered node types appear automatically. Edits re-validate through the real
  loader: hard `IncantoError`s appear in a banner. Validation is STRUCTURE-level —
  physics semantics are checked when physics actually runs.
- A node placed with **`instance:`** shows a PLACEMENT card first — which
  prefab file it is (editable, to re-point it) and its `overrides` as JSON, the
  props deep-merged onto the sub-scene root. Dragging its gizmo writes there
  too; `props` on a placement is a load error and is read by nothing.
- **Inspector** — schema-driven from the node registry, with STRUCTURED editors for
  the hard parts: `collider` (shape dropdown + per-shape dimensions, mirrored by
  the wireframe), `network` (mode dropdown + sync-key chips + throttle),
  `script` — **the behaviors the engine SHIPS are a dropdown**, and picking one
  builds a form from its own prop schema (a `Health` gets `max`/`regenPerSec`/
  `invulnerableFor`/`freeOnDeath` with their defaults; a `Patrol`'s `mode` is a
  `loop`/`pingpong` menu), delta-only like every other field. A name that is NOT
  built in is your game's TypeScript: it keeps the raw-JSON props box and says
  so, with copy-paste Behavior boilerplate in its help — `groups` (name chips)
  and `tags` (the node's free-form identity JSON: what
  `connections[].filter.tag` matches on, and where a chart, a card or a loot
  entry lives; a value the loader would reject is marked invalid rather than
  written). Every one has a `?` help popover with examples. Values equal to the
  default are removed (delta-only, like the serializer).
  **A prop that holds a NODE PATH** (`Chase.target`, `Camera2D.follow`,
  `Spawner.prefab`, `Joint3D.target`, `skinPath`, `terrain`…) offers every node
  in the scene as a list — `%Name` where the name is unique, the absolute path
  where it repeats — and free text still works for the forms a list cannot
  enumerate (`../Skin`, a path into a subtree). A value that resolves to NOTHING
  is marked red with the reason: `'%Playerr' matches no node in this scene. The
  scene still loads — the prop just does nothing.` That last sentence is the
  point: unlike a connection, a dangling path prop is not a load error, so
  nothing else would ever have told you. A behavior's path props are checked
  exactly like the node's own, and `incanto-check` reports the same thing from
  the file.

**3D scenes** get full camera navigation: drag orbits, right/middle/Shift-drag pans,
wheel zooms, `F` frames the contents, and **`0` / the `game cam` button** returns to the
GAME's own camera — exactly what running the scene shows (the free camera re-syncs so
the next drag continues from there). A clickable ORIENTATION GIZMO (top right): the filled
X/Y/Z handles snap to right/top/front views, hollow ones to the opposite sides. The
hint bar swaps to the camera controls. AMBIENT VISUALS preview LIVE while logic time
stays frozen: ModelInstance3D plays its `animation`, Particles2D/Particles3D emit,
Water3D waves bob, Foliage3D sways — behaviors, physics and timers still wait for
play. The inspector suggests model/animation values from the scene's assets (plus
embedded clip names).

**▶ Play** runs the scene AS COMPOSED in the same pane: physics enabled, keyboard
attached, CharacterController2D and every engine-native feature live — tune a
collider or gravity, hit play, feel it, Esc back, keep editing. Game `script`s and
their signal wiring run only in your real game (a notice says so while simulating).

A **docs** button opens the in-editor node documentation: an overview of how
Incanto scenes work plus a tab per category with a detailed entry and live
props table for every node type — in English AND Korean (the 한/EN toggle also
localizes the inspector's help popovers and type balloons; values stay English).

Node types are FIXED once created (retyping would smuggle children past
parent rules) — but the ROOT is deletable like any node: the scene goes
empty and the next node added becomes the new root. The add-node dropdown
leads with the scene's dimension (3D defaults to MeshInstance3D) and
DISABLES types the engine would reject at the current target — probed by
validating a candidate scene, so any loader rule (e.g. CharacterController2D
needs a CharacterBody2D parent) is honored automatically, for children and
for root candidates alike.

Every node carries an immutable `uid` (assigned at creation; legacy scenes are
backfilled when opened) shown read-only above the name — click to copy. Saved
files are formatted biome-compatibly (80-col inline-when-fits), so saving from
the editor never breaks `pnpm lint`.

Save activates only when the working scene differs from the original
(`Ctrl/Cmd+S`); undo is `Ctrl/Cmd+Z`, and it keeps your selection whenever the
selected node survives the undo (only undoing the add that created it deselects). Saving writes pretty-printed JSON to
`--output` (default: the input file).

## The agent8 asset library (📚)

Every field that takes a resource carries a **📚** button:

| Field | Writes |
|---|---|
| `ModelInstance3D.model` | scene asset (`model`) + the `$ref` |
| `ModelInstance3D.animation` | scene asset (`animation`) + the `$ref` — or type an embedded clip name |
| `Sprite2D.texture`, `AnimatedSprite2D.sheet` | scene asset (`texture` / `spritesheet`) + the `$ref` |
| `TileMap2D.texture` | scene asset (`texture`) + the `$ref` |
| `Sprite3D.texture`, `AnimatedSprite3D.sheet` | the URL (3D sprites take raw URLs) |
| material `map` / `normalMap`, `Tree3D.leafTexture`, `AudioPlayer.src` | the URL |
| ASSETS ＋ form, and any asset's `url` | the URL (+ a suggested key) |

It opens the agent8 catalog that opens the agent8 catalog: shelves down the
left (**Everything** / 2D · sprite characters / 3D · characters, monsters,
objects, vehicles, weapons, polyhaven, textures), keyword search, and a detail
pane with the description, tags, format/size/mime and the URL. **Everything** is
the tagless shelf — the one that searches the whole catalog and the only way to
reach assets the tag tree does not name (sounds, one-off props).

The preview is the real thing: an image renders as an image, a **GLB is
rendered by the engine itself** (turntable, drag to spin, `targetHeight: 1` so
everything frames the same) — the same loader the game will use, so what you
see is what the scene gets.

Picking does the whole job, not half of it:

- a **URL field** (material map, leaf texture, audio src) gets the URL;
- a **`$assetKey` field** (`ModelInstance3D.model`, sprite texture/sheet) gets a
  scene `assets{}` entry AND the ref that resolves to it — one action, no
  separate "declare the asset" step. An entry with the same url is reused, so
  browsing to the model you already have does not duplicate it;
- the ASSETS **＋** form fills the url and suggests the key from the file name.

A sheet lands as a `spritesheet`, not a plain `texture` — the engine hard-fails
on the latter in an `AnimatedSprite2D.sheet` — with `frameWidth`/`frameHeight`
taken from the catalog's metadata when it carries them, and LEFT BLANK when it
does not (an invented frame size renders wrong art in silence; a blank field
asks you the question).

**Sprite configs are the good pick.** Many sprite characters ship a sibling
`<name>.json` (frame grid + named frame ranges) and the card wears an
`animated` badge. Picking one declares the sheet asset with its frame size AND
writes the node's `animations` + `autoplay` — one click gives you a sprite that
actually animates, instead of a still frame and a homework assignment.

What is on the shelves depends on the account behind your token. The public
catalog at the time of writing:

```
3d  5,325   characters 392 · objects 1,992 · textures 2,538 · polyhaven 2,723
            animations 24 · shaders 14 · vehicles 12 · weapons 6 · monsters 6
2d    436   projects 361 (ui, characters, sounds) · sprite_characters 54
            building 9 · minecraft 7 · characterportraits 5
```

Tags are lowercase — the catalog's own convention, and the query is
case-sensitive. If a shelf you expect is missing it is missing upstream: browse
from **Everything** and the tree shows what is actually there.

A field that knows where its art lives opens there — a model at
`3d / characters`, a clip at `3d / animations`, a material map at
`3d / textures`, a sprite sheet at `2d / sprite_characters` — and the rest start
at the root. It is a hint, not a cage: the breadcrumb walks anywhere. Either way
the kind filter applies, so a "model" field never offers you a .png.

**Access.** The catalog is per-account and its CORS list has no localhost in it,
so the page cannot call it directly — `incanto-editor` proxies it at
`GET /api/library`. Give the server a Verse8 token:

```bash
bunx incanto-editor --token <v8 access token>
INCANTO_V8_TOKEN=<token> bunx incanto-editor      # same thing
# either way the panel header shows WHICH token is in use, wallet-style
# (`v8_abc…wxyz · INCANTO_V8_TOKEN`) — enough to recognise, never enough to use
```

With no token the 📚 panel offers **sign in with Verse8** — the same device flow
agent8's own local-dev login uses. It opens the Verse8 approval page in a new
tab, shows you the code to approve, and stores the token it comes back with in
this browser
(`localStorage`), which is the right answer for a shared machine — the token is
sent only to your own editor server, never written to disk by it, and forwarded
upstream as a bearer. `INCANTO_LIBRARY_URL` points the proxy somewhere else
(a mirror, or a stub in tests).

A game hosting the editor (the live play ⇄ edit switch) has no catalog of its
own — but its dev server can serve the same proxy, and then the 📚 works there
too:

```ts
// vite.config.ts
import { incantoLibrary, incantoScenes } from 'incanto/vite';
export default defineConfig({ plugins: [incantoScenes(), incantoLibrary()] });
```
```ts
// App.tsx
createGame3D({
  ...,
  debug: import.meta.env.VITE_INCANTO_DEBUG === '1',
  editor: { library: true },   // "my dev server serves /api/library"
});
```

`incantoLibrary()` is `apply: 'serve'` — dev only, nothing reaches a build, and
the token stays in the terminal that started the server (`INCANTO_V8_TOKEN`, or
`incantoLibrary({ token })`). `editor: { library: fn }` plugs in your own
transport instead. With neither, the buttons are simply absent.

## Play, and the switch into the running game

**▶ play** means two different things and the editor asks which:

- **▶ preview here** — the scene simulates inside the editor viewport with the
  game's TypeScript behaviors STRIPPED. Engine-native motion only (physics,
  animation, water) — a quick look, not the game.
- **⤢ run as the game** — the page BECOMES the game: `createGame3D` boots the
  working scene with your behaviors, your loading screen and the debug overlay.
  This is the real thing, and it carries the edits you have not saved.

From that game, the ☰ debug menu's **✎ edit this scene** switches straight back
to the editor — same window, no reload, and the edit camera picks up the pose
the player was looking from. **✕ exit** returns to the game WITH the scene as
the editor has it — you were editing it, and half the time you saved it, so
handing back the json the page booted with would show you your own edit
disappearing on the way out. The pair is one loop you can ride all session:

```
game  --(☰ debug ▸ ✎ edit this scene)-->  editor
editor --(▶ play ▸ ⤢ run as the game)-->  game
```

What crosses is the AUTHORED scene, never the stepped tree: a running scene has
been moved by physics and spawned into by behaviors, and editing that would mean
editing wherever the simulation happened to be. Games hosting the editor
themselves (`createGame3D({ editor: … })`) get a **save** button only when they
pass `editor.save` — otherwise edits live as long as the session does.

**Saving keeps you in the editor.** It used to throw you out of it: the save
writes the scene through `incantoScenes()`'s `PUT /api/scene`, vite saw its own
watched file change, and a JSON module imported by `App.tsx` cannot be
hot-swapped — so vite full-page-RELOADED, the page came back as the game, and
the selection, the camera and the panel you were working in were gone. The
plugin now recognises a write it just made and lets the page be. A hand edit in
a text editor is news the page does not have, and still reloads.

**The handle you booted with does not survive the round trip.** ▶ builds a NEW
game, so after one ✎→▶ the `game` your app is holding refers to something that
no longer exists. It is inert rather than destructive — a stale `dispose()` and
a stale `openEditor()` both do nothing — but if your app keeps using the handle,
take the live one:

```ts
let game = await createGame3D({
  scene, canvas,
  debug: DEBUG,
  editor: DEBUG && { library: true, onReboot: (next) => { game = next; } },
});
```

## Animating a model from the inspector

Select a `ModelInstance3D` and everything about what it is DOING sits in one
**animation** card: the clip, whether it loops, how fast, and where in the clip
it starts. The clip list is the model's own embedded clips plus the scene's
animation assets, and 📚 pulls a clip-only GLB from the agent8 library
(`3d / animations`) — picking one declares the asset and writes the ref.

- **speed** −3…3. `1` is as authored, `0` freezes the pose, negative plays
  backwards. It retimes the clip already playing, so dragging it is smooth.
- **animationOffset** seconds into the clip. Changing it re-seeks live, which
  makes it a scrubber as well as a stagger control — give copies different
  offsets so a row of torches stops flickering in lockstep.
- The **upper-body layer** (a second clip on the upper body only) is folded away
  until you use it.

The edit viewport keeps ambient visuals moving, so all of this animates while
you are editing — you see the change, not a still frame.

## Painting TileMap2D levels

Select a `TileMap2D` node and the inspector grows a **🖌 paint tiles** tool:
toggle it on and left-clicks (and drags) on the canvas paint cells with the
active brush. The palette chips offer every char already used in `cells`, the
`legend` chars, and an eraser (`␡` = `.`); the small text box takes any other
single char (digits map straight to atlas tile indices, other chars need a
`legend` entry). The grid grows right/down automatically when you paint past
the edge — to grow left/up, move the node instead (cell (0,0) hangs on the
node origin). Merged solid colliders re-derive live as you paint. A whole
drag stroke is ONE undo step; toggle paint off (or select another node) to
get normal click-select back.

## Generate environments

The **✦ button** beside the add-node controls opens the Generate dialog — the
`incanto/env` generators inside the editor, driven by the same `GENERATORS`
catalog as the `incanto-env` CLI and filtered to the open scene's dimension
(3D: arena, terrain, maze; 2D: platforms2d, maze2d, dungeon2d). The param form is built from the catalog
metadata — numbers clamp to their min/max, option lists become dropdowns — so
a new generator needs zero editor changes. The seed starts random (↻ rerolls);
the same seed always generates the same level. **insert** runs the generator
and splices the subtree under the selected node (the root when nothing is
selected) through the normal validated path: sibling names uniquify, every
node gets a fresh uid, the whole insert is ONE undo step, and the new subtree
arrives selected in the tree. Esc / backdrop / ✕ dismiss the dialog.

## Embedding (host frontends / agent8 containers)

Run the CLI inside the container (`--host 0.0.0.0`), iframe the served URL — the same
flow as a build preview. **The iframe URL must carry the host's origin**:

```html
<iframe src="http://<container>:5179/?parentOrigin=https://your.app"></iframe>
```

Messages are posted ONLY to that pinned origin (never `*`); without the param an
embedded editor posts nothing. The page posts to its parent window:

```ts
{ type: 'incanto-editor:ready',  input, output, version }   // input/output null until a scene opens (project mode)
{ type: 'incanto-editor:open',   input, output }             // a scene was opened/switched (project mode)
{ type: 'incanto-editor:change', dirty }                    // Save-button state
{ type: 'incanto-editor:save',   input, output, data }      // data = full scene JSON
{ type: 'incanto-editor:error',  message }                  // loader/save errors
```

### Hearing a save from OUTSIDE the browser

A preview running in a container saves into that container, and the process
supervising it — the one that rebuilds, commits or syncs — is not in the page.
It has two ways to hear about it, and neither needs the iframe:

**1. stdout, zero configuration.** Every scene this dev server writes announces
itself on the terminal you are already tailing, as ONE line: a fixed `[incanto] `
prefix and a JSON object.

```
[incanto] {"event":"scene:saved","file":"src/game.scene.json","bytes":13284,"at":"2026-09-08T10:19:57.412Z"}
[incanto] {"event":"scene:created","file":"src/levels/arena.scene.json","bytes":214,"at":"…"}
```

`file` is project-relative with forward slashes on every OS, `bytes` is what was
written, `at` is ISO-8601. The line is printed only when the bytes LANDED — a
refused write (not a scene, would overwrite a non-scene) says nothing, so the
line means "there is something new on disk". Read the fields you know and ignore
the rest: more may be added, these will not change. Nothing to configure; a
project with `incantoScenes()` in its vite config already prints it.

Do not scrape vite's own `page reload <file>` line for this. It appears for a
HAND edit and deliberately does not for the editor's own save (that reload used
to throw you out of the editor mid-edit).

**2. `onSave`, in-process, with the scene itself.** When the host wants the JSON
rather than the path:

```ts
incantoScenes({
  onSave: ({ event, file, abs, json }) => {
    // event: 'scene:saved' | 'scene:created'
    void fetch('http://127.0.0.1:9000/scene-saved', {
      method: 'POST',
      body: JSON.stringify({ file, json }),
    });
  },
})
```

It runs after the write, and a throw from it is reported and swallowed — a
broken hook must not take the dev server down or silence the stdout line.

The page can never choose filesystem paths — `input`/`output` are fixed at launch;
the API only reads the input and writes the output. Saves are rejected (422, file
untouched) unless the body is scene-shaped (`format: 1`, `type: "scene"`, `name`,
`root`). On the default loopback bind, non-loopback `Host` headers are rejected
(DNS-rebinding defense) and cross-origin writes are refused; `--host 0.0.0.0`
trusts the surrounding network — use it only inside containers.

## Scope

The EDIT view freezes game time — nothing falls or fires until you press play;
only ambient visuals (model animations, particles, water, foliage sway) keep
moving. The PLAY view simulates everything engine-native but cannot execute the
game's TypeScript behaviors. Both dimensions have the W/E/R gizmos in the
viewport; 2D additionally has click-pick, body drag and wheel zoom-at-cursor. Runtime-injected textures (asset URLs
like `"GENERATED_AT_RUNTIME"`) render as a magenta checkerboard — position/size
stay visible; the real art appears in the running game.
