incanto 0.68.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 (75) hide show
  1. package/assets/catalog.json +9 -5
  2. package/bin/incanto-assets.mjs +14 -1
  3. package/bin/incanto-check.mjs +53 -3
  4. package/bin/incanto-new.mjs +29 -1
  5. package/bin/incanto-serve.mjs +160 -0
  6. package/bin/incanto-verify.mjs +40 -0
  7. package/bin/incanto.mjs +1 -0
  8. package/dist/2d.d.ts +7 -5
  9. package/dist/2d.js +4 -4
  10. package/dist/3d.d.ts +4 -4
  11. package/dist/3d.js +7 -7
  12. package/dist/{audio-player-DOrq7sP-.d.ts → audio-player-C0j2k-yB.d.ts} +32 -13
  13. package/dist/{behavior-DoFPYrgo.d.ts → behavior-rm8vVzu_.d.ts} +67 -0
  14. package/dist/{create-game-DbWtVTxD.js → create-game-CvJ1wSRP.js} +13 -9
  15. package/dist/{create-game-IZIydDwI.js → create-game-CwxEDlCm.js} +13 -9
  16. package/dist/debug.d.ts +1 -1
  17. package/dist/{duplicate-IWIqk0HJ.js → duplicate-Dj4L-5AA.js} +1 -1
  18. package/dist/editor.js +1 -1
  19. package/dist/{environment-presets-CybQXNqS.js → environment-presets-BO-7XoFV.js} +4 -4
  20. package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
  21. package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
  22. package/dist/{gameplay-DM1eu_cV.js → gameplay-D1KixWK8.js} +157 -151
  23. package/dist/gameplay.d.ts +1 -1
  24. package/dist/gameplay.js +1 -1
  25. package/dist/index.d.ts +4 -4
  26. package/dist/index.js +6 -6
  27. package/dist/{loader-BC4PNtJX.js → loader-DZE7B07H.js} +22 -6
  28. package/dist/{loader-CcB533FR.d.ts → loader-DadV2QCT.d.ts} +1 -1
  29. package/dist/net.d.ts +2 -2
  30. package/dist/net.js +1 -1
  31. package/dist/{physics-2d-B7Y6dPZO.js → physics-2d-CF5yyjbj.js} +2 -2
  32. package/dist/{physics-3d-bG3n70Ky.js → physics-3d-DbYaEcZy.js} +3 -3
  33. package/dist/react.d.ts +1 -1
  34. package/dist/react.js +1 -1
  35. package/dist/{register-ibCjm-wH.js → register-DbVArsns.js} +19 -10
  36. package/dist/{register-3ta-2Xig.js → register-mmfugpaQ.js} +107 -15
  37. package/dist/{replay-CYvhVHHN.js → replay-BEHLDcQV.js} +2 -2
  38. package/dist/{replay-Dvn8aeBd.d.ts → replay-BQfkX4x7.d.ts} +1 -1
  39. package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
  40. package/dist/{split-screen-CYwDkbLF.js → split-screen-CauMwu6y.js} +2 -2
  41. package/dist/{split-screen-DhrSzZIB.d.ts → split-screen-Cg76MCV5.d.ts} +2 -2
  42. package/dist/{src-D7RIqXYF.js → src-CYEu-_S9.js} +1 -1
  43. package/dist/{teardown-D2NEmxPB.js → teardown-CMqFVxO9.js} +5 -4
  44. package/dist/{test-8hoHeRmo.js → test-D6ISmuFE.js} +60 -17
  45. package/dist/test.d.ts +15 -4
  46. package/dist/test.js +2 -2
  47. package/dist/vite.d.ts +70 -2
  48. package/dist/vite.js +176 -14
  49. package/dist/{webgl-unavailable-N9nQqesw.js → webgl-unavailable-Z5_g8x9H.js} +12 -1
  50. package/editor/assets/{agent8-BrrHOjMJ.js → agent8-BDvaZMQJ.js} +1 -1
  51. package/editor/assets/{debug-BBhKuBNV.js → debug-CMULinBW.js} +1 -1
  52. package/editor/assets/{index-eVd0BToA.js → index-CZIRr5EL.js} +91 -91
  53. package/editor/index.html +1 -1
  54. package/package.json +4 -3
  55. package/skills/incanto-assets.md +16 -2
  56. package/skills/incanto-audio.md +19 -3
  57. package/skills/incanto-building-2d-games.md +29 -0
  58. package/skills/incanto-building-3d-games.md +60 -2
  59. package/skills/incanto-gameplay-behaviors.md +18 -1
  60. package/skills/incanto-save-slots.md +27 -0
  61. package/skills/incanto-verifying-your-game.md +96 -2
  62. package/skills/incanto-your-first-game.md +2 -1
  63. package/templates-app/beacon-isle-3d/package.json +1 -1
  64. package/templates-app/beacon-isle-3d/src/main.ts +13 -3
  65. package/templates-app/platformer-2d/index.html +0 -23
  66. package/templates-app/platformer-2d/package.json +1 -1
  67. package/templates-app/platformer-2d/src/behaviors.ts +26 -16
  68. package/templates-app/platformer-2d/src/game.scene.json +17 -27
  69. package/templates-app/platformer-2d/src/main.ts +24 -12
  70. package/templates-app/star-survivor/package.json +1 -1
  71. package/templates-app/star-survivor/src/main.ts +17 -6
  72. package/templates-app/tps-3d/package.json +1 -1
  73. package/templates-app/tps-3d/src/main.ts +26 -15
  74. package/templates-app/village-quest-3d/package.json +1 -1
  75. package/templates-app/village-quest-3d/src/main.ts +13 -3
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-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.68.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
  }
@@ -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
 
@@ -414,7 +414,7 @@ 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
  });
@@ -422,9 +422,14 @@ engine.updated.connect(() => {
422
422
 
423
423
  - **`engine.sfx.now`** — seconds on the audio clock, the one the sound is
424
424
  actually placed on. `0` headless and before the first sound.
425
- - **`AudioPlayer.play(at)`** and **`engine.sfx.play(params, gain, { when })`** —
425
+ - **`AudioPlayer.playAt(when)`** and **`engine.sfx.play(params, gain, { when })`** —
426
426
  PRESETS only. A `src` clip goes through an `<audio>` element, which has no
427
- scheduling clock; `at` is ignored for one rather than approximated.
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.
428
433
  - A time already past plays immediately (Web Audio's own rule), so a scheduler
429
434
  that ran late is late, not silent.
430
435
  - Everything else still applies: the bus gain, `engine.audio.recent()`, and
@@ -479,6 +484,17 @@ session.engine.audio.recent();
479
484
  its volume comes from. `countOf(name)` is the assertion you usually want;
480
485
  `clearLog()` resets between steps.
481
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
+
482
498
  This covers every path: `AudioPlayer.play()` on both the procedural and the
483
499
  `src` route, `engine.music.play`/`crossfadeTo`, and `engine.sfx.startVoice`. It
484
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')`.
@@ -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
 
@@ -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/
@@ -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
@@ -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
@@ -242,7 +242,8 @@ Things that cost real time, in the order you will meet them.
242
242
  | a 3D character that feels right | `incanto-3d-character.md` |
243
243
  | terrain, water, trees, sky | `incanto-environment.md` |
244
244
  | sound | `incanto-audio.md` |
245
- | shake, flash, hit-stop, particles | `incanto-game-feel.md` |
245
+ | shake, flash, hit-stop, particles | `incanto-gameplay-behaviors.md` (`CameraShake`, `screenFlash`, `hitStop`, `Particles2D/3D`) |
246
+ | is the feel RIGHT? measure it | `incanto-game-feel.md` |
246
247
  | HUD, menus, inventory | `incanto-hud.md` |
247
248
  | proving it works | `incanto-verifying-your-game.md` |
248
249
 
@@ -14,7 +14,7 @@
14
14
  "@dimforge/rapier2d-compat": "0.19.3",
15
15
  "@dimforge/rapier3d-compat": "0.19.3",
16
16
  "@pixiv/three-vrm": "^3.5.3",
17
- "incanto": "^0.68.0",
17
+ "incanto": "^0.69.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {
@@ -34,7 +34,17 @@ const game = await createGame3D({
34
34
  throw e;
35
35
  });
36
36
 
37
- document.querySelector('#loading')?.remove();
38
-
39
- // console-poking handle for playtesting
37
+ // The console handle FIRST, before any wiring of your own can throw.
38
+ // `game.stats()`, `game.assetErrors()`, `game.frame()` and `game.engine.log`
39
+ // are the entire in-page diagnostic surface, and a static build has no other.
40
+ // Assigned after the wiring, one mistake below took all of them with it.
40
41
  (window as unknown as { game: typeof game }).game = game;
42
+
43
+ // The overlay comes down LAST, after any wiring of your own. There is none in
44
+ // this template — when you add some, put it above this line and wrap it:
45
+ //
46
+ // try { …your wiring… } catch (e) { showBootFailure(e); throw e; }
47
+ //
48
+ // A throw between the overlay coming down and the handle going up used to leave
49
+ // a level that renders perfectly with no player and no error of any kind.
50
+ document.querySelector('#loading')?.remove();
@@ -49,28 +49,6 @@
49
49
  transform: rotate(360deg);
50
50
  }
51
51
  }
52
- .round-btn {
53
- position: fixed;
54
- bottom: 2rem;
55
- right: 2rem;
56
- z-index: 1001;
57
- width: 88px;
58
- height: 88px;
59
- border: none;
60
- border-radius: 9999px;
61
- background: rgba(255, 209, 102, 0.45);
62
- color: #1d2a44;
63
- font-weight: bold;
64
- font-size: 0.9rem;
65
- cursor: pointer;
66
- user-select: none;
67
- -webkit-user-select: none;
68
- touch-action: none;
69
- transition: transform 0.1s;
70
- }
71
- .round-btn:active {
72
- transform: scale(0.9);
73
- }
74
52
  </style>
75
53
  </head>
76
54
  <body>
@@ -79,7 +57,6 @@
79
57
  <div class="spinner"></div>
80
58
  </div>
81
59
  <canvas id="game"></canvas>
82
- <button type="button" id="btn-jump" class="round-btn">JUMP</button>
83
60
  <script>
84
61
  // agent8 embed harness: report the game size to a hosting iframe parent.
85
62
  function postGameSize() {
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
- "incanto": "^0.68.0",
14
+ "incanto": "^0.69.0",
15
15
  "three": "^0.184.0"
16
16
  },
17
17
  "devDependencies": {
@@ -19,9 +19,9 @@
19
19
  * HudUpdater — paint ScoreKeeper/hearts/lives into the HUD + win/lose banner.
20
20
  */
21
21
 
22
- import type { Node } from 'incanto';
22
+ import type { Node, UiBanner, UiText } from 'incanto';
23
23
  import { Behavior } from 'incanto';
24
- import type { AnimatedSprite2D, Camera2D, CharacterBody2D, Label, Node2D } from 'incanto/2d';
24
+ import type { AnimatedSprite2D, Camera2D, CharacterBody2D, Node2D } from 'incanto/2d';
25
25
  import { Particles2D } from 'incanto/2d';
26
26
 
27
27
  // ---- tuning (px, y-down; scene gravity is [0, 1800]) --------------------------
@@ -499,17 +499,27 @@ interface HeartState {
499
499
  hearts: number;
500
500
  }
501
501
  export class HudUpdater extends Behavior {
502
- private get coinLabel(): Label {
503
- return this.getNode('CoinLabel') as unknown as Label;
504
- }
505
- private get heartLabel(): Label {
506
- return this.getNode('HeartLabel') as unknown as Label;
507
- }
508
- private get livesLabel(): Label {
509
- return this.getNode('LivesLabel') as unknown as Label;
510
- }
511
- private get banner(): Label {
512
- return this.getNode('/root/BannerLayer/Banner') as unknown as Label;
502
+ /*
503
+ * `UiText` under a `HudLayer`, not `Label` under a `UILayer`.
504
+ *
505
+ * A `Label` lives in the WORLD and scales with `viewport.design`. At
506
+ * 1280x800 this HUD painted at 20 device px; on a 390x844 phone the same
507
+ * nodes painted at 6.1 — an unreadable grey smear — because the viewport
508
+ * scale there is 0.406. The DOM widgets are declared in CSS pixels and are
509
+ * immune, which is why the engine's own volume sliders and touch controls
510
+ * stayed legible in the same capture while the flagship's HUD did not.
511
+ */
512
+ private get coinLabel(): UiText {
513
+ return this.getNode('CoinLabel') as unknown as UiText;
514
+ }
515
+ private get heartLabel(): UiText {
516
+ return this.getNode('HeartLabel') as unknown as UiText;
517
+ }
518
+ private get livesLabel(): UiText {
519
+ return this.getNode('LivesLabel') as unknown as UiText;
520
+ }
521
+ private get banner(): UiBanner {
522
+ return this.getNode('/root/BannerLayer/Banner') as unknown as UiBanner;
513
523
  }
514
524
  private get score(): ScoreLike {
515
525
  return this.getNode('/root').behavior as unknown as ScoreLike;
@@ -534,8 +544,8 @@ export class HudUpdater extends Behavior {
534
544
  this.livesLabel.text = `x ${Math.max(0, this.score.lives)}`;
535
545
  }
536
546
  private show(text: string, color: string): void {
537
- const b = this.banner;
538
- b.text = text;
539
- b.color = color;
547
+ // `UiBanner` owns its own fade and queue; a sticky one stays until the
548
+ // next `show`, which is what a win/lose screen wants.
549
+ this.banner.show(text, { color, seconds: 0 });
540
550
  }
541
551
  }
@@ -1848,52 +1848,49 @@
1848
1848
  },
1849
1849
  {
1850
1850
  "name": "HUD",
1851
- "type": "UILayer",
1852
- "props": {
1853
- "anchor": "top-left"
1854
- },
1851
+ "type": "HudLayer",
1855
1852
  "script": {
1856
1853
  "name": "HudUpdater"
1857
1854
  },
1858
1855
  "children": [
1859
1856
  {
1860
1857
  "name": "CoinLabel",
1861
- "type": "Label",
1858
+ "type": "UiText",
1862
1859
  "props": {
1863
- "position": [20, 30],
1860
+ "anchor": "topLeft",
1864
1861
  "text": "◆ 0",
1865
- "fontSize": 26,
1862
+ "size": 22,
1866
1863
  "color": "#ffd166"
1867
1864
  }
1868
1865
  },
1869
1866
  {
1870
1867
  "name": "HeartLabel",
1871
- "type": "Label",
1868
+ "type": "UiText",
1872
1869
  "props": {
1873
- "position": [20, 64],
1870
+ "anchor": "topLeft",
1874
1871
  "text": "♥♥♥",
1875
- "fontSize": 26,
1872
+ "size": 22,
1876
1873
  "color": "#ff6b81"
1877
1874
  }
1878
1875
  },
1879
1876
  {
1880
1877
  "name": "LivesLabel",
1881
- "type": "Label",
1878
+ "type": "UiText",
1882
1879
  "props": {
1883
- "position": [120, 64],
1880
+ "anchor": "topLeft",
1884
1881
  "text": "x 3",
1885
- "fontSize": 22,
1882
+ "size": 18,
1886
1883
  "color": "#f4f4f4"
1887
1884
  }
1888
1885
  },
1889
1886
  {
1890
1887
  "name": "Hint",
1891
- "type": "Label",
1888
+ "type": "UiText",
1892
1889
  "props": {
1893
- "position": [20, 100],
1890
+ "anchor": "bottom",
1894
1891
  "text": "A/D run · Space jump (double-jump!) · stomp goblins · reach the gold flag",
1895
- "fontSize": 15,
1896
- "color": "#1d2a44"
1892
+ "size": 14,
1893
+ "color": "#f4f4f4"
1897
1894
  }
1898
1895
  }
1899
1896
  ],
@@ -1901,20 +1898,13 @@
1901
1898
  },
1902
1899
  {
1903
1900
  "name": "BannerLayer",
1904
- "type": "UILayer",
1905
- "props": {
1906
- "anchor": "center"
1907
- },
1901
+ "type": "HudLayer",
1908
1902
  "children": [
1909
1903
  {
1910
1904
  "name": "Banner",
1911
- "type": "Label",
1905
+ "type": "UiBanner",
1912
1906
  "props": {
1913
- "position": [0, 0],
1914
- "text": "",
1915
- "fontSize": 64,
1916
- "color": "#ffffff",
1917
- "align": "center"
1907
+ "size": 44
1918
1908
  }
1919
1909
  }
1920
1910
  ]