incanto 0.72.0 → 0.74.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-BAiA-FjP.js} +7 -7
  11. package/dist/{create-game-C9rWqqAC.js → create-game-ssvGvP7W.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-BkqlWewf.js} +277 -60
  16. package/dist/{gameplay-BlQe-M07.js → gameplay-Bxe1sMVT.js} +611 -39
  17. package/dist/gameplay.d.ts +141 -7
  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-C-SfvjzH.js} +94 -11
  25. package/dist/{physics-3d-BzOCw1VS.js → physics-3d-rOzeg890.js} +202 -15
  26. package/dist/{picking-C9McVJrO.js → picking-BMV34Pjl.js} +2 -2
  27. package/dist/react.d.ts +1 -1
  28. package/dist/react.js +1 -1
  29. package/dist/{register-5IYfooAN.js → register-CjociOtt.js} +19 -4
  30. package/dist/{register-C8HuRkOf.js → register-DRQyZKGU.js} +45 -9
  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-CqtvYtSN.js} +1 -1
  38. package/dist/{test-Djuq3Vhq.js → test-CEl0y2Sw.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-BlzturGi.js} +1 -1
  44. package/editor/assets/{debug-DLrQm-az.js → debug-BMUi8usj.js} +1 -1
  45. package/editor/assets/{index-BEKuxYcE.js → index-CrUCQoaB.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 +179 -16
  58. package/skills/incanto-node-reference.md +124 -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
@@ -35,6 +35,10 @@ independent; enable the one matching your scene.
35
35
  `{"shape":"heightfield"}` (STATIC-only; reads the grid from a `Terrain3D` child — see the
36
36
  incanto-environment skill).
37
37
  Malformed colliders are hard `BAD_FORMAT` errors at enable time.
38
+ The collider FOLLOWS the terrain: reshape the `Terrain3D` at runtime — `maxHeight`,
39
+ `seed`, `roughness`, a channel a `River3D` cuts — and the heightfield is rebuilt on the
40
+ next physics step, so the ground is walked on where it is drawn (the editor's collider
41
+ outline moves with it for the same reason).
38
42
 
39
43
  **`{"shape":"auto"}` is the one to reach for on props.** It shapes the collider like the
40
44
  `MeshInstance3D` the body CARRIES — box→cuboid, sphere→ball, cylinder/capsule→their own
@@ -49,6 +53,10 @@ the corners. Give the body its mesh as a CHILD and let the shape follow:
49
53
  "children": [ { "name": "Deck", "type": "MeshInstance3D",
50
54
  "props": { "mesh": "box", "size": [14, 0.22, 2.2] } } ] }
51
55
  ```
56
+ It FOLLOWS the mesh, too: resize it, turn the box into a sphere, or move it inside the
57
+ body at runtime and the collider is rebuilt on the next physics step — the same rule the
58
+ heightfield keeps with its terrain. A body that takes its shape from another node moves
59
+ with that node, not only with its own `collider` prop.
52
60
 
53
61
  The child's OWN `position`, `rotation` and `scale` are part of the shape: raise the plank on
54
62
  the child instead of the body and the collider goes up with it; scale the crate 4× and it is
@@ -77,19 +85,48 @@ The body simply does not exist until the model has downloaded — nothing is inv
77
85
  meantime — and it appears the step the asset lands. A body is ONE shape, so `auto` fits one
78
86
  mesh or one model and warns when the body carries more.
79
87
 
80
- - **`StaticBody2D/3D`** — immovable (ground, walls). Props: `collider`.
88
+ - **`StaticBody2D/3D`** — immovable (ground, walls). Props: `collider`, and the SURFACE it
89
+ is: `friction 0.5` (0 = ice, 1+ = glue) and `restitution 0` (1 = trampoline; above 1
90
+ is allowed and is a catapult). Two things a pad under the PLAYER taught: the controller's
91
+ falls run at `fallGravity` (2.5×) and its rises at 1×, so a `restitution 1` pad injects
92
+ energy on every bounce of a character that falls onto it (measured: 6.9 m, then 11.6 m) —
93
+ use ~0.8 for a pad players land on again and again; and `friction 0` is not ice for a
94
+ CharacterController3D, because the controller brakes itself (its own damping, not the
95
+ floor's friction, stops it) — an ice patch slows a crate, not the character. A static
96
+ body's authored value WINS the pair — restitution above 0 combines by Max, friction below
97
+ the default by Min and above it by Max — so a trampoline launches a dead ball and an ice
98
+ patch slides a grippy crate. The same on a `RigidBody`: its own `friction`/`restitution`
99
+ win the pair too; when both sides speak, Rapier's order (Max > Multiply > Min > Average)
100
+ decides. Left at the defaults it pairs by average, as it always did.
101
+ On a `Terrain3D`, `snapToGround: <lift>` on the body sits the pad on the island at load
102
+ (beacon-isle's trampoline: `snapToGround: 0.1` for a 0.2 m slab).
81
103
  - **`RigidBody2D/3D`** — simulated. Props: `collider`, `mass 1`, `gravityScale 1`,
82
104
  `fixedRotation false`, `friction 0.5`, `restitution 0`, `linearVelocity` (write to launch,
83
105
  read back every step; `velocity` is an alias for it). 3D also has
84
- `angularVelocity`. Every one of them is LIVEwrite it mid-run and the solver
85
- picks it up on the next step.
106
+ `angularVelocity` (RADIANS per second about each world axis the one place the
107
+ engine is not in degrees). Every one of them is LIVE — write it mid-run and the solver
108
+ picks it up on the next step. Three things a first composition of these measured:
109
+ an authored `restitution` or `friction` WINS the pair whichever side wrote it — a `0.75`
110
+ ball on a plain floor keeps 75% of its speed (it used to average with the floor's 0 and
111
+ keep 37%), a `friction: 0` body slides on a plain floor as if the floor were ice (the
112
+ character skill's recipe, honoured at last: walk 4.0, not 3.7);
113
+ `gravityScale: 0` with an `angularVelocity` and a `linearVelocity` is a floating,
114
+ spinning, drifting prop and a NEGATIVE `gravityScale` rises; and the body's
115
+ `rotation` is written back as Euler XYZ, which reports a yaw past 90° as
116
+ `[180, 180 − yaw, 180]` — recover a heading from all three, never from `rotation[1]`.
86
117
  - **`Area2D/3D`** — sensor. Emits `triggerEnter(other)` / `triggerExit(other)`. Never blocks
87
118
  movement. Solid bodies emit the same signals on real contact (one mental model).
88
119
  Areas overlapping OTHER Areas fire too (e.g. a weapon-hitbox Area over an
89
120
  enemy-hitbox Area) — neither side needs to be a Body.
90
121
  - **`CharacterBody2D/3D`** — kinematic character (Rapier KCC). Props: `collider`
91
122
  (capsule recommended), `velocity`, `stickToGround true`, `slopeLimitDeg 45`,
92
- `stepHeight` (3D `0.35` m / 2D `35` px — see below).
123
+ `stepHeight` (3D `0.35` m / 2D `35` px — see below), and
124
+ **`collideWithCharacters true`** — whether OTHER characters are obstacles to
125
+ this one. Leave it on for a few enemies; turn it OFF for a horde: eighty
126
+ chasers around a player cost 64–286 ms a STEP with it on (each slide
127
+ shape-casts against every neighbour — O(n²) in the crowd, and 93% of the
128
+ profile was Rapier) and 1.6 ms off. The horde overlaps itself and nobody
129
+ minds; it still collides with the floor and the walls.
93
130
  API: `moveAndSlide()` (call from `fixedUpdate`), `isOnFloor()`,
94
131
  **`isOnWall()`** and **`isOnCeiling()`**. Which wall is answered in the shape
95
132
  each dimension has: 2D's `wallSide()` is `-1` left / `+1` right, 3D's
@@ -336,7 +373,8 @@ per frame from `update(dt)` scaled by `dt`. In 2D the impulse is in px·kg/s,
336
373
  y-down, exactly like `linearVelocity`.
337
374
 
338
375
  Readable/writable body state, in BOTH adapters: `velocity` (= `linearVelocity`),
339
- `angularVelocity`, `mass`, `gravityScale`, `friction`, `restitution`. Read the
376
+ `angularVelocity`, `mass`, `gravityScale`, `friction`, `restitution`,
377
+ `linearDamping`, `angularDamping`. Read the
340
378
  spin to see how fast a lever is swinging; write it to launch a spinning body.
341
379
  3D's is a 3-vector about the world axes, 2D's is the scalar rad/s about z with
342
380
  the same sign as `rotation` (clockwise, because 2D is y-down). There is no
@@ -352,6 +390,30 @@ body is CREATED. Changing it mid-run does nothing, and a locked body's
352
390
  > in both, `mass`/`friction`/`restitution` were read once at creation in both,
353
391
  > and `gravityScale` was live in 3D and dead in 2D.
354
392
 
393
+ ### A ball that never stops (`angularDamping`, `linearDamping`)
394
+
395
+ A sphere on a flat floor, one impulse: 4.27 m/s at one second and 4.27 at
396
+ eight. Rapier has no rolling resistance — friction only converts slip into
397
+ spin, and a rolling ball is a wheel — so a golf ball, a bowling ball, a marble
398
+ and a kicked can all rolled to the edge of the world. Two props, per second,
399
+ `0` by default (nothing you built changes):
400
+
401
+ | prop | what it damps | reach for it when |
402
+ |---|---|---|
403
+ | `angularDamping` | the SPIN, so a rolling body coasts to a stop | anything that rolls: `2` stops a golf ball from 4 m/s in about four seconds, `0.5` is a long coast |
404
+ | `linearDamping` | the VELOCITY itself, `e^(-k·t)` | drag through air or water: a parachute, a puck on felt, a body that must not fly forever |
405
+
406
+ ```json
407
+ { "name": "Ball", "type": "RigidBody3D",
408
+ "props": { "friction": 0.6, "restitution": 0.3, "angularDamping": 2,
409
+ "collider": { "shape": "sphere", "radius": 0.2 } } }
410
+ ```
411
+
412
+ Both are live — a green that slows the ball, a pond that drags it — and both
413
+ exist on `RigidBody2D` in the same terms. "At rest" is yours to decide: read
414
+ `physics.velocityOf(ball)` and call it stopped under ~0.05 for a few frames.
415
+ `examples/minigolf-3d` is the composition.
416
+
355
417
  ## What is inside this Area right now?
356
418
 
357
419
  `triggerEnter(other)` / `triggerExit(other)` tell you about CROSSINGS. A pressure
@@ -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.74.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.74.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.74.0",
15
15
  "three": "^0.184.0"
16
16
  },
17
17
  "devDependencies": {