incanto 0.69.0 → 0.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/bin/_behaviors-loader.mjs +22 -0
  2. package/bin/_read-json.mjs +28 -0
  3. package/bin/incanto-assets.mjs +5 -4
  4. package/bin/incanto-check.mjs +58 -31
  5. package/bin/incanto-editor.mjs +128 -5
  6. package/bin/incanto-env.mjs +3 -2
  7. package/bin/incanto-feel.mjs +24 -13
  8. package/bin/incanto-frame.mjs +8 -1
  9. package/bin/incanto-multiplay.mjs +11 -9
  10. package/bin/incanto-new.mjs +105 -10
  11. package/bin/incanto-play.mjs +158 -16
  12. package/bin/incanto-playtest.mjs +66 -23
  13. package/bin/incanto-skills.mjs +14 -2
  14. package/bin/incanto-verify.mjs +125 -44
  15. package/bin/incanto.mjs +3 -2
  16. package/dist/2d.d.ts +281 -34
  17. package/dist/2d.js +4 -4
  18. package/dist/3d.d.ts +157 -9
  19. package/dist/3d.js +7 -7
  20. package/dist/{agent8-CvsfVskX.js → agent8-CmNF01gA.js} +61 -8
  21. package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-DaMxqfNE.d.ts} +2 -2
  22. package/dist/{behavior-rm8vVzu_.d.ts → behavior-DZExDn9o.d.ts} +744 -46
  23. package/dist/{create-game-CwxEDlCm.js → create-game-Bwvh6q8A.js} +142 -59
  24. package/dist/{create-game-CvJ1wSRP.js → create-game-C7ffQWW7.js} +97 -47
  25. package/dist/debug.d.ts +1 -1
  26. package/dist/debug.js +2 -3
  27. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  28. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  29. package/dist/editor.js +1065 -863
  30. package/dist/env.d.ts +1 -1
  31. package/dist/env.js +5 -3
  32. package/dist/{environment-presets-BO-7XoFV.js → environment-presets-D6Q5BxeE.js} +298 -45
  33. package/dist/{gameplay-D1KixWK8.js → gameplay-BfHkuzVb.js} +809 -211
  34. package/dist/gameplay.d.ts +187 -7
  35. package/dist/gameplay.js +1 -1
  36. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  37. package/dist/index.d.ts +156 -13
  38. package/dist/index.js +10 -12
  39. package/dist/json-CfTjpvW8.js +67 -0
  40. package/dist/{loader-DadV2QCT.d.ts → loader-Cff09LMm.d.ts} +2 -2
  41. package/dist/net.d.ts +27 -3
  42. package/dist/net.js +2 -2
  43. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  44. package/dist/{physics-2d-CF5yyjbj.js → physics-2d-CE0Qvy3V.js} +136 -11
  45. package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CpH-2gn5.js} +104 -23
  46. package/dist/{teardown-CMqFVxO9.js → picking-CQJ_PJKh.js} +103 -12
  47. package/dist/react.d.ts +2 -2
  48. package/dist/react.js +2 -2
  49. package/dist/{register-mmfugpaQ.js → register-6DYnKZcy.js} +546 -817
  50. package/dist/{register-DbVArsns.js → register-Bkk0wSDB.js} +332 -23
  51. package/dist/{replay-BEHLDcQV.js → replay-DjAkAzMq.js} +224 -14
  52. package/dist/{replay-BQfkX4x7.d.ts → replay-Dmw-PKQu.d.ts} +20 -3
  53. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  54. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  55. package/dist/{loader-DZE7B07H.js → save-slots-BXVg148r.js} +4570 -2322
  56. package/dist/{split-screen-Cg76MCV5.d.ts → split-screen--k-XpBjr.d.ts} +36 -4
  57. package/dist/{split-screen-CauMwu6y.js → split-screen-PL78oVXP.js} +159 -26
  58. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  59. package/dist/{src-CYEu-_S9.js → src-Cxfiv1Hg.js} +2 -17
  60. package/dist/test-iHYVUcDK.js +4036 -0
  61. package/dist/test.d.ts +531 -30
  62. package/dist/test.js +3 -3
  63. package/dist/touch-BnCyPA0G.js +519 -0
  64. package/dist/vite.d.ts +20 -37
  65. package/dist/vite.js +176 -6
  66. package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
  67. package/editor/assets/agent8-D0MS174y.js +1 -0
  68. package/editor/assets/{debug-CMULinBW.js → debug-BnXkKuYu.js} +2 -2
  69. package/editor/assets/index-CIu3uc3l.js +11046 -0
  70. package/editor/index.html +1 -1
  71. package/package.json +4 -14
  72. package/schemas/scene.schema.json +30 -3
  73. package/skills/incanto-3d-character.md +14 -1
  74. package/skills/incanto-3d-models.md +12 -0
  75. package/skills/incanto-assets.md +9 -1
  76. package/skills/incanto-audio.md +8 -2
  77. package/skills/incanto-behaviors-and-scripts.md +83 -6
  78. package/skills/incanto-building-2d-games.md +77 -8
  79. package/skills/incanto-building-3d-games.md +58 -4
  80. package/skills/incanto-editor.md +46 -7
  81. package/skills/incanto-environment.md +19 -1
  82. package/skills/incanto-game-feel.md +70 -0
  83. package/skills/incanto-gameplay-behaviors.md +109 -19
  84. package/skills/incanto-hud.md +128 -7
  85. package/skills/incanto-localization.md +13 -5
  86. package/skills/incanto-multiplayer.md +83 -3
  87. package/skills/incanto-node-reference.md +222 -58
  88. package/skills/incanto-performance.md +52 -0
  89. package/skills/incanto-physics-and-input.md +123 -24
  90. package/skills/incanto-playtesting.md +78 -2
  91. package/skills/incanto-save-slots.md +161 -6
  92. package/skills/incanto-scene-json-authoring.md +69 -12
  93. package/skills/incanto-verifying-your-game.md +196 -10
  94. package/skills/incanto-web-integration.md +28 -0
  95. package/skills/incanto-your-first-game.md +3 -1
  96. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  97. package/templates-app/beacon-isle-3d/package.json +2 -2
  98. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  99. package/templates-app/beacon-isle-3d/src/game.scene.json +103 -378
  100. package/templates-app/beacon-isle-3d/src/main.ts +11 -1
  101. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  102. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  103. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  104. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  105. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  106. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  107. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  108. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  109. package/templates-app/molehill-2d/index.html +73 -0
  110. package/templates-app/molehill-2d/package.json +23 -0
  111. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  112. package/templates-app/molehill-2d/src/game.scene.json +1255 -0
  113. package/templates-app/molehill-2d/src/main.ts +41 -0
  114. package/templates-app/molehill-2d/tsconfig.json +13 -0
  115. package/templates-app/molehill-2d/verify.ts +247 -0
  116. package/templates-app/molehill-2d/vite.config.ts +12 -0
  117. package/templates-app/platformer-2d/package.json +2 -2
  118. package/templates-app/platformer-2d/src/game.scene.json +128 -600
  119. package/templates-app/platformer-2d/src/main.ts +11 -1
  120. package/templates-app/platformer-2d/tsconfig.json +1 -1
  121. package/templates-app/star-survivor/package.json +2 -2
  122. package/templates-app/star-survivor/src/game.scene.json +41 -195
  123. package/templates-app/star-survivor/src/main.ts +11 -1
  124. package/templates-app/star-survivor/tsconfig.json +1 -1
  125. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  126. package/templates-app/tps-3d/package.json +2 -2
  127. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  128. package/templates-app/tps-3d/src/game.scene.json +78 -217
  129. package/templates-app/tps-3d/src/main.ts +13 -2
  130. package/templates-app/tps-3d/tsconfig.json +1 -1
  131. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  132. package/templates-app/village-quest-3d/package.json +2 -2
  133. package/templates-app/village-quest-3d/src/behaviors.ts +21 -0
  134. package/templates-app/village-quest-3d/src/grove.scene.json +54 -221
  135. package/templates-app/village-quest-3d/src/main.ts +11 -1
  136. package/templates-app/village-quest-3d/src/village.scene.json +199 -838
  137. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  138. package/templates-app/village-quest-3d/verify.ts +14 -1
  139. package/dist/duplicate-Dj4L-5AA.js +0 -22
  140. package/dist/json-CwwhxQgb.js +0 -36
  141. package/dist/registry-CF70EArN.js +0 -212
  142. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  143. package/dist/test-D6ISmuFE.js +0 -2383
  144. package/dist/touch-DEAmqGdf.js +0 -225
  145. package/editor/assets/agent8-BDvaZMQJ.js +0 -1
  146. package/editor/assets/index-CZIRr5EL.js +0 -10958
@@ -324,3 +324,55 @@ rendered a five-pass frame at 3.3 ms where `low` rendered one pass at 1.24 ms
324
324
  A tier is also REVERSIBLE now: it is applied against the scene FILE rather than
325
325
  against whatever the last tier left behind, so `low` → `high` restores exactly
326
326
  what you authored instead of leaving it stripped.
327
+
328
+ ## How big a game actually is, and the chunk named after the wrong thing
329
+
330
+ Measured on all six templates, scaffolded from the packed tarball and built:
331
+
332
+ ```
333
+ eager on disk
334
+ molehill-2d 967 KB 5.8 MB
335
+ platformer-2d 981 KB 5.9 MB
336
+ star-survivor 962 KB 5.9 MB
337
+ beacon-isle-3d 1414 KB 5.8 MB
338
+ tps-3d 1376 KB 5.8 MB
339
+ village-quest-3d 1469 KB 5.8 MB
340
+ ```
341
+
342
+ **`dist/` is six megabytes and a player downloads one.** The rest is lazy: the
343
+ Rapier wasm builds (2D and 3D, ~4.5 MB together) load only when a scene has
344
+ physics bodies, and the editor chunk only if someone opens it. Read the
345
+ `<script>` and `<link rel=modulepreload>` tags in `dist/index.html` for what
346
+ actually loads — `du -sh dist` answers a question nobody asked.
347
+
348
+ **The vendor chunk is named after whichever module the bundler happened to pick,
349
+ and it is not the one you think.** In every one of those builds the ~790 KB
350
+ chunk is called `quiet-rapier-*.js` and contains **three.js** — no Rapier at
351
+ all. A size audit that reads names concludes the physics engine is eager in a
352
+ mouse game with no bodies in it. Read the contents:
353
+
354
+ ```bash
355
+ grep -c WebGLRenderer dist/assets/quiet-rapier-*.js # three.js lives here
356
+ ```
357
+
358
+ Name them yourself if it matters to you — it changes nothing about what loads,
359
+ only what the file is called:
360
+
361
+ ```ts
362
+ export default defineConfig({
363
+ build: {
364
+ rollupOptions: {
365
+ output: {
366
+ manualChunks: (id) =>
367
+ id.includes('node_modules/three') ? 'three'
368
+ : id.includes('@dimforge/rapier') ? 'rapier'
369
+ : undefined,
370
+ },
371
+ },
372
+ },
373
+ });
374
+ ```
375
+
376
+ three is eager in a 2D game too, and that is not a bug: `Renderer2D` draws
377
+ through the same WebGL renderer as the 3D one — shared-PlaneGeometry meshes and
378
+ an orthographic camera. It is the price of one renderer instead of two.
@@ -80,14 +80,22 @@ mesh or one model and warns when the body carries more.
80
80
  - **`StaticBody2D/3D`** — immovable (ground, walls). Props: `collider`.
81
81
  - **`RigidBody2D/3D`** — simulated. Props: `collider`, `mass 1`, `gravityScale 1`,
82
82
  `fixedRotation false`, `friction 0.5`, `restitution 0`, `linearVelocity` (write to launch,
83
- read back every step).
83
+ read back every step; `velocity` is an alias for it). 3D also has
84
+ `angularVelocity`. Every one of them is LIVE — write it mid-run and the solver
85
+ picks it up on the next step.
84
86
  - **`Area2D/3D`** — sensor. Emits `triggerEnter(other)` / `triggerExit(other)`. Never blocks
85
87
  movement. Solid bodies emit the same signals on real contact (one mental model).
86
88
  Areas overlapping OTHER Areas fire too (e.g. a weapon-hitbox Area over an
87
89
  enemy-hitbox Area) — neither side needs to be a Body.
88
90
  - **`CharacterBody2D/3D`** — kinematic character (Rapier KCC). Props: `collider`
89
- (capsule recommended), `velocity`, `stickToGround true`, `slopeLimitDeg 45`.
90
- API: `moveAndSlide()` (call from `fixedUpdate`), `isOnFloor()`.
91
+ (capsule recommended), `velocity`, `stickToGround true`, `slopeLimitDeg 45`,
92
+ `stepHeight` (3D `0.35` m / 2D `35` px — see below).
93
+ API: `moveAndSlide()` (call from `fixedUpdate`), `isOnFloor()`,
94
+ **`isOnWall()`** and **`isOnCeiling()`**. Which wall is answered in the shape
95
+ each dimension has: 2D's `wallSide()` is `-1` left / `+1` right, 3D's
96
+ `wallNormal()` is the wall's normal pointing away from it — the direction a
97
+ wall jump pushes. Without them a wall jump or a wall slide is not awkward to
98
+ write, it is impossible: there is nothing to ask.
91
99
 
92
100
  ### `enabled` — a collider that is off for now
93
101
 
@@ -105,6 +113,13 @@ sword.enabled = true; // the active window of the swing
105
113
  sword.enabled = false; // …and done
106
114
  ```
107
115
 
116
+ **A collider's keys are CLOSED**: `shape`, `size`, `radius`, `height`, `offset`
117
+ and (2D) `oneWay` — plus `vertices`/`indices` for a 3D trimesh. Anything else is
118
+ a load error naming the nearest one, because
119
+ `{"shape": "circle", "radius": 8, "offest": [0, -24]}` used to load clean with
120
+ its hitbox 24 px from where it was meant to be. `friction`, `restitution` and
121
+ mass are props of the BODY, not of its collider.
122
+
108
123
  **Do not arm a hitbox by swapping `collider` in and out.** Replacing the
109
124
  collider prop tears the rigid body down and builds a new one — twice per swing —
110
125
  and a scene authored `"collider": {}` warns `has no collider — physics skips it`
@@ -169,9 +184,19 @@ Space/arrows; pass `'none'` to opt out). Keys typed into INPUT/TEXTAREA/SELECT/
169
184
  contenteditable are ignored entirely, so DOM UI overlays keep working.
170
185
  `engine.input.dispose()` detaches every attached source (keyboard and pointer).
171
186
 
172
- ⚠️ `justPressed` edges settle once per RENDER tick: on a dropped frame with multiple fixed
173
- steps, the edge is visible in every fixed step of that tick. Gate one-shot actions on a
174
- state change too (the jump example works because `isOnFloor()` flips after the first step).
187
+ **One press is ONE edge**, in `update` and in `fixedUpdate` alike the fixed pass has
188
+ its own edge view, drained after its first step, so a dropped frame carrying five steps
189
+ delivers the edge once and a 144 Hz tick that runs no fixed step at all does not lose it.
190
+ Same for `justReleased`. (Until 0.63 this warned that the edge repeated in every fixed
191
+ step of a slow frame; it has not since the fixed pass got its own edge sets, and nothing
192
+ noticed for two releases.)
193
+
194
+ ⚠️ What IS worth knowing: **a press injected from inside the frame lands on the NEXT
195
+ tick.** `pressAction`/`releaseAction` called from a `fixedUpdate`, an `update` or an
196
+ `engine.updated` handler — replay drivers, AI input, touch overlays — is invisible for
197
+ the rest of that tick and arrives at the next one, because an edge created after the
198
+ pass that reads it would be created and destroyed without anything seeing it. The HELD
199
+ state (`isPressed`) is immediate either way.
175
200
 
176
201
  ## Touch controls (mobile web)
177
202
 
@@ -307,11 +332,25 @@ body.velocity = [2, 0, 0]; // or set the velocity outright
307
332
 
308
333
  `applyImpulse` is what the character controller itself runs on. It is an
309
334
  IMPULSE, not a force: it changes velocity once, so a continuous push is one call
310
- per frame from `update(dt)` scaled by `dt`.
311
-
312
- Readable/writable body state: `velocity`, `mass`, `gravityScale`, `friction`,
313
- `restitution`, `fixedRotation`. There is no torque call and no `angularVelocity`
314
- yet a body's spin can be neither set nor read.
335
+ per frame from `update(dt)` scaled by `dt`. In 2D the impulse is in px·kg/s,
336
+ y-down, exactly like `linearVelocity`.
337
+
338
+ Readable/writable body state, in BOTH adapters: `velocity` (= `linearVelocity`),
339
+ `angularVelocity`, `mass`, `gravityScale`, `friction`, `restitution`. Read the
340
+ spin to see how fast a lever is swinging; write it to launch a spinning body.
341
+ 3D's is a 3-vector about the world axes, 2D's is the scalar rad/s about z with
342
+ the same sign as `rotation` (clockwise, because 2D is y-down). There is no
343
+ torque call in either. Writes land on the next step — these are live props, not
344
+ load-time constants.
345
+
346
+ `fixedRotation` is authored, not live: it locks the body's rotation when the
347
+ body is CREATED. Changing it mid-run does nothing, and a locked body's
348
+ `angularVelocity` is zero rather than whatever was authored.
349
+
350
+ > Four of these were once false, silently: `applyImpulse` existed only in
351
+ > 3D (a TypeError in 2D), `body.velocity = […]` wrote a stray field nothing read
352
+ > in both, `mass`/`friction`/`restitution` were read once at creation in both,
353
+ > and `gravityScale` was live in 3D and dead in 2D.
315
354
 
316
355
  ## What is inside this Area right now?
317
356
 
@@ -354,7 +393,12 @@ question you usually mean.
354
393
  only** — never their contact with the rest of the world. Defaulting a spring
355
394
  off drops a barrel roped to the floor straight through it, which is how this
356
395
  table was arrived at.
357
- 3. **`anchor`/`targetAnchor` are local offsets from each body's ORIGIN**,
396
+ 3. **A joint's two ends must be two DIFFERENT bodies.** It is a child of one and
397
+ points at the other, so `"target": ".."` — which reads like naming the body
398
+ it belongs to — is now a load-time error: Rapier accepts a body jointed to
399
+ itself and constrains nothing, and the rope fell exactly as if it were not
400
+ there.
401
+ 4. **`anchor`/`targetAnchor` are local offsets from each body's ORIGIN**,
358
402
  resolved against the bodies' positions at load. If the two disagree the solver
359
403
  snaps the body into place on the first frame, and a 4 cm typo is silent. Author
360
404
  them with arithmetic (generate the scene) rather than by hand.
@@ -380,19 +424,41 @@ Both runtimes expose the same query (2D in PIXELS y-down, 3D in meters):
380
424
 
381
425
  ```ts
382
426
  const hit = physics.castRay(origin, dir, maxLen, excludeBody?, { staticOnly?: true });
427
+ // hit: { distance, normal, node, point } — `point` is WHERE it landed, in the
428
+ // scene's own units. `dir` may be any length; the ray normalizes it, so
429
+ // `origin + dir * distance` by hand is wrong unless you normalize first.
383
430
  // → { distance, normal, node } | null (sensors never block rays)
384
431
  ```
385
432
 
433
+ Both also answer two questions about a body that the node props cannot:
434
+
435
+ ```ts
436
+ physics.velocityOf(body); // px/s (2D, y-down) or m/s (3D) — from the SOLVER
437
+ physics.massOf(body); // what the solver settled on, collider-derived
438
+ ```
439
+
440
+ `linearVelocity` on the node is written back once per step, AFTER the solve — so
441
+ a behavior reading it from inside `fixedUpdate`, which is where a collision
442
+ response belongs, sees the value from BEFORE the impact. That is exactly the
443
+ moment a game wants to know how hard it hit something. Scale a push by `massOf`
444
+ rather than by the authored `mass` prop, which the solver may not be using.
445
+
446
+ `dir` may be ANY length — `target - eye` is the usual spelling and is metres
447
+ long. It is normalized for you, so `distance` and `maxLen` are always plain
448
+ metres (2D: pixels), never multiples of the vector you passed. A zero-length
449
+ direction points nowhere and returns `null`.
450
+
386
451
  Exclude the shooter's own body when casting from inside it — a ray that starts
387
452
  inside its own collider hits itself at distance 0, which reads as "blocked" and
388
453
  is the reason a vision cone can come back permanently blind.
389
454
 
390
- 3D also has a THICK ray — a sphere sweep for probes where skimming matters
391
- (the camera boom, ledge feelers):
455
+ Both also have a THICK ray — a sphere sweep in 3D, a circle sweep in 2D — for
456
+ probes where skimming matters (the camera boom, ledge feelers, a shot that must
457
+ not thread a one-pixel gap between two floor tiles):
392
458
 
393
459
  ```ts
394
460
  const hit = physics.castSphere(origin, dir, radius, maxLen, excludeBody?, { staticOnly?: true });
395
- // → { distance, node } | null (distance = travel of the sphere CENTER)
461
+ // → { distance, node } | null (distance = travel of the CENTRE)
396
462
  ```
397
463
 
398
464
  ## Gamepad
@@ -440,12 +506,15 @@ the tree says, without your scene falling over while you look at it. Use it for
440
506
  any "show me the collision geometry" tool. Do NOT use it to freeze a running
441
507
  game — that is `engine.timeScale = 0`, which keeps the world coherent.
442
508
 
443
- ## A walking body climbs a step (`stepHeight`, 3D)
509
+ ## A walking body climbs a step (`stepHeight`, 2D **and** 3D)
444
510
 
445
- `CharacterBody3D.stepHeight` (default `0.35` m, `0` = off) is how high a ledge
446
- the body walks UP without jumping. It is Rapier's autostep, and **Rapier does not
447
- autostep unless it is asked** before 0.63 nothing asked, so any walking body
448
- was stopped dead by a curb.
511
+ `CharacterBody3D.stepHeight` (default `0.35` m) and `CharacterBody2D.stepHeight`
512
+ (default `35` px the same 0.35 m at the 2D world's scale of 100 px = 1 m,
513
+ which is why gravity defaults to 980) are how high a ledge the body walks UP
514
+ without jumping. `0` = off. It is Rapier's autostep, and **Rapier does not
515
+ autostep unless it is asked** — before 0.63 nothing asked in 3D, and for longer
516
+ still nothing asked in 2D at all, where a lip ONE PIXEL high stopped a walking body
517
+ dead.
449
518
 
450
519
  You will not see this on the player: `CharacterController3D` rides a hover spring
451
520
  rather than the character controller, so it floats over small ledges already.
@@ -522,6 +591,16 @@ game.pick(x, y); // the node under that pixel, or null
522
591
  A stretched canvas is handled: `pointerPosition()` scales client → canvas pixels,
523
592
  so the coordinates are the ones `pick()` wants.
524
593
 
594
+ **The pointer has to be attached, and now attaches itself.** `createGame2D`/
595
+ `createGame3D` turn it on when the scene contains a `Clickable` — the same
596
+ `'auto'` shape `physics` has always had — and they ask again on every scene
597
+ change, because a title screen you click into a level has no `Clickable` in it
598
+ (a `UiButton` is DOM) and the level is full of them. Pass `pointer: true` explicitly when a
599
+ behaviour of YOUR OWN reads `pointerPosition()`; the boot cannot see that. A
600
+ `Clickable` that finds no pointer (or no picker) for two seconds says so by
601
+ name instead of sitting there inert, which is what it used to do: no error, a
602
+ clean `incanto-check`, a clean audit, and a board that did nothing when clicked.
603
+
525
604
  From scene JSON, the **`Clickable`** behavior needs no code at all:
526
605
 
527
606
  ```json
@@ -531,7 +610,7 @@ From scene JSON, the **`Clickable`** behavior needs no code at all:
531
610
  | prop | default | |
532
611
  | --- | --- | --- |
533
612
  | `button` | `0` | 0 left · 1 middle · 2 right |
534
- | `maxDistance` | `0` | ignore clicks further than this (0 = any) |
613
+ | `maxDistance` | `0` | ignore clicks further than this **from the current camera** (0 = any) |
535
614
  | `enabled` | `true` | stop responding without detaching |
536
615
 
537
616
  Signals: **`clicked(node)`**, **`hovered(node)`**, **`unhovered(node)`** — wire
@@ -550,11 +629,31 @@ ends elsewhere is not a click, the way every button on every platform behaves),
550
629
  and a hit on a CHILD counts as a hit on the node — the raycast lands on the
551
630
  visual mesh, which is usually a child.
552
631
 
632
+ **A finger that lifts stops hovering.** `hovered`/`unhovered` follow the cursor,
633
+ and a mouse cursor stays where you left it — but a finger ceases to exist, so a
634
+ touch release clears the pointer at the end of that frame (after the click has
635
+ fired, not with it). Without that the last-tapped node stayed `hovering: true`
636
+ for the rest of the session: a permanently highlighted tile, mole or card on
637
+ every phone. Do not build a hover-only affordance — on touch there is no hover
638
+ before the tap, only after it, and only for a frame.
639
+
640
+ **`visible: false` is not clickable.** A pixel the frame does not draw has
641
+ nothing under it, so hiding a node (or any ancestor of it) takes it out of the
642
+ pick — which is how you disable a button, a dialogue choice or a mole down its
643
+ hole without detaching anything. Before 0.71 three's raycaster reported hidden
644
+ geometry and `pick()` handed it straight to `Clickable`.
645
+
553
646
  ### Testing a mouse-driven game headlessly
554
647
 
555
- There is no renderer and therefore no raycast, so `engine.picker` is null and
556
- `Clickable` is inert. That is not a dead end: give the engine a picker of your
557
- own and drive the cursor.
648
+ **Use `runScript`'s `click` step** `{ atMs: 100, click: 'Board/Tile3' }` which
649
+ installs a geometric picker, points the cursor and spreads the press and release
650
+ across the two frames `Clickable` needs. `incanto-playtest` clicks too, and its
651
+ report says how many clicks it landed. See `incanto-verifying-your-game.md`.
652
+
653
+ The raw recipe below is what that does, and is still what you want when the
654
+ cursor has to be somewhere a node is not. There is no renderer and therefore no
655
+ raycast, so `engine.picker` is null and `Clickable` is inert. That is not a dead
656
+ end: give the engine a picker of your own and drive the cursor.
558
657
 
559
658
  ```ts
560
659
  // A picker that answers from the tree instead of a GPU raycast.
@@ -51,12 +51,36 @@ two.
51
51
  | `fell` | the player left the world past `--fall-below` (default: 50 m under the spawn in 3D, 1000 px under it in 2D) |
52
52
  | `error` | `stats().errors` went above zero — a behavior threw |
53
53
  | `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 |
54
- | `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`. |
54
+ | `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. |
55
55
  | `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 |
56
56
  | `never reached` | destinations the bot never came within `--reach-radius` of (default 2 m in 3D, 32 px in 2D) |
57
57
  | `never fired` | signals a `connections[]` entry listens to that never happened |
58
+ | `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 |
58
59
  | `danger` | how many times the player's `Health` emitted `damaged` — and when that is ZERO, WHICH zero it is: a scene with no hazard wired at all is a walkabout and worth shouting about; a scene full of them 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. |
59
60
 
61
+ **The bot presses the HUD.** Every focusable widget a player can currently see —
62
+ `UiButton`, `UiToggle`, `UiSelect`, `UiSlider` — gets pressed occasionally,
63
+ through the same entry point Enter and the pad's A use. That is the whole
64
+ interface of a menu, a shop, a settings screen or an idle game, and it was on
65
+ none of the lists the bot searched: one built from the tarball reported
66
+ `nothing here is drivable … nothing to point at` and `✗ stuck in 2/2` about a
67
+ game that plays perfectly. A widget under a hidden panel is not pressed, because
68
+ a player could not press it either. `report.runs[].presses` counts them.
69
+
70
+ **The bot drags between SLOTS.** Every `draggable` widget gets picked up now and
71
+ then and dropped on a `dropTarget` that is not its own parent, through
72
+ `dropOnto` — the same entry point a harness uses, with the same rules as the
73
+ pointer path. An inventory is the one screen the HUD skill says a click cannot
74
+ express, and it was the one screen the bot could not touch: a potion bench whose
75
+ every verb is a drag reported `nothing here is drivable` and `✗ stuck in 6/6`,
76
+ and now reports `324 slot drag(s)` and which of its wires never fired.
77
+ `report.runs[].widgetDrags` counts them.
78
+
79
+ **The line names every channel it used**, not the first one that fired: a game
80
+ played by dragging AND by keys reports both counts, because an either/or ladder
81
+ printed the drags and said nothing about three hundred key presses on the same
82
+ run.
83
+
60
84
  **The bot can use the MOUSE.** If your scene has `Clickable` nodes, it presses
61
85
  and releases over them at random — through the real `Clickable`, the real
62
86
  `mouseJustPressed` edge and your real connection, with a picker that answers
@@ -70,6 +94,12 @@ A random clicker still cannot finish a game that needs a SEQUENCE — select a
70
94
  trooper, then a tile, then end the turn — and that is reported as unmeasured,
71
95
  not failed.
72
96
 
97
+ **A wire on a HUD widget is visible now too.** The whole screen-space subtree was
98
+ skipped when the run collected signals — right for "never reached", which is
99
+ about walking, and it took every `pressed`, `changed` and `dropped` wire with
100
+ it, so the line that exists to say "this wire does nothing" could never say it
101
+ about a HUD.
102
+
73
103
  **Nothing here is a guess about your design.** The scene already declares what
74
104
  winning is; this only listens.
75
105
 
@@ -102,10 +132,38 @@ geometry says you cannot. Check the jump height, the gap, the collider.
102
132
  **`never fired`** means you drew a wire that does nothing. Either the signal is
103
133
  unreachable or the connection is wrong.
104
134
 
135
+ **`silent`** names a declared emitter — an `AudioPlayer`, a particle node — that
136
+ never went off in any run. Emitters on a SPAWNER's prefab count: the template is
137
+ lifted out of the tree at load, so the check reads the scene FILE as well as the
138
+ loaded tree, and a wave of forty enemies is reported as its one authored
139
+ emitter, not forty.
140
+
105
141
  **`nothing here can hurt you`** is a design report, not an error. Sometimes it
106
142
  is exactly right (a walking simulator). Usually it means the hazards are not
107
143
  wired up.
108
144
 
145
+ **`!` lines** are what the ENGINE warned about during the runs — a camera whose
146
+ `follow` resolves to a node with no position, a `DayNight` with no sky, a body
147
+ with no collider. They are not failures and they do not change the exit code;
148
+ they are the things that leave a game running and wrong, and they are usually
149
+ the first thing to read. One line per distinct warning, however many times it
150
+ fired.
151
+
152
+ **`error`** prints the throw under it — the behavior, the node and the
153
+ exception, in the engine's own words:
154
+
155
+ ```
156
+ ✗ error in 8/8
157
+ behavior 'Boom' on /Game/Player/Sword (Node2D) threw in update — THE
158
+ SCRIPT is now skipped … Error: the sword script is broken
159
+ ```
160
+
161
+ A script that throws in `update` is quarantined (the node keeps running, its
162
+ script does not), so everything downstream of it simply stops happening and
163
+ every other line of the report describes the silence rather than the cause.
164
+ Read this one first. `incanto-verify` carries the same lines under its `plays`
165
+ rung.
166
+
109
167
  **`stuck` in every run** means the player is not going anywhere: wedged in
110
168
  geometry, spawned inside a collider, or missing the input map that moves it.
111
169
  That is a real bug and worth chasing.
@@ -156,6 +214,18 @@ real thing:
156
214
  bunx incanto-playtest src/game.scene.json --behaviors src/behaviors.ts
157
215
  ```
158
216
 
217
+ **More than one file.** Behaviours split as a game grows — bosses in `boss.ts`,
218
+ the scene table in `scenes.ts` — so comma-separate them or repeat the flag,
219
+ rather than writing a barrel that re-exports everything for the CLI's benefit:
220
+
221
+ ```bash
222
+ bunx incanto-playtest src/game.scene.json --behaviors src/behaviors.ts,src/boss.ts
223
+ bunx incanto-verify src/game.scene.json --behaviors src/behaviors.ts --behaviors src/boss.ts
224
+ ```
225
+
226
+ Every bin that takes `--behaviors` accepts both forms: `check`, `playtest`,
227
+ `verify`, `feel`, `play`, `multiplay`.
228
+
159
229
  ## From TypeScript
160
230
 
161
231
  ```ts
@@ -166,7 +236,13 @@ console.log(playtestText(report));
166
236
  ```
167
237
 
168
238
  `report.runs` carries per-run outcome, timing, reached paths, fired signals,
169
- damage and the replay.
239
+ damage and the replay. `report.inertActions` is the dead-control list above.
240
+
241
+ The same question, without a playtest — after any run, scripted or hand-driven:
242
+
243
+ ```ts
244
+ game.engine.input.unreadActions(); // ['restart'] — declared, nothing read it
245
+ ```
170
246
 
171
247
  ## Pairs with the feel report
172
248
 
@@ -42,10 +42,17 @@ Reloading from source sidesteps all three. The structure comes from the file
42
42
  (authoritative, already validated); the save carries only what the file cannot
43
43
  know.
44
44
 
45
- **The cost, stated plainly: spawned enemies and mid-level positions are not
46
- restored.** You resume at the scene's start with stats, inventory, unlocks and
47
- quest flags intact — a checkpoint save. If your game needs a position, save it:
48
- `serialize()` returns anything.
45
+ **The cost, stated plainly: spawned enemies, mid-level positions and anything
46
+ else that lives in the TREE are not restored.** You resume at the scene's start
47
+ with stats, inventory, unlocks and quest flags intact — a checkpoint save. If
48
+ your game needs a position, save it: `serialize()` returns anything.
49
+
50
+ "Inventory" there means a behavior's DATA — a list of item ids, a wallet, a
51
+ quest flag. An inventory made of WIDGETS MOVED BETWEEN SLOTS
52
+ (`incanto-hud.md`'s drag recipe) is structure, so it reloads exactly as the
53
+ file has it: measured on a bench with one item dragged across, the save
54
+ mentions no slot and the reload puts the item back on its shelf. That skill
55
+ prints the `serialize` that keeps the slot map as data.
49
56
 
50
57
  **What the run CONSUMED is remembered.** Reloading from the file brings back
51
58
  every gem you already picked up, which would let a collect-five-to-win run
@@ -83,11 +90,59 @@ class QuestLog extends Behavior {
83
90
  `maxHealth` no — that comes back from the scene JSON, and duplicating it makes
84
91
  old saves fight your balance patches.
85
92
 
93
+ **Randomness is carried for you.** `captureState()` records where
94
+ `engine.rng` has got to and `restoreState()` puts it back, because without that
95
+ a load replays the seeded sequence from the top — the first "random" event after
96
+ a save is the one the RUN opened with, and every loot roll, wander and spawn
97
+ wobble repeats the opening of the game. Nothing to do; `engine.rng.position` is
98
+ readable if you want it for something else.
99
+
86
100
  `deserialize` is defensive on purpose: that data may come from a build of your
87
101
  game that shipped six weeks ago. Check what you read.
88
102
 
89
- Built-ins that already save: `Health` (current, dead), `ScoreKeeper` (score,
90
- lives, won/lost), `Collector` (total).
103
+ Built-ins that already save: `Collector` (total), `Currency` (amount),
104
+ `DayNight` (hour, paused), `Health` (current, dead), `SavePoint` (playtime),
105
+ `ScoreKeeper` (score, lives, won/lost), `WaveSpawner` (which wave).
106
+
107
+ `WaveSpawner` RESTARTS the wave you were on rather than resuming mid-spawn —
108
+ spawned entities are never restored, so resuming "four enemies into wave 3"
109
+ would resume a wave whose enemies do not exist. `waveStarted` fires again, so a
110
+ HUD wired to it catches up on its own.
111
+
112
+ Every one of them needs a `uid` on its node, and `incanto-check` says so if it
113
+ has none — that check reads the same list.
114
+
115
+ ## Tell the screen: `announce()`
116
+
117
+ `deserialize` writes fields. Your HUD is wired to **signals** — that is what
118
+ `incanto-hud.md` teaches and the only thing the editor can wire — so a restore
119
+ that only writes fields leaves the screen showing a fresh start:
120
+
121
+ ```
122
+ HUD score="0" hp=100/100 gems="0"
123
+ TRUTH score=1400 hp=38 gems=7
124
+ ```
125
+
126
+ with no error anywhere and a restore report of `{missing: [], restored: 3}`.
127
+
128
+ So there is a third hook, called once after the whole restore pass:
129
+
130
+ ```ts
131
+ override announce() {
132
+ this.emit('questChanged', this.stage); // what the screen SHOWS
133
+ }
134
+ ```
135
+
136
+ **Emit only what displays.** Not `died`, not `won`, not `levelUp` — a save is
137
+ being read, nothing just happened, and re-firing an outcome signal on load is
138
+ how a Continue lands straight on the game-over screen it was loaded to escape.
139
+ It runs after every `deserialize` in the pass, so a handler that reads a sibling
140
+ sees restored values there too.
141
+
142
+ `Health`, `ScoreKeeper`, `Collector` and `Currency` implement it. `ScoreKeeper`
143
+ gained **`livesChanged`** for this: `lifeLost` is the EVENT (flash, sound,
144
+ respawn) and fires only on a real loss, while `livesChanged` is the COUNT and
145
+ fires on both. Wire a lives counter to `livesChanged`.
91
146
 
92
147
  ## Every node you save — and every node that VANISHES — needs a uid
93
148
 
@@ -109,6 +164,20 @@ it just wrote is already wrong. It does not guess a replacement: a uid survives
109
164
  a rename and a reparent, and a saved PATH would point at whatever node moved
110
165
  into that slot after your next edit.
111
166
 
167
+ **Put the uid on the node that OWNS the state, not on its art.** A collectible
168
+ is normally one node with the script and a plain child for the sprite:
169
+
170
+ ```jsonc
171
+ { "name": "Sword", "type": "Area2D", "uid": "n_sr9scvhygy01mgxr",
172
+ "script": { "name": "ItemPickup" },
173
+ "children": [ { "name": "Icon", "type": "Sprite2D" } ] } // no uid needed
174
+ ```
175
+
176
+ Freeing the sword takes the icon with it, so the parent's uid in `#freed`
177
+ records both — the audit says nothing about the child, and adding a uid to it
178
+ changes nothing. What the audit still catches is the child that goes while its
179
+ parent LIVES: that one really does come back.
180
+
112
181
 
113
182
 
114
183
  The uid is the join key, because it is the one identifier that survives a rename
@@ -156,6 +225,35 @@ if (slot) {
156
225
  `onReady` is where a behavior sets its starting values, so restoring first would
157
226
  be overwritten.
158
227
 
228
+ **Not from `onReady` itself, either**, which is the tempting place to put
229
+ "continue my run" and the one that cannot work: the ready pass runs while the
230
+ scene is still loading, and `engine.scene` — which both halves of this API read
231
+ — is not assigned until `setScene`. Both say so now rather than answering
232
+ quietly:
233
+
234
+ ```
235
+ [incanto] restoreState() ran before the engine had a scene, and restored NOTHING.
236
+ ```
237
+
238
+ Before that it reported every uid in the save as `missing`, which is the
239
+ signature of a save format change, so the hunt started in the wrong place
240
+ entirely.
241
+
242
+ **To keep the continue INSIDE the game** (where a harness can reach it —
243
+ `runScript` boots the scene, never your `main.ts`), do it on the first frame:
244
+
245
+ ```ts
246
+ class Game extends Behavior {
247
+ private continued = false;
248
+ override update(): void {
249
+ if (this.continued) return;
250
+ this.continued = true;
251
+ const slot = this.slots.read('1');
252
+ if (slot) this.engine.restoreState(slot.state);
253
+ }
254
+ }
255
+ ```
256
+
159
257
  It never throws. A save naming a uid this build deleted reports it in
160
258
  `report.missing` and restores everything else; refusing to load would mean a
161
259
  patch that moves one node deletes everyone's progress.
@@ -235,6 +333,63 @@ slots.clear(); // "delete all data"
235
333
  One `SavePoint` per slot is the declarative version: three nodes with
236
334
  `slot: "1" | "2" | "3"`, each probing into its own row of the menu.
237
335
 
336
+ ### Restore into a scene that has been PLAYED
337
+
338
+ A save is restored INTO a freshly loaded scene. The structure comes from the
339
+ file; `#freed` then takes away the nodes that run had consumed — and nothing
340
+ puts any back. So a pause-menu Load, a slot menu, or death wired straight to
341
+ `SavePoint.restore()` leaves THIS run's collectibles deleted:
342
+
343
+ ```
344
+ CONTROL restart the scene from source, then restore → gems [Gem2,Gem3], won
345
+ death wired straight to restore (no reload) → gems [Gem3], lost
346
+ ```
347
+
348
+ …and the next autosave writes that hybrid back to the slot, so the unwinnable
349
+ run survives a page reload. Reload first — `GameFlow.restart` with
350
+ `restoreOnReady`, or `restartScene(engine)` and then restore. After a restart
351
+ the SavePoint node is a NEW one, so a held behavior reference is stale.
352
+
353
+ `report.stale` names the authored nodes this tree consumed that the save does
354
+ not account for, and the engine warns when it is non-empty. It is a warning
355
+ rather than an error because a node that freed ITSELF on a timer lands in the
356
+ same set, and only your game knows which of its nodes are transient.
357
+
358
+ ### A save hook that throws
359
+
360
+ `serialize()`, `deserialize()` and `announce()` are your code, and your code
361
+ throws. All three are caught now, and each says something different:
362
+
363
+ - a **`serialize()`** that throws leaves a HOLE in the save — that behavior's
364
+ whole run is missing — so `SavePoint.save()` **refuses to write** and emits
365
+ `saveFailed` instead of `saved`. Overwriting the previous slot with a holed
366
+ save destroys the progress the player actually had. `engine.lastCaptureFailures`
367
+ names them if you call `captureState()` yourself.
368
+ - a **`deserialize()`** that throws is in `report.skipped` as before, and now
369
+ also in **`report.refused`** with the reason — a save this build cannot read
370
+ and a node with no `deserialize` at all are different problems.
371
+ - an **`announce()`** that throws means the state IS restored and the SCREEN was
372
+ not told: **`report.unannounced`**. It reported `restored: 2, expected: 2`
373
+ over a HUD showing zeros, and it does not take an override to reach — a
374
+ `getNode('HUD/ScoreLabel')` on a renamed node lands there.
375
+
376
+ ### A save that cannot be read
377
+
378
+ A truncated write (a power cut, a tab closed mid-save) or a slot from a build
379
+ that predates this one is not offered by `all()` — but it is not *gone* either,
380
+ and a load menu that quietly shows one fewer row than the player remembers is
381
+ the worst thing a save system can do:
382
+
383
+ ```ts
384
+ for (const { id, why } of slots.problems()) {
385
+ render(`slot ${id}: ${why === 'corrupt' ? 'damaged' : 'from an older version'}`);
386
+ }
387
+ ```
388
+
389
+ The slot INDEX is rebuilt from storage when it cannot be read, so one corrupt
390
+ byte in a derived list no longer hides every save on the machine — nor lets the
391
+ next `write()` orphan them. It says so on the console when it does.
392
+
238
393
  ## Checking your coverage
239
394
 
240
395
  ```ts