incanto 0.67.0 → 0.69.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 (87) hide show
  1. package/assets/catalog.json +9 -5
  2. package/bin/_pick-server.mjs +48 -0
  3. package/bin/incanto-assets.mjs +14 -1
  4. package/bin/incanto-check.mjs +69 -5
  5. package/bin/incanto-frame.mjs +10 -25
  6. package/bin/incanto-logs.mjs +8 -18
  7. package/bin/incanto-new.mjs +29 -1
  8. package/bin/incanto-serve.mjs +160 -0
  9. package/bin/incanto-verify.mjs +40 -0
  10. package/bin/incanto.mjs +1 -0
  11. package/dist/2d.d.ts +10 -8
  12. package/dist/2d.js +6 -4
  13. package/dist/3d.d.ts +31 -7
  14. package/dist/3d.js +9 -7
  15. package/dist/{audio-player-BNUHo0JF.d.ts → audio-player-C0j2k-yB.d.ts} +80 -3
  16. package/dist/{behavior-ZnfzKDo3.d.ts → behavior-rm8vVzu_.d.ts} +145 -1
  17. package/dist/boot-failure-CKYrEcGF.js +47 -0
  18. package/dist/{create-game-FAXdHta3.js → create-game-CvJ1wSRP.js} +27 -27
  19. package/dist/{create-game-DMetd3vR.js → create-game-CwxEDlCm.js} +15 -11
  20. package/dist/debug.d.ts +1 -1
  21. package/dist/{duplicate-B8QnX2-r.js → duplicate-Dj4L-5AA.js} +1 -1
  22. package/dist/{editor-switch-DAvWQeld.d.ts → editor-switch-CnIOiyNJ.d.ts} +1 -1
  23. package/dist/editor.js +12 -6
  24. package/dist/env.d.ts +1 -1
  25. package/dist/{environment-presets-C-5g2pMh.js → environment-presets-BO-7XoFV.js} +58 -9
  26. package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
  27. package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
  28. package/dist/{gameplay-CGSv-9XR.js → gameplay-D1KixWK8.js} +175 -151
  29. package/dist/gameplay.d.ts +51 -3
  30. package/dist/gameplay.js +2 -2
  31. package/dist/index.d.ts +6 -6
  32. package/dist/index.js +8 -7
  33. package/dist/{loader-KZESOZmE.js → loader-DZE7B07H.js} +48 -7
  34. package/dist/{loader-DyT30ArB.d.ts → loader-DadV2QCT.d.ts} +2 -2
  35. package/dist/net.d.ts +2 -2
  36. package/dist/net.js +1 -1
  37. package/dist/{physics-2d-_B6zj3zU.js → physics-2d-CF5yyjbj.js} +2 -2
  38. package/dist/{physics-3d-i1IeAY0F.js → physics-3d-DbYaEcZy.js} +3 -3
  39. package/dist/react.d.ts +2 -2
  40. package/dist/react.js +1 -1
  41. package/dist/{register-vIxU3Xbv.js → register-DbVArsns.js} +19 -10
  42. package/dist/{register-Cs53CY7s.js → register-mmfugpaQ.js} +159 -6
  43. package/dist/{replay-BkpDV51O.js → replay-BEHLDcQV.js} +28 -5
  44. package/dist/{replay-D0GcEHYU.d.ts → replay-BQfkX4x7.d.ts} +38 -4
  45. package/dist/{schema-CFeioQRE.d.ts → schema-B6ugCV1Q.d.ts} +11 -0
  46. package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
  47. package/dist/{split-screen-DS8RaUV_.js → split-screen-CauMwu6y.js} +2 -2
  48. package/dist/{split-screen-DD5eYIgW.d.ts → split-screen-Cg76MCV5.d.ts} +3 -3
  49. package/dist/{src-BLlsGJW9.js → src-CYEu-_S9.js} +1 -1
  50. package/dist/{teardown-LB9cTnXJ.js → teardown-CMqFVxO9.js} +5 -4
  51. package/dist/{test-81uZ967a.js → test-D6ISmuFE.js} +73 -25
  52. package/dist/test.d.ts +17 -6
  53. package/dist/test.js +3 -3
  54. package/dist/vite.d.ts +70 -2
  55. package/dist/vite.js +176 -14
  56. package/dist/{rendering-options-ByAdi8PF.js → webgl-unavailable-Z5_g8x9H.js} +57 -2
  57. package/editor/assets/{agent8-C2jtNKbx.js → agent8-BDvaZMQJ.js} +1 -1
  58. package/editor/assets/{debug-DoU3KeqS.js → debug-CMULinBW.js} +1 -1
  59. package/editor/assets/{index-CNqHojcC.js → index-CZIRr5EL.js} +92 -92
  60. package/editor/index.html +1 -1
  61. package/package.json +4 -3
  62. package/schemas/scene.schema.json +4 -0
  63. package/skills/incanto-3d-character.md +1 -0
  64. package/skills/incanto-assets.md +16 -2
  65. package/skills/incanto-audio.md +65 -0
  66. package/skills/incanto-building-2d-games.md +66 -0
  67. package/skills/incanto-building-3d-games.md +97 -2
  68. package/skills/incanto-gameplay-behaviors.md +46 -2
  69. package/skills/incanto-node-reference.md +1 -0
  70. package/skills/incanto-physics-and-input.md +23 -0
  71. package/skills/incanto-save-slots.md +27 -0
  72. package/skills/incanto-scene-json-authoring.md +12 -1
  73. package/skills/incanto-verifying-your-game.md +96 -2
  74. package/skills/incanto-your-first-game.md +2 -1
  75. package/templates-app/beacon-isle-3d/package.json +1 -1
  76. package/templates-app/beacon-isle-3d/src/main.ts +21 -4
  77. package/templates-app/platformer-2d/index.html +0 -23
  78. package/templates-app/platformer-2d/package.json +1 -1
  79. package/templates-app/platformer-2d/src/behaviors.ts +26 -16
  80. package/templates-app/platformer-2d/src/game.scene.json +17 -27
  81. package/templates-app/platformer-2d/src/main.ts +32 -13
  82. package/templates-app/star-survivor/package.json +1 -1
  83. package/templates-app/star-survivor/src/main.ts +25 -7
  84. package/templates-app/tps-3d/package.json +1 -1
  85. package/templates-app/tps-3d/src/main.ts +34 -16
  86. package/templates-app/village-quest-3d/package.json +1 -1
  87. package/templates-app/village-quest-3d/src/main.ts +21 -4
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-CNqHojcC.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-CZIRr5EL.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.67.0",
3
+ "version": "0.69.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,7 +54,7 @@
54
54
  "scripts": {
55
55
  "build": "tsdown",
56
56
  "dev": "tsdown --watch",
57
- "typecheck": "tsc --noEmit",
57
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
58
58
  "test": "vitest run --project unit --root ../.."
59
59
  },
60
60
  "peerDependencies": {
@@ -102,6 +102,7 @@
102
102
  "incanto-new": "bin/incanto-new.mjs",
103
103
  "incanto-frame": "bin/incanto-frame.mjs",
104
104
  "incanto-verify": "./bin/incanto-verify.mjs",
105
- "incanto-logs": "./bin/incanto-logs.mjs"
105
+ "incanto-logs": "./bin/incanto-logs.mjs",
106
+ "incanto-serve": "./bin/incanto-serve.mjs"
106
107
  }
107
108
  }
@@ -2378,6 +2378,10 @@
2378
2378
  "type": "boolean",
2379
2379
  "default": true
2380
2380
  },
2381
+ "platformCarry": {
2382
+ "type": "boolean",
2383
+ "default": true
2384
+ },
2381
2385
  "turnSpeed": {
2382
2386
  "type": "number",
2383
2387
  "default": 100
@@ -54,6 +54,7 @@ did not until now.
54
54
  | `maxJumps` | `1` | `2` = double jump. Extra jumps work in mid-air. |
55
55
  | `airControl` | `0.2` | how much ground control you keep airborne (0 = committed, 1 = full). |
56
56
  | `fallGravity` | `2.5` | gravity multiplier while falling. Higher = snappier arc. |
57
+ | `platformCarry` | `true` | ride whatever you are standing on. Off = the controller steers in WORLD space, so standing still on a moving floor brakes you off it (measured: 95.9% of a moving deck's travel kept, 0.0% with this off). |
57
58
 
58
59
  Every one defaults to OFF (`0` / `1`) or to the previous hard-coded constant, so
59
60
  a scene that asks for nothing behaves exactly as before.
@@ -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
 
@@ -396,6 +396,60 @@ changed under it. `engine.music` deliberately survives a scene swap (it belongs
396
396
  to the engine, not the scene): call `engine.music.stop(1)` or `crossfadeTo` when
397
397
  the music should change.
398
398
 
399
+ ## Sounds on a GRID: rhythm games and anything charted to a soundtrack
400
+
401
+ A sound fired from `update()` cannot land closer than one frame to where a chart
402
+ wants it — 16.67 ms at 60 Hz, 33.33 ms at 30, and that is the whole difference
403
+ between a rhythm game that feels tight and one that does not. The engine's frame
404
+ clock is exact (3600 steps land on `engine.time` 60.000000000 s and beat error
405
+ never compounds); the last 16 ms is the gap.
406
+
407
+ Schedule on the AUDIO clock instead. Queue a short LOOKAHEAD ahead of now, every
408
+ frame, and let Web Audio place the sound:
409
+
410
+ ```ts
411
+ const LEAD = 0.08; // schedule this far ahead of the speaker
412
+ let cursor = 0; // next un-queued note
413
+
414
+ engine.updated.connect(() => {
415
+ const horizon = engine.sfx.now + LEAD;
416
+ while (cursor < chart.length && startedAt + chart[cursor].atSec <= horizon) {
417
+ hit.playAt(startedAt + chart[cursor].atSec); // NOT play() — see below
418
+ cursor++;
419
+ }
420
+ });
421
+ ```
422
+
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.playAt(when)`** and **`engine.sfx.play(params, gain, { when })`** —
426
+ PRESETS only. A `src` clip goes through an `<audio>` element, which has no
427
+ scheduling clock, so `playAt` on one plays immediately.
428
+ - **`play()` takes no arguments, deliberately.** It is the method scenes wire
429
+ signals to, and a signal hands its handler whatever it carries — `collected`
430
+ leads with a number, so `collected → play` would have become `play(10)` and
431
+ scheduled the pickup sound at absolute audio-clock second 10. Scheduling has
432
+ its own name.
433
+ - A time already past plays immediately (Web Audio's own rule), so a scheduler
434
+ that ran late is late, not silent.
435
+ - Everything else still applies: the bus gain, `engine.audio.recent()`, and
436
+ `muted`. A scheduler hand-rolled on a raw `AudioContext` hits the same
437
+ accuracy and loses all three.
438
+
439
+ Measured: 0 of 64 notes were given a scheduled start on the frame path; the same
440
+ 64 queued this way land at **|mean| 0.000000 ms**.
441
+
442
+ **Charting against a music FILE** needs the playhead of the thing you can hear,
443
+ not a counter you started next to `music.play()` (a different clock, which keeps
444
+ counting through a gesture-block, a stall or a seek):
445
+
446
+ ```ts
447
+ const songSeconds = engine.music.playhead; // null = nothing playing, or no clock
448
+ ```
449
+
450
+ 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.
452
+
399
453
  ## Decision guide
400
454
 
401
455
  - **Need a quick game sound (coin/jump/hit/explosion/…)** → set `preset`. Done.
@@ -430,6 +484,17 @@ session.engine.audio.recent();
430
484
  its volume comes from. `countOf(name)` is the assertion you usually want;
431
485
  `clearLog()` resets between steps.
432
486
 
487
+ **A LOOPING sound is recorded once, and its entry carries `loop: true`.**
488
+ Re-recording every pass would evict the rest of the log within seconds for a
489
+ 0.2 s preset, so the count stays 1 — which used to make "the alarm loops"
490
+ indistinguishable from "the alarm fired once and stopped". Assert the flag when
491
+ that is the difference you care about:
492
+
493
+ ```ts
494
+ const alarm = session.engine.audio.recent().find((e) => e.name === 'alarm');
495
+ expect(alarm?.loop).toBe(true);
496
+ ```
497
+
433
498
  This covers every path: `AudioPlayer.play()` on both the procedural and the
434
499
  `src` route, `engine.music.play`/`crossfadeTo`, and `engine.sfx.startVoice`. It
435
500
  records the INTENT to play — that the wiring fired — not that a speaker moved;
@@ -212,6 +212,35 @@ offsets that survive any canvas size: a `Label` at `[-16, 16]` under a
212
212
  `top-right` layer hugs the corner everywhere. Misspelled anchors fail at load
213
213
  listing the valid set. With a viewport design, UI coordinates are design px.
214
214
 
215
+ ### A `UILayer` still SCALES. On a phone, use `HudLayer`
216
+
217
+ `UILayer` ignores the camera; it does not ignore the viewport. Its contents are
218
+ design pixels multiplied by the same scale as the world, and on a portrait phone
219
+ that scale is small:
220
+
221
+ ```
222
+ 1280×800 scale 1.333 a fontSize 15 Label paints at 20 device px
223
+ 390×844 scale 0.406 the same Label paints at 6.1 device px
224
+ ```
225
+
226
+ Six device pixels is an unreadable smear, and it is the flagship 2D template's
227
+ control hint before this was fixed. The vertical framing goes the same way:
228
+ `design: [960, 540]` under `fit: "expand"` shows a portrait phone **2077 world
229
+ px** of height, so the play surface is a band with acres of empty sky.
230
+
231
+ **`HudLayer` and the `Ui*` widgets are DOM at their declared CSS pixels** and are
232
+ immune — which is why the engine's own volume sliders and touch controls stayed
233
+ legible in the same capture that reduced the template's HUD to a grey line. Use
234
+ them for anything a player has to READ:
235
+
236
+ | | |
237
+ | --- | --- |
238
+ | `Label` under `UILayer` | part of the picture — damage numbers, world-anchored callouts, art |
239
+ | `UiText` under `HudLayer` | part of the interface — score, hearts, hints, menus |
240
+
241
+ See `incanto-hud.md`. It costs nothing on desktop and it is the difference
242
+ between a readable and an unusable phone build.
243
+
215
244
  ## Patterns
216
245
 
217
246
  - Group pickups/enemies (`"groups": ["coins"]`) and query `scene.tree.getNodesInGroup('coins')`.
@@ -485,3 +514,40 @@ which is why a wall jump was not awkward to write but impossible.
485
514
  headless bot sessions and reports what could not be reached, which wires never
486
515
  fired, and whether anything in the level can hurt the player. See
487
516
  `incanto-playtesting.md`.
517
+
518
+ ## When the boot FAILS: say so, or the bar sits at 100% forever
519
+
520
+ `createGame2D`/`createGame2D` reject like any promise, and the loading overlay
521
+ every starter shows is removed on the line *after* the await. So a boot that
522
+ fails leaves the bar at 100% with no text, and the reason in a console the
523
+ player will never open. Measured with WebGL denied on a pristine scaffold: the
524
+ overlay was still reading `Emberwood 100%` 8.6 seconds later.
525
+
526
+ One `.catch` fixes it, and every shipped template now carries it:
527
+
528
+ ```ts
529
+ import { createGame2D, showBootFailure } from 'incanto/2d';
530
+
531
+ const game = await createGame2D({ canvas, scene: sceneJson }).catch((e) => {
532
+ showBootFailure(e); // paints the reason into #loading (or its own panel)
533
+ throw e;
534
+ });
535
+
536
+ document.querySelector('#loading')?.remove();
537
+ ```
538
+
539
+ **No WebGL is the case worth naming** — the 2D renderer is WebGL too. A machine
540
+ with acceleration off, a blocklisted driver, a locked-down or headless browser, or a page that already
541
+ holds too many contexts cannot make one, and three throws
542
+ `Error('Error creating WebGL context.')` with nothing to branch on. The engine
543
+ rethrows it as an `IncantoError` with code **`WEBGL_UNAVAILABLE`** and a message
544
+ naming the likely causes, so you can show a real screen:
545
+
546
+ ```ts
547
+ import { isWebGLAvailable } from 'incanto/2d';
548
+
549
+ if (!isWebGLAvailable()) showMyOwnBrowserTooOldScreen(); // ask BEFORE booting
550
+ ```
551
+
552
+ `isWebGLAvailable()` probes a throwaway canvas and never touches yours; it
553
+ returns `false` outside a browser.
@@ -320,8 +320,66 @@ atmosphere recipe below).
320
320
 
321
321
  ## Reference example
322
322
 
323
- A minimal 3D scene ground plane + 3 orbiting primitives + sun +
324
- lamp + camera, verified rendering. Copy it as a starting point.
323
+ The smallest 3D scene that renders something you can see. Every line matters:
324
+ without the light it is black, without `current: true` the camera is a guess,
325
+ and without `ambient` the unlit faces are pitch dark.
326
+
327
+ ```json
328
+ {
329
+ "format": 1,
330
+ "type": "scene",
331
+ "dimension": "3d",
332
+ "name": "Hello",
333
+ "environment": {
334
+ "sky": { "type": "atmosphere", "elevationDeg": 35, "azimuthDeg": 140 },
335
+ "ambient": { "color": "#ffffff", "intensity": 0.2 },
336
+ "shadows": true
337
+ },
338
+ "root": {
339
+ "name": "World",
340
+ "type": "Node3D",
341
+ "children": [
342
+ { "name": "Sun", "type": "DirectionalLight3D",
343
+ "props": { "position": [6, 10, 4], "intensity": 2, "castShadow": true } },
344
+ { "name": "Camera", "type": "Camera3D",
345
+ "props": { "position": [7, 6, 9], "rotation": [-26, 36, 0], "current": true } },
346
+ { "name": "Ground", "type": "MeshInstance3D",
347
+ "props": { "mesh": "plane", "size": [40, 1, 40], "receiveShadow": true,
348
+ "material": { "color": "#6b8f3a", "roughness": 1 } } },
349
+ { "name": "Pivot", "type": "Node3D", "script": { "name": "Spin" },
350
+ "children": [
351
+ { "name": "Cube", "type": "MeshInstance3D",
352
+ "props": { "mesh": "box", "position": [2, 0.5, 0], "castShadow": true,
353
+ "material": { "color": "#e0563f", "roughness": 0.4 } } },
354
+ { "name": "Ball", "type": "MeshInstance3D",
355
+ "props": { "mesh": "sphere", "size": [0.6, 0.6, 0.6], "position": [-2, 0.6, 0],
356
+ "castShadow": true,
357
+ "material": { "color": "#4f8fe0", "metalness": 0.6, "roughness": 0.2 } } },
358
+ { "name": "Gem", "type": "MeshInstance3D",
359
+ "props": { "mesh": "gem", "size": [0.5, 0.5, 0.5], "position": [0, 0.8, 2],
360
+ "castShadow": true,
361
+ "material": { "color": "#ffd166", "flatShading": true, "roughness": 0.1 } } }
362
+ ] }
363
+ ]
364
+ }
365
+ }
366
+ ```
367
+
368
+ Rotating the PIVOT orbits all three children — no maths in the behaviour:
369
+
370
+ ```ts
371
+ import { Behavior } from 'incanto';
372
+
373
+ export class Spin extends Behavior {
374
+ override update(dt: number): void {
375
+ const node = this.node as unknown as { rotation: number[] };
376
+ node.rotation = [0, (node.rotation[1] ?? 0) + 40 * dt, 0];
377
+ }
378
+ }
379
+ ```
380
+
381
+ Check it before you open a browser: `bunx incanto-check` says the scene is legal
382
+ and lit, and `bunx incanto-verify` walks the whole ladder.
325
383
 
326
384
  ## Atmosphere (sky, fog, shadows, exposure)
327
385
 
@@ -648,3 +706,40 @@ It stubs unregistered behaviors (it measures geometry, not logic; a scene with
648
706
  any custom behavior used to read as clean — measured 0 floating before
649
707
  `registerBehavior`, 26 after) and throws when it cannot measure. A level built
650
708
  from mesh floors has no terrain height to read: use a physics raycast instead.
709
+
710
+ ## When the boot FAILS: say so, or the bar sits at 100% forever
711
+
712
+ `createGame2D`/`createGame3D` reject like any promise, and the loading overlay
713
+ every starter shows is removed on the line *after* the await. So a boot that
714
+ fails leaves the bar at 100% with no text, and the reason in a console the
715
+ player will never open. Measured with WebGL denied on a pristine scaffold: the
716
+ overlay was still reading `Emberwood 100%` 8.6 seconds later.
717
+
718
+ One `.catch` fixes it, and every shipped template now carries it:
719
+
720
+ ```ts
721
+ import { createGame3D, showBootFailure } from 'incanto/3d';
722
+
723
+ const game = await createGame3D({ canvas, scene: sceneJson }).catch((e) => {
724
+ showBootFailure(e); // paints the reason into #loading (or its own panel)
725
+ throw e;
726
+ });
727
+
728
+ document.querySelector('#loading')?.remove();
729
+ ```
730
+
731
+ **No WebGL is the case worth naming.** A machine with acceleration off, a
732
+ blocklisted driver, a locked-down or headless browser, or a page that already
733
+ holds too many contexts cannot make one, and three throws
734
+ `Error('Error creating WebGL context.')` with nothing to branch on. The engine
735
+ rethrows it as an `IncantoError` with code **`WEBGL_UNAVAILABLE`** and a message
736
+ naming the likely causes, so you can show a real screen:
737
+
738
+ ```ts
739
+ import { isWebGLAvailable } from 'incanto/3d';
740
+
741
+ if (!isWebGLAvailable()) showMyOwnBrowserTooOldScreen(); // ask BEFORE booting
742
+ ```
743
+
744
+ `isWebGLAvailable()` probes a throwaway canvas and never touches yours; it
745
+ returns `false` outside a browser.
@@ -71,7 +71,14 @@ warn: World/Enemy: Chase.target — '%Playerr' matches no node in this scene.
71
71
 
72
72
  — which is the one failure that used to be completely silent (a prop path
73
73
  resolves with `getNodeOrNull`, so the scene opens and the enemy just never
74
- chases). `Chase.target` and `FollowCamera.target` are `required`: an EMPTY one
74
+ chases). That is for a behaviour the ENGINE ships: `incanto-check` reads scene
75
+ files and cannot load your TypeScript, so a `nodePath: true` prop on a
76
+ behaviour of YOUR OWN is invisible to it. `auditScene` from `incanto/test`
77
+ gives the identical sentence once your behaviours are registered — which is why
78
+ every scaffolded `verify.ts` calls it before `runScript`, and why a
79
+ hand-written harness that skips it loses this check.
80
+
81
+ `Chase.target` and `FollowCamera.target` are `required`: an EMPTY one
75
82
  is a load error naming the node, not a surprise in the browser.
76
83
 
77
84
  **Never write `/root/<RootName>/...`.** If your root node is named `Game`, the
@@ -1189,6 +1196,16 @@ Multi-scene games: `flow.goToScene(nextSceneJson, { fadeSeconds: 0.4 })`
1189
1196
  fades to black, swaps, fades back (headless = instant). Title → level →
1190
1197
  next level is three JSON files and this one call.
1191
1198
 
1199
+ **A swap builds the physics world the incoming scene needs.** `physics: 'auto'`
1200
+ can only look at the scene the game BOOTS with, and a title screen has no
1201
+ bodies — so this used to leave the level with no physics at all: it rendered
1202
+ perfectly, nothing fell, nothing collided, nothing could be collected or hurt.
1203
+ `createGame2D`/`createGame3D` now hand the engine a way to build one, and the
1204
+ swap asks. It is asynchronous (Rapier is WASM), so the first frames after a
1205
+ swap into a physics scene run unsimulated — imperceptible, and the alternative
1206
+ was a game that never simulated. `physics: false` stays off across swaps, and a
1207
+ scene with no bodies still pays for nothing.
1208
+
1192
1209
  ## DayNight (live 24-hour cycle)
1193
1210
 
1194
1211
  Drives the scene `environment` (needs an atmosphere sky): sun elevation/
@@ -1211,7 +1228,7 @@ spawners, music, or monster aggression to it. The `hour` field (0-24) is
1211
1228
  writable: jump to a time of day in one assignment. For one-off look changes
1212
1229
  use `setEnvironment3D(engine, patch)` from `incanto/3d` directly.
1213
1230
 
1214
- ## Boot loading overlay## Boot loading overlay
1231
+ ## Boot loading overlay
1215
1232
 
1216
1233
  ```ts
1217
1234
  import { preloadSceneAssets } from 'incanto';
@@ -1221,6 +1238,33 @@ await preloadSceneAssets(sceneJson.assets); // black screen + bar + %, auto-re
1221
1238
  Replaces the ~125-line hand-rolled loading screen; `createLoadingOverlay()`
1222
1239
  gives you the raw overlay when you preload something else.
1223
1240
 
1241
+ ## Where a node actually IS (`position` is parent-relative)
1242
+
1243
+ `node.position` is the offset from its PARENT, not the world. A scene nests
1244
+ freely — `Terrain/Clouds/Cloud1/Puff1` four deep is what the engine's own
1245
+ generator writes — so a distance check reading `position` on a nested node
1246
+ measures from the wrong origin and says nothing about it. Measured on a crate
1247
+ parented to a rock: `position` is `[0, 3.02, 0]` and the world point is
1248
+ `[13, 14.31, 80.18]` — **82 m apart**.
1249
+
1250
+ ```ts
1251
+ import { localFromWorld, worldPosition } from 'incanto/gameplay';
1252
+
1253
+ const here = worldPosition(crate); // [13, 14.31, 80.18]
1254
+ crate.position = localFromWorld(crate, [0, 20, 0]); // put it at world [0,20,0]
1255
+ ```
1256
+
1257
+ Both work headless and in both dimensions — arity comes from the node (2 for a
1258
+ 2D node, 3 for a 3D one), and an ancestor with no `position` contributes
1259
+ nothing. Do NOT reach for `_ensureObject3D().matrixWorld`: it is internal, and
1260
+ in a headless verify script there is no renderer to parent the object, so it
1261
+ hands back the LOCAL position with no warning.
1262
+
1263
+ **Translation only.** They sum ancestor positions and ignore ancestor rotation
1264
+ and scale — the same contract physics already uses (`worldPosition2D` /
1265
+ `worldPosition3D`) and that `applyGroundSnap` assumes. A node under a ROTATING
1266
+ parent needs the renderer's matrix, not this.
1267
+
1224
1268
  ## Persistence (high scores, unlocks, settings)
1225
1269
 
1226
1270
  ```ts
@@ -297,6 +297,7 @@ Signals: `movementStateChanged(state)`
297
297
  | `pitchMin` | `-1.3` | number |
298
298
  | `pitchMax` | `1.5` | number |
299
299
  | `cameraCollision` | `true` | boolean |
300
+ | `platformCarry` | `true` | boolean |
300
301
  | `turnSpeed` | `100` | number |
301
302
  | `camLerp` | `25` | number |
302
303
  | `moveAction` | `"move"` | string |
@@ -479,6 +479,29 @@ rotating discs. It is what makes a floating-island game work.
479
479
  Author it the obvious way: move the platform's `position` (a behavior, a
480
480
  `PathFollow`, an `Oscillate`). Nothing else to declare.
481
481
 
482
+ **The 3D PLAYER is a different rig, and it needs one prop.** A
483
+ `CharacterController3D` must sit under a dynamic `RigidBody3D` (hard error
484
+ otherwise), not a `CharacterBody3D` — the character bodies in a 3D game are its
485
+ enemies and NPCs. The controller steers toward a target velocity every fixed
486
+ step, so pressing nothing means "target zero", which in world space is a brake
487
+ aimed at the platform's own motion. `platformCarry` (default **true**) makes
488
+ that target relative to the floor instead. Measured on a 400 m deck moving
489
+ 4 m/s, rider pressing nothing: **95.9%** of the travel kept, **0.0%** with
490
+ `platformCarry: false`.
491
+
492
+ ```json
493
+ { "name": "Ctl", "type": "CharacterController3D",
494
+ "props": { "view": "free", "platformCarry": false } }
495
+ ```
496
+
497
+ Turn it off for a conveyor you want to be scenery. Vertical carry needs no prop
498
+ either way — the hover spring rides whatever surface is under it, which is why
499
+ a 3D elevator always looked right while a conveyor did not.
500
+
501
+ The carry comes from the platform's POSITION DELTA, not its `linearVelocity`,
502
+ because writing `position` calls `setTranslation` — a teleport that transfers no
503
+ momentum to anything. Both authoring styles work.
504
+
482
505
  The carry stops the moment the character is no longer grounded on it, so walking
483
506
  off an edge or jumping is not "sticky", and it is vertical as well as horizontal
484
507
  — an elevator lifts you.
@@ -253,6 +253,33 @@ that DOES serialize, on a node with no uid, is state that goes nowhere.
253
253
  (`Health`, `ScoreKeeper`, `Collector`); a scene file cannot be asked whether
254
254
  YOUR behavior serializes, so this walks the live tree and names those too.
255
255
 
256
+ ## When the browser will not store anything
257
+
258
+ A private window, storage disabled, or an exhausted quota: `localStorage`
259
+ throws, and the store falls back to memory. **That fallback is right** —
260
+ refusing to save would be worse — but everything in the session still reads
261
+ healthy, so the player only finds out by reloading and losing the run. Measured
262
+ under Safari-private conditions: `set('highScore', 4200)` then `get(...)`
263
+ returned 4200, with zero warnings and no way to ask.
264
+
265
+ ```ts
266
+ if (!slots.persistent) {
267
+ banner.show('This browser will not keep your progress — private window?', { seconds: 6 });
268
+ }
269
+ ```
270
+
271
+ `SaveSlots.persistent` (and `SaveStore.persistent`) is `false` whenever writes
272
+ live only as long as the tab. The engine also says it once per namespace: on the
273
+ console, and — from `SavePoint` — through `engine.log`, so `incanto-logs` and
274
+ the `says` rung of `incanto-verify` see it too.
275
+
276
+ `SavePoint` still emits `saved(slot)` in that state, deliberately: it did save,
277
+ for as long as the page is open, and a Continue button that never lights up
278
+ would be a second bug. Ask `persistent` before promising the player anything.
279
+
280
+ Headless — tests, verify scripts, SSR — `persistent` is `false` and nothing is
281
+ logged: in-memory is the design there, not a failure.
282
+
256
283
  ## Saving from the scene — `SavePoint`
257
284
 
258
285
  *When* to save is a design decision (checkpoint, level end, on quit) and only
@@ -26,6 +26,7 @@ wiring, and composition are all in the file. TS is for *behavior only* (attached
26
26
  "constants": { "UI": 1000, "Background": -100 },
27
27
  "input": { "<action>": { "...": "...", "touch": "joystick|button (optional — mobile on-screen controls)" } },
28
28
  "multiplayer": { "room": "auto" },
29
+ "fragment": false,
29
30
  "root": { "name": "Level1", "type": "Node", "children": [] },
30
31
  "connections": []
31
32
  }
@@ -33,6 +34,13 @@ wiring, and composition are all in the file. TS is for *behavior only* (attached
33
34
 
34
35
  - `format` MUST be `1`. `type` MUST be `"scene"`. `name` non-empty.
35
36
  - `dimension` is optional: `"2d"` or `"3d"` only.
37
+ - `fragment` (optional, default `false`) says this file is a PIECE of another
38
+ scene — a spawner's prefab, a sub-scene something instances — and is never
39
+ rendered on its own. It changes nothing at runtime; it tells the checker to
40
+ stop asking a prefab where its camera and its sun are. Set it on any scene
41
+ you load from TypeScript and hand to a spawner. (A scene another scene
42
+ embeds with `instance` needs no key — `incanto-check` walks the project and
43
+ can see that for itself.)
36
44
  - `viewport` (optional) makes scene JSON own responsive layout: author the world
37
45
  in fixed `design` pixels (`[width, height]`, positive numbers) and the renderer
38
46
  maps them onto any canvas size. `fit`: `"expand"` (design rect always fully
@@ -158,7 +166,10 @@ level 1's layers.
158
166
  ## Parents and children
159
167
 
160
168
  EVERY node type can hold children — `children` is universal, so any node works
161
- as a grouping container. The inverse is not true: some types demand a SPECIFIC
169
+ as a grouping container. **A child's `position` is relative to its parent**, so
170
+ nesting is also how you move a group: shift the parent and everything under it
171
+ follows. From code, `worldPosition(node)` (`incanto/gameplay`) is where a nested
172
+ node actually is; `node.position` is the local offset. The inverse is not true: some types demand a SPECIFIC
162
173
  parent and are therefore also invalid as the root. Today's rule:
163
174
 
164
175
  - `CharacterController2D` must be a direct child of a `CharacterBody2D` —
@@ -116,6 +116,16 @@ The sections below are the same rungs one at a time, for when you need the detai
116
116
 
117
117
  ## 1. After EVERY scene edit: `incanto-check`
118
118
 
119
+ ```bash
120
+ bunx incanto-check # structure, assets, semantics
121
+ bunx incanto-check --behaviors src/behaviors.ts --strict-behaviors
122
+ ```
123
+
124
+ `--strict-behaviors` makes a `script.name` that resolves nowhere a FAILURE
125
+ instead of a stub — and it needs `--behaviors` to know what you registered.
126
+ Without it, on any project with a behaviour of its own, it could only ever fail:
127
+ `Unknown behavior 'Hunter' … Did you forget registerBehavior?`
128
+
119
129
  ```bash
120
130
  bunx incanto-check # validates every *.scene.json under cwd
121
131
  bunx incanto-check --json # machine-readable (code + details.path/prop)
@@ -637,8 +647,9 @@ the path, then **▶ play ▸ ⤢ run as the game** and the page is the game aga
637
647
  running your edit. `game.openEditor()` is the same switch from code.
638
648
 
639
649
  It follows `debug` (so a build with the overlay has it) and loads lazily, so a
640
- game that never opens it never downloads it. `editor: false` opts out;
641
- `editor: { save }` adds the editor's save button, wired to your own writer:
650
+ game that never opens it never downloads it. `editor: false` opts out at
651
+ RUNTIME — the button is absent — and `editor: { save }` adds the editor's save
652
+ button, wired to your own writer:
642
653
 
643
654
  ```ts
644
655
  createGame3D({
@@ -660,6 +671,89 @@ overlay is a lazily-imported chunk — it stays out of the main bundle (no size
660
671
  cost), but it still ships in the build, so that runtime `debug` flag is the only
661
672
  thing gating it: keep it `false` (or dev-gated) in anything you publish.
662
673
 
674
+ ## 9. Verify the thing you SHIP, not just the thing you run
675
+
676
+ Everything above measures a dev server. The artifact a player gets is
677
+ `bun run build` — a bundled, minified `dist/` with different asset paths and a
678
+ different module graph — and the boot path is the only code a BUILD adds over a
679
+ SCENE. Two of the nastiest defects this engine has had lived exactly there: a
680
+ `main.ts` whose scene swap left the game with no physics, and a boot that threw
681
+ behind a loading overlay that never came down.
682
+
683
+ `incanto-frame` and `incanto-logs` ask a dev server, and the dev server asks the
684
+ page over vite's HMR channel. A build has no HMR, so they used to answer:
685
+
686
+ ```
687
+ ? draws — no incanto dev server found.
688
+ ? says — no incanto dev server found.
689
+ ```
690
+
691
+ **`incanto-serve` closes that.** It serves your build as plain static files —
692
+ no bundler, no transform, byte-for-byte what you deploy — and adds only the
693
+ `/__incanto/*` endpoints the CLIs already speak, plus one inline script injected
694
+ into `index.html` at serve time. Nothing is written to disk, so what you measure
695
+ is what you ship.
696
+
697
+ ```bash
698
+ bun run build # the artifact
699
+ bunx incanto-serve # ./dist on :4180 (or: incanto-serve build --port 5050)
700
+ # open http://127.0.0.1:4180/ , then:
701
+ bunx incanto-verify # draws and says now measure the BUILT game
702
+ bunx incanto-logs # what the built page is complaining about
703
+ bunx incanto-frame --out shipped.png
704
+ ```
705
+
706
+ **Ship-check list**, in the order things actually break:
707
+
708
+ 1. **`bun run build` at all.** Dev-only code (`import.meta.env.DEV`, a plugin
709
+ that is `apply: 'serve'`) can compile and then do nothing.
710
+ 2. **Open the built page.** A blank screen with a console error is the common
711
+ failure; `incanto-logs` reads it without you opening devtools.
712
+ 3. **`incanto-verify` against `incanto-serve`.** `draws` catches an asset base
713
+ path that survived dev and broke under a subdirectory deploy; `says` catches
714
+ the 404s.
715
+ 4. **A phone-sized window.** See `incanto-web-integration.md` — a HUD that is
716
+ legible at 1280×800 can be 6 px tall at 390×844.
717
+ 5. **A second load.** Saves, settings and unlocks come back; a private window
718
+ answers `slots.persistent === false` (see `incanto-save-slots.md`).
719
+
720
+ The one thing `incanto-serve` is not is a production server: it is a local tool
721
+ for looking at a build, on `127.0.0.1` by default.
722
+
723
+ ### The editor is in your build, even in a 2D game
724
+
725
+ `editor: false` is a runtime option and **cannot remove bytes from a bundle**.
726
+ The editor chunk is lazy — a game that never opens it never downloads it — but
727
+ it is in `dist/`, and it imports `incanto/3d`, which imports rapier3d. Measured
728
+ on a pristine `platformer-2d` scaffold:
729
+
730
+ ```
731
+ 2236503 assets/rapier-*.js rapier3d ← a 2D game can never execute this
732
+ 1692278 assets/rapier-*.js rapier2d ← the one it uses
733
+ 471186 assets/3d-*.js the 3D adapter
734
+ ─────────
735
+ 5979588 total
736
+ ```
737
+
738
+ A build-time decision needs a build-time lever:
739
+
740
+ ```ts
741
+ import { incantoScenes, incantoTrim } from 'incanto/vite';
742
+ export default defineConfig({
743
+ plugins: [incantoScenes(), incantoTrim({ editor: false })],
744
+ });
745
+ ```
746
+
747
+ ```
748
+ 5979588 → 2769972 bytes 53.7% smaller, same game
749
+ ```
750
+
751
+ Build only: the dev server keeps the editor, so `☰ debug ▸ ✎ edit this scene`
752
+ works the whole time you are making the game and is simply absent from what you
753
+ ship. Opening it in a trimmed build says so in one sentence rather than failing
754
+ obscurely. Reach for this when deploy size matters — a CDN, a jam upload, an
755
+ agent8 zip — and leave it off otherwise.
756
+
663
757
  ## 8. Debugging signals
664
758
 
665
759
  - `this.log.info(...)` in behaviors — shows up in `runScript().logs` (and the