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
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-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.69.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",
@@ -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
@@ -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
@@ -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,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,13 @@ 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
+
215
245
  ### A `UILayer` still SCALES. On a phone, use `HudLayer`
216
246
 
217
247
  `UILayer` ignores the camera; it does not ignore the viewport. Its contents are
@@ -274,13 +304,33 @@ between a readable and an unusable phone build.
274
304
  touched. Its inverse, `renderer.screenFromWorld(wx, wy)`, pins DOM to the
275
305
  world (see incanto-web-integration).
276
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
+
277
323
  ## Game flow recipes
278
324
 
279
325
  - **Scene transition** (level 2, title → game):
280
326
  ```ts
281
327
  import { loadScene } from 'incanto';
282
- game.engine.setScene(loadScene(level2Json));
328
+ game.engine.setScene(loadScene(level2Json, { engine: game.engine }));
283
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
+
284
334
  `setScene` frees the old root, clears + redeclares the input map from the new
285
335
  scene's `input{}`, and emits `sceneChanged` — `createGame2D`'s touch overlay
286
336
  rebuilds itself on that signal, the PHYSICS world registers the new scene's
@@ -295,7 +345,7 @@ between a readable and an unusable phone build.
295
345
  last one is why the game-over recipe below is safe — a swap out of a frozen
296
346
  screen boots the next level running, not frozen.
297
347
  - **Game over / restart**: swap to a fresh load of the SAME JSON —
298
- `engine.setScene(loadScene(levelJson))`. `loadScene` treats the JSON as
348
+ `engine.setScene(loadScene(levelJson, { engine }))`. `loadScene` treats the JSON as
299
349
  read-only (everything it keeps is cloned), so reloading the same imported
300
350
  object yields a clean run; only clone (`structuredClone(levelJson)`) first if
301
351
  your own code mutated that object. (`createGame`'s `scene` option already
@@ -323,7 +373,7 @@ constructor).
323
373
  ## Particles (effects in one node)
324
374
 
325
375
  ```json
326
- { "name": "Torch", "type": "Particles2D", "props": { "preset": "fire" } }
376
+ { "name": "Torch", "type": "Particles2D", "props": { "preset": "fire" } },
327
377
  { "name": "Boom", "type": "Particles2D",
328
378
  "props": { "preset": "explosion", "rate": 0, "burst": 60 } }
329
379
  ```
@@ -423,7 +473,7 @@ anything a 540 px window can show.
423
473
 
424
474
  ```
425
475
  camera /Game/Camera centred [480, 274] showing 960×540px
426
- 11 in view, 2 outside it
476
+ 11 in view, 2 outside it, 1 hidden (visible: false)
427
477
  onScreen /Game/Level (TileMap2D) [0, 0] screen [0.333, -0.007] 160px
428
478
  onScreen /Game/Gems/Gem1/Icon (Sprite2D) [512, 428] screen [0.067, 0.57] 157px
429
479
  offscreen /Game/Goblins/Goblin2/Skin (AnimatedSprite2D) [1002, 512] … 574px
@@ -434,14 +484,26 @@ camera /Game/Camera centred [480, 274] showing 960×540px
434
484
  edge". A node counts as in view when its BOX is — a `ColorRect2D`'s `size`, a
435
485
  `TileMap2D`'s whole grid (which hangs from its TOP-LEFT, not its centre), or a
436
486
  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.
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.
440
493
 
441
494
  Then read `stats().errors` (something threw and got skipped) and `assetErrors()`
442
495
  (a texture 404'd). All of these are silent failures otherwise — the screen just
443
496
  looks wrong, or empty, and nothing throws.
444
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
+
445
507
  ## Platformer game feel (CharacterController2D)
446
508
 
447
509
  A jump that only fires while `isOnFloor()` is true feels BROKEN, and players do
@@ -491,6 +553,13 @@ entry, which is what the states you have no artwork for should be:
491
553
  }
492
554
  ```
493
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
+
494
563
  `incanto-assets copy` and `spriteFromLibraryMeta` already print these aliases,
495
564
  so pasting their output next to the wiring above works as-is; point any of them
496
565
  somewhere better when you have the art. Aliases may chain (`fall` → `jump` →
@@ -81,7 +81,7 @@ All 3D nodes extend `Node3D` and therefore have the transform props:
81
81
  ### `MeshInstance3D`
82
82
  | Prop | Default | Notes |
83
83
  |---|---|---|
84
- | `mesh` | `"box"` | `box \| sphere \| capsule \| plane \| cylinder \| gem` — `gem` is a faceted crystal (icosahedron); with `material.flatShading: true` + low `roughness` it reads as a sparkling jewel (spin/tumble it for the sparkle) |
84
+ | `mesh` | `"box"` | `box \| sphere \| capsule \| plane \| cylinder \| gem` — `gem` is a faceted crystal (icosahedron); with `material.flatShading: true` + low `roughness` it reads as a sparkling jewel (spin/tumble it for the sparkle). **This list is the whole list**: for a CURVED hull — a car body, a boat, a canopy, a fuselage — there is no primitive, and stacking boxes never gives you one. Use `LoftMesh3D` (below) |
85
85
  | `size` | `[1,1,1]` | box: extents · sphere/gem: radius = x · capsule: radius = x, height = y · plane: x·z ground (laid FLAT on XZ) · cylinder: radius = x, height = y |
86
86
  | `material` | `{}` | `{color: '#hex', metalness: 0..1, roughness: 0..1, opacity: 0..1, clearcoat: 0..1, clearcoatRoughness: 0..1, envMapIntensity: >=0, wireframe, flatShading, depthTest, depthWrite, emissive, emissiveIntensity, map, normalMap, repeat: [u,v]}` — the object IS the material state: omitted keys reset to defaults (`#ffffff`, 0, 1, 1, false, false, depthTest/Write true). `opacity < 1` turns on transparency; `flatShading` gives faceted per-face glints (the gem sparkle). `depthTest: false` (+ a high `renderOrder`) makes a flat `plane`/`cylinder` an ALWAYS-ON-TOP ground decal — AoE telegraphs, selection/spawn rings — that never z-fights with or is hidden by bumpy terrain (`depthWrite: false` keeps it from occluding later effects). `map`/`normalMap` are texture URLs (lazy-loaded, headless-safe; map samples sRGB, normalMap linear), tiled `repeat` times across each face — box/plane UVs span 0..1 per face, so for worldspace density use `repeat: [length/tile, height/tile]` (e.g. a 6×2.5 m brick wall at one tile per 2 m → `[3, 1.25]`). `color` tints the map (near-white keeps the texture's own color). CAR PAINT: `clearcoat: 1` adds a glossy lacquer layer with its own reflection over a metallic base (`clearcoatRoughness` ~0.05-0.1 = mirror lacquer) — THE premium vehicle-paint look; `envMapIntensity` scales how strongly the surface mirrors the scene's sky/HDRI (glass windows want ~2, matte surfaces <1; needs a `sky`/`hdri` environment to reflect — at night with no sky there is nothing to mirror). NB: under ACES a very bright `emissiveIntensity` blows out to white — keep it modest (~1–2) + a saturated color for a vivid GLOWING look. Unknown keys, malformed `repeat`, or `repeat` without a map hard-fail at load |
87
87
  | `castShadow` / `receiveShadow` | `false` | |
@@ -270,10 +270,43 @@ and `Fill.position = [-(1 - frac) / 2, 0, 0.01]`. The offsets stay local to the
270
270
  (rotated) bar, so the drain always hugs the bar's left edge.
271
271
 
272
272
  ### `Camera3D`
273
- `fov: 60`, `near: 0.1`, `far: 1000`, `current: false`.
273
+ `fov: 60`, `near: 0.1`, `far: 1000`, `current: false`, `lookAt: ""`.
274
274
  Mark exactly ONE camera `current: true` (otherwise the first camera in tree order is used).
275
- Aspect ratio is automatic. To frame an origin-centered scene, a good default is
276
- `position: [7, 6, 9]`, `rotation: [-26, 36, 0]`.
275
+ Aspect ratio is automatic.
276
+
277
+ **Point it with `lookAt`, not with a `rotation` you worked out.**
278
+
279
+ ```json
280
+ { "name": "Cam", "type": "Camera3D",
281
+ "props": { "current": true, "position": [7, 6, 9], "lookAt": "%Player" } }
282
+ ```
283
+
284
+ `lookAt` is a node path, re-aimed every frame, so it also tracks a target that
285
+ moves. It orients with WORLD UP, which is the part you cannot write yourself in
286
+ scene JSON — and cannot write in game code either without importing three, which
287
+ this engine's own guardrail forbids ("mutate node props — never touch three.js
288
+ objects").
289
+
290
+ This is not a convenience. A hand-written `rotation` for a camera is
291
+ **wrong at every heading but one**, and the way it is wrong is invisible from
292
+ inside the frame:
293
+
294
+ - The one published direction→rotation formula (`incanto-3d-character.md`, "the
295
+ +Z-FORWARD rule", *"There is ONE facing formula"*) is derived for a SKIN — a rod,
296
+ which has no meaningful up. Measured over 72 look headings, applying it to a
297
+ camera aims perfectly (dot 1.000 in all 72) and puts the world **upside down in
298
+ 36 of them**. Its own published check, `rodAxis · (target − eye) ≈ 1`, scores
299
+ 1.000 on every upside-down one.
300
+ - **No two-angle recipe can be right.** `rotation` is Euler XYZ, so
301
+ `[pitch, yaw, 0]` is Rx·Ry while a level camera is Ry·Rx: an upright camera's
302
+ third angle is NON-ZERO. The `[-26, 36, 0]` this document recommended for years
303
+ aims at 0.9924 and tilts the horizon **16°**; the level answer is
304
+ `[-33.69, 32.91, 19.91]`, which nobody would write by hand.
305
+ - A camera looks down its own local **−Z**, unlike every skin in the engine.
306
+
307
+ `framing()` now prints `up [x, y, z]` and the roll in degrees, so a tilted or
308
+ inverted camera is something you can SEE in a report rather than something you
309
+ notice in a screenshot.
277
310
 
278
311
  ### Lights
279
312
  - `DirectionalLight3D` — `color: '#ffffff'`, `intensity: 1`, `castShadow: false`.
@@ -524,6 +557,13 @@ for (const o of off) console.error(` ${o.path}: ${o.gap > 0 ? 'floats' : 'burie
524
557
  Put it in your `verify.ts` next to `auditScene`. It costs nothing and it is the
525
558
  difference between shipping a floating bridge and not.
526
559
 
560
+ **Mark what is not meant to touch the ground.** `snapToGround: false` is the
561
+ escape and it INHERITS, so one mark exempts a whole assembly: a cloud made of
562
+ puffs, a palm made of trunk segments, a lamp at the top of a lighthouse, a rock
563
+ sunk into the slope on purpose. Put it on the group, not on its parts — before
564
+ 0.73 it read the node's own prop only, and this repo's own flagship template
565
+ reported 64 floating cloud puffs.
566
+
527
567
  ## The loading screen has to cover the WHOLE boot
528
568
 
529
569
  Downloading the assets is the first half. Building a dressed scene and
@@ -569,6 +609,20 @@ previous explosion across the level) as
569
609
  point sprites in meters: preset distances auto-scale ÷100 (presets are authored
570
610
  in 2D px). See incanto-building-2d-games for the full prop list.
571
611
 
612
+ **Firing one from a behaviour is `replay()`** — move the emitter, then replay:
613
+
614
+ ```ts
615
+ const dust = this.node.getNode('Dust') as Particles3D;
616
+ dust.position = [x, 0.2, z];
617
+ dust.replay(); // `emitting: false` stays false; this is the game asking
618
+ ```
619
+
620
+ Not `play`, not `burst`, not `emit` (that one belongs to signals). The 2D skill
621
+ has always named it and this one did not, so a 3D game built from this page
622
+ alone had a one-shot it could not fire. `particles()` is the same node's test
623
+ hook — the live particle list, for a harness asking whether anything came out.
624
+ `engine.effects.countOf('explosion')` is the cheaper question.
625
+
572
626
  Points render through a built-in soft sprite with a SOLID opaque core (not hard
573
627
  squares), so colours read even at small sizes, and `'normal'` blend fades cleanly
574
628
  to transparent (per-particle RGBA alpha). `paletteColors` (per-particle