incanto 0.72.0 → 0.73.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 (84) hide show
  1. package/bin/incanto-check.mjs +114 -42
  2. package/bin/incanto-verify.mjs +9 -2
  3. package/dist/2d.d.ts +29 -4
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +137 -15
  6. package/dist/3d.js +6 -6
  7. package/dist/{agent8-BqZ0_O1u.js → agent8-CHTT4unP.js} +1 -1
  8. package/dist/{audio-player-ioZ7VKC6.d.ts → audio-player-BOrk4eQx.d.ts} +1 -1
  9. package/dist/{behavior-Bf9P9oB2.d.ts → behavior-J1cmVah0.d.ts} +8 -0
  10. package/dist/{create-game-CmFAtLZp.js → create-game-CAu-ucHg.js} +7 -7
  11. package/dist/{create-game-C9rWqqAC.js → create-game-CbTLFjis.js} +7 -7
  12. package/dist/debug.d.ts +1 -1
  13. package/dist/debug.js +1 -1
  14. package/dist/editor.js +965 -893
  15. package/dist/{environment-presets-BeHoaGhy.js → environment-presets-BsFJM3v0.js} +277 -60
  16. package/dist/{gameplay-BlQe-M07.js → gameplay-CRte0OVf.js} +589 -35
  17. package/dist/gameplay.d.ts +136 -4
  18. package/dist/gameplay.js +2 -2
  19. package/dist/index.d.ts +6 -4
  20. package/dist/index.js +6 -6
  21. package/dist/{loader-CBfQzB6S.d.ts → loader-BAeWMYfF.d.ts} +1 -1
  22. package/dist/net.d.ts +2 -2
  23. package/dist/net.js +2 -2
  24. package/dist/{physics-2d-DrIgTlLk.js → physics-2d-CovokK7s.js} +94 -11
  25. package/dist/{physics-3d-BzOCw1VS.js → physics-3d-BiK6Emwn.js} +202 -15
  26. package/dist/{picking-C9McVJrO.js → picking-Dtd8JInh.js} +2 -2
  27. package/dist/react.d.ts +1 -1
  28. package/dist/react.js +1 -1
  29. package/dist/{register-C8HuRkOf.js → register-CY9QCCZs.js} +45 -9
  30. package/dist/{register-5IYfooAN.js → register-CjociOtt.js} +19 -4
  31. package/dist/{replay-datiAE-b.js → replay-B6BbgGM_.js} +42 -6
  32. package/dist/{replay-CBEChqXq.d.ts → replay-wZK5Ok5c.d.ts} +1 -1
  33. package/dist/{save-slots-CEuJPUle.js → save-slots-CEUGeLuB.js} +11 -2
  34. package/dist/{split-screen-Bm5DTElG.js → split-screen-BxZ5rBe7.js} +4 -4
  35. package/dist/{split-screen-DkqLng6q.d.ts → split-screen-D6sx8z3Z.d.ts} +2 -2
  36. package/dist/{sprite-animation-CqR2o3SA.js → sprite-animation-Bz3DHD_4.js} +1 -1
  37. package/dist/{src-DRSsRleD.js → src-D-dnVrFY.js} +1 -1
  38. package/dist/{test-Djuq3Vhq.js → test-DZmCFz0Q.js} +162 -30
  39. package/dist/test.d.ts +56 -5
  40. package/dist/test.js +2 -2
  41. package/dist/{touch-BnCyPA0G.js → touch-CioTZB-y.js} +1 -1
  42. package/dist/vite.js +3 -3
  43. package/editor/assets/{agent8-CMKz2cax.js → agent8-GuZ5u5bi.js} +1 -1
  44. package/editor/assets/{debug-DLrQm-az.js → debug-BbxionO2.js} +1 -1
  45. package/editor/assets/{index-BEKuxYcE.js → index-CNlTzQbm.js} +53 -53
  46. package/editor/index.html +1 -1
  47. package/package.json +1 -1
  48. package/schemas/scene.schema.json +634 -10
  49. package/skills/incanto-3d-character.md +113 -5
  50. package/skills/incanto-assets.md +9 -3
  51. package/skills/incanto-behaviors-and-scripts.md +26 -2
  52. package/skills/incanto-building-2d-games.md +9 -0
  53. package/skills/incanto-building-3d-games.md +45 -0
  54. package/skills/incanto-editor.md +6 -1
  55. package/skills/incanto-environment.md +7 -2
  56. package/skills/incanto-game-feel.md +16 -11
  57. package/skills/incanto-gameplay-behaviors.md +174 -15
  58. package/skills/incanto-node-reference.md +122 -45
  59. package/skills/incanto-physics-and-input.md +67 -5
  60. package/skills/incanto-scene-json-authoring.md +9 -3
  61. package/skills/incanto-verifying-your-game.md +19 -2
  62. package/templates-app/beacon-isle-3d/PROJECT/Status.md +8 -0
  63. package/templates-app/beacon-isle-3d/PROJECT/Structure.md +3 -0
  64. package/templates-app/beacon-isle-3d/package.json +1 -1
  65. package/templates-app/beacon-isle-3d/src/game.scene.json +136 -0
  66. package/templates-app/beacon-isle-3d/verify.ts +144 -0
  67. package/templates-app/molehill-2d/package.json +1 -1
  68. package/templates-app/platformer-2d/PROJECT/Status.md +7 -0
  69. package/templates-app/platformer-2d/PROJECT/Structure.md +3 -0
  70. package/templates-app/platformer-2d/package.json +1 -1
  71. package/templates-app/platformer-2d/src/game.scene.json +126 -0
  72. package/templates-app/platformer-2d/verify.ts +54 -0
  73. package/templates-app/star-survivor/package.json +1 -1
  74. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  75. package/templates-app/tps-3d/PROJECT/Status.md +40 -1
  76. package/templates-app/tps-3d/PROJECT/Structure.md +19 -0
  77. package/templates-app/tps-3d/package.json +1 -1
  78. package/templates-app/tps-3d/src/behaviors.ts +9 -0
  79. package/templates-app/tps-3d/src/cover.scene.json +26 -0
  80. package/templates-app/tps-3d/src/game.scene.json +331 -75
  81. package/templates-app/tps-3d/src/main.ts +14 -0
  82. package/templates-app/tps-3d/src/pillar.scene.json +25 -0
  83. package/templates-app/tps-3d/verify.ts +582 -3
  84. package/templates-app/village-quest-3d/package.json +1 -1
@@ -103,7 +103,11 @@ Rules the engine enforces with **hard errors** (it never warns silently):
103
103
  On export, default-equal values are omitted automatically.
104
104
  3. Prop keys and value kinds are validated against the type's schema:
105
105
  unknown key → `UNKNOWN_PROP` (message lists valid keys); wrong JSON kind
106
- (e.g. string where the default is boolean) → `PROP_TYPE_MISMATCH`.
106
+ (e.g. string where the default is boolean) → `PROP_TYPE_MISMATCH`. A prop
107
+ whose default is `null` means AUTO — the engine decides — and validates the
108
+ other kinds it declares (`drape`/`collide`/`islandEdge`/`Chase.ground` take
109
+ a boolean, `snapToGround` a boolean or a lift in metres, `reflectivity` a
110
+ number); the editor offers the boolean ones as auto / on / off.
107
111
  A few props accept TWO kinds — `Flowers3D.density` takes a preset name or
108
112
  plants per m², `Water3D.underwater` takes `true`/`false` or a settings
109
113
  object — and `incanto-node-reference.md` names both in its Kind column
@@ -425,8 +429,10 @@ const resolveScene = (path: string) => (path === 'crate.scene.json' ? crateJson
425
429
  await runScript(sceneJson, { durationMs: 4000, resolveScene, steps: [...] });
426
430
  ```
427
431
 
428
- `examples/tiles-2d` is the worked example: two crates that ARE
429
- `crate.scene.json`, one of them adding a rope child of its own.
432
+ `examples/tiles-2d` is the worked example in 2D: two crates that ARE
433
+ `crate.scene.json`, one of them adding a rope child of its own. `examples/tps-3d`
434
+ is the 3D one: the arena's cover blocks and pillars are `cover.scene.json` and
435
+ `pillar.scene.json`, placed with a `position` override each.
430
436
 
431
437
  ## Error codes you will see (all hard failures at load)
432
438
 
@@ -41,6 +41,10 @@ $ bunx incanto verify # finds your scene AND your behaviou
41
41
  passes what was measured — plays, draws not measured.
42
42
  next: nothing here is broken — a win that takes skill or a sequence is out of reach of random play. Judge it with a scripted run: `bun run verify`
43
43
  ```
44
+ `--runs N` gives the `plays` rung more seeds (default 8). Eight see a one-in-eight event
45
+ 64% of the time; `bun run sweep` asks the six shipped starters for sixteen, and a starter that
46
+ falls out of the world once in twelve runs is how the arena in tps-3d got its `Respawn`.
47
+
44
48
 
45
49
  ### `agrees` — do two clients hold the same values?
46
50
 
@@ -72,7 +76,10 @@ encodes so you do not have to remember them:
72
76
  warned about is printed under it with a `!` — `loads` runs `incanto-check`,
73
77
  and the ladder used to read only whether it passed and drop the warnings, so a
74
78
  scene the check describes as "It will render black" came back as
75
- `✓ loads — the scene is legal and its assets resolve`;
79
+ `✓ loads — the scene is legal and its assets resolve` (LOCAL art is looked for on
80
+ disk; REMOTE urls are asked with a HEAD request — a 404 drops the claim to
81
+ `the scene is legal`, and a host that cannot be reached is a `note:`, never a
82
+ failure);
76
83
  - an **unmeasured** rung (`?`) is not a failure. "No dev server" means the
77
84
  question was never asked; treating that as a broken game sends you editing a
78
85
  scene that is fine;
@@ -218,7 +225,7 @@ const result = await runScript(sceneJson, {
218
225
  durationMs: 3000,
219
226
  seed: 42, // same seed → identical run
220
227
  steps: [
221
- { atMs: 0, vector: ['move', 1, 0] }, // hold right
228
+ { atMs: 0, vector: ['move', 1, 0] }, // hold right (y is DOWN: forward is [0, -1])
222
229
  { atMs: 1000, press: 'jump' },
223
230
  { atMs: 1100, release: 'jump' },
224
231
  { atMs: 1500, label: 'jump apex reached',
@@ -252,6 +259,16 @@ captures; `result.logs` holds everything behaviors logged via `this.log`.
252
259
  Steps that never execute (atMs beyond the run) are reported as failures —
253
260
  an assert cannot "pass" by not running.
254
261
 
262
+ **A game that has ended does not advance.** `GameFlow` stops the clock
263
+ (`timeScale 0`) on a win or a loss, and a pause does the same; after that every
264
+ `ctx.engine.step()` is a no-op and every value you read is the last one before
265
+ it stopped. A harness that lost its player three times inside one `do:` then
266
+ measured `0.00` for everything after it. The report now names each `do:`/`assert:`
267
+ step that ran with the clock stopped (`! at 4000ms [measure apex]: the clock
268
+ was stopped …`, also `result.stalled`) — read that line before reading the
269
+ zeros. Measure before the game ends, or `restartScene` / read `Flow.state` in
270
+ the step.
271
+
255
272
  Note: `validateScene`/`runScript` register node types and your `behaviors`
256
273
  into the process-global registries (hot-replacing same names). In a test
257
274
  suite, isolate with `clearRegistry()`/`clearBehaviors()` between files if
@@ -36,3 +36,11 @@
36
36
 
37
37
  - Boss attack telegraphs (charge-up glow, ground slam particles).
38
38
  - A hurt/attack clip for shades once one lands in the catalog.
39
+
40
+ ## This round's surfaces, on the island (2026-09-04)
41
+
42
+ `Pad` (StaticBody3D, `restitution 1`, `snapToGround: 0.1`) with a dead `Ball` dropped on it,
43
+ and `Walker` (CharacterBody3D + `Chase` on a child, `slopeLimitDeg 50`, `stepHeight 0.5`) on
44
+ the west slope, `loseRange 25` so it never joins the quest. `verify.ts` measures the rebound
45
+ (2.4 m of 3), the chase up the slope (8.5 m of 10, +1.75 m) and that the walker stays on the
46
+ terrain (0.97 m over `heightAt`).
@@ -22,3 +22,6 @@ verify.ts audit + quest E2E + hunt + death/respawn + replay
22
22
  keeps lit wards; `IsleDirector.onReady` re-applies their glow.
23
23
  - The whole Terrain subtree is static EXCEPT Sea and Clouds (auditScene
24
24
  catches the freeze-the-water mistake if you regress this).
25
+
26
+ - `Pad`, `Ball`, `Walker` — the harness's fixtures for a trampoline and a ground chaser on the
27
+ heightfield.
@@ -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.72.0",
17
+ "incanto": "^0.73.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {
@@ -2759,6 +2759,142 @@
2759
2759
  }
2760
2760
  ],
2761
2761
  "uid": "n_0q1r3a6rijr3hc1w"
2762
+ },
2763
+ {
2764
+ "name": "Pad",
2765
+ "type": "StaticBody3D",
2766
+ "uid": "n_uae445vafun14t1w",
2767
+ "props": {
2768
+ "position": [2, 0, 66],
2769
+ "snapToGround": 0.1,
2770
+ "collider": { "shape": "box", "size": [2, 0.2, 2] },
2771
+ "restitution": 1
2772
+ },
2773
+ "children": [
2774
+ {
2775
+ "name": "Skin",
2776
+ "type": "MeshInstance3D",
2777
+ "uid": "n_r5z35fqhlhntir5y",
2778
+ "props": {
2779
+ "mesh": "box",
2780
+ "size": [2, 0.2, 2],
2781
+ "castShadow": true,
2782
+ "material": { "color": "#3b82f6", "roughness": 0.7 }
2783
+ }
2784
+ }
2785
+ ]
2786
+ },
2787
+ {
2788
+ "name": "Ball",
2789
+ "type": "RigidBody3D",
2790
+ "uid": "n_rbwioxy4v827q3a4",
2791
+ "props": { "position": [2, 60, 66], "collider": { "shape": "sphere", "radius": 0.3 } },
2792
+ "children": [
2793
+ {
2794
+ "name": "Skin",
2795
+ "type": "MeshInstance3D",
2796
+ "uid": "n_hy0cclrpbgge9wiy",
2797
+ "props": {
2798
+ "mesh": "sphere",
2799
+ "size": [0.3, 0.3, 0.3],
2800
+ "castShadow": true,
2801
+ "material": { "color": "#f4f1de" }
2802
+ }
2803
+ }
2804
+ ]
2805
+ },
2806
+ {
2807
+ "name": "Walker",
2808
+ "type": "CharacterBody3D",
2809
+ "uid": "n_mjjx1odvbl668a8p",
2810
+ "props": {
2811
+ "position": [-92, 0, -64],
2812
+ "snapToGround": 0.95,
2813
+ "slopeLimitDeg": 50,
2814
+ "stepHeight": 0.5,
2815
+ "collider": { "shape": "capsule", "radius": 0.4, "height": 1.1 }
2816
+ },
2817
+ "children": [
2818
+ {
2819
+ "name": "AI",
2820
+ "type": "Node3D",
2821
+ "uid": "n_73h5b3fzsep3ksnf",
2822
+ "script": {
2823
+ "name": "Chase",
2824
+ "props": {
2825
+ "target": "/root/Player",
2826
+ "speed": 2.4,
2827
+ "stopRange": 1.5,
2828
+ "loseRange": 25,
2829
+ "moveParent": true
2830
+ }
2831
+ }
2832
+ },
2833
+ {
2834
+ "name": "Skin",
2835
+ "type": "MeshInstance3D",
2836
+ "uid": "n_lt6ypdouri4cicwd",
2837
+ "props": {
2838
+ "mesh": "capsule",
2839
+ "size": [0.4, 1.1, 0.4],
2840
+ "castShadow": true,
2841
+ "material": { "color": "#6b4d8a", "roughness": 0.7 }
2842
+ }
2843
+ }
2844
+ ]
2845
+ },
2846
+ {
2847
+ "name": "Buoy",
2848
+ "type": "RigidBody3D",
2849
+ "uid": "n_y0kpfi62yas7ai09",
2850
+ "props": {
2851
+ "position": [-150, 9.6, 0],
2852
+ "snapToGround": false,
2853
+ "mass": 20,
2854
+ "collider": { "shape": "box", "size": [1, 1, 1] }
2855
+ },
2856
+ "script": {
2857
+ "name": "Buoyancy",
2858
+ "props": { "water": "/root/Terrain/Sea", "samples": 1, "drag": 0.1, "drift": 0 }
2859
+ },
2860
+ "children": [
2861
+ {
2862
+ "name": "Skin",
2863
+ "type": "MeshInstance3D",
2864
+ "uid": "n_tz2qooe24d5evtq2",
2865
+ "props": {
2866
+ "mesh": "box",
2867
+ "size": [1, 1, 1],
2868
+ "castShadow": true,
2869
+ "material": { "color": "#f59e0b", "roughness": 0.6 }
2870
+ }
2871
+ }
2872
+ ]
2873
+ },
2874
+ {
2875
+ "name": "Crate",
2876
+ "type": "RigidBody3D",
2877
+ "uid": "n_x0on4nkgt4l83sic",
2878
+ "props": {
2879
+ "position": [-150, 9.6, 8],
2880
+ "snapToGround": false,
2881
+ "mass": 20,
2882
+ "collider": { "shape": "box", "size": [1, 1, 1] }
2883
+ },
2884
+ "script": { "name": "Buoyancy", "props": { "drift": 0 } },
2885
+ "children": [
2886
+ {
2887
+ "name": "Skin",
2888
+ "type": "MeshInstance3D",
2889
+ "uid": "n_u0ckada9zfkpv9zz",
2890
+ "props": {
2891
+ "mesh": "box",
2892
+ "size": [1, 1, 1],
2893
+ "castShadow": true,
2894
+ "material": { "color": "#8b5a2b", "roughness": 0.8 }
2895
+ }
2896
+ }
2897
+ ]
2762
2898
  }
2763
2899
  ],
2764
2900
  "uid": "n_nxftk1ep8wbi1bn9"
@@ -285,3 +285,147 @@ const ok = (label: string, cond: boolean): void => {
285
285
  }
286
286
 
287
287
  console.log(process.exitCode ? '\nVERIFY FAILED' : '\nVERIFY OK');
288
+
289
+ // ---- HEIGHTFIELD: this round's surfaces, on terrain instead of a flat arena -----
290
+ // A trampoline pad snapped to the island (`snapToGround: 0.1` on a StaticBody3D
291
+ // with `restitution 1`), a dead ball dropped on it, and a `CharacterBody3D`
292
+ // walker chasing the player up the island's slope (`Chase.ground` auto: the
293
+ // KCC's slopeLimitDeg and stepHeight decide, gravity keeps it on the ground).
294
+ {
295
+ type Vec = { position: number[] };
296
+ type Terrain = { heightAt(x: number, z: number): number };
297
+ const ballY: number[] = [];
298
+ let walkerFrom: number[] = [];
299
+ let walkerTo: number[] = [];
300
+ let groundUnderWalker = 0;
301
+ let targetY = 0;
302
+ let closed = 0;
303
+ const result = await runScript(gameJson, {
304
+ behaviors: BEHAVIORS,
305
+ durationMs: 8000,
306
+ steps: [
307
+ {
308
+ atMs: 100,
309
+ do: (ctx) => {
310
+ const pad = ctx.getNode('/root/Pad') as unknown as Vec;
311
+ const ball = ctx.getNode('/root/Ball') as unknown as Vec;
312
+ ball.position = [pad.position[0] ?? 0, (pad.position[1] ?? 0) + 3, pad.position[2] ?? 0];
313
+ },
314
+ },
315
+ ...Array.from({ length: 30 }, (_, i) => ({
316
+ atMs: 200 + i * 100,
317
+ do: (ctx: { getNode(p: string): unknown }) => {
318
+ ballY.push((ctx.getNode('/root/Ball') as Vec).position[1] ?? 0);
319
+ },
320
+ })),
321
+ {
322
+ atMs: 3300,
323
+ do: (ctx) => {
324
+ const terrain = ctx.getNode('/root/Terrain/Ground/Surface') as unknown as Terrain;
325
+ const walker = ctx.getNode('/root/Walker') as unknown as Vec;
326
+ const player = ctx.getNode('/root/Player') as unknown as Vec;
327
+ const [wx = 0, , wz = 0] = walker.position;
328
+ // the steepest way UP from the walker, 10 m out: that is where the player goes
329
+ let best = { x: wx + 10, z: wz, h: -Infinity };
330
+ for (let a = 0; a < 16; a++) {
331
+ const x = wx + 10 * Math.cos((a / 16) * Math.PI * 2);
332
+ const z = wz + 10 * Math.sin((a / 16) * Math.PI * 2);
333
+ const h = terrain.heightAt(x, z);
334
+ if (h > best.h) best = { x, z, h };
335
+ }
336
+ player.position = [best.x, best.h + 1.4, best.z];
337
+ targetY = best.h;
338
+ walkerFrom = [...walker.position];
339
+ const d0 = Math.hypot(best.x - wx, best.z - wz);
340
+ for (let i = 0; i < 240; i++) ctx.engine.step();
341
+ walkerTo = [...walker.position];
342
+ const [tx = 0, , tz = 0] = walkerTo;
343
+ groundUnderWalker = terrain.heightAt(tx, tz);
344
+ closed = d0 - Math.hypot(best.x - tx, best.z - tz);
345
+ player.position = [-4.5, 18.73, 70.5]; // and back to the spawn for the quest
346
+ },
347
+ },
348
+ ],
349
+ });
350
+ ok('runScript reported no failures (HEIGHTFIELD path)', result.ok);
351
+ let touch = ballY.findIndex((y, i) => i > 0 && y > (ballY[i - 1] ?? 0));
352
+ if (touch < 1) touch = 1;
353
+ const low = ballY[touch - 1] ?? 0;
354
+ const rebound = Math.max(...ballY.slice(touch)) - low;
355
+ ok(
356
+ `a pad snapped to the island with restitution 1 launches a dead ball back up ${rebound.toFixed(2)} m of its 3 m drop`,
357
+ rebound > 1.8,
358
+ );
359
+ const rose = (walkerTo[1] ?? 0) - (walkerFrom[1] ?? 0);
360
+ const gap = targetY - (walkerFrom[1] ?? 0);
361
+ ok(
362
+ `the walker chased the player UP the slope: closed ${closed.toFixed(1)} m of 10, rose ${rose.toFixed(2)} m of a ${gap.toFixed(2)} m climb`,
363
+ closed > 5 && rose > gap * 0.5,
364
+ );
365
+ const hover = (walkerTo[1] ?? 0) - groundUnderWalker;
366
+ ok(
367
+ `…and stayed on the ground while doing it (capsule centre ${hover.toFixed(2)} m over the terrain, 0.95 expected)`,
368
+ hover > 0.5 && hover < 1.6,
369
+ );
370
+ }
371
+
372
+ // ---- FLOATS: what `Buoyancy.water`, `samples` and `drag` do on the open sea --------
373
+ // Set by no example: every raft in the repo took the first Water3D, four samples
374
+ // and the default drag. Two crates float west of the island (the heightfield
375
+ // ends at x −120): the Buoy names its water, samples once and says `drag: 0.1`;
376
+ // the Crate keeps the defaults. Both `drift: 0`, so a 3 m/s shove is the only
377
+ // thing moving them and drag is the only thing that differs. Asserted by how
378
+ // far each travels in 2 s — e^{-drag·t} says 5.4 m against 2.8 m — and by both
379
+ // still sitting at the waterline afterwards.
380
+ {
381
+ type Vec = { position: number[] };
382
+ type Body = { mass: number; applyImpulse(i: [number, number, number]): void };
383
+ type Water = { heightAt(x: number, z: number): number };
384
+ let buoyRest = 0;
385
+ let crateRest = 0;
386
+ let buoyRun = 0;
387
+ let crateRun = 0;
388
+ let buoyOver = 99;
389
+ let crateOver = 99;
390
+ const result = await runScript(gameJson, {
391
+ behaviors: BEHAVIORS,
392
+ durationMs: 8000,
393
+ steps: [
394
+ {
395
+ atMs: 100,
396
+ do: (ctx) => {
397
+ const sea = ctx.getNode('/root/Terrain/Sea') as unknown as Water;
398
+ const buoy = ctx.getNode('/root/Buoy') as unknown as Vec & Body;
399
+ const crate = ctx.getNode('/root/Crate') as unknown as Vec & Body;
400
+ for (let i = 0; i < 240; i++) ctx.engine.step(); // 4 s to settle on the waterline
401
+ buoyRest = buoy.position[1] ?? 0;
402
+ crateRest = crate.position[1] ?? 0;
403
+ const bx = buoy.position[0] ?? 0;
404
+ const cx = crate.position[0] ?? 0;
405
+ buoy.applyImpulse([3 * buoy.mass, 0, 0]);
406
+ crate.applyImpulse([3 * crate.mass, 0, 0]);
407
+ for (let i = 0; i < 120; i++) ctx.engine.step();
408
+ buoyRun = (buoy.position[0] ?? 0) - bx;
409
+ crateRun = (crate.position[0] ?? 0) - cx;
410
+ buoyOver =
411
+ (buoy.position[1] ?? 0) - sea.heightAt(buoy.position[0] ?? 0, buoy.position[2] ?? 0);
412
+ crateOver =
413
+ (crate.position[1] ?? 0) - sea.heightAt(crate.position[0] ?? 0, crate.position[2] ?? 0);
414
+ },
415
+ },
416
+ ],
417
+ });
418
+ ok('runScript reported no failures (FLOATS path)', result.ok);
419
+ ok(
420
+ `water "/root/Terrain/Sea", samples 1: the Buoy settles at y ${buoyRest.toFixed(2)} (sea 9.19 + draft 0.35 ≈ 9.54), the Crate at ${crateRest.toFixed(2)}`,
421
+ Math.abs(buoyRest - 9.54) < 0.5 && Math.abs(crateRest - 9.54) < 0.5,
422
+ );
423
+ ok(
424
+ `drag 0.1 vs 0.9: the same 3 m/s shove carries the Buoy ${buoyRun.toFixed(2)} m and the Crate ${crateRun.toFixed(2)} m in 2 s (5.4 vs 2.8 expected)`,
425
+ buoyRun > crateRun * 1.5 && buoyRun > 4 && crateRun > 1.5 && crateRun < 3.5,
426
+ );
427
+ ok(
428
+ `…and both still ride the waterline (${buoyOver.toFixed(2)} m and ${crateOver.toFixed(2)} m over the sea)`,
429
+ Math.abs(buoyOver - 0.35) < 0.5 && Math.abs(crateOver - 0.35) < 0.5,
430
+ );
431
+ }
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
- "incanto": "^0.72.0",
14
+ "incanto": "^0.73.0",
15
15
  "three": "^0.184.0"
16
16
  },
17
17
  "devDependencies": {
@@ -58,3 +58,10 @@
58
58
  - One hand-authored level — extend by copying nodes into the right GROUP
59
59
  (`enemy`/`hazard`/`pit`/`checkpoint`/`platform`); `PlayerController` picks them
60
60
  up with no extra wiring.
61
+
62
+ ## The round's surfaces, in 2D (2026-09-04)
63
+
64
+ A course past GroundD (x 3000+): `Trampoline` (`restitution 1`) with a dead `Bouncer`, `Ice`
65
+ (`friction 0`) with a `Sled` at 260 px/s, and a `Hound` (`CharacterBody2D` + `Chase`
66
+ `ground: true`) under a `Perch`. `verify.ts` measures the rebound (287 of 300 px), the slide
67
+ (400 px of ice) and that the hound stays on the floor under a perched player.
@@ -75,3 +75,6 @@ The vibe-coding rules: read the skills, JSON structure, built-ins first, omit
75
75
  uids, declared assets, y-down space, collider props, GROUP-DRIVEN centralised
76
76
  damage (§8), deferred engine access (§11), debug draw, delta discipline, verify
77
77
  like a user.
78
+
79
+ - `CourseFloor`, `Trampoline`, `Bouncer`, `Ice`, `Sled`, `Hound`, `Perch` — the harness's course for
80
+ the 2D surfaces and the ground chaser.
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
- "incanto": "^0.72.0",
14
+ "incanto": "^0.73.0",
15
15
  "three": "^0.184.0"
16
16
  },
17
17
  "devDependencies": {
@@ -1350,6 +1350,132 @@
1350
1350
  }
1351
1351
  ],
1352
1352
  "uid": "n_i3xs3gnb3cyrar1u"
1353
+ },
1354
+ {
1355
+ "name": "CourseFloor",
1356
+ "type": "StaticBody2D",
1357
+ "uid": "n_9p55c0wu33vbe194",
1358
+ "props": { "position": [3300, 520], "collider": { "shape": "rect", "size": [900, 120] } },
1359
+ "children": [
1360
+ {
1361
+ "name": "Skin",
1362
+ "type": "ColorRect2D",
1363
+ "uid": "n_56chxbokw5okl2ar",
1364
+ "props": { "size": [900, 120], "color": "#6b5638", "anchor": [0.5, 0.5] }
1365
+ }
1366
+ ]
1367
+ },
1368
+ {
1369
+ "name": "Trampoline",
1370
+ "type": "StaticBody2D",
1371
+ "uid": "n_vqjbknb54e0zq787",
1372
+ "props": {
1373
+ "position": [3000, 455],
1374
+ "collider": { "shape": "rect", "size": [120, 10] },
1375
+ "restitution": 1
1376
+ },
1377
+ "children": [
1378
+ {
1379
+ "name": "Skin",
1380
+ "type": "ColorRect2D",
1381
+ "uid": "n_bed7i5blyg8p63ij",
1382
+ "props": { "size": [120, 10], "color": "#3b82f6", "anchor": [0.5, 0.5] }
1383
+ }
1384
+ ]
1385
+ },
1386
+ {
1387
+ "name": "Bouncer",
1388
+ "type": "RigidBody2D",
1389
+ "uid": "n_8vd73zp82idtm5gx",
1390
+ "props": { "position": [3000, 150], "collider": { "shape": "circle", "radius": 14 } },
1391
+ "children": [
1392
+ {
1393
+ "name": "Skin",
1394
+ "type": "ColorRect2D",
1395
+ "uid": "n_n18pdy731ltnmb3l",
1396
+ "props": { "size": [28, 28], "color": "#f4f1de", "anchor": [0.5, 0.5] }
1397
+ }
1398
+ ]
1399
+ },
1400
+ {
1401
+ "name": "Ice",
1402
+ "type": "StaticBody2D",
1403
+ "uid": "n_zlp89ikkls0xkbwn",
1404
+ "props": {
1405
+ "position": [3400, 455],
1406
+ "collider": { "shape": "rect", "size": [400, 10] },
1407
+ "friction": 0
1408
+ },
1409
+ "children": [
1410
+ {
1411
+ "name": "Skin",
1412
+ "type": "ColorRect2D",
1413
+ "uid": "n_opic7894wramqynx",
1414
+ "props": { "size": [400, 10], "color": "#bfe9ff", "anchor": [0.5, 0.5] }
1415
+ }
1416
+ ]
1417
+ },
1418
+ {
1419
+ "name": "Sled",
1420
+ "type": "RigidBody2D",
1421
+ "uid": "n_nhcrprd8no2pzb9i",
1422
+ "props": {
1423
+ "position": [3215, 435],
1424
+ "collider": { "shape": "circle", "radius": 14 },
1425
+ "linearVelocity": [260, 0]
1426
+ },
1427
+ "children": [
1428
+ {
1429
+ "name": "Skin",
1430
+ "type": "ColorRect2D",
1431
+ "uid": "n_y2owjzo7khqchken",
1432
+ "props": { "size": [28, 28], "color": "#8c6a3f", "anchor": [0.5, 0.5] }
1433
+ }
1434
+ ]
1435
+ },
1436
+ {
1437
+ "name": "Hound",
1438
+ "type": "CharacterBody2D",
1439
+ "uid": "n_kmnt12lx070t311c",
1440
+ "props": { "position": [3650, 440], "collider": { "shape": "rect", "size": [30, 40] } },
1441
+ "children": [
1442
+ {
1443
+ "name": "AI",
1444
+ "type": "Node2D",
1445
+ "uid": "n_juz8aw6j311cstbs",
1446
+ "script": {
1447
+ "name": "Chase",
1448
+ "props": {
1449
+ "target": "/root/Player",
1450
+ "speed": 160,
1451
+ "stopRange": 24,
1452
+ "loseRange": 500,
1453
+ "moveParent": true,
1454
+ "ground": true
1455
+ }
1456
+ }
1457
+ },
1458
+ {
1459
+ "name": "Skin",
1460
+ "type": "ColorRect2D",
1461
+ "uid": "n_k5h72ttrivo8vy0m",
1462
+ "props": { "size": [30, 40], "color": "#6b4d8a", "anchor": [0.5, 0.5] }
1463
+ }
1464
+ ]
1465
+ },
1466
+ {
1467
+ "name": "Perch",
1468
+ "type": "StaticBody2D",
1469
+ "uid": "n_t6vi97e88isgglef",
1470
+ "props": { "position": [3650, 300], "collider": { "shape": "rect", "size": [160, 20] } },
1471
+ "children": [
1472
+ {
1473
+ "name": "Skin",
1474
+ "type": "ColorRect2D",
1475
+ "uid": "n_m7qcxz6z1sc6rfbv",
1476
+ "props": { "size": [160, 20], "color": "#9aa3bd", "anchor": [0.5, 0.5] }
1477
+ }
1478
+ ]
1353
1479
  }
1354
1480
  ],
1355
1481
  "uid": "n_d9t51gs0vhpnp2jj"
@@ -273,3 +273,57 @@ interface Player {
273
273
  }
274
274
 
275
275
  console.log(process.exitCode ? '\nVERIFY FAILED' : '\nVERIFY OK — Castle Run plays end to end');
276
+
277
+ // ---- TWIN: this round's 3D surfaces, in two dimensions ----------------------------
278
+ // StaticBody2D.restitution/friction (#1295) and Chase.ground (#1297) were built
279
+ // with the 3D case in hand and 2D tests beside it; a course past the last
280
+ // ground slab composes them in the 2D flagship and measures each. y is DOWN.
281
+ {
282
+ type Body = { position: number[] };
283
+ const bouncerY: number[] = [];
284
+ let sledX = 0;
285
+ let houndY = 0;
286
+ let houndX = 0;
287
+ const result = await runScript(sceneJson, {
288
+ behaviors,
289
+ durationMs: 4200,
290
+ steps: [
291
+ ...Array.from({ length: 30 }, (_, i) => ({
292
+ atMs: 100 + i * 100,
293
+ do: (ctx: { getNode(p: string): unknown }) => {
294
+ bouncerY.push((ctx.getNode('Bouncer') as Body).position[1] ?? 0);
295
+ },
296
+ })),
297
+ {
298
+ atMs: 3100,
299
+ do: (ctx) => {
300
+ sledX = (ctx.getNode('Sled') as unknown as Body).position[0] ?? 0;
301
+ // the player on the perch above the hound: a ground chaser closes in below, never up
302
+ const player = ctx.getNode('Player') as unknown as Body;
303
+ player.position = [3650, 260];
304
+ for (let i = 0; i < 120; i++) ctx.engine.step();
305
+ const hound = ctx.getNode('Hound') as unknown as Body;
306
+ houndX = hound.position[0] ?? 0;
307
+ houndY = hound.position[1] ?? 0;
308
+ },
309
+ },
310
+ ],
311
+ });
312
+ ok('runScript reported no failures (TWIN path)', result.ok);
313
+ let touch = bouncerY.findIndex((y, i) => i > 0 && y < (bouncerY[i - 1] ?? 0));
314
+ if (touch < 1) touch = 1;
315
+ const low = bouncerY[touch - 1] ?? 0;
316
+ const rebound = low - Math.min(...bouncerY.slice(touch));
317
+ ok(
318
+ `StaticBody2D restitution 1: a dead ball climbs back ${rebound.toFixed(0)} px of its 300 px drop`,
319
+ rebound > 180,
320
+ );
321
+ ok(
322
+ `StaticBody2D friction 0: the sled slides to x ${sledX.toFixed(0)} across the ice (from 3215)`,
323
+ sledX > 3450,
324
+ );
325
+ ok(
326
+ `Chase.ground: the hound stays on the floor (y ${houndY.toFixed(0)}, floor 440) under a player on the perch, ${Math.abs(houndX - 3650).toFixed(0)} px off its x`,
327
+ houndY > 400 && Math.abs(houndX - 3650) < 60,
328
+ );
329
+ }
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
- "incanto": "^0.72.0",
14
+ "incanto": "^0.73.0",
15
15
  "three": "^0.184.0"
16
16
  },
17
17
  "devDependencies": {
@@ -5,7 +5,7 @@
5
5
  "Vanguard" — a 3D THIRD-PERSON shooter. The camera rides over your soldier's
6
6
  shoulder (you SEE your character); the crosshair is where the hitscan rifle
7
7
  fires. Move with WASD, look with the mouse (click locks the pointer), Shift to
8
- sprint, Space to jump, click / F to fire, R to reload, Enter to restart. Survive and clear three
8
+ sprint, Space to jump (tap for a short hop, hold for the full jump, press again in the air for a second), click / F to fire, R to reload, Enter to restart. Survive and clear three
9
9
  escalating waves of chasing enemies to **SECURE THE AREA**; let them drain your
10
10
  HP to 0 and the **MISSION FAILS**.
11
11