---
name: incanto-playtesting
description: The engine plays your game and reports whether it works — bunx incanto-playtest runs seeded headless bot sessions and reports win rate, unreachable destinations, wires that never fire, falls out of the world, and whether anything can hurt the player. Failing runs come back as replays. Use after a level or game loop is in place, before handing the game back.
---

# Playtesting — is it a game?

`incanto-check` answers *will it load*. This answers *is it a game*.

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

```
20 runs × up to 60s

  ✓ reached "won" in 17/20   median 38s
  ✗ fell in 3/20 (last at y=-52.0)

  never reached (1 of 6):
    /Game/Vault/Chest

  never fired (2 of 9):
    /Game/Boss.died
    /Game/QuestLog.completed

  danger: the player took damage 0 times in 20 runs — nothing here can hurt you

  replays: .incanto/playtest/fell-seed7.json
```

Exits **1** when no run reached a win, so it gates CI.

## What it does

A bot plays your game, headlessly, with real physics and a seeded RNG. It reads
the scene's own `input{}` block for its controls, so it can play a game it has
never seen. It holds a heading for a while rather than flipping a coin per
frame, and steers toward the part of the world it has visited least — enough to
find a door, not enough to solve a puzzle.

No renderer is involved, so twenty minutes of simulated play takes a second or
two.

**Two buttons it will stop pressing.** The bot drives every declared button
action, then notices what a player would notice. If pressing one STOPS THE
CLOCK, that is a pause menu, and a run spent inside one measures nothing — it
lets go and never presses that button again. If pressing one replaces the scene
with THAT SAME SCENE, that is a restart, and it does not just waste the run, it
ends it: the bot is bound to the tree it started in. One run of the set pays for
that lesson and the rest play the game. Both are reported (`the bot stopped
pressing …`), both are OBSERVED rather than read off the words "pause" and
"restart" — your game may call them anything, or nothing — and neither is a
defect in your game.

The second one matters more than it sounds: mid-run retry is ordinary in puzzle,
racing and tower-defense games, and `GameFlow` restarts only from a terminal
state, so the games that reload mid-play are the ones that call `restartScene`
themselves. Every one of them used to report `8 runs played without reaching a
win (8 changed scene)` — and each run died a second in, which took the emitters,
the hazards and the destinations down with it.

## What it reports, and where each answer comes from

| line | source |
|---|---|
| `reached "won"` | `GameFlow` state `'won'`, or a `won` signal (e.g. `ScoreKeeper`) |
| `lost` | `GameFlow` `'gameover'`, a `lost` signal, or the player's `Health.died` |
| `fell` | the player left the world past `--fall-below` (default: 50 m under the spawn in 3D, 1000 px under it in 2D) |
| `error` | `stats().errors` went above zero — a behavior threw |
| `stuck` | the clock ran out AND the player never got more than 3 m (96 px) from its spawn — it is wedged, or nothing moves it |
| `swapped` | the game changed scene. The bot binds player, destinations and the win/lose oracle ONCE, from the tree it started in, so nothing after a `goToScene` is measured — the run stops there and says so. It is not a defect, and the destinations it never got to are reported as unmeasured rather than missed. Judge a multi-scene game with a scripted `runScript`. The bot's own KEYS are re-read on the swap: it used to hold the first scene's buttons into the second, and letting go of one the new scene never declared threw the whole playtest away. |
| `unfinished` | the clock ran out on a player that was getting around. Not a defect: a win that needs a SEQUENCE (talk to the NPC, then fetch, then return) is out of reach of a random walker, forever |
| `never reached` | destinations the bot never came within `--reach-radius` of (default 2 m in 3D, 32 px in 2D) — of the node's EXTENT, not its origin, so standing on a 30 m quay counts as reaching it. A node earns a place on this list by carrying a group, running a behaviour, or being a trigger volume; measured against origins, every tagged floor in every 3D game reported as unreached while the player stood on it |
| `never fired` | signals a `connections[]` entry listens to that never happened |
| `declared and never read` | actions in the scene's `input{}` that NOTHING asked about, in any run — a key the control list promises and no code implements. Measured as a read, not a press, so the bot pressing it does not clear it. Two shipped starters had one: `tps-3d` declared `restart` (on `KeyR`, already the reload key) and `MISSION FAILED` was a dead end you left with F5. An action polled only on a screen no run reached lands here honestly — read it, do not gate on it |
| `danger` | how many times the player's `Health` emitted `damaged` — and when that is ZERO, WHICH zero it is. A scene with no `DamageOnContact` AND no player-side `Health` is a walkabout and worth shouting about; one with a Health but no hits says so WITHOUT claiming the scene is harmless, because damage that comes from a behaviour calling `Health.damage()` has no hazard node to count and this cannot see it; a scene full of hazards that never landed one is a report about the BOT, which a real player would outrun less easily. Hazards are counted from the scene FILE, because a `Spawner` detaches its prefab at ready and a wave game's hazards are not in the tree yet. **Put the player's `Health` under the player** — on it or on a child, the way `star-survivor` composes `/Game/Player/Vitals` — or none of this counts it: the oracle asks whether the damaged node is player-SIDE. |

**The bot presses the HUD.** Every focusable widget a player can currently see —
`UiButton`, `UiToggle`, `UiSelect`, `UiSlider` — gets pressed occasionally,
through the same entry point Enter and the pad's A use. That is the whole
interface of a menu, a shop, a settings screen or an idle game, and it was on
none of the lists the bot searched: one built from the tarball reported
`nothing here is drivable … nothing to point at` and `✗ stuck in 2/2` about a
game that plays perfectly. A widget under a hidden panel is not pressed, because
a player could not press it either. `report.runs[].presses` counts them.

**The bot drags between SLOTS.** Every `draggable` widget gets picked up now and
then and dropped on a `dropTarget` that is not its own parent, through
`dropOnto` — the same entry point a harness uses, with the same rules as the
pointer path. An inventory is the one screen the HUD skill says a click cannot
express, and it was the one screen the bot could not touch: a potion bench whose
every verb is a drag reported `nothing here is drivable` and `✗ stuck in 6/6`,
and now reports `324 slot drag(s)` and which of its wires never fired.
`report.runs[].widgetDrags` counts them.

**The line names every channel it used**, not the first one that fired: a game
played by dragging AND by keys reports both counts, because an either/or ladder
printed the drags and said nothing about three hundred key presses on the same
run.

**The bot can use the MOUSE.** If your scene has `Clickable` nodes, it presses
and releases over them at random — through the real `Clickable`, the real
`mouseJustPressed` edge and your real connection, with a picker that answers
from the tree (the recipe under "Testing a mouse-driven game headlessly"). It
drove actions and nothing else before, so a tactics game, a tower defense and a
duel all reported the identical `? plays — 8 runs played without reaching a
win`. A scene with nothing clickable is untouched: it does not gain an
`engine.picker` it never had.

A random clicker still cannot finish a game that needs a SEQUENCE — select a
trooper, then a tile, then end the turn — and that is reported as unmeasured,
not failed.

**A wire on a HUD widget is visible now too.** The whole screen-space subtree was
skipped when the run collected signals — right for "never reached", which is
about walking, and it took every `pressed`, `changed` and `dropped` wire with
it, so the line that exists to say "this wire does nothing" could never say it
about a HUD.

**Nothing here is a guess about your design.** The scene already declares what
winning is; this only listens.

**2D and 3D are read in their own units.** A 2D scene is pixels with **+y
DOWN**, so "left the world" means y grew past the line and a jump is not a fall;
a 3D scene is metres with +y up. Both defaults scale to the character —
32 px next to a ~34 px sprite is the same reach as 2 m next to a 1.8 m rig.
Set `--fall-below` / `--reach-radius` yourself only when your level says
otherwise (a very tall 2D shaft, a tiny 3D prop).

## Destinations are what YOU marked

A node counts as somewhere to go when it **runs a behavior**, **carries a
group**, or **is a trigger volume** (`Area2D`/`Area3D`). Decoration is silent —
keying off the tree alone reported 150 unreached nodes on one village scene and
138 of them were roof beams.

So: if you want the playtester to care about something, give it a group.

```json
{ "name": "Chest", "type": "MeshInstance3D", "groups": ["loot"] }
```

## Reading the failures

**`never reached`** is the reachability question, and research on game QA puts
it as the single largest bug class: a place the level says you can go and the
geometry says you cannot. Check the jump height, the gap, the collider.

**`never fired`** means you drew a wire that does nothing. Either the signal is
unreachable or the connection is wrong.

**`silent`** names a declared emitter — an `AudioPlayer`, a particle node — that
never went off in any run. Emitters on a SPAWNER's prefab count: the template is
lifted out of the tree at load, so the check reads the scene FILE as well as the
loaded tree, and a wave of forty enemies is reported as its one authored
emitter, not forty.

**`made and still held`** counts the clones alive when the run ended, grouped by
the TEMPLATE they came from, with what the count was at the halfway mark:

```
· made and still held: 12 × /Game/Prefabs/Bolt (3 at halfway),
                        6 × /Game/Prefabs/Tower (3 at halfway)
```

Six towers from three is a player who kept building; twelve projectiles from
three is a projectile that never learned to stop. **Furniture holds steady; a
leak climbs** — and nothing in the engine can tell them apart, so both are
printed and the judgement is yours. It exists because a shipped tower defense
kept every bolt that MISSED alive forever (its `Lifetime` was asleep on the
prefab shelf) and no instrument said a word: the playtest was green, the audit
was green, the harness was green, every rung of the ladder was green.

**`nothing here can hurt you`** is a design report, not an error. Sometimes it
is exactly right (a walking simulator). Usually it means the hazards are not
wired up.

**`!` lines** are what the ENGINE warned about during the runs — a camera whose
`follow` resolves to a node with no position, a `DayNight` with no sky, a body
with no collider. They are not failures and they do not change the exit code;
they are the things that leave a game running and wrong, and they are usually
the first thing to read. One line per distinct warning, however many times it
fired.

**`error`** prints the throw under it — the behavior, the node and the
exception, in the engine's own words:

```
  ✗ error in 8/8
      behavior 'Boom' on /Game/Player/Sword (Node2D) threw in update — THE
      SCRIPT is now skipped … Error: the sword script is broken
```

A script that throws in `update` is quarantined (the node keeps running, its
script does not), so everything downstream of it simply stops happening and
every other line of the report describes the silence rather than the cause.
Read this one first. `incanto-verify` carries the same lines under its `plays`
rung.

**`stuck` in every run** means the player is not going anywhere: wedged in
geometry, spawned inside a collider, or missing the input map that moves it.
That is a real bug and worth chasing.

**`unfinished` in every run** is the normal report for a quest or story game
and means nothing is wrong. Random play cannot perform a sequence. Judge those
games with a SCRIPTED harness — `runScript` from `incanto/test`, which every
template's `verify.ts` is built on — and let the playtest tell you about
crashes, falls and dead hazards instead. `incanto-verify` marks the rung
`unmeasured` rather than failed for exactly this reason.

(These were one word until 0.58: `stuck` was the default verdict, so a quest
game that visited every landmark and fired two dozen signals was reported the
same as a player stuck in a wall.)

## Replays

A run that did not win is written out. Because the engine is deterministic
(seeded RNG, injected clock), a replay reproduces that run **bit-identically** —
feed the file straight back in, with the seed the run used:

```bash
bunx incanto-play src/game.scene.json --seed 1 --commands .incanto/playtest/fell-seed1.json
#  {"ok":true,"cmd":"replay","frames":804,"events":963}
#  … then the scene as it ended: /Game/Player position=[28.9, -49.46, …]
```

`--commands` takes either a text command script or a replay file and tells them
apart by content. The seed matters: the world's own randomness (spawners,
wandering AI) comes from it, and only the PLAYER's input is in the recording.

Then read where it ended. The player above left a walled 40×40 arena at
`x = 28.9` before falling — a thing you cannot learn from "fell in 1/4" and can
read straight off the replay.

One file per *kind* of failure — twenty identical "stuck" replays teach nothing
the first one does not. `unfinished` runs write no replay at all: a replay is
for reproducing a failure, and eight of them per verify bury the one that is
real.

## Your behaviors

Without `--behaviors`, unregistered scripts are stubbed: the structure plays and
your game logic does not, which is fine for a reachability pass. To test the
real thing:

```bash
bunx incanto-playtest src/game.scene.json --behaviors src/behaviors.ts
```

**More than one file.** Behaviours split as a game grows — bosses in `boss.ts`,
the scene table in `scenes.ts` — so comma-separate them or repeat the flag,
rather than writing a barrel that re-exports everything for the CLI's benefit:

```bash
bunx incanto-playtest src/game.scene.json --behaviors src/behaviors.ts,src/boss.ts
bunx incanto-verify   src/game.scene.json --behaviors src/behaviors.ts --behaviors src/boss.ts
```

Every bin that takes `--behaviors` accepts both forms: `check`, `playtest`,
`verify`, `feel`, `play`, `multiplay`.

## From TypeScript

```ts
import { playtest, playtestText, failingReplays } from 'incanto/test';

const report = await playtest(sceneJson, { runs: 20, seconds: 60 });
console.log(playtestText(report));
```

`report.runs` carries per-run outcome, timing, reached paths, fired signals,
damage and the replay. `report.inertActions` is the dead-control list above.

The same question, without a playtest — after any run, scripted or hand-driven:

```ts
game.engine.input.unreadActions();   // ['restart'] — declared, nothing read it
```

## Pairs with the feel report

`incanto-playtest` answers *can it be finished*. `bunx incanto-feel` answers
*how does it move* — jump apex, turnaround, and the coyote/jump-buffer windows
measured rather than read. A level whose apex is 0.26 u and whose gaps are 2 u
apart shows up here as `never reached` and there as the reason why. See
`incanto-game-feel.md`.

## What it is NOT

It does not know whether your game is fun. It reports reachability,
completability, coverage and danger. Taste is still yours.

## Before you hand a game back

```bash
bunx incanto-check src/*.scene.json      # will it load
bunx incanto-playtest src/game.scene.json --runs 10 --seconds 45   # is it a game
```
