incanto 0.68.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 (153) hide show
  1. package/assets/catalog.json +9 -5
  2. package/bin/_behaviors-loader.mjs +22 -0
  3. package/bin/_read-json.mjs +28 -0
  4. package/bin/incanto-assets.mjs +19 -5
  5. package/bin/incanto-check.mjs +92 -15
  6. package/bin/incanto-editor.mjs +128 -5
  7. package/bin/incanto-env.mjs +3 -2
  8. package/bin/incanto-feel.mjs +24 -13
  9. package/bin/incanto-frame.mjs +8 -1
  10. package/bin/incanto-multiplay.mjs +11 -9
  11. package/bin/incanto-new.mjs +128 -5
  12. package/bin/incanto-play.mjs +158 -16
  13. package/bin/incanto-playtest.mjs +66 -23
  14. package/bin/incanto-serve.mjs +160 -0
  15. package/bin/incanto-skills.mjs +14 -2
  16. package/bin/incanto-verify.mjs +165 -44
  17. package/bin/incanto.mjs +4 -2
  18. package/dist/2d.d.ts +285 -36
  19. package/dist/2d.js +4 -4
  20. package/dist/3d.d.ts +158 -10
  21. package/dist/3d.js +8 -8
  22. package/dist/{agent8-CvsfVskX.js → agent8-CmNF01gA.js} +61 -8
  23. package/dist/{audio-player-DOrq7sP-.d.ts → audio-player-DaMxqfNE.d.ts} +33 -14
  24. package/dist/{behavior-DoFPYrgo.d.ts → behavior-DZExDn9o.d.ts} +809 -44
  25. package/dist/{create-game-IZIydDwI.js → create-game-Bwvh6q8A.js} +148 -61
  26. package/dist/{create-game-DbWtVTxD.js → create-game-C7ffQWW7.js} +103 -49
  27. package/dist/debug.d.ts +1 -1
  28. package/dist/debug.js +2 -3
  29. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  30. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  31. package/dist/editor.js +1066 -864
  32. package/dist/env.d.ts +1 -1
  33. package/dist/env.js +5 -3
  34. package/dist/{environment-presets-CybQXNqS.js → environment-presets-D6Q5BxeE.js} +299 -46
  35. package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
  36. package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
  37. package/dist/{gameplay-DM1eu_cV.js → gameplay-BfHkuzVb.js} +825 -221
  38. package/dist/gameplay.d.ts +187 -7
  39. package/dist/gameplay.js +1 -1
  40. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  41. package/dist/index.d.ts +156 -13
  42. package/dist/index.js +10 -12
  43. package/dist/json-CfTjpvW8.js +67 -0
  44. package/dist/{loader-CcB533FR.d.ts → loader-Cff09LMm.d.ts} +2 -2
  45. package/dist/net.d.ts +27 -3
  46. package/dist/net.js +2 -2
  47. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  48. package/dist/{physics-2d-B7Y6dPZO.js → physics-2d-CE0Qvy3V.js} +136 -11
  49. package/dist/{physics-3d-bG3n70Ky.js → physics-3d-CpH-2gn5.js} +104 -23
  50. package/dist/{teardown-D2NEmxPB.js → picking-CQJ_PJKh.js} +106 -14
  51. package/dist/react.d.ts +2 -2
  52. package/dist/react.js +2 -2
  53. package/dist/{register-3ta-2Xig.js → register-6DYnKZcy.js} +652 -831
  54. package/dist/{register-ibCjm-wH.js → register-Bkk0wSDB.js} +348 -30
  55. package/dist/{replay-CYvhVHHN.js → replay-DjAkAzMq.js} +224 -14
  56. package/dist/{replay-Dvn8aeBd.d.ts → replay-Dmw-PKQu.d.ts} +20 -3
  57. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  58. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  59. package/dist/{loader-BC4PNtJX.js → save-slots-BXVg148r.js} +4558 -2294
  60. package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
  61. package/dist/{split-screen-DhrSzZIB.d.ts → split-screen--k-XpBjr.d.ts} +36 -4
  62. package/dist/{split-screen-CYwDkbLF.js → split-screen-PL78oVXP.js} +159 -26
  63. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  64. package/dist/{src-D7RIqXYF.js → src-Cxfiv1Hg.js} +2 -17
  65. package/dist/test-iHYVUcDK.js +4036 -0
  66. package/dist/test.d.ts +542 -30
  67. package/dist/test.js +3 -3
  68. package/dist/touch-BnCyPA0G.js +519 -0
  69. package/dist/vite.d.ts +54 -3
  70. package/dist/vite.js +349 -17
  71. package/dist/{webgl-unavailable-N9nQqesw.js → webgl-unavailable-C8aDbGmR.js} +56 -1
  72. package/editor/assets/agent8-D0MS174y.js +1 -0
  73. package/editor/assets/{debug-BBhKuBNV.js → debug-BnXkKuYu.js} +2 -2
  74. package/editor/assets/index-CIu3uc3l.js +11046 -0
  75. package/editor/index.html +1 -1
  76. package/package.json +7 -16
  77. package/schemas/scene.schema.json +30 -3
  78. package/skills/incanto-3d-character.md +14 -1
  79. package/skills/incanto-3d-models.md +12 -0
  80. package/skills/incanto-assets.md +25 -3
  81. package/skills/incanto-audio.md +27 -5
  82. package/skills/incanto-behaviors-and-scripts.md +83 -6
  83. package/skills/incanto-building-2d-games.md +106 -8
  84. package/skills/incanto-building-3d-games.md +118 -6
  85. package/skills/incanto-editor.md +46 -7
  86. package/skills/incanto-environment.md +19 -1
  87. package/skills/incanto-game-feel.md +70 -0
  88. package/skills/incanto-gameplay-behaviors.md +121 -14
  89. package/skills/incanto-hud.md +128 -7
  90. package/skills/incanto-localization.md +13 -5
  91. package/skills/incanto-multiplayer.md +83 -3
  92. package/skills/incanto-node-reference.md +222 -58
  93. package/skills/incanto-performance.md +52 -0
  94. package/skills/incanto-physics-and-input.md +123 -24
  95. package/skills/incanto-playtesting.md +78 -2
  96. package/skills/incanto-save-slots.md +188 -6
  97. package/skills/incanto-scene-json-authoring.md +69 -12
  98. package/skills/incanto-verifying-your-game.md +292 -12
  99. package/skills/incanto-web-integration.md +28 -0
  100. package/skills/incanto-your-first-game.md +5 -2
  101. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  102. package/templates-app/beacon-isle-3d/package.json +2 -2
  103. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  104. package/templates-app/beacon-isle-3d/src/game.scene.json +103 -378
  105. package/templates-app/beacon-isle-3d/src/main.ts +24 -4
  106. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  107. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  108. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  109. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  110. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  111. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  112. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  113. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  114. package/templates-app/molehill-2d/index.html +73 -0
  115. package/templates-app/molehill-2d/package.json +23 -0
  116. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  117. package/templates-app/molehill-2d/src/game.scene.json +1255 -0
  118. package/templates-app/molehill-2d/src/main.ts +41 -0
  119. package/templates-app/molehill-2d/tsconfig.json +13 -0
  120. package/templates-app/molehill-2d/verify.ts +247 -0
  121. package/templates-app/molehill-2d/vite.config.ts +12 -0
  122. package/templates-app/platformer-2d/index.html +0 -23
  123. package/templates-app/platformer-2d/package.json +2 -2
  124. package/templates-app/platformer-2d/src/behaviors.ts +26 -16
  125. package/templates-app/platformer-2d/src/game.scene.json +143 -625
  126. package/templates-app/platformer-2d/src/main.ts +35 -13
  127. package/templates-app/platformer-2d/tsconfig.json +1 -1
  128. package/templates-app/star-survivor/package.json +2 -2
  129. package/templates-app/star-survivor/src/game.scene.json +41 -195
  130. package/templates-app/star-survivor/src/main.ts +28 -7
  131. package/templates-app/star-survivor/tsconfig.json +1 -1
  132. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  133. package/templates-app/tps-3d/package.json +2 -2
  134. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  135. package/templates-app/tps-3d/src/game.scene.json +78 -217
  136. package/templates-app/tps-3d/src/main.ts +39 -17
  137. package/templates-app/tps-3d/tsconfig.json +1 -1
  138. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  139. package/templates-app/village-quest-3d/package.json +2 -2
  140. package/templates-app/village-quest-3d/src/behaviors.ts +21 -0
  141. package/templates-app/village-quest-3d/src/grove.scene.json +54 -221
  142. package/templates-app/village-quest-3d/src/main.ts +24 -4
  143. package/templates-app/village-quest-3d/src/village.scene.json +199 -838
  144. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  145. package/templates-app/village-quest-3d/verify.ts +14 -1
  146. package/dist/duplicate-IWIqk0HJ.js +0 -22
  147. package/dist/json-CwwhxQgb.js +0 -36
  148. package/dist/registry-CF70EArN.js +0 -212
  149. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  150. package/dist/test-8hoHeRmo.js +0 -2340
  151. package/dist/touch-DEAmqGdf.js +0 -225
  152. package/editor/assets/agent8-BrrHOjMJ.js +0 -1
  153. package/editor/assets/index-eVd0BToA.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-eVd0BToA.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-CIu3uc3l.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.68.0",
3
+ "version": "0.70.0",
4
4
  "description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
5
5
  "keywords": [
6
6
  "game-engine",
@@ -54,8 +54,10 @@
54
54
  "scripts": {
55
55
  "build": "tsdown",
56
56
  "dev": "tsdown --watch",
57
- "typecheck": "tsc --noEmit",
58
- "test": "vitest run --project unit --root ../.."
57
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
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",
@@ -102,6 +92,7 @@
102
92
  "incanto-new": "bin/incanto-new.mjs",
103
93
  "incanto-frame": "bin/incanto-frame.mjs",
104
94
  "incanto-verify": "./bin/incanto-verify.mjs",
105
- "incanto-logs": "./bin/incanto-logs.mjs"
95
+ "incanto-logs": "./bin/incanto-logs.mjs",
96
+ "incanto-serve": "./bin/incanto-serve.mjs"
106
97
  }
107
98
  }
@@ -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
@@ -9442,7 +9462,14 @@
9442
9462
  "default": true
9443
9463
  },
9444
9464
  "underwater": {
9445
- "type": "boolean",
9465
+ "anyOf": [
9466
+ {
9467
+ "type": "boolean"
9468
+ },
9469
+ {
9470
+ "type": "object"
9471
+ }
9472
+ ],
9446
9473
  "default": true
9447
9474
  },
9448
9475
  "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
@@ -21,13 +21,27 @@ playable character — model, locomotion clips, body, controller, skin and input
21
21
  ## 1. Built-in assets (in the package)
22
22
 
23
23
  ```bash
24
- bunx incanto-assets list # the full catalog (see categories below)
24
+ bunx incanto-assets list # name · kind · GRID · what it is
25
25
  bunx incanto-assets info medieval-knight # description + animation names
26
26
  bunx incanto-assets copy medieval-knight --out public/assets
27
27
  ```
28
28
 
29
29
  `list --json` prints every entry. Each entry carries a **`url`** — the drop-in
30
- reference you put in scene JSON so the asset LOADS (the contract, see §1b).
30
+ reference you put in scene JSON so the asset LOADS (the contract, see §1b) —
31
+ and, where the art has a grid, the numbers a node needs:
32
+
33
+ ```
34
+ 2dbasic character [animated] 111×83 2dbasic sprite sheet image…
35
+ minecraft-tiles tile 16×16 (25 tiles) Minecraft-themed tiles…
36
+ ```
37
+
38
+ `frameWidth`/`frameHeight` are what `AnimatedSprite2D.frameWidth` and
39
+ `TileMap2D.tileSize` want; `columns` and `tiles` tell you the highest index a
40
+ `legend` may name (25 tiles means 0–24, and asking for 99 draws clamp-streaks —
41
+ the engine reports it, but the catalog is where you get the number). **The sizes
42
+ live in these fields and nowhere else** — a frame size in the description was a
43
+ second copy, and it drifted: `2dbasic` said 192×192 for art whose real grid is
44
+ 111×83.
31
45
 
32
46
  ### Categories — the whole built-in set
33
47
 
@@ -55,7 +69,7 @@ Every catalog entry has a `url` that is directly usable; there are two classes:
55
69
  // Tree3D — a bare node already loads oak leaves from the agent8 CDN; override
56
70
  // leafTexture only to swap in a different built-in cutout (e.g. ash)
57
71
  { "type": "Tree3D", "props": { "type": "broadleaf",
58
- "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" } },
59
73
 
60
74
  // MeshInstance3D material — built-in ground/bark color as a map (+ a normal map)
61
75
  { "type": "MeshInstance3D", "props": { "material": {
@@ -224,3 +238,11 @@ node keeps its own texture cache. In **2D** the same question is `renderer.asset
224
238
  (`$ref`, url and reason per failed entry), and the scene EDITOR reads it: a
225
239
  failed asset is red in the explorer with the url in its tooltip and the
226
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.
@@ -414,17 +414,26 @@ let cursor = 0; // next un-queued note
414
414
  engine.updated.connect(() => {
415
415
  const horizon = engine.sfx.now + LEAD;
416
416
  while (cursor < chart.length && startedAt + chart[cursor].atSec <= horizon) {
417
- hit.play(startedAt + chart[cursor].atSec); // AudioPlayer.play(at)
417
+ hit.playAt(startedAt + chart[cursor].atSec); // NOT play() — see below
418
418
  cursor++;
419
419
  }
420
420
  });
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.
425
- - **`AudioPlayer.play(at)`** and **`engine.sfx.play(params, gain, { when })`** —
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.)
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
- scheduling clock; `at` is ignored for one rather than approximated.
431
+ scheduling clock, so `playAt` on one plays immediately.
432
+ - **`play()` takes no arguments, deliberately.** It is the method scenes wire
433
+ signals to, and a signal hands its handler whatever it carries — `collected`
434
+ leads with a number, so `collected → play` would have become `play(10)` and
435
+ scheduled the pickup sound at absolute audio-clock second 10. Scheduling has
436
+ its own name.
428
437
  - A time already past plays immediately (Web Audio's own rule), so a scheduler
429
438
  that ran late is late, not silent.
430
439
  - Everything else still applies: the bus gain, `engine.audio.recent()`, and
@@ -443,7 +452,9 @@ const songSeconds = engine.music.playhead; // null = nothing playing, or no cl
443
452
  ```
444
453
 
445
454
  It is the raw element time, so a looping track wraps to 0 each pass — a loop
446
- 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.
447
458
 
448
459
  ## Decision guide
449
460
 
@@ -479,6 +490,17 @@ session.engine.audio.recent();
479
490
  its volume comes from. `countOf(name)` is the assertion you usually want;
480
491
  `clearLog()` resets between steps.
481
492
 
493
+ **A LOOPING sound is recorded once, and its entry carries `loop: true`.**
494
+ Re-recording every pass would evict the rest of the log within seconds for a
495
+ 0.2 s preset, so the count stays 1 — which used to make "the alarm loops"
496
+ indistinguishable from "the alarm fired once and stopped". Assert the flag when
497
+ that is the difference you care about:
498
+
499
+ ```ts
500
+ const alarm = session.engine.audio.recent().find((e) => e.name === 'alarm');
501
+ expect(alarm?.loop).toBe(true);
502
+ ```
503
+
482
504
  This covers every path: `AudioPlayer.play()` on both the procedural and the
483
505
  `src` route, `engine.music.play`/`crossfadeTo`, and `engine.sfx.startVoice`. It
484
506
  records the INTENT to play — that the wiring fired — not that a speaker moved;
@@ -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,46 @@ 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
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 |
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 — `visible: false` hides a node, it does not switch it
191
+ off. `Spawner` and `WaveSpawner` sidestep this by DETACHING their `prefab` at
192
+ ready; a shelf of templates you clone yourself is still in the tree, so author
193
+ their scripts `"enabled": false` and wake the clone:
194
+
195
+ ```ts
196
+ const tower = duplicateNode(this.node.getNode('/Game/Prefabs/Tower'));
197
+ tower.behavior?.enable(); // the template stays asleep, this one works
198
+ this.node.getNode('/Game/Towers').addChild(tower);
199
+ ```
200
+
201
+ Without it an invisible tower at the origin defends your map, and the game looks
202
+ fine. **And look the template up by PATH, not `%Name`**: a clone keeps its
203
+ template's name, so `%Tower` is unambiguous exactly until the first one is
204
+ placed, and then it throws `DUPLICATE_UNIQUE_NAME` from inside your build
205
+ handler — a game that works once. `engine.tick(16)` does
131
206
  not mean "advance 16ms": tick wants wall-clock timestamps, so scripted loops
132
207
  should call `engine.step()` instead. And `queueFree` inside `update` is always
133
208
  safe — the node keeps existing until the pass ends.
@@ -209,6 +284,8 @@ no TypeScript, and all five are clean.
209
284
  { "name": "Spawner", "type": "Timer", "props": { "waitTime": 2, "autostart": true } }
210
285
  ```
211
286
  Emits `timeout` every `waitTime` s (`oneShot` for once). API: `start(time?)`, `stop()`, `running`.
287
+ A `waitTime` of 0 is a load error: the update guard stops a timer with no period
288
+ on its first frame, so it would never fire and never say so.
212
289
 
213
290
  ## CharacterController2D (JSON-only playable characters)
214
291
 
@@ -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,6 +96,10 @@ 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
@@ -103,7 +108,25 @@ stop hand-placing dozens of ColorRect walls:
103
108
  - No texture yet? Colliders still work — pair with ColorRect2D placeholders or
104
109
  just leave it invisible while you block out the level.
105
110
  - Change the map at runtime by REPLACING `cells` (mutations are not watched):
106
- `map.cells = [...rows]` — geometry and colliders rebuild next frame.
111
+ `map.cells = [...rows]` — geometry and colliders rebuild next frame. For ONE
112
+ cell use `setTile` below, which does that correctly.
113
+
114
+ **Pointing at tiles** — the whole input of a tile game, and four calls:
115
+
116
+ ```ts
117
+ const cell = map.cellAt(...this.engine.pointerWorld() ?? []); // [cx, cy] | null
118
+ if (cell && map.tileAt(...cell) === 1) {
119
+ map.setTile(cell[0], cell[1], '.'); // dig it out (a CHARACTER)
120
+ marker.position = map.worldAt(...cell); // the cell's CENTRE
121
+ }
122
+ ```
123
+
124
+ `cellAt` is null off the grid, `tileAt` is `-1` for an empty cell or one that is
125
+ not there, and `worldAt` gives the cell's **centre** — the half-tile a
126
+ hand-rolled version forgets. All four measure from the node's WORLD position
127
+ (ancestors composed), which is the other thing hand-rolling gets wrong. An
128
+ ancestor that SCALES or rotates the map moves the picture and not the answer,
129
+ and `incanto-check` says so.
107
130
 
108
131
  ### `Sprite2D`
109
132
  | Prop | Default | Notes |
@@ -212,6 +235,42 @@ offsets that survive any canvas size: a `Label` at `[-16, 16]` under a
212
235
  `top-right` layer hugs the corner everywhere. Misspelled anchors fail at load
213
236
  listing the valid set. With a viewport design, UI coordinates are design px.
214
237
 
238
+ **Two spellings, and the engine means it.** `UILayer` writes them with hyphens
239
+ (`top-left`); the `HudLayer` widgets write them in camel case (`topLeft`). Nine
240
+ of the same anchors, two vocabularies, and neither accepts the other's — a
241
+ `UiText` with `"anchor": "top-left"` is a load error, and so is a `UILayer` with
242
+ `"topLeft"`. Both errors print their own valid set, so you are one edit from
243
+ right; this note is so you know why.
244
+
245
+ ### A `UILayer` still SCALES. On a phone, use `HudLayer`
246
+
247
+ `UILayer` ignores the camera; it does not ignore the viewport. Its contents are
248
+ design pixels multiplied by the same scale as the world, and on a portrait phone
249
+ that scale is small:
250
+
251
+ ```
252
+ 1280×800 scale 1.333 a fontSize 15 Label paints at 20 device px
253
+ 390×844 scale 0.406 the same Label paints at 6.1 device px
254
+ ```
255
+
256
+ Six device pixels is an unreadable smear, and it is the flagship 2D template's
257
+ control hint before this was fixed. The vertical framing goes the same way:
258
+ `design: [960, 540]` under `fit: "expand"` shows a portrait phone **2077 world
259
+ px** of height, so the play surface is a band with acres of empty sky.
260
+
261
+ **`HudLayer` and the `Ui*` widgets are DOM at their declared CSS pixels** and are
262
+ immune — which is why the engine's own volume sliders and touch controls stayed
263
+ legible in the same capture that reduced the template's HUD to a grey line. Use
264
+ them for anything a player has to READ:
265
+
266
+ | | |
267
+ | --- | --- |
268
+ | `Label` under `UILayer` | part of the picture — damage numbers, world-anchored callouts, art |
269
+ | `UiText` under `HudLayer` | part of the interface — score, hearts, hints, menus |
270
+
271
+ See `incanto-hud.md`. It costs nothing on desktop and it is the difference
272
+ between a readable and an unusable phone build.
273
+
215
274
  ## Patterns
216
275
 
217
276
  - Group pickups/enemies (`"groups": ["coins"]`) and query `scene.tree.getNodesInGroup('coins')`.
@@ -245,13 +304,33 @@ listing the valid set. With a viewport design, UI coordinates are design px.
245
304
  touched. Its inverse, `renderer.screenFromWorld(wx, wy)`, pins DOM to the
246
305
  world (see incanto-web-integration).
247
306
 
307
+ **From a Behavior, ask the ENGINE** — `this.engine` is all a behavior has, and
308
+ the renderer is not on it:
309
+
310
+ ```ts
311
+ const at = this.engine.pointerWorld(); // world px, or null
312
+ if (at) this.node.position = at; // it IS a position array
313
+ this.engine.toWorld(sx, sy); // any screen point
314
+ this.engine.toScreen(this.node.position); // → { x, y, behind }
315
+ ```
316
+
317
+ The renderer installs these, exactly as it installs `engine.picker`: `pickAt`
318
+ answers WHICH NODE the cursor is on, `pointerWorld` answers WHERE it is, and a
319
+ drag-and-launch needs the second one. Both are null with no renderer, and a
320
+ `runScript` `click` step (or `incanto-play`'s `at`) installs a geometric
321
+ version for the run — so aiming is testable headless, not only clicking.
322
+
248
323
  ## Game flow recipes
249
324
 
250
325
  - **Scene transition** (level 2, title → game):
251
326
  ```ts
252
327
  import { loadScene } from 'incanto';
253
- game.engine.setScene(loadScene(level2Json));
328
+ game.engine.setScene(loadScene(level2Json, { engine: game.engine }));
254
329
  ```
330
+ Pass `{ engine }`: `onReady` runs during the LOAD, so a behaviour that reads
331
+ `this.engine` or `this.rng` there — `Wander` draws its first heading in it —
332
+ throws `TREE_VIOLATION` without it.
333
+
255
334
  `setScene` frees the old root, clears + redeclares the input map from the new
256
335
  scene's `input{}`, and emits `sceneChanged` — `createGame2D`'s touch overlay
257
336
  rebuilds itself on that signal, the PHYSICS world registers the new scene's
@@ -266,7 +345,7 @@ listing the valid set. With a viewport design, UI coordinates are design px.
266
345
  last one is why the game-over recipe below is safe — a swap out of a frozen
267
346
  screen boots the next level running, not frozen.
268
347
  - **Game over / restart**: swap to a fresh load of the SAME JSON —
269
- `engine.setScene(loadScene(levelJson))`. `loadScene` treats the JSON as
348
+ `engine.setScene(loadScene(levelJson, { engine }))`. `loadScene` treats the JSON as
270
349
  read-only (everything it keeps is cloned), so reloading the same imported
271
350
  object yields a clean run; only clone (`structuredClone(levelJson)`) first if
272
351
  your own code mutated that object. (`createGame`'s `scene` option already
@@ -294,7 +373,7 @@ constructor).
294
373
  ## Particles (effects in one node)
295
374
 
296
375
  ```json
297
- { "name": "Torch", "type": "Particles2D", "props": { "preset": "fire" } }
376
+ { "name": "Torch", "type": "Particles2D", "props": { "preset": "fire" } },
298
377
  { "name": "Boom", "type": "Particles2D",
299
378
  "props": { "preset": "explosion", "rate": 0, "burst": 60 } }
300
379
  ```
@@ -394,7 +473,7 @@ anything a 540 px window can show.
394
473
 
395
474
  ```
396
475
  camera /Game/Camera centred [480, 274] showing 960×540px
397
- 11 in view, 2 outside it
476
+ 11 in view, 2 outside it, 1 hidden (visible: false)
398
477
  onScreen /Game/Level (TileMap2D) [0, 0] screen [0.333, -0.007] 160px
399
478
  onScreen /Game/Gems/Gem1/Icon (Sprite2D) [512, 428] screen [0.067, 0.57] 157px
400
479
  offscreen /Game/Goblins/Goblin2/Skin (AnimatedSprite2D) [1002, 512] … 574px
@@ -405,14 +484,26 @@ camera /Game/Camera centred [480, 274] showing 960×540px
405
484
  edge". A node counts as in view when its BOX is — a `ColorRect2D`'s `size`, a
406
485
  `TileMap2D`'s whole grid (which hangs from its TOP-LEFT, not its centre), or a
407
486
  collider. A bare `Sprite2D` declares no size, so read `offscreen` there as "its
408
- ORIGIN is outside the view". Nothing is ever "behind" a 2D camera and nothing is
409
- lit, so neither is reported; `overlap` is real interpenetration, not resting on
410
- a platform. HUD widgets are screen-space and are left out entirely.
487
+ ORIGIN is outside the view". `hidden` is `visible: false` on the node or on any
488
+ ancestor — counted apart from `offscreen` because the fix differs, and printed as
489
+ `NOTHING IS DRAWN` when it is every drawable in the scene. Nothing is ever
490
+ "behind" a 2D camera and nothing is lit, so neither is reported; `overlap` is
491
+ real interpenetration, not resting on a platform. HUD widgets are screen-space
492
+ and are left out entirely.
411
493
 
412
494
  Then read `stats().errors` (something threw and got skipped) and `assetErrors()`
413
495
  (a texture 404'd). All of these are silent failures otherwise — the screen just
414
496
  looks wrong, or empty, and nothing throws.
415
497
 
498
+ **A game with no jump key is fine.** `jumpAction`, `dashAction` and even
499
+ `moveAction` may name an action the scene never declares — a top-down RPG has
500
+ nothing to jump over — and the character simply never does that thing. Until
501
+ this was fixed the controller THREW every frame, the node was quarantined, and
502
+ the character could not move either: a top-down game that declared only `move`
503
+ reported `✗ error in 1/1` and stood still, with the reason in a log nobody
504
+ prints. An action you TYPED that does not exist is still reported, once, because
505
+ that is a typo and not an omission.
506
+
416
507
  ## Platformer game feel (CharacterController2D)
417
508
 
418
509
  A jump that only fires while `isOnFloor()` is true feels BROKEN, and players do
@@ -462,6 +553,13 @@ entry, which is what the states you have no artwork for should be:
462
553
  }
463
554
  ```
464
555
 
556
+ **The whole map is checked when the scene loads**, not when a clip first plays:
557
+ a clip is `{frames, fps, loop}` and nothing else, so `frame`, `frameRate`,
558
+ `speed` and `repeat` — all real fields in the sheet formats people convert from
559
+ — are load errors naming the field you meant. Only the aliases used to be
560
+ checked, so a misspelled `frames` was a hard error the first time the character
561
+ walked, twenty minutes in.
562
+
465
563
  `incanto-assets copy` and `spriteFromLibraryMeta` already print these aliases,
466
564
  so pasting their output next to the wiring above works as-is; point any of them
467
565
  somewhere better when you have the art. Aliases may chain (`fall` → `jump` →