---
name: incanto-verifying-your-game
description: Verify an Incanto game WITHOUT a browser — headless scene validation (incanto-check), scripted gameplay runs with assertions (runScript), scene-state captures as text screenshots, and engine logs. Use after every scene/behavior edit, and whenever you need to prove a gameplay change actually works.
---

# Verifying your game (browserless)

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

You usually cannot SEE the game (no browser in most agent environments). The
scene state itself is your screenshot. The loop is: **edit → check → scripted
run → read the capture → fix**. Never hand a game back without steps 2–3.

**The four things to read before you call it done.** Three of them are new, and
each answers a failure that used to look like nothing at all:

| read this | when it is not clean | see |
| --- | --- | --- |
| `incanto-check` | the scene will not load, or renders black | §1, §5b |
| `stats().errors` | something threw and was skipped to keep the game alive | §6b |
| `assetErrors()` | a model/texture 404'd — the thing is simply not there | §6c |
| `framing` | the camera is pointed the wrong way, or nothing is lit | §6a |

A run that finishes is not a run that worked. `runScript()` fails the run on
BOTH halves of "the engine stayed alive": behaviours the loop quarantined after
a throw (`stats().errors`), and anything the engine itself reported as an
**error** — a sentence it wrote precisely because it chose to keep going. Each
distinct one is a failure quoting what was said, deduped, so a per-frame
complaint is one fault rather than six hundred. Measured on a boat whose
`Buoyancy` hung on a plain `Node` instead of the hull: the engine said
`needs a body it can push … it will never float` by name every frame, and the
run used to report `ok: true` while the boat sank through the sea. Warnings are
printed and do NOT fail; `framing` and `assetErrors()` you still have to ASK
for.

## 0. The whole ladder, one command: `incanto-verify`

```
$ bunx incanto verify                       # finds your scene AND your behaviours
· behaviours: src/behaviors.ts (found, not named)
✓ loads — the scene is legal and its assets resolve
    ! /Game/Chest: script Chest — target '%Ke' matches nothing
? plays — 8 runs played without reaching a win (4 lost, 4 ran out the clock)
? feels — 5 of 7 fired — silent: /Game/Boss/Roar, /Game/Boss/Boom
· agrees — not run — this scene has no `multiplayer` header
? draws — the dev server is running on :5173, but no page answered

passes what was measured — plays, draws not measured.
next: nothing here is broken — a win that takes skill or a sequence is out of reach of random play. Judge it with a scripted run: `bun run verify`
```
`--runs N` gives the `plays` rung more seeds (default 8). Eight see a one-in-eight event
64% of the time; `bun run sweep` asks the six shipped starters for sixteen, and a starter that
falls out of the world once in twelve runs is how the arena in tps-3d got its `Respawn`.


### `agrees` — do two clients hold the same values?

Skipped outright unless the scene has a `multiplayer` header. When it does,
`incanto multiplay` runs a real match headlessly, lets it QUIESCE, then reads
every key in the owner's `network.sync` on the sender and on each other
client's spawned copy and compares them — plus one client that joins AFTER
everything settles, which is the only observer that reads the authoritative
snapshot fresh.

```
✓ agrees — 8 replicated value(s) match across clients and a late joiner
           — over one in-memory server, not a live wire
✗ agrees — p2 has no `Skin.rotation` for p1 — the key path does not resolve on
           the spawned scene (a renamed child?), or it was never sent.
```

That trailing clause is not modesty, it is the measurement's boundary: one
in-memory server has no latency, no loss and no reordering, so this proves the
apply path and says nothing about a reconnect that never re-joins or a batch
dropped on a closed socket.

Runs the rungs below in order and says the ONE thing to do next. Three rules it
encodes so you do not have to remember them:

- a **failed** rung makes the ones above it meaningless, so only the first is
  worth reading — the rest are marked `·` skipped;
- a rung that PASSED can still have something to say. Anything its own tool
  warned about is printed under it with a `!` — `loads` runs `incanto-check`,
  and the ladder used to read only whether it passed and drop the warnings, so a
  scene the check describes as "It will render black" came back as
  `✓ loads — the scene is legal and its assets resolve` (LOCAL art is looked for on
  disk; REMOTE urls are asked with a HEAD request — a 404 drops the claim to
  `the scene is legal`, and a host that cannot be reached is a `note:`, never a
  failure);
- an **unmeasured** rung (`?`) is not a failure. "No dev server" means the
  question was never asked; treating that as a broken game sends you editing a
  scene that is fine;
- a scene that declares **no win** is not a scene that cannot be won. A
  walkabout has no end, and that is reported as unmeasured, not failed;
- but a **defect** fails the rung whatever the scene declares. `error` (a
  behaviour threw) and `fell` (the player left the world) are counted BEFORE
  anything else is decided:

  ```
  ✗ plays — 8 of 8 runs ended in a defect (8 fell out of the world)
  next: the player left the world with nothing catching them — give the level a
        floor, walls, or a respawn
  ```

  The routed answer to that last one is a `Respawn` node under the player
  (`incanto-behaviors-and-scripts.md`) — one line of JSON, no per-frame `y`
  check in your own code.

  This used to be the sentence `8 runs played without error` — printed for a
  scene with no declared win no matter what happened in it, including 8/8
  `fell`, and exit 0. The child `incanto-playtest` had just printed
  `✗ fell in 8/8`; the parent said the opposite of it.

`stuck` — the player went nowhere — is the third defect and the one that needs
a fair chance before it counts. It is only a failure when there WAS someone to
move and your behaviours were loaded. A scene with no character controller, no
`player` group and no node named `Player` reports `nothing here is drivable`,
and a run without `--behaviors` reports that instead: neither is a broken game.

`feels` is the sound-and-effects rung: it lists what the scene DECLARES against
what actually fired during the playtest. A game whose feedback is wired but
never triggered plays perfectly and feels dead, and every other rung calls it
healthy. Nothing firing at all is a FAILURE. Some nodes staying silent is
`unmeasured` (`?`) with the names listed — a silent emitter is either a broken
wire or a moment random play never reached, and the two look identical from
here. Reach it in a scripted run and this rung can tell you which. It ticks
(`✓`) only when every declared emitter fired.

`says` reads the running page's own log. Errors and failed assets fail it;
WARNINGS are carried under it with a `!` and it still passes — a camera whose
`follow` points at a node with no position, a `DayNight` with no sky to move, a
body with no collider. Those leave a game running and wrong, and they are the
lines worth reading before anything else on this list.

Exit 1 only when a rung actually failed. `--behaviors src/behaviors.ts` matters:
without it your game logic is stubbed and only the structure is played.

The sections below are the same rungs one at a time, for when you need the detail.

## 1. After EVERY scene edit: `incanto-check`

```bash
bunx incanto-check                                  # structure, assets, semantics
bunx incanto-check --behaviors src/behaviors.ts --strict-behaviors
```

`--strict-behaviors` makes a `script.name` that resolves nowhere a FAILURE
instead of a stub — and it needs `--behaviors` to know what you registered.
Without it, on any project with a behaviour of its own, it could only ever fail:
`Unknown behavior 'Hunter' … Did you forget registerBehavior?`

```bash
bunx incanto-check               # validates every *.scene.json under cwd
bunx incanto-check --json        # machine-readable (code + details.path/prop)
```

Runs every hard load-time check (unknown types/props, bad colliders, dangling
connections, undeclared signals, duplicate uids…) and names the offending node
path. Unregistered behaviors are stubbed — no TypeScript needed.
(`--strict-behaviors` fails on behavior names the ENGINE doesn't know;
incanto-check can't load your TS, so with custom behaviors skip the flag and
get code-aware validation from `incanto-play --behaviors` / `runScript`
instead.) Wire incanto-check as a `"check"` script in package.json and run
it before declaring anything done.

## 2. Play it from bash (incanto-play)

The interactive headless loop — inputs in, the screen out AS SCENE JSON:

```bash
bunx incanto-play src/game.scene.json --behaviors src/behaviors.ts <<'EOF'
vector move 1 0
step 1000
press jump
step 500
capture
logs
quit
EOF
```

Every response is one JSON line. `capture` returns the CURRENT state in the
exact *.scene.json format (positions, text, everything non-default) — read
it like a screenshot; `capture snap.json` writes it to disk for diffing.
Other commands: `release <action>`, `key KeyW down|up`, `mouse 0 down|up`,
`pointer <dx> <dy>`, `wheel <dy>`, `describe` (one line per node), `logs`
(your `this.log` output), `--commands file` to replay a script. Without
`--behaviors` scripts are stubbed (structure-only play); `--behaviors`
accepts your .ts directly. `--seed` makes runs reproducible. `--behaviors` registers every EXPORTED
`Behavior` subclass under its export name.

Script steps' `do:`/`assert:` callbacks receive the full RunContext —
`{ engine, scene, timeMs, getNode, capture }` — so closed-loop autopilots
work inside runScript: `do: (ctx) => ctx.engine.input.setActionVector('move',
chase(ctx), 0)`.

**A sound can be asked where it came from.** `engine.audio.recent()` entries
carry `pan`, `gain` and `distance` for spatial sounds, computed from the scene's
camera when nothing has rendered — so a game whose mechanic is a noise in the
dark is checkable (`examples/earshot-3d`).

**A HUD widget can be asked where it is.** Every headless run arms the camera's
projection (`engine.toScreen` / `toWorld`), so `UiWaypoint.screen()` answers
instead of returning null — a marker that pins to the screen edge when its
target is behind you is now something a harness can check.

**A two-player game is driven by two hands.** The bot finds every
`CharacterController2D`/`3D` in the scene and drives each one on the action set
it declares (`moveAction`, `jumpAction`), so a couch co-op game gets both
characters walking. It used to drive the first `vector2` action it found:
measured on a two-player scene, player one walked 17.3 m and player two walked
0.0 m, and the report said the game could never be won.

**Watching every frame: `onFrame`.** Steps fire at named times, which is the
wrong shape for anything continuous — the APEX of a jump, the CLOSEST a chaser
ever got, whether a shell cleared the wall between two named moments:

```ts
let apex = 0;
await runScript(scene, {
  durationMs: 4000,
  onFrame: (ctx) => {
    apex = Math.max(apex, (ctx.getNode('Ball') as { position: number[] }).position[1] ?? 0);
  },
});
```

It runs after every simulated frame with the live context, and a throw inside it
is a reported failure like a throw in a step. Before it existed every harness in
this repo wrote the same workaround — one `do:` that stepped the engine by hand
in a loop, which moves the clock out from under the script's own steps.

**Reaching nodes & state from a step (the gotchas a sim hit):**
- `ctx.getNode(path)` and `ctx.scene.root.getNode(path)` resolve from the ROOT.
  When the root node IS named `Game`, the root is `ctx.scene.root` (or
  `ctx.getNode('.')`) — `ctx.getNode('Game')` looks for a CHILD named `Game` and
  throws. Reach a child as `ctx.getNode('Player')`, a grandchild as
  `ctx.getNode('Player/Weapon')`.
- **Behavior state lives on `node.behavior`.** Read a Health's HP via
  `ctx.getNode('Player').behavior.current`; a ScoreKeeper's score via
  `ctx.scene.root.behavior.score`. The behavior's own methods (`addScore`,
  `damage`) are on `node.behavior` too.
- **Signals are events to CONNECT, not fields to read.** `allCleared`, `won`,
  `died` are emitted — listen with `ctx.scene.root.on('won', …)` inside a `do:`
  step (or assert the resulting STATE, e.g. `behavior.score`). There is no
  `behavior.allCleared` boolean.

Rapier may print a deprecated-parameters warning on stderr when physics
initializes headlessly — library-internal noise, safe to ignore.

Headless gotchas: `engine.start()` needs requestAnimationFrame (a browser) —
headless code always advances time with `engine.step()` (one fixed tick;
default 60/s, so 60 steps ≈ 1s). A `vector2` input action must declare ALL
FOUR direction arrays — use `[]` for directions you don't bind.

## 3. Prove gameplay with a scripted run (runScript)

`incanto/test` plays the game headlessly at a fixed timestep, driving input by
ACTION intent (the same actions your scene's `input{}` declares):

```ts
import { runScript } from 'incanto/test';
import { PlayerControl } from './src/behaviors';
import sceneJson from './src/game.scene.json';

const result = await runScript(sceneJson, {
  behaviors: { PlayerControl },        // real classes — props validate too
  durationMs: 3000,
  seed: 42,                            // same seed → identical run
  steps: [
    { atMs: 0,    vector: ['move', 1, 0] },          // hold right (y is DOWN: forward is [0, -1])
    { atMs: 1000, press: 'jump' },
    { atMs: 1100, release: 'jump' },
    { atMs: 1500, label: 'jump apex reached',
      assert: (ctx) => (ctx.getNode('Player') as never as { position: number[] }).position[1]! < 300 },
  ],
  snapshotEveryMs: 500,
});
console.log(result.describe());        // run report + final scene, one line per node
if (!result.ok) process.exit(1);       // failures carry atMs + label + message
```

**An assert fails on `false`, on a non-empty STRING, or on a throw.** The
string is used as the message, which is how a harness explains itself:

```ts
assert: (ctx) => {
  const lives = (ctx.getNode('Score').behavior as never as { lives: number }).lives;
  if (lives <= 0) return `the keep fell — ${lives} lives`;   // the failure, in words
  return null;                                               // nothing to say
}
```

`true`, `null`, `undefined` and `''` are passes. Anything else — a number, an
object — is recorded as a failure naming the contract, because a check that
cannot fail is not a check. (It used to read only `false` and a throw, so every
assert in the shape above passed by not being `false`.)

Run it with `bunx tsx verify.ts` (or inside a vitest test). Physics enables
automatically from the scene's `dimension`. `result.snapshots` holds periodic
captures; `result.logs` holds everything behaviors logged via `this.log`.
Steps that never execute (atMs beyond the run) are reported as failures —
an assert cannot "pass" by not running.

**A game that has ended does not advance.** `GameFlow` stops the clock
(`timeScale 0`) on a win or a loss, and a pause does the same; after that every
`ctx.engine.step()` is a no-op and every value you read is the last one before
it stopped. A harness that lost its player three times inside one `do:` then
measured `0.00` for everything after it. The report now names each `do:`/`assert:`
step that ran with the clock stopped (`! at 4000ms [measure apex]: the clock
was stopped …`, also `result.stalled`) — read that line before reading the
zeros. Measure before the game ends, or `restartScene` / read `Flow.state` in
the step.

Note: `validateScene`/`runScript` register node types and your `behaviors`
into the process-global registries (hot-replacing same names). In a test
suite, isolate with `clearRegistry()`/`clearBehaviors()` between files if
you register conflicting classes.

### Clicking, for a game the mouse plays

`press`/`release`/`vector`/`key` are all ACTIONS, and `Clickable` reads the mouse
BUTTON — so a match-3, a tower defense, a card game, a point-and-click, an RTS or
a flip puzzle needs one more verb:

```ts
steps: [
  { atMs: 100, click: 'Board/Tile3' },   // by node path — the usual way
  { atMs: 400, click: [300, 100] },      // or a raw point, in the scene's units
]
```

A click is a press and a release on two DIFFERENT frames — `clicked` fires on
release over the node the press started on — and that is handled for you. So is
the picker: headless there is no renderer and therefore no raycast, so a
**geometric** one is installed for the run, answering from the same world boxes
`framing()` measures. A game that installs a picker of its own keeps it — its
answer always wins.

2D coordinates are world pixels (the space every 2D `position` in the file is
already written in); 3D casts a ray from the scene's current camera. A `click`
naming a path that resolves to nothing is a FAILURE, not a click into space.

`geometricPicker(scene)` from `incanto/test` is the same thing on its own, for a
harness that wants to ask "what is under this point" directly.

**Click the THING, not one of its triangles.** Anything bigger than a cube is a
grouping node with meshes under it and the behaviour on the group — so
`click: 'Keep/Ballista'` aims at whatever that subtree DRAWS, and a click that
lands on a different part of the same clickable is not a miss. Both of those
used to be failures: the group answered `resolves to no node — nothing to point
at` (a grouping node has no framing entry of its own), and naming a child was
reported as a miss whenever the ray met its sibling first, while the click had
already gone through the real `Clickable` and fired it. A click is wrong when it
reaches a different HANDLER — a wall in the way still says so.

**A `click` naming a HUD widget PRESSES it** — `click: 'Hud/Shop/Buy'` is the
whole verb for a menu, a shop, a settings screen, an idle game or a card game.
There is no cursor involved: a widget lives in the DOM overlay, not the world,
so the click goes to what a MOUSE does to that widget — a button presses, a
toggle flips, a select advances, and a `UiDialogue` ADVANCES THE LINE without
answering a waiting choice, exactly as clicking the box does in a browser
(`advance()` refuses to skip choices). To answer one, do what a player does and
press the choice, or call `talk.choose(i)` from a `do:` step. Enter and the
pad's A are a different verb and DO answer.
A widget that is hidden — or that sits under a hidden `UiPanel` or a hidden
`HudLayer` — is a FAILURE naming it, because a player could not have pressed it
either.

**A drag needs the two edges apart**, and `click` puts them at one point:

```ts
steps: [
  { atMs: 100, at: [400, 300], mouse: 'down' },   // press, here
  { atMs: 200, at: [300, 380] },                  // and move while held
  { atMs: 300, mouse: 'up' },                     // let go, there
]
```

`at` on its own is a HOVER — the state a game shows for a cursor that has not
clicked yet. `button` picks a different one (0 left · 1 middle · 2 right) for
`mouse` and for `click` alike. Written with `click`, a drag-and-launch game
launches with a vector of `from - to` where both are the same point: **it fires
every shot and every shot goes nowhere**, and the run reports fine.

The aiming side is on the engine, not the renderer: a behaviour reads
`this.engine.pointerWorld()` (and `toWorld`/`toScreen`), and a `click` or `at`
step installs a geometric one for the run alongside the picker.

### Asking the game a question

An assert needs to READ something, and the readable thing is often neither a
prop nor a method. Every node type's **`Reads:`** line in
`incanto-node-reference.md` lists its read-only accessors — the ones that exist
for exactly this:

```ts
(ctx.getNode('Hud/Banner') as never as { showing: string }).showing   // '' when idle
(ctx.getNode('Talk') as never as { active: boolean }).active          // mid-dialogue?
(ctx.getNode('Gun').behavior as never as { liveCount: number }).liveCount
(ctx.getNode('Player').behavior as never as { isDead: boolean }).isDead
```

`banner.text` does not exist and reads as `undefined`, which an assert will
happily compare against and pass. Look the accessor up rather than guessing a
prop name.

## 4. Read the scene like a screenshot

```ts
import { captureScene, describeCapture } from 'incanto/test';
// `scene` is a loaded Scene (e.g. `ctx.scene` in a step, or `result.finalCapture`
// for the end state — runScript also returns `result.snapshots`).
console.log(describeCapture(captureScene(scene)));
// scene MyGame (2d)
// /Root Node2D
// /Root/Player CharacterBody2D script=PlayerControl position=[312,180] velocity=[180,0]
// /Root/UI/Score Label text="Score: 3"
```

One grep-able line per node, showing only NON-default props — position, text,
visibility, velocity. Assert against these instead of pixels.

## 5. Validate programmatically

```ts
import { validateScene } from 'incanto/test';
const res = validateScene(sceneJson);          // { ok } | { ok: false, error }
if (!res.ok) console.error(res.error.code, res.error.details); // details.path/prop/signal/validOptions
```

## 5b. What a green `incanto-check` now covers

`validateScene` / `incanto-check` hard-fail on all of these, so they never reach
a browser:

- **Every prop's type AND its enum values** — `"mesh": "crystal"` names what is
  valid instead of printing `ok` and throwing at boot. All 31 enum props.
- **`input{}`** — a declaration's shape (`"keys": "Space"` instead of
  `["Space"]`, an unknown `type`, a wrong `touch`). This used to be checked only
  by `Engine.setScene`, i.e. never headlessly.
- **`assets{}`** — every entry an object with a non-empty `url`.
- **`environment.preset`** — resolved only inside Renderer3D before, so a typo
  survived every check and died at boot.

And `auditScene` (the warnings `incanto-check` prints) gained the loudest
"why is my screen black" of all: **nothing lights this 3D scene** — no light
node, no `environment` sky/hdri/preset, no ambient intensity. Ambient defaults to
0, so such a scene renders nothing, throws nothing and logs nothing.

## 6. Catch broken edits at save time (vite)

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

Invalid `*.scene.json` saves keep the last good scene running and surface the
IncantoError in the terminal AND as a browser overlay.

The same plugin serves this project's scenes to the editor, so the one you switch
into from the running game (`☰ debug ▸ ✎ edit this scene`) can open, create and save
ANY scene in the project — not just the one the game booted with. `incantoScenes({
root })` bounds that to a directory other than vite's; nothing outside it is readable
or writable, and none of it exists in a build (`configureServer`).

## 6a. See what the camera sees — without a GPU

`describe()` tells you a gem is at `[4, 1, -2]`. It cannot tell you whether you
can SEE it, which is the question you actually had. `framing()` answers that,
from geometry alone — no renderer, no canvas, no browser:

```
> framing
camera /World/Player/Cam at [0, 2, 8] looking [0, -0.2, -0.97] up [0, 1, 0] fov 60
lit by: /World/Sun (DirectionalLight3D), environment.sky
3 on screen, 1 off screen, 2 behind the camera, 1 hidden (visible: false)
  onScreen  /World/Island (MeshInstance3D) [0, 0, 0] screen [0.02, -0.11] 8.2m
  behind    /World/Chest (ModelInstance3D) [0, 0, 40] 32m
  offscreen /World/Gem (MeshInstance3D) [80, 0, 0] screen [4.4, 0] 80.1m
  hidden    /World/Boss (ModelInstance3D) [0, 0, -12] 20.1m
  overlap   /World/Platform ∩ /World/Gem
```

Read it for the five visual failures that look identical from a node list:

- **`behind`** — the thing is fine, the camera has its back to it. Indistinguishable
  from "my model did not load" until you can see this line.
- **`hidden`** — `visible: false` on the node or on any ancestor. The renderer skips
  the whole subtree, so it is NOT in view; a screen drawing nothing used to report
  every one of them as in view. `NOTHING IS DRAWN` when that is all of them.
- **`up`** — which way is up on screen. Roll changes NOTHING else, so a camera rolled
  180° prints the same `at` and the same `looking` as an upright one; the report
  says `is UPSIDE DOWN` when `up.y` goes negative (a top-down camera measures
  `up.y = 0` and is left alone).
- **`lit by: NOTHING`** — the scene renders black. No error, no log, nothing wrong
  with any node.
- **`overlap`** — two declared colliders intersect: the gem is inside the platform.

Also `incanto-play`'s `framing` command, and `describeFraming(scene)` /
`framingText(report)` from `incanto/test` for the structured form. A node counts
as on screen when its `size` box (or collider) is, not only its origin — so a
200 m island whose origin sits off to the left is correctly `onScreen`.

**HUD widgets are counted separately** (`report.widgets`), because asking
whether the camera can see one is a category error — and because leaving them
out meant a shop screen made entirely of widgets and an empty scene file both
read `0/0 on screen`. A scene with no drawable node AND no widget now says
`nothing on screen`, and the gate treats it as broken: a scene that draws
nothing looks exactly like one that failed to load.

**A 2D scene gets a 2D report**, in pixels: the `viewport.design` window centred
on the current `Camera2D` and CLAMPED by its `limits`, which is the view the
renderer actually draws. Nothing is ever `behind` a 2D camera and nothing is
lit, so those two lines are absent rather than answered — read
`report.dimension` if you consume the structured form. A 2D scene with no
camera is not a failure: the documented view is `(0,0)`–`(design)`.

**For actual pixels**, turn on `preserveDrawingBuffer` — WebGL clears the buffer
the instant a frame composites, so `canvas.toDataURL()` and every screenshot
built on it come back BLANK without it:

```ts
createGame3D({ ..., preserveDrawingBuffer: true });   // createGame2D too
// or in the scene: "environment": { "rendering": { "preserveDrawingBuffer": true } }
```

Then drive the page with whatever browser tool you have. It costs a buffer copy
per frame, which is why it is off by default.

## 6b. A thrown error no longer stops the game

One exception used to end everything: `requestAnimationFrame` reschedules AFTER
your callback returns, so a throw in any behavior's `update` killed the loop
permanently — no message, `stats().running` still `true`, a game that just
stopped. It no longer can.

- The thing that threw is **reported once** (which node, which script, which
  phase) to `engine.log` AND the console, then **skipped**. Its children and
  every other node keep running.
- **Your SCRIPT and the engine's NODE are skipped separately.** A typo in a
  behavior attached to a `CharacterController3D` stops that behavior — the
  character still walks and the camera still turns. Only a throw from the node's
  own update quarantines the node.
- **A `connections[]` handler that throws takes down THAT WIRE, and nothing
  else.** The handler runs inside the emit, so its exception used to surface in
  whichever node emitted the signal — a `movementStateChanged → play` wire
  naming a clip the sheet does not have switched off the character controller,
  and the player stopped walking because an animation was missing. The wire is
  reported once (naming the handler, not the emitter), disconnected, and counted
  in `stats().errors`; every other wire on that signal keeps firing.
- `engine.stats().errors` counts everything swallowed to stay alive. **A game
  that looks fine with `errors: 3` is a broken game** — read `engine.log`.
- `engine.erroredNodes()` lists them; `engine.resumeErroredNodes()` puts them
  back after you fix the code.
- `runScript()` FAILS the run when the count is non-zero. "It finished" must not
  mean "nothing broke" — that is right for a player and wrong for a harness.

So the check after every edit is still `ok`, plus: **read `stats().errors`**.

## 6c. Engine diagnostics you can actually read

Every internal warning the engine produces — a GLB that 404'd, a body with no
collider, a retarget that dropped bones, an exception swallowed inside a physics
trigger handler — used to go to `console.*` and nowhere else, which is the one
channel a headless tool cannot read. The symptom was always the same: the model
simply never appears, and `logs` comes back empty.

They now go to **`engine.log` as well as the console**, so:

- `runScript().logs` contains them, and `describe()` prints them under the
  failures (`! warn: …`).
- The debug overlay's **Logs** panel shows them live.
- `game.assetErrors()` lists every asset that failed **with its reason and ref** —
  textures, models, AND sounds (an `AudioPlayer` whose `src` the browser cannot
  play), in 2D and 3D alike
  — the direct answer to "why is my model not there". The store itself is
  reachable as `renderer.assets` in both dimensions.

So the after-every-edit check is: `ok`, then **`stats().errors`**, then
**`assetErrors()`**.

## 7. See inside the RUNNING game (debug overlay)

```ts
// Default is OFF. Gate it to your dev server with an env var — never ship it on.
createGame2D({ ..., debug: import.meta.env.VITE_INCANTO_DEBUG === '1' });
```
```bash
VITE_INCANTO_DEBUG=1 bun run dev   # overlay on; plain `bun run dev` / prod build = off
```

A ☰ debug menu (top-left) toggles four draggable/resizable panels over the
live game: **Explorer** (the scene tree — click any node), **Inspector** (the
selected node's current state; numbers/strings/booleans/vectors editable
in-place, and an OBJECT prop like `collider` opens into a row per key with the
`shape` as a dropdown — picking a shape writes a complete valid collider, never
a box's `size` under a sphere's tag; bulky values like a 54-row `transforms`
fold behind `▸ Array(54)` and expand on click, one row per element), **Logs** (an `engine.log` tail with level filters and an optional
browser-console capture toggle), **Time** (see below) — plus **Stats**, an always-on-top chip pinned
top-right (`60 fps · 12.1 ms` / `nodes 34 · tris 18.2k · calls 21`, refreshed
~2×/s; the same numbers any code can read via `game.stats()`) — and
**Colliders**, a switch that draws every physics shape as light-green wireframe
in the game view. Manual boots:
`(await import('incanto/debug')).attachDebugOverlay(engine)`.

**Time** is the panel for anything that happens too fast to see. A slider
(0–2) and a number box — synced, and the box takes values past the slider's
ceiling — drive `engine.timeScale`, with `0.25× 0.5× 1× 2×` presets:

- **slow motion** is how you watch a hit land, a jump arc, a particle burst
- **⏸ Pause** freezes GAME time and nothing else. Rendering keeps running, so
  the camera still moves and the panels stay live — it is a pause, not a stop.
  Resume returns to the scale you paused at.
- **⏭ Next frame** pauses first, then advances exactly one fixed step (1/60 s
  by default) — the engine's clock included. Click it repeatedly to walk a
  collision, a state machine or a spawn one frame at a time.

`timeScale` is just `engine.timeScale`, so a behavior can do the same thing
(`hitStop()` in `incanto/gameplay` is this on a timer). A value that is not a
number is refused and negatives clamp to 0: the scale multiplies every dt in
the engine, so one NaN would take physics, timers and animation with it.

### `bunx incanto-logs` — what the game is SAYING

`incanto-frame` gets the pixels out of the browser; this gets the words. The
engine writes diagnostics for exactly this — and until they had a channel out,
they reached a human only, in the debug overlay's logs panel.

```
$ bunx incanto-logs
asset $characters/base never loaded: … responded with 404
[error] incanto: failed to load model '…/NO-SUCH-MODEL.glb'
82 nodes · frame rate not measurable — the page is hidden (a window covered by
another counts), and the browser throttles it. Bring it to the front
```

That game's grass, water and trees all rendered — `incanto-frame` called it
healthy (`subject fills 84.2%`) because they ARE there. Only the player was
missing. **Pixels and words answer different questions; a game can pass one and
fail the other**, which is why both are rungs.

It needs no render, so it answers from a page the browser has stopped drawing.
Exit 1 when something is wrong: an error logged, an error swallowed to keep the
game alive, or an asset that never loaded.

A repeated warning is folded into one line with a count (`×600`) — a game
shouting the same thing every frame has one problem, not six hundred.

### `bunx incanto-frame` — what is actually on screen

Works for **2D and 3D** games alike (as does `incanto-logs`, and `game.frame()`).
Both used to be answered by the 3D boot only, so a 2D preview that was open and
drawing reported "no page answered".

The rung above framing. `framing` PREDICTS what the camera should see from the
scene graph; this measures what the GPU drew — the only one that can catch a
black screen, a material that failed, or a shader drawing where it should not.

```
$ bunx incanto-frame
frame 1280×720 · luminance 0.62
subject 412×690 at 434,18 · fills 21.4% of frame
```

`subject` is what the frame is OF: everything that is not the background,
measured from the frame's own edges. It answers the question `black` and
`uniform` cannot — **is the thing IN the shot, and can you see it**:

```
nothing but background — the camera is pointed away from everything in the scene
```
```
the subject fills almost nothing (0.3%) — the camera is too far back, or what
you meant to see is not what got drawn
```
```
subject 1280×720 at 0,0 · fills 84.1% of frame · CLIPPED by the frame edge
```

```
$ bunx incanto-frame
BLACK SCREEN — nothing was drawn (no light, no camera, or nothing in view)
frame 1280×720 · luminance 0.00
```

Start your game's dev server and open the page first: **the pixels are in the
browser, not in the dev server.** You do not have to tell it where anything is —
it reads the ports that are actually listening, asks each one whether it is an
incanto dev server, and asks that page for a frame over vite's own HMR channel.

#### Drive it somewhere first

Every interesting state in a game is downstream of input, and the boot screen is
the only frame anything could capture before this:

```
$ bunx incanto-frame --do "vector move 0 1; step 3000; vector move 0 0" --out bridge.png
frame 1280×720 · luminance 0.68
subject fills 21.4% of frame
drove 180 frames before capturing
wrote bridge.png — open it, or read it
```

The same words `incanto-play` takes — `press`/`release ACTION`, `vector ACTION X Y`,
`key CODE down|up`, `pointer DX DY`, `step MS`, separated by semicolons or
newlines. `--do-file` reads a longer plan from a file. One vocabulary for the
headless gamepad and the live one.

**Pointing at things**, for the games that are played that way:

```
$ bunx incanto-frame --do "at %Mole; click; step 300" --out whacked.png
```

`at X Y` puts the cursor on a canvas pixel and `at <node path>` puts it wherever
that node is ON SCREEN — the page knows, and you cannot compute it from out
here. **A `click` before anything has aimed is refused**, because it would land
wherever the cursor was left, and a plan whose `at` failed would otherwise
report the aim false and the click true. Headless, `click` also answers with
what it HIT (`"hit": "/Game/Field/Hole11/Mole"`, or `null`) — the reason to aim
is to hit something, and from outside the process there is no way to ask. **`incanto-play` takes the same two words**, headless, installing a
geometric picker for them (there is no renderer to raycast with):

```
$ printf 'step 700\nat Field/Hole12/Mole\nclick\ndescribe\nquit\n' \
    | bunx incanto-play src/game.scene.json --behaviors src/behaviors.ts
{"ok":true,"cmd":"at","at":[560,291]}
{"ok":true,"cmd":"click","t":733}
… /Game/Hud/Score UiText text="Moles 1 / 12"
``` `click` (optionally `left`/`middle`/`right`) presses and releases where the
cursor is, with a frame in between so the game gets to show that it is being
pressed.

**A drag is `mouse` — press, move, let go**, because `click` is both edges at
one point and a slingshot, a selection box, a swipe and an item carried between
inventory slots are all "press here, move there, let go":

```
$ bunx incanto-frame --do "at 400 300; mouse left down; at 300 380; mouse left up; step 400"
```

`mouse` takes the same three words `click` does, and the same `0|1|2` the
`incanto-play` REPL takes. The language used to have only `pointer DX DY`, which is a delta —
the mouse-LOOK question — so an agent driving its own match-3, tower defense,
card game or point-and-click could reach no state at all.

`step` waits on the REAL clock: this is the running game, with its own loop, its
own physics and its own animation, and stepping it by hand would fight all three.

**`drove N frames` is the number to check.** A browser stops animating a tab it
considers hidden — including a window merely covered by another — and the inputs
then land on a game that never advances: the capture comes back UNCHANGED and
looks perfectly healthy. `drove 0 frames` is refused with an error rather than
handed over as a picture.

**A PLAIN capture works on a hidden page.** Only a DRIVE needs the window in
front. `captureFrame` waits ~120 ms for the game loop and then draws a frame
itself, exactly because a hidden tab gets none — so the pixels are current, the
world is simply paused. Measured, hiding every node from the console and
capturing again: 2D `luminance 0.09 → 0.00 BLACK SCREEN`, 3D `0.64 → 0.96,
subject 44.3% → 22.0%`.

That is the whole point for an agent: **your terminal is in front and the game
is behind it, and `draws` still answers.** The report says the world is paused,
and the `draws` rung carries that as a note under a pass.

#### Look at it

```
$ bunx incanto-frame --out shot.png
frame 1280×720 · luminance 0.62
subject 412×690 at 434,18 · fills 21.4% of frame
wrote shot.png — open it, or read it
```

`--out` writes the frame as a PNG (`--size N` sets the longest side, default
512). Read it like any other image. The numbers above are cheaper and are what
you should assert on, but when a report says a scene is fine and it plainly is
not, a picture settles it in one look — and there is still no browser to install
and nothing headless involved: the page that is already open does the drawing.

Without `--out` it writes **no files**. `--json` gives the full report, including a 16×9 grid of
mean cell colours — enough to answer *where* the frame changed, small enough to
read. From game code or the console, `game.frame()` returns the same object.

Three outcomes, each with its own fix:

| | |
| --- | --- |
| `no incanto dev server found` | start the dev server |
| `running on :5173, but no page is connected` | open the preview |
| a report | read it |

Exit code 1 on a black screen, so CI can gate on it.

**What one frame cannot tell you:** a 16×9 grid does not see a one-pixel seam,
and no report of a single frame ever could — averaged into a cell, a hairline is
a fraction of a level, and nothing distinguishes it from a thin rope in the
scene. Absolutely, it is undecidable.

Relatively, it is obvious.

### `--remember` / `--diff` — did my change do what I think it did

```
$ bunx incanto-frame --remember          # before
frame 1280×720 · luminance 0.62
remembered as "last"

...edit the shader...

$ bunx incanto-frame --diff              # after
frame 1280×720 · luminance 0.62
changed 0.9% of the frame · largest shift 74/255, mean 0.4
region x 636..660, y 0..720 — a vertical band
```

The single-frame report is **identical** across that change — same luminance,
same verdict. The comparison names it and says where.

**Pause the game first** (debug ☰ → Time → Pause), or you are measuring time
passing rather than your change; when more than 90% of the frame moved, the
output says so. Baselines live in the **dev server's memory** and die with it —
no file is written anywhere, and a restarted server has forgotten. Name them
(`--remember shader-work`, `--diff shader-work`) to keep more than one.

`--threshold N` (default 3) is how far a cell must move to count. Sensitivity is
a function of size: at 720p a one-pixel line covers 5% of a cell, so the default
catches it when it is ~60 levels from its surroundings — a dark seam on bright
water, comfortably. Drop to `--threshold 1` for fainter ones, and expect the
frame's own noise to start showing up.

For regressions ACROSS builds — where the browser, the window and the session
are all different — use the numbers instead (`incanto-feel`, conformance
baselines). They are diffable and they do not flap.

### Handing a person's eyes to the agent

Two menu entries for the moment you can see something wrong and the agent
cannot:

- **Copy scene JSON** — the whole scene on the clipboard, ready to paste into a
  conversation. The half a screenshot cannot carry.
- **Capture region** — drag a rectangle over the game, then adjust it: eight
  handles resize, the middle grabs and slides, and the running label shows the
  size of the image you will get. **Copy to clipboard** confirms; Enter does the
  same, Esc and **Cancel** get out. Nothing leaves the page until you say so.

The image comes from the renderer's own end-of-frame read, not
`canvas.toDataURL()`, which returns black once a frame has composited. The
selection is scaled by `devicePixelRatio`, so what you dragged is what you get.

Clipboard writes need a secure context (a dev server on localhost counts) and a
click, which the menu is. When a browser refuses, the overlay says so rather
than pretending.

The Inspector HOLDS STILL while you use it: it stops refreshing whenever an
input has focus or the pointer is inside the panel, and it keeps your scroll
position across refreshes. Live values resume the moment you move away.

**Colliders is the answer to "why did I stop in mid-air / fall through the
floor".** What you see is the shape data the solver holds — put it next to the
art and a collider that disagrees with its mesh is obvious in one glance.

One click cycles **off → all → selected** (the menu stays open while you cycle —
the item's label shows the state you landed on):

- `all` is every collider in the world. On a real map that means a terrain
  heightfield's grid over everything, which is why the third state exists.
- `selected` draws only what the Explorer has picked: that node's colliders, its
  descendants', or — if it owns none — the nearest BODY above it, so clicking a
  visual `MeshInstance3D` shows the collider it hangs under rather than nothing.
  A scatter field gives you all of its rows.

From code: `physics.debugDraw = true` and `physics.debugScope = node` (null for
everything).

**✎ edit this scene** is the last item in that menu, and it is the fastest loop
in the engine: the page you are playing in BECOMES the scene editor, from the
camera you were standing at — no reload, no second tab. Drag a boulder out of
the path, then **▶ play ▸ ⤢ run as the game** and the page is the game again,
running your edit. `game.openEditor()` is the same switch from code.

It follows `debug` (so a build with the overlay has it) and loads lazily, so a
game that never opens it never downloads it. `editor: false` opts out at
RUNTIME — the button is absent — and `editor: { save }` adds the editor's save
button, wired to your own writer:

```ts
createGame3D({
  ...,
  debug: import.meta.env.VITE_INCANTO_DEBUG === '1',
  editor: { save: (json) => fetch('/api/scene', { method: 'PUT', body: JSON.stringify(json) }) },
});
```

What crosses the switch is the AUTHORED scene JSON, not the live tree — physics
has moved the live one, and "edit this scene" must not mean "edit wherever the
simulation happened to be". Unsaved edits survive the round trip both ways; a
page reload is what drops them.

**Security — `debug` defaults to `false` and there is NO URL/query toggle**, so
a DEPLOYED build can never be switched on by an end user (no more `?incanto_debug=1`).
Enable it only from your own dev environment via the env-var gate above. The
overlay is a lazily-imported chunk — it stays out of the main bundle (no size
cost), but it still ships in the build, so that runtime `debug` flag is the only
thing gating it: keep it `false` (or dev-gated) in anything you publish.

## 9. Verify the thing you SHIP, not just the thing you run

Everything above measures a dev server. The artifact a player gets is
`bun run build` — a bundled, minified `dist/` with different asset paths and a
different module graph — and the boot path is the only code a BUILD adds over a
SCENE. Two of the nastiest defects this engine has had lived exactly there: a boot
file whose scene swap left the game with no physics, and a boot that threw
behind a loading overlay that never came down.

`incanto-frame` and `incanto-logs` ask a dev server, and the dev server asks the
page over vite's HMR channel. A build has no HMR, so they used to answer:

```
? draws — no incanto dev server found.
? says  — no incanto dev server found.
```

**`incanto-serve` closes that.** It serves your build as plain static files —
no bundler, no transform, byte-for-byte what you deploy — and adds only the
`/__incanto/*` endpoints the CLIs already speak, plus one inline script injected
into `index.html` at serve time. Nothing is written to disk, so what you measure
is what you ship.

```bash
bun run build                 # the artifact
bunx incanto-serve            # ./dist on :4180 (or: incanto-serve build --port 5050)
# open http://127.0.0.1:4180/ , then:
bunx incanto-verify           # draws and says now measure the BUILT game
bunx incanto-logs             # what the built page is complaining about
bunx incanto-frame --out shipped.png
```

**Ship-check list**, in the order things actually break:

1. **`bun run build` at all.** Dev-only code (`import.meta.env.DEV`, a plugin
   that is `apply: 'serve'`) can compile and then do nothing.
2. **Open the built page.** A blank screen with a console error is the common
   failure; `incanto-logs` reads it without you opening devtools.
3. **`incanto-verify` against `incanto-serve`.** `draws` catches an asset base
   path that survived dev and broke under a subdirectory deploy; `says` catches
   the 404s.
4. **A phone-sized window.** See `incanto-web-integration.md` — a HUD that is
   legible at 1280×800 can be 6 px tall at 390×844.
5. **A second load.** Saves, settings and unlocks come back; a private window
   answers `slots.persistent === false` (see `incanto-save-slots.md`).

The one thing `incanto-serve` is not is a production server: it is a local tool
for looking at a build, on `127.0.0.1` by default.

### The editor is in your build, even in a 2D game

`editor: false` is a runtime option and **cannot remove bytes from a bundle**.
The editor chunk is lazy — a game that never opens it never downloads it — but
it is in `dist/`, and it imports `incanto/3d`, which imports rapier3d. Measured
on a pristine `platformer-2d` scaffold:

```
2236503  assets/rapier-*.js    rapier3d      ← a 2D game can never execute this
1692278  assets/rapier-*.js    rapier2d      ← the one it uses
 471186  assets/3d-*.js        the 3D adapter
─────────
5979588  total
```

A build-time decision needs a build-time lever:

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

```
5979588  →  2769972 bytes     53.7% smaller, same game
```

Build only: the dev server keeps the editor, so `☰ debug ▸ ✎ edit this scene`
works the whole time you are making the game and is simply absent from what you
ship. Opening it in a trimmed build says so in one sentence rather than failing
obscurely. Reach for this when deploy size matters — a CDN, a jam upload, an
agent8 zip — and leave it off otherwise.

## 8. Debugging signals

- `this.log.info(...)` in behaviors — shows up in `runScript().logs` (and the
  browser console never has to be scraped).
- Determinism: draw randomness from `this.rng`, never `Math.random()`, and any
  seeded run reproduces exactly.
- A run that "does nothing": remember the first engine tick only primes the
  clock — `runScript` already handles this for you.

## Finding a node ON SCREEN (selection highlight)

Selecting a node in the dev overlay's Explorer draws its world-space
bounding box as an ORANGE outline in the running game (children included;
depth-test off, so it shows through walls). Extent-less nodes get a small
marker cube at their position. That is the fastest answer to "which thing
on screen IS this node?" — and the reverse of it: walk the tree clicking
rows until the orange box lands on the thing you're hunting.

## Semantic warnings (audit)

`incanto-check` now WARNS about scenes that load fine but play wrong: no
`current` camera, physics bodies without colliders, animated nodes frozen
inside a `static` subtree, HUD widgets outside a HudLayer. Programmatic:
`auditScene(json)` from `incanto` or `incanto/test` returns the warnings.

**Including a HUD line that names the wrong key.** `tps-3d`'s pause menu read
`Esc to resume · R to restart` for exactly three hours after `restart` was moved
off `KeyR` — the binding moved and the sentence about it did not, which is the
whole failure mode of any fact written down twice. The check is narrow on
purpose: it fires only when a line names both a key it recognises AND a word
that is a DECLARED ACTION in the same scene, so "Esc to resume" says nothing
(there is no `resume` action) and prose is left alone.

**Including one key bound to two actions.** `tps-3d` shipped `reload` and
`restart` both on `KeyR`: pressing R reloaded, and `restart` — which nothing
read — looked broken. Two actions on one key are legal, sometimes deliberate,
so this warns rather than refuses; the sibling question ("does anything read
this action at all?") is `input.unreadActions()`, in `incanto-playtesting.md`.

**Including a `Clickable` nobody listens to.** It produces `clicked`, `hovered`
and `unhovered` and nothing else, so attaching it and wiring none of them costs
a pick every frame and can never do anything — the likeliest mistake a mouse
game makes, and one with no error anywhere. A board of sixteen unwired tiles
used to print `1/1 scene(s) valid`.

```
warn: Game/A: Clickable produces nothing but 'clicked'/'hovered'/'unhovered',
      and no connection listens to any of them — so it can never do anything.
      Wire one, or listen in code with `node.on('clicked', …)`.
```

**Including a node path that points at nothing.** `Chase.target`,
`Camera2D.follow`, `Spawner.prefab`, `Joint3D.target`, `skinPath`, `terrain` —
these hold a path to another node, and a wrong one is SILENT: a connection that
dangles is a hard load error, but a prop resolves with `getNodeOrNull`, so the
scene opens and the enemy simply never chases.

```
warn: World/Enemy: Chase.target — '%Playerr' matches no node in this scene.
      The scene loads and the prop does nothing.
warn: World/Cam: follow — '/Level/Player' starts at 'Level', but the scene
      root is 'World' (or write '/root/…'). The scene loads and the prop does nothing.
```

An empty value is never reported — `""` is the default of most of these and
means "not set" (and `getNodeOrNull("")` returns null, so the guard you would
write for it is unnecessary). A behavior of YOUR OWN is reported too, once you
hand the checker the file that registers it: `incanto-check src --behaviors
src/behaviors.ts`. Without the flag it has no schema for your behaviours and
cannot know which of their props are paths — so pass it, the way every
scaffolded project's `check` script does.

## Deterministic replay (record once, regression-test forever)

The engine is fully deterministic under a seed + injected clock, so a
recording of (tick timestamps + input events) replays BIT-identically:

```ts
import { Engine, loadScene, replay, startRecording } from 'incanto';

// while playing (dev build):
const rec = startRecording(engine);
// ... play the level ...
save.set('replays/level1', rec.stop());

// in a headless test:
const engine = new Engine({ seed: SAME_SEED });
engine.setScene(loadScene(sceneJson, { engine }));
replay(engine, recording, { onTick(i) { /* mid-run asserts */ } });
expect(player.position).toEqual(expected); // exact, not approximate
```

Rules that make it hold (the engine's own rules anyway): use `engine.rng`
never Math.random, dt/`engine.time` never Date.now. Gamepads replay through
the ACTIONS they were bound to, not raw pad state.

### The same machinery is a REWIND

Determinism means a rewind needs no snapshot: cut the tape, load the scene
again, and re-run the ticks that are left. A crate that was mid-air comes back
mid-air, because it is the same simulation and not a recording of where things
were.

```ts
const tape = startRecording(game.engine);

function takeBack(seconds: number): void {
  const upTo = tape.rewind(tape.take().ticks.length - Math.round(seconds * 60));
  game.engine.setScene(loadScene(structuredClone(sceneJson), { engine: game.engine }));
  replay(game.engine, upTo);          // …and the tape keeps recording from the cut
}
```

- `tape.take()` reads the recording WITHOUT ending it — `stop()` detaches the
  taps, so reading used to mean stopping, and the next recording's tick indices
  restarted at zero.
- `tape.rewind(n)` drops everything after tick `n`, returns the tape up to it,
  and keeps recording from there — so a game can rewind, play on, and rewind
  again.
- `replay(engine, recording, { until })` stops where you say, for a scrub or a
  mid-run assertion.

Measured in `examples/back-3d`: a 150-tick prefix replayed into a freshly
loaded scene reproduces both crates and the walker to the millimetre.


## Sound: did the right thing sound?

Audio makes no sound in the VM, but the calls still happen — so the wiring is
checkable where the game can be run a thousand times:

```ts
session.engine.audio.clearLog();
hitTheEnemy();
session.step(200);
expect(session.engine.audio.countOf('hit')).toBe(1);
```

`engine.audio.recent()` is the last 200 sounds with their node path, bus and
time; `countOf(name)` counts one. A clip whose FILE is broken shows up in
`assetErrors()` instead. Full guide: `incanto-audio.md`.

## Speed: which part of the frame is expensive?

```ts
game.stats().phases;
// { fixedMs: 21.4, updateMs: 2.2, renderMs: 6.1, otherMs: 2.4 }
```

Physics, behaviors, the renderer, and everything else. The last one is where GC
and per-frame allocation hide, and it is the slice a screenshot can never show.
See `incanto-performance.md`.

## Sight: did the right thing SHOW?

The same question as sound, about the other half of a game's feedback. Nothing
renders in the VM, so shake, flash and freeze frames are no-ops and a particle
system that never fired sits at exactly the same coordinates as one that fired a
hundred times — `framing()` reports WHERE an emitter is, never whether it went
off.

```ts
session.engine.effects.clearLog();
smashTheCrystal();
session.step(200);
expect(session.engine.effects.countOf('explosion')).toBe(1);
```

`engine.effects.recent()` is the last 200 effects — `kind` (`burst` · `emit` ·
`shake` · `flash` · `hitstop`), the preset or colour, the node path, and how big.
`countFrom(path)` asks about one emitter, which is usually the wiring question.
A particle node's `aliveCount` answers the other one: is it running right now.

## Multiplayer: do the players end up in the SAME world?

Every rung above asks about one player. A multiplayer game's first question is
whether there are two of them in the same room at all — and when there are not,
nothing throws, nothing logs, and each browser looks perfectly fine on its own.

```ts
import { playMultiplayer, multiplayText } from 'incanto/test';
import { Server } from '../server/src/server';   // your real server class

const report = await playMultiplayer({
  scene: gameJson,
  server: Server,                                 // optional: runs YOUR rules
  scenes: { 'remote-player': remoteJson },
  players: 2,
  seconds: 5,
  drive: ({ engine, manager, account }, frame) => {
    engine.input.setActionVector('move', 1, 0);   // steer each client
  },
});
console.log(multiplayText(report));
```

```
multiplayer: 2 clients, 5s, room room1
  p1: sees [p2] spawned Remotes=1
  p2: sees [p1] spawned Remotes=1
  ✓ every client in one room, seeing the others
```

`report.problems` names the silent ones: clients in different rooms, a client
that never saw another account's state, a `NetworkSpawner` that materialised
nothing, frame errors per client. `report.roomState` is the shared state at the
end, so a `$roomTick` match clock is checkable too.

It runs the whole match on ONE in-memory server (`LocalGameServer`), so it also
pays the trap a hand-rolled harness has to know about: those server calls are
QUEUED, and a synchronous frame loop enqueues a thousand ticks that never run.

## The fifth question: is it a GAME?

The four signals above answer *will it load*, *did something throw*, *did the art
arrive* and *can the camera see it*. None of them answers whether the thing can
be played.

```bash
bunx incanto-playtest src/game.scene.json --runs 20 --seconds 60
```

A seeded bot plays it headlessly — reading the scene's own `input{}` for its
controls — and reports the win rate, destinations it could never reach, wires
that never fired, falls out of the world, and whether anything can hurt the
player at all. Failing runs come back as replays.

**`won`, `lost` and `unfinished` are all gameplay; `error`, `fell` and `stuck`
are defects.** A random bot cannot perform a sequence, so a quest or story game
reports `unfinished` in every run and that means nothing is wrong — judge those
with a scripted `runScript` harness and let this one tell you about crashes,
falls and dead hazards.

Read `incanto-playtesting.md` before shipping a level.


## Do not teleport the player in a verify script

`player.position = [...]` between beats proves the quest LOGIC and quietly
assumes the thing most likely to be broken: that the player can get there. That
assumption is the largest single bug class in level design, and a verify script
that makes it is testing everything except the level.

Walk instead. In a camera-relative rig (`view: "free"`), CALIBRATE rather than
guess the heading — push one direction, measure the world displacement it
produced, and rotate every later aim by the difference:

```ts
const before = at();
ctx.engine.input.setActionVector('move', 1, 0);
for (let i = 0; i < 20; i++) ctx.engine.step();
const inputToWorld = Math.atan2(at()[1] - before[1], at()[0] - before[0]);
// then aim: setActionVector('move', cos(want - inputToWorld), sin(want - inputToWorld))
```

`examples/village-quest-3d/verify.ts` does exactly this and fails with
"the player cannot reach it on foot" when it cannot.


## When node refuses your TypeScript

The bins' shebang is node, and node's type stripping **erases** types — it does
not transform. Three ordinary things it refuses, all of which vite and bun
compile without comment:

| what you wrote | what node says |
|---|---|
| `import scene from './game.scene.json'` | needs an import attribute of "type: json" |
| `import { quest } from './quest'` | Cannot find module …/quest |
| `constructor(private root: Node3D)` | TypeScript parameter property is not supported in strip-only mode |

**You do not have to change any of them.** Every bin that loads a `--behaviors`
file re-runs itself under bun when node refuses it, so the command just works.
If bun is not on PATH the message says which of the three it hit.

Prefix with `bunx --bun` to skip the round trip.
