incanto 0.69.0 → 0.71.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 (147) 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 +121 -102
  15. package/bin/incanto.mjs +3 -2
  16. package/dist/2d.d.ts +296 -34
  17. package/dist/2d.js +4 -4
  18. package/dist/3d.d.ts +157 -9
  19. package/dist/3d.js +8 -8
  20. package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
  21. package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
  22. package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
  23. package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
  24. package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
  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 +1088 -882
  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-99BzP_L-.js} +298 -45
  33. package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
  34. package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
  35. package/dist/gameplay.d.ts +254 -10
  36. package/dist/gameplay.js +2 -2
  37. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  38. package/dist/index.d.ts +179 -13
  39. package/dist/index.js +10 -12
  40. package/dist/json-CfTjpvW8.js +67 -0
  41. package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
  42. package/dist/net.d.ts +27 -3
  43. package/dist/net.js +2 -2
  44. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  45. package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
  46. package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
  47. package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
  48. package/dist/react.d.ts +2 -2
  49. package/dist/react.js +2 -2
  50. package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
  51. package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
  52. package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
  53. package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
  54. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  55. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  56. package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
  57. package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
  58. package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
  59. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  60. package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
  61. package/dist/test-CmN54kWv.js +4264 -0
  62. package/dist/test.d.ts +639 -30
  63. package/dist/test.js +3 -3
  64. package/dist/touch-BnCyPA0G.js +519 -0
  65. package/dist/vite.d.ts +20 -37
  66. package/dist/vite.js +177 -7
  67. package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
  68. package/editor/assets/agent8-yLIEYHbd.js +1 -0
  69. package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
  70. package/editor/assets/index-DK9xMGpW.js +11046 -0
  71. package/editor/index.html +1 -1
  72. package/package.json +4 -14
  73. package/schemas/scene.schema.json +34 -3
  74. package/skills/incanto-3d-character.md +14 -1
  75. package/skills/incanto-3d-models.md +12 -0
  76. package/skills/incanto-assets.md +9 -1
  77. package/skills/incanto-audio.md +8 -2
  78. package/skills/incanto-behaviors-and-scripts.md +105 -7
  79. package/skills/incanto-building-2d-games.md +97 -10
  80. package/skills/incanto-building-3d-games.md +58 -4
  81. package/skills/incanto-editor.md +46 -7
  82. package/skills/incanto-environment.md +19 -1
  83. package/skills/incanto-game-feel.md +70 -0
  84. package/skills/incanto-gameplay-behaviors.md +172 -20
  85. package/skills/incanto-hud.md +142 -7
  86. package/skills/incanto-localization.md +13 -5
  87. package/skills/incanto-multiplayer.md +83 -3
  88. package/skills/incanto-node-reference.md +239 -59
  89. package/skills/incanto-performance.md +52 -0
  90. package/skills/incanto-physics-and-input.md +123 -24
  91. package/skills/incanto-playtesting.md +112 -2
  92. package/skills/incanto-save-slots.md +217 -7
  93. package/skills/incanto-scene-json-authoring.md +69 -12
  94. package/skills/incanto-verifying-your-game.md +207 -10
  95. package/skills/incanto-web-integration.md +28 -0
  96. package/skills/incanto-your-first-game.md +3 -1
  97. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  98. package/templates-app/beacon-isle-3d/package.json +2 -2
  99. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  100. package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
  101. package/templates-app/beacon-isle-3d/src/main.ts +11 -1
  102. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  103. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  104. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  105. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  106. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  107. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  108. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  109. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  110. package/templates-app/molehill-2d/index.html +73 -0
  111. package/templates-app/molehill-2d/package.json +23 -0
  112. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  113. package/templates-app/molehill-2d/src/game.scene.json +1205 -0
  114. package/templates-app/molehill-2d/src/main.ts +41 -0
  115. package/templates-app/molehill-2d/tsconfig.json +13 -0
  116. package/templates-app/molehill-2d/verify.ts +247 -0
  117. package/templates-app/molehill-2d/vite.config.ts +12 -0
  118. package/templates-app/platformer-2d/package.json +2 -2
  119. package/templates-app/platformer-2d/src/game.scene.json +178 -986
  120. package/templates-app/platformer-2d/src/main.ts +11 -1
  121. package/templates-app/platformer-2d/tsconfig.json +1 -1
  122. package/templates-app/star-survivor/package.json +2 -2
  123. package/templates-app/star-survivor/src/game.scene.json +43 -216
  124. package/templates-app/star-survivor/src/main.ts +11 -1
  125. package/templates-app/star-survivor/tsconfig.json +1 -1
  126. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  127. package/templates-app/tps-3d/package.json +2 -2
  128. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  129. package/templates-app/tps-3d/src/game.scene.json +66 -263
  130. package/templates-app/tps-3d/src/main.ts +13 -2
  131. package/templates-app/tps-3d/tsconfig.json +1 -1
  132. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  133. package/templates-app/village-quest-3d/package.json +2 -2
  134. package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
  135. package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
  136. package/templates-app/village-quest-3d/src/main.ts +11 -1
  137. package/templates-app/village-quest-3d/src/village.scene.json +202 -863
  138. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  139. package/templates-app/village-quest-3d/verify.ts +14 -1
  140. package/dist/duplicate-Dj4L-5AA.js +0 -22
  141. package/dist/json-CwwhxQgb.js +0 -36
  142. package/dist/registry-CF70EArN.js +0 -212
  143. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  144. package/dist/test-D6ISmuFE.js +0 -2383
  145. package/dist/touch-DEAmqGdf.js +0 -225
  146. package/editor/assets/agent8-BDvaZMQJ.js +0 -1
  147. package/editor/assets/index-CZIRr5EL.js +0 -10958
package/editor/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Incanto Scene Editor</title>
7
7
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%236ee7dc'/><text x='8' y='12' text-anchor='middle' font-size='11' font-family='monospace' fill='%230e1018'>i</text></svg>" />
8
- <script type="module" crossorigin src="./assets/index-CZIRr5EL.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-DK9xMGpW.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="./assets/GameServer-C56iOUgF.js">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incanto",
3
- "version": "0.69.0",
3
+ "version": "0.71.0",
4
4
  "description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
5
5
  "keywords": [
6
6
  "game-engine",
@@ -55,7 +55,9 @@
55
55
  "build": "tsdown",
56
56
  "dev": "tsdown --watch",
57
57
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
58
- "test": "vitest run --project unit --root ../.."
58
+ "test": "vitest run --project unit --root ../..",
59
+ "prepack": "node scripts/strip-dev-deps.mjs",
60
+ "postpack": "node scripts/strip-dev-deps.mjs --restore"
59
61
  },
60
62
  "peerDependencies": {
61
63
  "@agent8/gameserver": ">=1.10.0",
@@ -70,18 +72,6 @@
70
72
  "optional": true
71
73
  }
72
74
  },
73
- "devDependencies": {
74
- "@agent8/gameserver": "1.10.2",
75
- "@types/react": "^19.2.17",
76
- "@types/react-dom": "^19.2.3",
77
- "@types/three": "catalog:",
78
- "react": "^19.2.7",
79
- "react-dom": "^19.2.7",
80
- "three": "catalog:",
81
- "tsdown": "0.22.2",
82
- "typescript": "catalog:",
83
- "vitest": "catalog:"
84
- },
85
75
  "dependencies": {
86
76
  "@dimforge/rapier2d-compat": "0.19.3",
87
77
  "@dimforge/rapier3d-compat": "0.19.3",
@@ -1771,6 +1771,11 @@
1771
1771
  "current": {
1772
1772
  "type": "boolean",
1773
1773
  "default": false
1774
+ },
1775
+ "lookAt": {
1776
+ "type": "string",
1777
+ "description": "A node path: '%UniqueName', '/Absolute/From/Root', or relative to this node ('../Skin').",
1778
+ "default": ""
1774
1779
  }
1775
1780
  },
1776
1781
  "additionalProperties": false
@@ -1903,6 +1908,10 @@
1903
1908
  "slopeLimitDeg": {
1904
1909
  "type": "number",
1905
1910
  "default": 45
1911
+ },
1912
+ "stepHeight": {
1913
+ "type": "number",
1914
+ "default": 35
1906
1915
  }
1907
1916
  },
1908
1917
  "additionalProperties": false
@@ -2800,8 +2809,15 @@
2800
2809
  "default": null
2801
2810
  },
2802
2811
  "density": {
2803
- "type": "string",
2804
- "enum": ["lush", "sparse", "none"],
2812
+ "anyOf": [
2813
+ {
2814
+ "type": "string",
2815
+ "enum": ["lush", "sparse", "none"]
2816
+ },
2817
+ {
2818
+ "type": "number"
2819
+ }
2820
+ ],
2805
2821
  "default": "sparse"
2806
2822
  },
2807
2823
  "area": {
@@ -5532,6 +5548,10 @@
5532
5548
  "minItems": 2,
5533
5549
  "maxItems": 2,
5534
5550
  "default": [0, 0]
5551
+ },
5552
+ "angularVelocity": {
5553
+ "type": "number",
5554
+ "default": 0
5535
5555
  }
5536
5556
  },
5537
5557
  "additionalProperties": false
@@ -6737,6 +6757,10 @@
6737
6757
  "type": "object",
6738
6758
  "default": {}
6739
6759
  },
6760
+ "colors": {
6761
+ "type": "object",
6762
+ "default": {}
6763
+ },
6740
6764
  "solid": {
6741
6765
  "type": "array",
6742
6766
  "default": []
@@ -9442,7 +9466,14 @@
9442
9466
  "default": true
9443
9467
  },
9444
9468
  "underwater": {
9445
- "type": "boolean",
9469
+ "anyOf": [
9470
+ {
9471
+ "type": "boolean"
9472
+ },
9473
+ {
9474
+ "type": "object"
9475
+ }
9476
+ ],
9446
9477
  "default": true
9447
9478
  },
9448
9479
  "swell": {
@@ -252,7 +252,16 @@ That is the EXACT formula `CharacterController3D` uses for move-facing
252
252
  an enemy that idles facing you): use the direction from self TO the target —
253
253
  `dx = target.position[0] - self.position[0]`, `dz = target.position[2] - self.position[2]`,
254
254
  then `rotation[1] = atan2(dx, dz)*RAD2DEG`. Movement-facing is just this with the
255
- target being "where I'm walking." There is ONE facing formula; only the direction differs.
255
+ target being "where I'm walking." There is ONE facing formula for anything with a
256
+ SKIN; only the direction differs.
257
+
258
+ **NOT a camera.** A `Camera3D` looks down its own local **−Z**, and — more to the
259
+ point — it has an UP that a rod does not. This formula aims a camera perfectly and
260
+ rolls it: over 72 headings, dot 1.000 in all 72 and **upside down in 36**. No
261
+ two-angle recipe can fix it (Euler XYZ makes `[pitch, yaw, 0]` = Rx·Ry, while a
262
+ level camera is Ry·Rx, so its third angle is non-zero). Use
263
+ `Camera3D.lookAt: "%Target"` — a node path, re-aimed every frame with world up.
264
+ See `incanto-building-3d-games.md`.
256
265
 
257
266
  - **The 180° is NOT a facing formula.** It is only the AT-REST MOUNT — the character
258
267
  starts turned away from the behind-the-shoulder camera until it first moves. Adding
@@ -354,6 +363,10 @@ const rx = Math.atan2(-Dy, Dz);
354
363
  t.rotation = [rx*RAD2DEG, ry*RAD2DEG, 0];
355
364
  ```
356
365
 
366
+ (This is a ROD: a stretched box has no up, so two angles are enough for it and
367
+ `rodAxis · (target − muzzle) ≈ 1` is a complete check. A camera is not a rod —
368
+ that same check scores 1.000 on an upside-down one. See the Camera3D note above.)
369
+
357
370
  The "obvious" `[-atan2(dy, hypot(dx,dz)), atan2(dx,dz), 0]` is only correct for a LEVEL
358
371
  shot — for XYZ order the yaw denominator must be `hypot(Dy,Dz)`, not the ground `hypot(dx,
359
372
  dz)`. With the wrong form the rod skews off-axis when you aim up/down, so its near end
@@ -76,6 +76,18 @@ meta (name/authors) + humanoid bone count. Decision rules:
76
76
  (a center.y of half the height usually means feet at origin: good).
77
77
  - **animation names** are exact strings for the `animation` prop.
78
78
 
79
+ **At runtime the node itself will tell you**, which is what a harness or a
80
+ behaviour picking a random idle needs:
81
+
82
+ ```ts
83
+ model.availableAnimations(); // embedded clip names PLUS the scene's $animation assets
84
+ model.boneNames(); // every bone, for BoneAttachment3D's `bone`
85
+ model.findBone('RightHand'); // the Object3D, matching mixamorig prefixes too
86
+ ```
87
+
88
+ `availableAnimations` is the one to check before setting `animation` from data:
89
+ a clip name that is not in the list plays nothing and says nothing.
90
+
79
91
  ## Scene JSON
80
92
 
81
93
  ```json
@@ -69,7 +69,7 @@ Every catalog entry has a `url` that is directly usable; there are two classes:
69
69
  // Tree3D — a bare node already loads oak leaves from the agent8 CDN; override
70
70
  // leafTexture only to swap in a different built-in cutout (e.g. ash)
71
71
  { "type": "Tree3D", "props": { "type": "broadleaf",
72
- "leafTexture": "https://agent8-games.verse8.io/assets/3D/default/textures/vegetation/ash_color.png" } }
72
+ "leafTexture": "https://agent8-games.verse8.io/assets/3D/default/textures/vegetation/ash_color.png" } },
73
73
 
74
74
  // MeshInstance3D material — built-in ground/bark color as a map (+ a normal map)
75
75
  { "type": "MeshInstance3D", "props": { "material": {
@@ -238,3 +238,11 @@ node keeps its own texture cache. In **2D** the same question is `renderer.asset
238
238
  (`$ref`, url and reason per failed entry), and the scene EDITOR reads it: a
239
239
  failed asset is red in the explorer with the url in its tooltip and the
240
240
  consequence in its inspector.
241
+
242
+ **And a texture INSIDE a model.** A `.gltf` + `.bin` + a missing `textures/`
243
+ folder — the ordinary shape of a Blender "glTF Separate" export or a Sketchfab
244
+ download — used to be the one silent case: GLTFLoader swallows a sub-resource
245
+ failure, so the model resolved as a SUCCESS (`status: 'ready'`, `error: null`,
246
+ empty `assetErrors()`, `stats().errors` 0) and the character simply rendered
247
+ flat white. A half-loaded model was indistinguishable from a whole one. Now the
248
+ missing texture is in `assetErrors()` by its URL, like every other one.
@@ -421,7 +421,11 @@ engine.updated.connect(() => {
421
421
  ```
422
422
 
423
423
  - **`engine.sfx.now`** — seconds on the audio clock, the one the sound is
424
- actually placed on. `0` headless and before the first sound.
424
+ actually placed on. With no AudioContext — headless, and before the first
425
+ sound — it is the engine's own elapsed real time, so the loop above queues the
426
+ whole chart in the verify VM exactly as it does in a browser. (It was a frozen
427
+ `0`, which made that loop queue the first 80 ms of notes and then nothing ever
428
+ again: 1 of 21 beats, with no error anywhere.)
425
429
  - **`AudioPlayer.playAt(when)`** and **`engine.sfx.play(params, gain, { when })`** —
426
430
  PRESETS only. A `src` clip goes through an `<audio>` element, which has no
427
431
  scheduling clock, so `playAt` on one plays immediately.
@@ -448,7 +452,9 @@ const songSeconds = engine.music.playhead; // null = nothing playing, or no cl
448
452
  ```
449
453
 
450
454
  It is the raw element time, so a looping track wraps to 0 each pass — a loop
451
- boundary to chart against, not a fault.
455
+ boundary to chart against, not a fault. Headless there is no element and no
456
+ duration to wrap at, so it counts up from 0 for as long as the track plays —
457
+ enough for a harness to prove the chart advanced.
452
458
 
453
459
  ## Decision guide
454
460
 
@@ -50,10 +50,21 @@ silently breaks the link (`getNodeOrNull` returns null, nothing throws).
50
50
  ```ts
51
51
  static readonly props = {
52
52
  target: { default: '', nodePath: true }, // fed to getNode/getNodeOrNull
53
+ home: { default: '', nodePath: true, required: true }, // and you MUST set it
53
54
  damage: { default: 10 },
54
55
  };
55
56
  ```
56
57
 
58
+ `""` is the "not set" value of a node-path prop, and `getNodeOrNull('')` returns
59
+ `null` for it — so `const t = this.node.getNodeOrNull(this.target)` is safe to
60
+ write without a guard.
61
+
62
+ **`required: true` when the prop is not optional.** It turns "left empty" into a
63
+ LOAD error naming the prop and the node (`'script:Archer' needs a "home" — it is
64
+ empty. (at '/Level/Enemies/Slime3')`) instead of a behavior that silently does
65
+ nothing all game. It costs one word and it is the best message the engine has
66
+ for an unset prop.
67
+
57
68
  ## The three-artifact contract (keep them in sync!)
58
69
 
59
70
  1. scene JSON `"script": {"name": "CoinCounter"}`
@@ -75,10 +86,47 @@ under an existing name (re-registering a different class without it → `DUPLICA
75
86
  `createGame2D/3D` — they pass `{ engine }` to `loadScene`, attaching it BEFORE
76
87
  `onReady` fires. Under a manual boot it throws `TREE_VIOLATION` until
77
88
  `engine.setScene(scene)`: prefer `createGame` (or `loadScene(json, { engine })`),
78
- or defer engine-dependent work to the first `update`. Two onReady caveats even
79
- with the engine attached: `engine.scene` is still null (setScene runs after),
80
- and the scene's `input` actions are not declared yet — query input from
81
- `update`/`fixedUpdate`, never `onReady`.
89
+ or defer engine-dependent work to the first `update`. Caveats even with the
90
+ engine attached: `engine.scene` is still null (setScene runs after), and the
91
+ scene's `input` actions are not declared yet — query input from
92
+ `update`/`fixedUpdate`, never `onReady`. The scene's `strings` and its
93
+ `connections[]` ARE both in place by then, so `engine.t(...)` resolves and a
94
+ signal you emit from `onReady` reaches its JSON handler.
95
+
96
+ ## When your script throws
97
+
98
+ A throw is CONTAINED, not fatal — but only after the scene has loaded, and the
99
+ two halves are different on purpose:
100
+
101
+ - **at load** (`loadScene`, including every `onReady` in the initial pass) a
102
+ throw is a hard failure. That is where an authoring mistake belongs: you want
103
+ to hear about it before the game runs, not to play a game with a piece
104
+ missing.
105
+ - **at runtime** — a spawned prefab, a clone, a node a behaviour adds — a throw
106
+ from `onEnterTree`, `onReady`, `onExitTree`, `update` or `fixedUpdate`
107
+ quarantines THAT script and nothing else. The node keeps its state, the rest
108
+ of the scene keeps running, and `engine.log` names the node, the script and
109
+ the hook.
110
+
111
+ The runtime half used to cover only `update`/`fixedUpdate`, so a spawned
112
+ prefab's `onReady` bug unwound into the `update()` of whatever spawned it: the
113
+ innocent Spawner was quarantined, spawning stopped permanently, and the log
114
+ named the wrong node.
115
+
116
+ **A signal listener is quarantined the same way** — the one that throws is
117
+ disconnected, everything else keeps running, and the log names the node that
118
+ OWNS the listener rather than the one that emitted:
119
+
120
+ ```
121
+ [incanto] a 'scoreChanged' listener owned by behavior 'Hud' on /World/Hud threw
122
+ — THIS LISTENER is now off; /World/Score, which emitted it, and the rest of the
123
+ scene keep running.
124
+ ```
125
+
126
+ Both spellings, `node.on(...)` and a JSON `connections` wire. Only the JSON
127
+ half used to be covered, so a cosmetic HUD handler with a typo in it disabled
128
+ the SCRIPT OF THE NODE THAT EMITTED — on the five shipped examples that do
129
+ `controller.on('movementStateChanged', …)`, that stops the player moving.
82
130
  - `this.rng` — seeded engine randomness: `next()` [0,1), `range(min,max)`,
83
131
  `int(min,max)` (inclusive), `pick(arr)`. **Never `Math.random()` in game logic** —
84
132
  with `new Engine({ seed: 42 })` a run replays identically (scripted verification).
@@ -115,19 +163,67 @@ What behavior code actually calls at runtime — all instance methods, no global
115
163
  | `parent.addChild(node)` | attach a DETACHED node (already-parented → `TREE_VIOLATION`); sibling name collisions auto-rename (`Enemy` → `Enemy2`) |
116
164
  | `duplicateNode(node)` | deep-clone via serialize→rebuild — returns a **detached** node; attach it explicitly with `addChild` |
117
165
  | `node.queueFree()` | deferred destruction — flushed at the END of the current update pass (queued during a flush = freed next pass) |
118
- | `node.free()` | immediate detach + teardown (children first, signals disconnected) |
166
+ | `node.free()` | immediate detach + teardown (children first; the signals it owns AND the ones it subscribed to elsewhere are both disconnected — see below) |
119
167
  | `root.getNodesByName('Enemy')` | EVERY node with that name in the subtree, document order |
120
168
  | `node.getNodeOrNull(path)` | like `getNode` but `null` instead of `NODE_NOT_FOUND` |
121
169
  | `node.getNode('%Player')` | unique-name lookup across the whole tree (≥2 matches → `DUPLICATE_UNIQUE_NAME`) |
170
+ | `node.findChild('Skin')` | the first descendant with that name, or `null` — `findChild(name, false)` searches direct children only |
171
+ | `parent.removeChild(node)` | DETACH without tearing down: the node keeps its children and its props, and is yours to `addChild` somewhere else. Nothing frees it — a detached node nobody re-attaches is a leak |
172
+ | `node.reparent(newParent)` | detach-and-attach in one call, which is the safe order (`addChild` on a still-parented node is a `TREE_VIOLATION`) |
173
+ | `node.getPath()` | its absolute path, `/Level/Enemies/Slime3` — what every error message and report prints, and what to log when a lookup surprises you |
174
+ | `other.isInGroup('enemy')` | **the question a trigger asks** — the first line of nearly every `triggerEnter` handler. `node.groups` is a SET, not the array the JSON writes it as, so `groups.includes(…)` is a TypeError |
175
+ | `node.addToGroup('enemies')` / `node.removeFromGroup('enemies')` | tag at RUNTIME; `groups` in the JSON is the same set, declared |
122
176
  | `this.node.tree?.getNodesInGroup('enemies')` | group query (also `tree.callGroup(group, method, ...args)`) |
123
177
  | `engine.stop()` / `engine.start()` | pause / resume the loop — stop resets the clock and accumulator, so no banked sim time leaks into the resume |
124
178
  | `engine.step()` | advance exactly ONE fixed step + one update (both dt = the fixed step) — the unit of time for headless tests |
125
179
  | `engine.tick(timestampMs)` | manual frame advance — takes an **absolute** ms timestamp (rAF-style), NOT a dt; the first call after (re)start only primes the clock |
126
- | `engine.setScene(scene)` | swap scenes: the previous root is freed, the input map is cleared and redeclared from the new scene's `input{}`, the clock resets (`time`, `unscaledTime`, and **`timeScale` back to 1** — a level restarted out of a frozen game-over must not boot frozen), then `sceneChanged` fires |
180
+ | `engine.setScene(scene)` | swap scenes: the previous root is freed, the input map is cleared and redeclared from the new scene's `input{}`, the clock resets (`time`, `unscaledTime`, and **`timeScale` back to 1** — a level restarted out of a frozen game-over must not boot frozen), then `sceneChanged` fires. It fires with **null** at `dispose()` too — a handler that reads `scene.root` has to check, or teardown says so and continues without it |
181
+ | `node.off(signal, fn)` | disconnect ONE listener you connected with `on` — pass the same function reference. (`free()` disconnects everything a node owns, so this is for a listener that must stop while the node lives on) |
182
+ | `node.signal('died')` | the `Signal` object itself, for `connect`/`disconnect` by hand; undeclared names throw, like `emit` |
183
+ | `node.listenerCount('died')` | how many are listening — a test's way to prove a wire was made, or dropped |
127
184
  | `engine.stats()` | live perf counters `{ fps, frameMs, nodes, running }` — fps/frameMs average the last ~60 REAL `tick` frames (headless `step()` runs report 0), nodes is the current tree size. GPU counters (triangles/draw calls) live on `renderer.stats()` / the merged `game.stats()` |
128
185
 
129
186
  The recurring traps: `duplicateNode` does NOT insert the clone anywhere — a
130
- "spawner that does nothing" usually forgot `addChild`. `engine.tick(16)` does
187
+ "spawner that does nothing" usually forgot `addChild`.
188
+
189
+ **The template you clone FROM is a live node.** Its behaviors run, its timers
190
+ tick, its turret shoots, its `autoplay` audio plays — `visible: false` hides a
191
+ node, it does not switch it off. `Spawner` and `WaveSpawner` sidestep this by
192
+ DETACHING their `prefab` at enter. For a shelf of templates you clone yourself,
193
+ put `PrefabShelf` on the shelf node and it does the same for all of them:
194
+
195
+ ```json
196
+ { "name": "Prefabs", "type": "Node2D", "script": { "name": "PrefabShelf" },
197
+ "children": [ { "name": "Tower", "type": "Node2D", "children": [] } ] }
198
+ ```
199
+ ```ts
200
+ const shelf = this.node.getNode('/Game/Prefabs').behavior as PrefabShelf;
201
+ const tower = shelf.make('Tower'); // detached, awake all the way down
202
+ tower.position = at; // set it up BEFORE it readies
203
+ this.node.getNode('/Game/Towers').addChild(tower);
204
+ ```
205
+
206
+ Its children never ENTER the tree, so nothing has to be hidden and nothing has
207
+ to be asleep. `make()` returns the clone DETACHED, like `duplicateNode` — not a
208
+ formality: `onReady` fires on attach, and a behavior that banks its node's
209
+ position there (`FloatAway`) would bank the wrong one. `names()` lists what the
210
+ shelf holds, and asking for anything else fails saying so.
211
+
212
+ **Why not just author the templates `"enabled": false`?** That was the old
213
+ advice and it half-works: `enabled` is a per-behavior pause switch, so
214
+ `clone.behavior?.enable()` wakes ONE node — while the shape this same skill
215
+ teaches (one behavior per node, so a prefab's parts live on children) leaves
216
+ every child asleep, silently and for good. Measured on a shipped tower defense:
217
+ the bolt's root `Projectile` was woken and its sibling `Lifetime` was not, so
218
+ every bolt that MISSED flew forever — five still in the tree at 69 seconds. And
219
+ `enabled` never silenced the template's `autoplay` audio at all, which is how
220
+ that game's `feels` rung read `every one of the 4 emitters fired` while two of
221
+ the four were templates going off on the shelf. `enabled: false` is for a state
222
+ a node deliberately starts in (a `Chase` waiting on `spotted`), not for hiding a
223
+ template from the tree. **And look the template up by PATH, not `%Name`**: a clone keeps its
224
+ template's name, so `%Tower` is unambiguous exactly until the first one is
225
+ placed, and then it throws `DUPLICATE_UNIQUE_NAME` from inside your build
226
+ handler — a game that works once. `engine.tick(16)` does
131
227
  not mean "advance 16ms": tick wants wall-clock timestamps, so scripted loops
132
228
  should call `engine.step()` instead. And `queueFree` inside `update` is always
133
229
  safe — the node keeps existing until the pass ends.
@@ -209,6 +305,8 @@ no TypeScript, and all five are clean.
209
305
  { "name": "Spawner", "type": "Timer", "props": { "waitTime": 2, "autostart": true } }
210
306
  ```
211
307
  Emits `timeout` every `waitTime` s (`oneShot` for once). API: `start(time?)`, `stop()`, `running`.
308
+ A `waitTime` of 0 is a load error: the update guard stops a timer with no period
309
+ on its first frame, so it would never fire and never say so.
212
310
 
213
311
  ## CharacterController2D (JSON-only playable characters)
214
312
 
@@ -15,6 +15,7 @@ Prerequisite: `incanto-scene-json-authoring.md` (this directory) for the file fo
15
15
  ```bash
16
16
  bunx incanto new my-game --template platformer-2d # tilemap, jump feel, follow cam, coins
17
17
  bunx incanto new my-game --template star-survivor # endless waves, auto-attack, upgrades
18
+ bunx incanto new my-game --template molehill-2d # played with the MOUSE — no character at all
18
19
  bunx incanto new --list # every starter, 3D and 2D
19
20
  ```
20
21
 
@@ -95,15 +96,55 @@ stop hand-placing dozens of ColorRect walls:
95
96
  - `.` / space = empty; digits `0-9` = atlas tile index directly; any OTHER char
96
97
  must be in `legend` — unknown chars **hard-fail at load** (fix the map, not
97
98
  the runtime).
99
+ - **A legend value is an INDEX, not a description.** Every other tilemap format
100
+ puts solidity in the tile definition, so `"legend": { "#": { "solid": true } }`
101
+ is the natural guess — and it used to load clean and produce a level with no
102
+ floor. It is a hard error now, naming the `solid` prop that was meant.
98
103
  - `solid` tiles are merged into a handful of `StaticBody2D` rect colliders
99
104
  (greedy rectangles), so a 100×50 level costs a few bodies, not thousands.
100
105
  - Cell (0,0) hangs its TOP-LEFT on the node's origin; position the node to
101
106
  place the level. `columns: 0` (default) derives atlas columns from the
102
107
  texture width.
103
- - No texture yet? Colliders still work pair with ColorRect2D placeholders or
104
- just leave it invisible while you block out the level.
108
+ - **No art yet? Paint with `colors`.** A char `#rrggbb` map draws that cell as
109
+ a flat quad, in the same single draw call, with no atlas at all:
110
+
111
+ ```jsonc
112
+ { "name": "Walls", "type": "TileMap2D", "props": {
113
+ "tileSize": 56,
114
+ "cells": ["########", "#......#", "########"],
115
+ "colors": { "#": "#3a4356" },
116
+ "solid": ["#"]
117
+ } }
118
+ ```
119
+
120
+ Exclusive with `texture` (one material, one draw call — a half-textured level
121
+ that loads clean is exactly the silence this engine fails loudly about), and
122
+ `solid` takes the CHAR you painted as well as an atlas index, because that is
123
+ the thing an author knows. Everything else is unchanged: `cellAt`, `tileAt`,
124
+ `setTile`, `worldAt` and the merged colliders work the same. It exists because
125
+ a sokoban built from the published package came out as 24 hand-placed
126
+ `ColorRect2D` walls — the one node that answers every question a grid game
127
+ asks could not be SEEN.
105
128
  - Change the map at runtime by REPLACING `cells` (mutations are not watched):
106
- `map.cells = [...rows]` — geometry and colliders rebuild next frame.
129
+ `map.cells = [...rows]` — geometry and colliders rebuild next frame. For ONE
130
+ cell use `setTile` below, which does that correctly.
131
+
132
+ **Pointing at tiles** — the whole input of a tile game, and four calls:
133
+
134
+ ```ts
135
+ const cell = map.cellAt(...this.engine.pointerWorld() ?? []); // [cx, cy] | null
136
+ if (cell && map.tileAt(...cell) === 1) {
137
+ map.setTile(cell[0], cell[1], '.'); // dig it out (a CHARACTER)
138
+ marker.position = map.worldAt(...cell); // the cell's CENTRE
139
+ }
140
+ ```
141
+
142
+ `cellAt` is null off the grid, `tileAt` is `-1` for an empty cell or one that is
143
+ not there, and `worldAt` gives the cell's **centre** — the half-tile a
144
+ hand-rolled version forgets. All four measure from the node's WORLD position
145
+ (ancestors composed), which is the other thing hand-rolling gets wrong. An
146
+ ancestor that SCALES or rotates the map moves the picture and not the answer,
147
+ and `incanto-check` says so.
107
148
 
108
149
  ### `Sprite2D`
109
150
  | Prop | Default | Notes |
@@ -212,6 +253,13 @@ offsets that survive any canvas size: a `Label` at `[-16, 16]` under a
212
253
  `top-right` layer hugs the corner everywhere. Misspelled anchors fail at load
213
254
  listing the valid set. With a viewport design, UI coordinates are design px.
214
255
 
256
+ **Two spellings, and the engine means it.** `UILayer` writes them with hyphens
257
+ (`top-left`); the `HudLayer` widgets write them in camel case (`topLeft`). Nine
258
+ of the same anchors, two vocabularies, and neither accepts the other's — a
259
+ `UiText` with `"anchor": "top-left"` is a load error, and so is a `UILayer` with
260
+ `"topLeft"`. Both errors print their own valid set, so you are one edit from
261
+ right; this note is so you know why.
262
+
215
263
  ### A `UILayer` still SCALES. On a phone, use `HudLayer`
216
264
 
217
265
  `UILayer` ignores the camera; it does not ignore the viewport. Its contents are
@@ -274,13 +322,33 @@ between a readable and an unusable phone build.
274
322
  touched. Its inverse, `renderer.screenFromWorld(wx, wy)`, pins DOM to the
275
323
  world (see incanto-web-integration).
276
324
 
325
+ **From a Behavior, ask the ENGINE** — `this.engine` is all a behavior has, and
326
+ the renderer is not on it:
327
+
328
+ ```ts
329
+ const at = this.engine.pointerWorld(); // world px, or null
330
+ if (at) this.node.position = at; // it IS a position array
331
+ this.engine.toWorld(sx, sy); // any screen point
332
+ this.engine.toScreen(this.node.position); // → { x, y, behind }
333
+ ```
334
+
335
+ The renderer installs these, exactly as it installs `engine.picker`: `pickAt`
336
+ answers WHICH NODE the cursor is on, `pointerWorld` answers WHERE it is, and a
337
+ drag-and-launch needs the second one. Both are null with no renderer, and a
338
+ `runScript` `click` step (or `incanto-play`'s `at`) installs a geometric
339
+ version for the run — so aiming is testable headless, not only clicking.
340
+
277
341
  ## Game flow recipes
278
342
 
279
343
  - **Scene transition** (level 2, title → game):
280
344
  ```ts
281
345
  import { loadScene } from 'incanto';
282
- game.engine.setScene(loadScene(level2Json));
346
+ game.engine.setScene(loadScene(level2Json, { engine: game.engine }));
283
347
  ```
348
+ Pass `{ engine }`: `onReady` runs during the LOAD, so a behaviour that reads
349
+ `this.engine` or `this.rng` there — `Wander` draws its first heading in it —
350
+ throws `TREE_VIOLATION` without it.
351
+
284
352
  `setScene` frees the old root, clears + redeclares the input map from the new
285
353
  scene's `input{}`, and emits `sceneChanged` — `createGame2D`'s touch overlay
286
354
  rebuilds itself on that signal, the PHYSICS world registers the new scene's
@@ -295,7 +363,7 @@ between a readable and an unusable phone build.
295
363
  last one is why the game-over recipe below is safe — a swap out of a frozen
296
364
  screen boots the next level running, not frozen.
297
365
  - **Game over / restart**: swap to a fresh load of the SAME JSON —
298
- `engine.setScene(loadScene(levelJson))`. `loadScene` treats the JSON as
366
+ `engine.setScene(loadScene(levelJson, { engine }))`. `loadScene` treats the JSON as
299
367
  read-only (everything it keeps is cloned), so reloading the same imported
300
368
  object yields a clean run; only clone (`structuredClone(levelJson)`) first if
301
369
  your own code mutated that object. (`createGame`'s `scene` option already
@@ -323,7 +391,7 @@ constructor).
323
391
  ## Particles (effects in one node)
324
392
 
325
393
  ```json
326
- { "name": "Torch", "type": "Particles2D", "props": { "preset": "fire" } }
394
+ { "name": "Torch", "type": "Particles2D", "props": { "preset": "fire" } },
327
395
  { "name": "Boom", "type": "Particles2D",
328
396
  "props": { "preset": "explosion", "rate": 0, "burst": 60 } }
329
397
  ```
@@ -423,7 +491,7 @@ anything a 540 px window can show.
423
491
 
424
492
  ```
425
493
  camera /Game/Camera centred [480, 274] showing 960×540px
426
- 11 in view, 2 outside it
494
+ 11 in view, 2 outside it, 1 hidden (visible: false)
427
495
  onScreen /Game/Level (TileMap2D) [0, 0] screen [0.333, -0.007] 160px
428
496
  onScreen /Game/Gems/Gem1/Icon (Sprite2D) [512, 428] screen [0.067, 0.57] 157px
429
497
  offscreen /Game/Goblins/Goblin2/Skin (AnimatedSprite2D) [1002, 512] … 574px
@@ -434,14 +502,26 @@ camera /Game/Camera centred [480, 274] showing 960×540px
434
502
  edge". A node counts as in view when its BOX is — a `ColorRect2D`'s `size`, a
435
503
  `TileMap2D`'s whole grid (which hangs from its TOP-LEFT, not its centre), or a
436
504
  collider. A bare `Sprite2D` declares no size, so read `offscreen` there as "its
437
- ORIGIN is outside the view". Nothing is ever "behind" a 2D camera and nothing is
438
- lit, so neither is reported; `overlap` is real interpenetration, not resting on
439
- a platform. HUD widgets are screen-space and are left out entirely.
505
+ ORIGIN is outside the view". `hidden` is `visible: false` on the node or on any
506
+ ancestor counted apart from `offscreen` because the fix differs, and printed as
507
+ `NOTHING IS DRAWN` when it is every drawable in the scene. Nothing is ever
508
+ "behind" a 2D camera and nothing is lit, so neither is reported; `overlap` is
509
+ real interpenetration, not resting on a platform. HUD widgets are screen-space
510
+ and are left out entirely.
440
511
 
441
512
  Then read `stats().errors` (something threw and got skipped) and `assetErrors()`
442
513
  (a texture 404'd). All of these are silent failures otherwise — the screen just
443
514
  looks wrong, or empty, and nothing throws.
444
515
 
516
+ **A game with no jump key is fine.** `jumpAction`, `dashAction` and even
517
+ `moveAction` may name an action the scene never declares — a top-down RPG has
518
+ nothing to jump over — and the character simply never does that thing. Until
519
+ this was fixed the controller THREW every frame, the node was quarantined, and
520
+ the character could not move either: a top-down game that declared only `move`
521
+ reported `✗ error in 1/1` and stood still, with the reason in a log nobody
522
+ prints. An action you TYPED that does not exist is still reported, once, because
523
+ that is a typo and not an omission.
524
+
445
525
  ## Platformer game feel (CharacterController2D)
446
526
 
447
527
  A jump that only fires while `isOnFloor()` is true feels BROKEN, and players do
@@ -491,6 +571,13 @@ entry, which is what the states you have no artwork for should be:
491
571
  }
492
572
  ```
493
573
 
574
+ **The whole map is checked when the scene loads**, not when a clip first plays:
575
+ a clip is `{frames, fps, loop}` and nothing else, so `frame`, `frameRate`,
576
+ `speed` and `repeat` — all real fields in the sheet formats people convert from
577
+ — are load errors naming the field you meant. Only the aliases used to be
578
+ checked, so a misspelled `frames` was a hard error the first time the character
579
+ walked, twenty minutes in.
580
+
494
581
  `incanto-assets copy` and `spriteFromLibraryMeta` already print these aliases,
495
582
  so pasting their output next to the wiring above works as-is; point any of them
496
583
  somewhere better when you have the art. Aliases may chain (`fall` → `jump` →