incanto 0.74.0 → 0.76.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 (92) hide show
  1. package/bin/incanto-multiplay.mjs +52 -4
  2. package/dist/2d.d.ts +83 -7
  3. package/dist/2d.js +4 -3
  4. package/dist/3d.d.ts +861 -167
  5. package/dist/3d.js +6 -6
  6. package/dist/{agent8-CHTT4unP.js → agent8-B-bh3n0X.js} +1 -1
  7. package/dist/{audio-player-BOrk4eQx.d.ts → audio-player-BRo2uvG6.d.ts} +23 -2
  8. package/dist/{behavior-J1cmVah0.d.ts → behavior-B_245qRy.d.ts} +15 -1
  9. package/dist/{create-game-ssvGvP7W.js → create-game-BMIPKrQW.js} +84 -8
  10. package/dist/{create-game-BAiA-FjP.js → create-game-Dai5vfVu.js} +80 -11
  11. package/dist/debug.d.ts +1 -1
  12. package/dist/{editor-switch-CFU9mCec.d.ts → editor-switch-Bzt0GzVp.d.ts} +13 -3
  13. package/dist/editor.js +1600 -1529
  14. package/dist/env.d.ts +1 -1
  15. package/dist/{environment-presets-BkqlWewf.js → environment-presets-e_9YCnGu.js} +2361 -104
  16. package/dist/{gameplay-Bxe1sMVT.js → gameplay-Bfff_beb.js} +10919 -6798
  17. package/dist/gameplay.d.ts +573 -31
  18. package/dist/gameplay.js +3 -2
  19. package/dist/index.d.ts +150 -6
  20. package/dist/index.js +6 -6
  21. package/dist/{loader-BAeWMYfF.d.ts → loader-CbkVdXL8.d.ts} +2 -2
  22. package/dist/net.d.ts +2 -2
  23. package/dist/net.js +2 -2
  24. package/dist/{physics-2d-C-SfvjzH.js → physics-2d-Tuf5LAyq.js} +250 -31
  25. package/dist/{physics-3d-rOzeg890.js → physics-3d-C5MOujfQ.js} +673 -51
  26. package/dist/{picking-BMV34Pjl.js → picking-DIwE48sU.js} +10 -7
  27. package/dist/{save-slots-CEUGeLuB.js → pose-ByFB_J3O.js} +217 -11
  28. package/dist/react.d.ts +2 -2
  29. package/dist/react.js +1 -1
  30. package/dist/{register-DRQyZKGU.js → register-7E3P1gz6.js} +191 -430
  31. package/dist/{register-CjociOtt.js → register-B768Qedj.js} +596 -2
  32. package/dist/{replay-B6BbgGM_.js → replay-DrqQ6dZj.js} +133 -18
  33. package/dist/{replay-wZK5Ok5c.d.ts → replay-W5nCw_cU.d.ts} +21 -2
  34. package/dist/{rng-Bb-IutXB.d.ts → rng-BsXZg3D6.d.ts} +7 -0
  35. package/dist/{split-screen-BxZ5rBe7.js → split-screen-B-dIcQsQ.js} +10 -5
  36. package/dist/{split-screen-D6sx8z3Z.d.ts → split-screen-CiLJf2-C.d.ts} +3 -3
  37. package/dist/{sprite-animation-Bz3DHD_4.js → sprite-animation-SQa5gIu2.js} +8 -6
  38. package/dist/{src-CqtvYtSN.js → src-D823V07k.js} +1 -1
  39. package/dist/{test-CEl0y2Sw.js → test-CVbxnXlv.js} +487 -82
  40. package/dist/test.d.ts +90 -12
  41. package/dist/test.js +2 -2
  42. package/dist/vite.js +34 -5
  43. package/editor/assets/{agent8-BlzturGi.js → agent8-DA-I5qhv.js} +1 -1
  44. package/editor/assets/{debug-BMUi8usj.js → debug-99tEPoGm.js} +1 -1
  45. package/editor/assets/index-Db74LZY-.js +11579 -0
  46. package/editor/index.html +1 -1
  47. package/package.json +1 -1
  48. package/schemas/scene.schema.json +1639 -280
  49. package/skills/incanto-3d-character.md +383 -20
  50. package/skills/incanto-audio.md +24 -2
  51. package/skills/incanto-building-2d-games.md +21 -2
  52. package/skills/incanto-building-3d-games.md +38 -3
  53. package/skills/incanto-editor.md +29 -8
  54. package/skills/incanto-environment.md +257 -14
  55. package/skills/incanto-game-feel.md +60 -1
  56. package/skills/incanto-gameplay-behaviors.md +668 -20
  57. package/skills/incanto-hud.md +116 -0
  58. package/skills/incanto-multiplayer.md +141 -2
  59. package/skills/incanto-node-reference.md +398 -16
  60. package/skills/incanto-performance.md +32 -0
  61. package/skills/incanto-physics-and-input.md +241 -13
  62. package/skills/incanto-playtesting.md +2 -2
  63. package/skills/incanto-save-slots.md +61 -2
  64. package/skills/incanto-scene-json-authoring.md +13 -0
  65. package/skills/incanto-verifying-your-game.md +86 -3
  66. package/templates-app/beacon-isle-3d/PROJECT/Status.md +12 -0
  67. package/templates-app/beacon-isle-3d/package.json +1 -1
  68. package/templates-app/beacon-isle-3d/src/game.scene.json +157 -3
  69. package/templates-app/molehill-2d/PROJECT/Status.md +7 -0
  70. package/templates-app/molehill-2d/package.json +1 -1
  71. package/templates-app/molehill-2d/src/game.scene.json +129 -2
  72. package/templates-app/platformer-2d/PROJECT/Status.md +7 -0
  73. package/templates-app/platformer-2d/docs/project-2d-rules.md +3 -2
  74. package/templates-app/platformer-2d/package.json +1 -1
  75. package/templates-app/platformer-2d/src/behaviors.ts +40 -26
  76. package/templates-app/platformer-2d/src/game.scene.json +144 -2
  77. package/templates-app/platformer-2d/verify.ts +18 -1
  78. package/templates-app/star-survivor/PROJECT/Status.md +7 -0
  79. package/templates-app/star-survivor/package.json +1 -1
  80. package/templates-app/star-survivor/src/behaviors.ts +9 -8
  81. package/templates-app/star-survivor/src/game.scene.json +142 -4
  82. package/templates-app/star-survivor/verify.ts +13 -0
  83. package/templates-app/tps-3d/PROJECT/Status.md +6 -0
  84. package/templates-app/tps-3d/package.json +1 -1
  85. package/templates-app/tps-3d/src/game.scene.json +71 -10
  86. package/templates-app/village-quest-3d/PROJECT/Status.md +7 -0
  87. package/templates-app/village-quest-3d/package.json +1 -1
  88. package/templates-app/village-quest-3d/src/behaviors.ts +8 -16
  89. package/templates-app/village-quest-3d/src/grove.scene.json +121 -5
  90. package/templates-app/village-quest-3d/src/village.scene.json +126 -2
  91. package/templates-app/village-quest-3d/verify.ts +52 -2
  92. package/editor/assets/index-CrUCQoaB.js +0 -11046
@@ -48,6 +48,38 @@ and are comparable to each other. Headless (`step()`), `fps`/`frameMs` are 0 and
48
48
  the slices are still real CPU times — a behavior that got slower shows up in a
49
49
  test.
50
50
 
51
+ ### Measuring a frame by hand: `step()` ALREADY renders
52
+
53
+ The renderer subscribes to `engine.updated`, so a frame is one call:
54
+
55
+ ```ts
56
+ const t0 = performance.now();
57
+ for (let i = 0; i < 120; i++) game.engine.step(1 / 60); // draws, too
58
+ const frameMs = (performance.now() - t0) / 120;
59
+ ```
60
+
61
+ Calling `game.renderer.render(...)` after `step()` draws the scene TWICE and
62
+ doubles every number you take. It reads as a plausible result rather than an
63
+ obvious mistake — the flagship template measured 24.9 ms a frame that way and
64
+ **8.96 ms** the honest way, which is the difference between "this does not hold
65
+ 60" and "this holds 60 twice over".
66
+
67
+ The same trap is why a hidden tab needs care: a tool-created tab has its
68
+ animation frames paused, so driving `step()` yourself is the only way to get
69
+ frames — and the one call is the whole frame.
70
+
71
+ For reference, the numbers `bunx incanto-new` scaffolds (Beacon Isle, an M-class
72
+ laptop, 220 nodes and 29 `Tree3D` groves):
73
+
74
+ | tier | frame | render | draw calls | triangles |
75
+ | --- | --- | --- | --- | --- |
76
+ | high | 8.96 ms | 8.44 ms | 999 | 7.3 M |
77
+ | medium | 7.75 ms | 7.04 ms | 580 | 6.2 M |
78
+ | low | 7.17 ms | 6.68 ms | 576 | 6.2 M |
79
+
80
+ The tier is doing its job — it takes 42% of the draw calls out — and the render
81
+ is where a 3D game's frame goes, which is what the dials in this skill are for.
82
+
51
83
  ## The short version
52
84
 
53
85
  ```ts
@@ -120,7 +120,13 @@ mesh or one model and warns when the body carries more.
120
120
  enemy-hitbox Area) — neither side needs to be a Body.
121
121
  - **`CharacterBody2D/3D`** — kinematic character (Rapier KCC). Props: `collider`
122
122
  (capsule recommended), `velocity`, `stickToGround true`, `slopeLimitDeg 45`,
123
- `stepHeight` (3D `0.35` m / 2D `35` px — see below), and
123
+ `stepHeight` (3D `0.35` m / 2D `35` px — see below), **`pushes 0`** — the
124
+ mass (kg) this body SHOVES dynamic bodies with. The KCC treats a dynamic
125
+ body as an OBSTACLE — a character walks around things — so a beam on a
126
+ `Patrol` slid up to the player and stopped dead against them, forever. A
127
+ piston, a sweeping arm, a moving wall or a bruiser enemy is a thing that
128
+ shoves: `pushes: 90` and it pushes what it meets, the harder the heavier
129
+ (`examples/tower-3d`'s beam). `0` keeps every scene as it was. And
124
130
  **`collideWithCharacters true`** — whether OTHER characters are obstacles to
125
131
  this one. Leave it on for a few enemies; turn it OFF for a horde: eighty
126
132
  chasers around a player cost 64–286 ms a STEP with it on (each slide
@@ -150,6 +156,22 @@ sword.enabled = true; // the active window of the swing
150
156
  sword.enabled = false; // …and done
151
157
  ```
152
158
 
159
+ A switched-off SENSOR overlaps nothing — including a body teleported into it
160
+ while it is off. Rapier still reports an intersection-start for a disabled
161
+ collider in that case (measured: `isEnabled()` false, event delivered), and a
162
+ guard's grab disarmed for a cutscene killed the player the camera had left
163
+ standing in it; both adapters now drop any event on a node whose `enabled` is
164
+ false, and re-arming reports what the sensor is inside of.
165
+
166
+ **A child body sits where its parent's frame puts it — rotation included.** A
167
+ hit sensor two metres down +z of a hull turned 90° is at world +x, where the
168
+ renderer draws it. Both adapters used to sum positions up the tree and ignore
169
+ every rotation, so a guard's grab box "in front" of a turned guard, a tank's
170
+ barrel-tip sensor and a turret's muzzle all sat where nothing was drawn; a
171
+ dynamic body under a turned parent keeps a LOCAL pose on the way back. The same
172
+ composition is behind `worldPosition()` for behaviours, so `Turret`, `Sight`
173
+ and `FollowCamera` measure from where a node IS. Scale is still not composed.
174
+
153
175
  **A collider's keys are CLOSED**: `shape`, `size`, `radius`, `height`, `offset`
154
176
  and (2D) `oneWay` — plus `vertices`/`indices` for a 3D trimesh. Anything else is
155
177
  a load error naming the nearest one, because
@@ -271,6 +293,21 @@ engine now does for you:
271
293
 
272
294
  Every `incanto-new` template and every runnable example ships exactly this.
273
295
 
296
+ **A 3D game needs two more things a 2D one does not**, and both are one line:
297
+
298
+ - **The camera has to turn.** The stick walks; looking around is a DRAG on the
299
+ play area, and that only exists when the game asks for it:
300
+ `createGame3D({ pointer: true })`. Without it a phone player can walk and
301
+ never look. (A drag on the stick or a button never turns the camera — the
302
+ controls are siblings of the canvas and eat their own gestures.)
303
+ - **Portrait is a keyhole.** `Camera3D.fov` is the VERTICAL field of view, so a
304
+ `fov: 60` camera shows 91° across a 16:9 window and **30° across a 390×844
305
+ phone** — the same game, down a drinking straw. Set
306
+ `Camera3D.minHorizontalFov` (52 is a good floor) and the vertical fov widens
307
+ until that much is on screen; a window already wider keeps the fov it was
308
+ authored with. Pair it with `CharacterController3D.pitchStart` (about −14°) —
309
+ a tall screen spends its top half on sky with a level camera.
310
+
274
311
  Two more phone facts worth knowing:
275
312
 
276
313
  - **Audio unlocks on ANY first gesture** — the canvas, a key, or the on-screen
@@ -349,11 +386,146 @@ body B (any node path — `%Name` is typical):
349
386
  ]}
350
387
  ```
351
388
 
352
- Types — 2D: `fixed` (weld) · `revolute` (pin/hinge) · `rope` (max distance)
353
- · `spring` (pull toward `length` with `stiffness`/`damping`). 3D swaps
354
- `revolute` for `spherical` (ball joint). `length: 0` measures the body
355
- distance at creation. `anchor`/`targetAnchor` are local offsets (px / m).
356
- Chains work (pendulums, bridges: each link a body + joint to the previous).
389
+ Types — 2D: `fixed` (weld) · `revolute` (pin/hinge) · `prismatic` (a SLIDE
390
+ along `axis`) · `rope` (max distance) · `spring` (pull toward `length` with
391
+ `stiffness`/`damping`). 3D has all of those plus `spherical` (ball joint);
392
+ its `revolute` turns about `axis` and its `prismatic` slides along it (local
393
+ to the joint's body, default `[0, 1, 0]`; 2D's `axis` defaults to `[0, 1]`,
394
+ so an "up" slide in y-down 2D is `[0, -1]`). `length: 0` measures the body
395
+ distance at creation — and a rope's or spring's `length`, `stiffness` and
396
+ `damping` are LIVE: a write REMAKES the joint on the next step, the bodies
397
+ keeping their motion, which is how a grapple reels its rope in
398
+ (`rope.length -= 5 * dt` — `examples/grapple-3d`). `anchor`/`targetAnchor`
399
+ are local offsets (px / m).
400
+ Chains work (pendulums, bridges: each link a body + joint to the previous —
401
+ `examples/wreck-3d` hangs a wrecking ball off six of them).
402
+
403
+ **A hinge has stops and a motor; so does a slide.** On a `revolute` or a
404
+ `prismatic` in either dimension:
405
+
406
+ | prop | default | what it is |
407
+ |---|---|---|
408
+ | `limits` | `[]` | the stops — `[minDeg, maxDeg]` for a hinge, `[min, max]` metres (px) for a slide; empty = free. A door that opens one way: `[0, 110]`; a portcullis that lifts three metres: `[0, 3]` |
409
+ | `motorSpeed` | `0` | the speed the motor drives it at — degrees a second (hinge) or metres a second (slide; px in 2D); `0` = no motor, the joint is free. A mill wheel: `40`; a drawbridge coming down: `-25` toward its lower limit, where the motor then HOLDS it; an elevator going up: `1.2` |
410
+ | `motorStrength` | `20` | the motor's gain — acceleration per unit of speed error. **Against gravity the motor sags by g/motorStrength**: a lift at `20` rises at half the asked speed, at `100` within a tenth of it |
411
+ | `angle` | read-only | a hinge's current angle in degrees: the joint's OWN body turned about the axis, relative to the target. `if (hinge.angle > 60)` is "the door is open" |
412
+ | `travel` | read-only | a slide's current travel in metres (px): the joint's own anchor along the axis relative to the target's — `0` where the anchors coincide, which is where the scene loaded |
413
+
414
+ All three props are LIVE — a lever behaviour that writes `hinge.motorSpeed = -25`
415
+ starts the winch that frame — and `limits`/`angle` share one convention (the
416
+ joint's body relative to the target), so a limit you read off `angle` is the
417
+ limit you write. `examples/contraption-3d` is a gate you push, a drawbridge a
418
+ lever lowers, a millstone that never stops and a seesaw with stops, all from
419
+ JSON — and the portcullis at its keep is a `prismatic` a `bridgeDown` signal
420
+ lifts.
421
+
422
+ Four things a hinge taught while that example was built:
423
+
424
+ - **`angle` is the ACTUAL turn, not "0 as loaded".** A drawbridge authored
425
+ raised at `rotation: [85, 0, 0]` reads `angle: 85` on the first frame, and
426
+ its limits are absolute too: `[0, 85]` is "flat to raised". Nothing is
427
+ measured from the pose the scene loaded in.
428
+ - **Author a turned body about its hinge.** The two anchors must COINCIDE at
429
+ load (rule 5 below). A deck turned 85° about its near edge has its CENTRE
430
+ where that turn puts it — compute it (`sill + [0, 3.1·sin 85°, −3.1·cos 85°]`
431
+ for a 6.2 m deck), do not type it; a mismatch snaps the body into place and
432
+ the snap looks like a limit pushing it.
433
+ - **A raised thing needs HOLDING.** A hinge with no motor is free: a deck
434
+ authored raised falls flat on the first frame. `motorSpeed: 25` toward the
435
+ upper stop holds it there; the lever writes `-25` and the same motor lowers
436
+ it to the lower stop and holds THAT.
437
+ - **A jointed thing must not rest on static ground.** A millstone whose rim
438
+ touches the walkway slabs is jammed by friction and turns at 2°/s; a seesaw
439
+ whose ends rest on the landings does not tilt. Leave a gap.
440
+
441
+ A grapple is a joint made at RUNTIME: `examples/grapple-3d` casts a ray from
442
+ the eye, and on a static hit builds a `Joint3D` in code — `new Joint3D('Rope')`,
443
+ `type: 'rope'`, `target` the hit body's path, `targetAnchor` the hit point in
444
+ that body's frame, `length` the distance plus a little slack — and adds it
445
+ under the player; `free()` it to let go. The adapter picks a new joint up on
446
+ the next step and drops a freed one; gravity does the swing, and a rope has a
447
+ little give under a swinging weight (about 4% of its length).
448
+
449
+ ## Vehicle3D — a car in one node
450
+
451
+ `examples/buggy-3d` proves a car can be BUILT from joints, and that building
452
+ one is a round's work with two traps in it. Every racing game, delivery game
453
+ and open world with a road wants one in a node: `Vehicle3D`, under a
454
+ `RigidBody3D` chassis, is Rapier's raycast vehicle — wheels on springs that
455
+ never touch anything (a ray each), so a car cannot catch a kerb on a wheel
456
+ body or disagree with its axle.
457
+
458
+ ```json
459
+ { "name": "Car", "type": "RigidBody3D",
460
+ "props": { "mass": 800, "collider": { "shape": "box", "size": [1.8, 0.6, 4] } },
461
+ "children": [
462
+ { "name": "Drive", "type": "Vehicle3D", "props": { "wheels": [
463
+ { "position": [-0.8, -0.1, 1.4], "steer": true },
464
+ { "position": [ 0.8, -0.1, 1.4], "steer": true },
465
+ { "position": [-0.8, -0.1, -1.4], "drive": true },
466
+ { "position": [ 0.8, -0.1, -1.4], "drive": true } ] } },
467
+ { "name": "Wheel0", "type": "Node3D", "props": { "position": [-0.8, -0.1, 1.4] },
468
+ "children": [ { "name": "Hub", "type": "Node3D", "children": [
469
+ { "name": "Tyre", "type": "MeshInstance3D",
470
+ "props": { "mesh": "cylinder", "size": [0.35, 0.25, 0.35], "rotation": [0, 0, 90] } } ] } ] }
471
+ ] }
472
+ ```
473
+
474
+ | prop | default | what it does |
475
+ |---|---|---|
476
+ | `wheels` | `[]` | `[{ position, radius?, rest?, steer?, drive? }]` — where each spring hangs from in the CHASSIS's frame; radius 0.35, rest length 0.3; at least one |
477
+ | `suspensionStiffness` · `suspensionCompression` · `suspensionRelaxation` · `maxTravel` | `30` · `2.3` · `3.5` · `0.3` | the springs (20 soft, 30 a car, 60 a kart) |
478
+ | `friction` · `sideFriction` | `2.5` · `1` | tyre grip along and across the wheel (lower side friction = more drift) |
479
+ | `maxSteerDeg` · `engineForce` · `brakeForce` | `32` · `1500` · `40` | full lock, force per driven wheel at full throttle, brake per wheel |
480
+ | `moveAction` · `brakeAction` | `"move"` · `"brake"` | the stick (up throttle, down reverse, left/right steer) and the brake button; `moveAction: ""` = your code writes `throttle`/`steer`/`brake` (−1..1, −1..1, 0..1) |
481
+
482
+ - **Forward is +z** (the +Z-forward rule, like a skin). Put the camera behind
483
+ at −z and turn it with the chassis (`examples/courier-3d`'s `ChaseCam`).
484
+ - **The wheel nodes are posed for you.** A child of the chassis named
485
+ `Wheel<i>` is put at its mount, hanging by the spring's current length, and
486
+ turned to steer; its child `Hub` spins as the wheel rolls — so a tyre mesh
487
+ under `Hub`, rotated `[0, 0, 90]` to lie on the axle, just works.
488
+ - **`enabled: false` is parked**: the throttle and steering read nothing and
489
+ the brake is held, while the suspension still settles and the wheels still
490
+ pose. Both the car and the character read `move`, so a game with a driver
491
+ who can get OUT flips this and `CharacterController3D.enabled` together
492
+ (`examples/errands-3d`; "Letting go" in `incanto-3d-character.md`).
493
+ - **Readbacks.** `speed` (m/s, negative in reverse), `wheel(i)` →
494
+ `{ contact, suspension, rotation, steering }`.
495
+ - The chassis is an ordinary `RigidBody3D`: `mass` is the car's weight, its
496
+ `collider` is what hits walls, `angularDamping` calms a flip, and a
497
+ `Respawn` under it catches a drive off the map. A car on its roof is a
498
+ stuck game — give the player a reset (the example's `R` rights the car).
499
+ - **Zones are driven THROUGH.** The wheel rays ignore every `Area3D`, so a
500
+ checkpoint, a delivery bay or a speed trap is a sensor box over the road
501
+ and the car passes through it level — it does not climb onto the box.
502
+ - **Lift-off is `linearDamping`.** A raycast vehicle has no rolling
503
+ resistance: released, it coasts at CONSTANT speed until it hits something
504
+ (measured 14.2 m/s, unchanged over a second). `linearDamping: 0.3` on the
505
+ chassis is the engine braking (a ~3 s decay) and the top speed —
506
+ `engineForce × driven wheels / mass ÷ damping` — in one number; the
507
+ errands van has none and stops on its brake, the race car has it.
508
+ - **Online, a car is its owner's body** — `network: { mode: "owner", sync:
509
+ ["position", "rotation", "Wheel0.rotation", "Wheel1.rotation"] }` on the
510
+ chassis, and the other player's car a kinematic `CharacterBody3D` copy you
511
+ can bump. `examples/race-mp-3d` and "An online race" in
512
+ `incanto-multiplayer.md`.
513
+
514
+ A vehicle is the same parts: `examples/buggy-3d` is a box chassis, four
515
+ cylinder wheels on `revolute` axles (the rear pair motored — forward is a
516
+ negative speed about +x), and two steering knuckles whose hinge motor is a
517
+ servo, `motorSpeed = (target − angle) · rate`, with `limits` at full lock. A
518
+ knuckle needs inertia (mass, a real box) and a strong motor to turn a tyre
519
+ that is scrubbing on the ground, and a brake is a motor asked for ~0 with a
520
+ high `motorStrength`.
521
+
522
+ And two things the ENGINE learned: a motorised body no longer falls asleep
523
+ (Rapier sleeps anything that moves slowly for a while, and a door at 30°/s is
524
+ slow — the motor ran two seconds and froze mid-swing), and a TURNING ground
525
+ now carries its rider round — the moving-platform carry was the ground's
526
+ origin, which a millstone never moves, so its rider stood still while the
527
+ floor turned under him. Both riders (a `CharacterBody3D`'s `moveAndSlide`
528
+ and the player's `platformCarry`), both dimensions.
357
529
 
358
530
  ## Pushing a body around (the API that was in no skill)
359
531
 
@@ -381,6 +553,13 @@ the same sign as `rotation` (clockwise, because 2D is y-down). There is no
381
553
  torque call in either. Writes land on the next step — these are live props, not
382
554
  load-time constants.
383
555
 
556
+ **An impulse composes with a velocity written the same frame.** A mirror
557
+ steering a ball by `linearVelocity`, a magnet, a conveyor — and a kick in the
558
+ same `update` — used to lose the kick: the write reached the solver a step
559
+ later and overwrote it. Now `applyImpulse` lands the pending write first, then
560
+ the impulse, and `linearVelocity` reads the result at once (a harness that
561
+ reads it right after the kick sees the kick).
562
+
384
563
  `fixedRotation` is authored, not live: it locks the body's rotation when the
385
564
  body is CREATED. Changing it mid-run does nothing, and a locked body's
386
565
  `angularVelocity` is zero rather than whatever was authored.
@@ -414,6 +593,25 @@ exist on `RigidBody2D` in the same terms. "At rest" is yours to decide: read
414
593
  `physics.velocityOf(ball)` and call it stopped under ~0.05 for a few frames.
415
594
  `examples/minigolf-3d` is the composition.
416
595
 
596
+ ### A rudder: write `angularVelocity`
597
+
598
+ `body.angularVelocity = [0, rate, 0]` (or `body.angularVelocity[1] = rate`)
599
+ spins a `RigidBody3D` from that step on, in rad/s about the world axes —
600
+ `+y` turns +z toward +x. It is how `examples/harbor-3d` steers a boat, and
601
+ it did NOTHING in 3D until the harbor round: the adapter read the solver's
602
+ spin back every step and never looked at a script's write (2D always had the
603
+ path).
604
+ A `fixedRotation` body ignores it, as it ignores an authored spin.
605
+
606
+ ### A crate that floats (`buoyancy`)
607
+
608
+ `RigidBody3D.buoyancy: 1` and a `Water3D` under it: the body is lifted by the
609
+ submerged part of its collider's volume at water's density, so `mass` against
610
+ the collider decides — a 1 m³ crate at 150 kg floats, at 3000 kg it sinks, a
611
+ wide hull rights itself, and it rides the water's waves. `Water3D.drag` slows
612
+ it. The whole rule, with the numbers, is in `incanto-environment.md` ("Things
613
+ that float"); `examples/harbor-3d` drives a boat with it.
614
+
417
615
  ## What is inside this Area right now?
418
616
 
419
617
  `triggerEnter(other)` / `triggerExit(other)` tell you about CROSSINGS. A pressure
@@ -435,10 +633,11 @@ question you usually mean.
435
633
 
436
634
  ## Joints: the three things that decide whether yours works
437
635
 
438
- 1. **There is no hinge in 3D.** The types are `fixed | spherical | rope | spring`.
439
- A hinge is **two `spherical` joints at two points along the axis** — that
440
- constrains rotation to the line between them, and it is exactly how a seesaw
441
- or a door is built. (2D has `revolute` and needs none of this.)
636
+ 1. **A hinge is `revolute` with an `axis`** (3D; 2D's needs no axis). Before it
637
+ existed the recipe was two `spherical` joints along the axis — that still
638
+ works, but it has no stops and no motor, and the sentence "there is no hinge
639
+ in 3D" was true for a year. A seesaw, a door, a wheel and a drawbridge are one
640
+ joint each now.
442
641
  2. **`collide` decides whether the two ends touch, and its default reads the
443
642
  type.** Rapier lets jointed bodies collide, and a hinge wants its bodies
444
643
  overlapping at the pivot — so the contact solver fights the joint and flings
@@ -460,13 +659,35 @@ question you usually mean.
460
659
  it belongs to — is now a load-time error: Rapier accepts a body jointed to
461
660
  itself and constrains nothing, and the rope fell exactly as if it were not
462
661
  there.
463
- 4. **`anchor`/`targetAnchor` are local offsets from each body's ORIGIN**,
662
+ 4. **A joint's own bodies are not the world to the character controller.**
663
+ A `CharacterBody3D` crane hook with a chain of jointed links hanging off it
664
+ used to RISE on its own (the controller's shape-cast saw the first link
665
+ inside the hook's box and pushed the hook out of it, every step) and, parked
666
+ over the next tower with its chain, landed twice as far as asked (the
667
+ controller's ground ray hit that same link, so the moving-platform carry
668
+ dragged the hook along by however far the link had moved — which was however
669
+ far the hook had just moved). Now a body jointed to a character with the
670
+ pair's contacts off is not its obstacle, and a body jointed to it is never
671
+ its ground. So the natural crane — a kinematic hook you drive with
672
+ `moveAndSlide`, a chain and a ball on `spherical` joints — just works;
673
+ `examples/wreck-3d` is that crane. Both dimensions.
674
+ 5. **The two bodies must AGREE about the axis.** A hinge's or a slide's `axis`
675
+ is one vector, and Rapier builds a frame from it in EACH body's local space.
676
+ So the two bodies' rotations may differ only by a turn ABOUT that axis: a
677
+ drawbridge authored raised about its own hinge is fine; a wheel body turned
678
+ on its side to lay its cylinder along the axle is not — the chassis reads
679
+ `[1,0,0]` as world x and the wheel reads it as world y, and a buggy built
680
+ that way went onto its roof on the first frame. A joint that breaks the
681
+ rule is refused at creation, by name, with the fix: keep the body
682
+ unrotated and turn the MESH inside it (`collider: auto` follows the mesh) —
683
+ `examples/buggy-3d` is four wheels built exactly so.
684
+ 6. **`anchor`/`targetAnchor` are local offsets from each body's ORIGIN**,
464
685
  resolved against the bodies' positions at load. If the two disagree the solver
465
686
  snaps the body into place on the first frame, and a 4 cm typo is silent. Author
466
687
  them with arithmetic (generate the scene) rather than by hand.
467
688
 
468
- There are no joint limits or motors yet: bound a lever's travel with static
469
- collision geometry in its way.
689
+ Limits and motors live on `revolute` (see the table above) — a lever's travel
690
+ is `limits`, not static geometry in its way.
470
691
 
471
692
  ## Raycasts
472
693
 
@@ -539,6 +760,13 @@ and physics rebuilds the body that step. In-place mutation of the existing
539
760
  collider object (`body.collider.size[0] = w`) is NOT watched (the per-step
540
761
  change scan was removed for performance).
541
762
 
763
+ **To switch a body OFF, write `enabled = false`** — not `collider = null`. The
764
+ prop is an object and a non-object used to reach the physics sync and throw
765
+ `null is not an object (evaluating 'node.collider.shape')` out of the frame
766
+ loop, with no node path: the loader refuses `"collider": null` in JSON with a
767
+ typed error, and a runtime write was checked nowhere. It is now refused with
768
+ that sentence, naming the node, and the collider it had is kept.
769
+
542
770
  ## Debug drawing
543
771
 
544
772
  `physics.debugDraw = true` (the instance `enablePhysics2D/3D` returns) renders
@@ -71,10 +71,10 @@ the hazards and the destinations down with it.
71
71
  | `stuck` | the clock ran out AND the player never got more than 3 m (96 px) from its spawn — it is wedged, or nothing moves it |
72
72
  | `swapped` | the game changed scene. The bot binds player, destinations and the win/lose oracle ONCE, from the tree it started in, so nothing after a `goToScene` is measured — the run stops there and says so. It is not a defect, and the destinations it never got to are reported as unmeasured rather than missed. Judge a multi-scene game with a scripted `runScript`. The bot's own KEYS are re-read on the swap: it used to hold the first scene's buttons into the second, and letting go of one the new scene never declared threw the whole playtest away. |
73
73
  | `unfinished` | the clock ran out on a player that was getting around. Not a defect: a win that needs a SEQUENCE (talk to the NPC, then fetch, then return) is out of reach of a random walker, forever |
74
- | `never reached` | destinations the bot never came within `--reach-radius` of (default 2 m in 3D, 32 px in 2D) |
74
+ | `never reached` | destinations the bot never came within `--reach-radius` of (default 2 m in 3D, 32 px in 2D) — of the node's EXTENT, not its origin, so standing on a 30 m quay counts as reaching it. A node earns a place on this list by carrying a group, running a behaviour, or being a trigger volume; measured against origins, every tagged floor in every 3D game reported as unreached while the player stood on it |
75
75
  | `never fired` | signals a `connections[]` entry listens to that never happened |
76
76
  | `declared and never read` | actions in the scene's `input{}` that NOTHING asked about, in any run — a key the control list promises and no code implements. Measured as a read, not a press, so the bot pressing it does not clear it. Two shipped starters had one: `tps-3d` declared `restart` (on `KeyR`, already the reload key) and `MISSION FAILED` was a dead end you left with F5. An action polled only on a screen no run reached lands here honestly — read it, do not gate on it |
77
- | `danger` | how many times the player's `Health` emitted `damaged` — and when that is ZERO, WHICH zero it is: a scene with no hazard wired at all is a walkabout and worth shouting about; a scene full of them that never landed one is a report about the BOT, which a real player would outrun less easily. Hazards are counted from the scene FILE, because a `Spawner` detaches its prefab at ready and a wave game's hazards are not in the tree yet. |
77
+ | `danger` | how many times the player's `Health` emitted `damaged` — and when that is ZERO, WHICH zero it is. A scene with no `DamageOnContact` AND no player-side `Health` is a walkabout and worth shouting about; one with a Health but no hits says so WITHOUT claiming the scene is harmless, because damage that comes from a behaviour calling `Health.damage()` has no hazard node to count and this cannot see it; a scene full of hazards that never landed one is a report about the BOT, which a real player would outrun less easily. Hazards are counted from the scene FILE, because a `Spawner` detaches its prefab at ready and a wave game's hazards are not in the tree yet. **Put the player's `Health` under the player** — on it or on a child, the way `star-survivor` composes `/Game/Player/Vitals` — or none of this counts it: the oracle asks whether the damaged node is player-SIDE. |
78
78
 
79
79
  **The bot presses the HUD.** Every focusable widget a player can currently see —
80
80
  `UiButton`, `UiToggle`, `UiSelect`, `UiSlider` — gets pressed occasionally,
@@ -100,10 +100,19 @@ readable if you want it for something else.
100
100
  `deserialize` is defensive on purpose: that data may come from a build of your
101
101
  game that shipped six weeks ago. Check what you read.
102
102
 
103
- Built-ins that already save: `Collector` (total), `Currency` (amount),
104
- `DayNight` (hour, paused), `Health` (current, dead), `SavePoint` (playtime),
103
+ Built-ins that already save: `Checkpoint` (which one was lit),
104
+ `Collector` (total), `Currency` (amount), `DayNight` (hour, paused),
105
+ `Health` (current, dead), `Phases` (which phase the fight reached),
106
+ `SavePoint` (playtime, and the poses it `keep`s),
105
107
  `ScoreKeeper` (score, lives, won/lost), `WaveSpawner` (which wave).
106
108
 
109
+ `Checkpoint` restores the `Respawn` redirect as well as its own `lit` flag, and
110
+ when a run lit several it comes back to the one it reached LAST — the order they
111
+ announce in does not decide where you land. Without that, a continued run's
112
+ first fall went back to the level's START, past every checkpoint the session had
113
+ reached, and the report was green because a behaviour with no `serialize` is not
114
+ counted as saveable in the first place.
115
+
107
116
  `WaveSpawner` RESTARTS the wave you were on rather than resuming mid-spawn —
108
117
  spawned entities are never restored, so resuming "four enemies into wave 3"
109
118
  would resume a wave whose enemies do not exist. `waveStarted` fires again, so a
@@ -196,6 +205,24 @@ At runtime `engine.captureState()` logs the same thing per node.
196
205
 
197
206
  Never hand-craft a uid. Use `newUid()`.
198
207
 
208
+ ## The two-button version
209
+
210
+ For a game whose save is "this scene, right now", `GameFlow` has the verbs and
211
+ you write no TypeScript at all:
212
+
213
+ ```json
214
+ { "from": "/root/HUD/PauseMenu/Save", "signal": "pressed", "to": "/root/Flow", "handler": "save" },
215
+ { "from": "/root/HUD/PauseMenu/Continue", "signal": "pressed", "to": "/root/Flow", "handler": "continueFrom" }
216
+ ```
217
+
218
+ with `"script": { "name": "GameFlow", "props": { "saveSlots": "beacon-isle" } }`
219
+ naming where the slots live. `save(slot)` writes `captureState()` under the
220
+ scene's own name; `continueFrom(slot)` reloads the scene and restores AFTER
221
+ `onReady`, which is the ordering everything below is about; `hasSave(slot)` is
222
+ what a CONTINUE button asks before it offers itself. A slot written by ANOTHER
223
+ scene is refused with a sentence — routing between levels is yours, and the rest
224
+ of this page is how to do it. Composed in `examples/beacon-isle-3d`.
225
+
199
226
  ## Saving and loading
200
227
 
201
228
  ```ts
@@ -284,6 +311,38 @@ const game = await createGame2D({ canvas, scene });
284
311
  // then `restoreOnReady: true` on that scene's SavePoint, or call restore()
285
312
  ```
286
313
 
314
+ ## Where the player was standing: `keep`
315
+
316
+ A save is behavior state keyed by uid and deliberately not a snapshot of the
317
+ tree, so **mid-level positions do not come back**. That is right for a
318
+ checkpoint save and wrong for a Continue: pressing it should put you on the
319
+ street you quit on, not at the depot. The escape hatch was one sentence —
320
+ "a game that wants a position saves it" — and every game wrote the same
321
+ behaviour whose only job is to hold a transform.
322
+
323
+ The scene says it instead:
324
+
325
+ ```json
326
+ { "name": "Save", "type": "Node3D", "uid": "n_…",
327
+ "script": { "name": "SavePoint",
328
+ "props": { "game": "longhaul", "slot": "1",
329
+ "keep": ["/root/Courier", "/root/Van"] } } }
330
+ ```
331
+
332
+ Each named node's `position` and `rotation` go into the slot alongside the
333
+ playtime, and come back in `announce()` — after the whole restore pass, so a
334
+ camera rig or a controller restoring on the same frame cannot overwrite them.
335
+
336
+ - **Each kept node needs a `uid`**, the same join key as the rest of the save,
337
+ so a rename or a reparent survives. A path that names nothing, a node with no
338
+ uid, and a node with no position each say so through `engine.log` on the way
339
+ IN — at save time, when you can still fix it — rather than restoring quietly
340
+ to the wrong place months later.
341
+ - A slot written before a node was added to `keep` restores everything else and
342
+ leaves that node where the scene file puts it.
343
+ - It is a POSE, not physics: velocity is not kept, so a continued run starts at
344
+ rest. Save a velocity yourself if a game needs one.
345
+
287
346
  A `SavePoint` records `scene` as the scene's own `name` unless you set the prop,
288
347
  so `level2.scene.json` named `level2` needs no wiring at all. Set `scene`
289
348
  explicitly when one file is entered more than one way (`"chapter-2-rescue"`).
@@ -367,6 +367,19 @@ that do exist at the failing spot).
367
367
  registered (`registerBehavior`) before `loadScene` → `UNKNOWN_BEHAVIOR` otherwise.
368
368
  - Unresolvable `from`/`to` → `DANGLING_CONNECTION` at load. Renaming a node breaks its
369
369
  connections **loudly** — update paths in the same edit.
370
+ - `args` calls the handler with THOSE values instead of whatever the signal
371
+ emitted — `Function.bind` for a wire:
372
+
373
+ ```json
374
+ { "signal": "phase2", "from": "Boss/Mood", "to": "HUD/Banner", "handler": "show",
375
+ "args": ["THE CINDERS CATCH", { "color": "#ffb454", "seconds": 1.6 }] }
376
+ ```
377
+
378
+ Without it, "when this happens, say that" needed a two-line behaviour whose
379
+ whole job was calling one method with one constant. Bound arguments REPLACE
380
+ the emitted ones — a wire that half-carried would be ambiguous, and the
381
+ emitted values are still one connection away (the same wire without `args`).
382
+ It must be a LIST: `"args": "ENRAGED"` is a load error.
370
383
  - `filter` gates firing on the first emitted argument: it must be a node in `filter.group`
371
384
  and/or match every `filter.tag` entry. **A filter's keys are `group` and `tag`, and a
372
385
  connection's are `[signal, from, to, handler, once, filter]`** — anything else is a load
@@ -22,9 +22,17 @@ each answers a failure that used to look like nothing at all:
22
22
  | `assetErrors()` | a model/texture 404'd — the thing is simply not there | §6c |
23
23
  | `framing` | the camera is pointed the wrong way, or nothing is lit | §6a |
24
24
 
25
- A run that finishes is not a run that worked. `runScript()` fails the run when
26
- `stats().errors` is non-zero, and prints the engine's warnings under the
27
- failures but `framing` and `assetErrors()` you have to ASK for.
25
+ A run that finishes is not a run that worked. `runScript()` fails the run on
26
+ BOTH halves of "the engine stayed alive": behaviours the loop quarantined after
27
+ a throw (`stats().errors`), and anything the engine itself reported as an
28
+ **error** — a sentence it wrote precisely because it chose to keep going. Each
29
+ distinct one is a failure quoting what was said, deduped, so a per-frame
30
+ complaint is one fault rather than six hundred. Measured on a boat whose
31
+ `Buoyancy` hung on a plain `Node` instead of the hull: the engine said
32
+ `needs a body it can push … it will never float` by name every frame, and the
33
+ run used to report `ok: true` while the boat sank through the sea. Warnings are
34
+ printed and do NOT fail; `framing` and `assetErrors()` you still have to ASK
35
+ for.
28
36
 
29
37
  ## 0. The whole ladder, one command: `incanto-verify`
30
38
 
@@ -187,6 +195,42 @@ Script steps' `do:`/`assert:` callbacks receive the full RunContext —
187
195
  work inside runScript: `do: (ctx) => ctx.engine.input.setActionVector('move',
188
196
  chase(ctx), 0)`.
189
197
 
198
+ **A sound can be asked where it came from.** `engine.audio.recent()` entries
199
+ carry `pan`, `gain` and `distance` for spatial sounds, computed from the scene's
200
+ camera when nothing has rendered — so a game whose mechanic is a noise in the
201
+ dark is checkable (`examples/earshot-3d`).
202
+
203
+ **A HUD widget can be asked where it is.** Every headless run arms the camera's
204
+ projection (`engine.toScreen` / `toWorld`), so `UiWaypoint.screen()` answers
205
+ instead of returning null — a marker that pins to the screen edge when its
206
+ target is behind you is now something a harness can check.
207
+
208
+ **A two-player game is driven by two hands.** The bot finds every
209
+ `CharacterController2D`/`3D` in the scene and drives each one on the action set
210
+ it declares (`moveAction`, `jumpAction`), so a couch co-op game gets both
211
+ characters walking. It used to drive the first `vector2` action it found:
212
+ measured on a two-player scene, player one walked 17.3 m and player two walked
213
+ 0.0 m, and the report said the game could never be won.
214
+
215
+ **Watching every frame: `onFrame`.** Steps fire at named times, which is the
216
+ wrong shape for anything continuous — the APEX of a jump, the CLOSEST a chaser
217
+ ever got, whether a shell cleared the wall between two named moments:
218
+
219
+ ```ts
220
+ let apex = 0;
221
+ await runScript(scene, {
222
+ durationMs: 4000,
223
+ onFrame: (ctx) => {
224
+ apex = Math.max(apex, (ctx.getNode('Ball') as { position: number[] }).position[1] ?? 0);
225
+ },
226
+ });
227
+ ```
228
+
229
+ It runs after every simulated frame with the live context, and a throw inside it
230
+ is a reported failure like a throw in a step. Before it existed every harness in
231
+ this repo wrote the same workaround — one `do:` that stepped the engine by hand
232
+ in a loop, which moves the clock out from under the script's own steps.
233
+
190
234
  **Reaching nodes & state from a step (the gotchas a sim hit):**
191
235
  - `ctx.getNode(path)` and `ctx.scene.root.getNode(path)` resolve from the ROOT.
192
236
  When the root node IS named `Game`, the root is `ctx.scene.root` (or
@@ -301,6 +345,16 @@ naming a path that resolves to nothing is a FAILURE, not a click into space.
301
345
  `geometricPicker(scene)` from `incanto/test` is the same thing on its own, for a
302
346
  harness that wants to ask "what is under this point" directly.
303
347
 
348
+ **Click the THING, not one of its triangles.** Anything bigger than a cube is a
349
+ grouping node with meshes under it and the behaviour on the group — so
350
+ `click: 'Keep/Ballista'` aims at whatever that subtree DRAWS, and a click that
351
+ lands on a different part of the same clickable is not a miss. Both of those
352
+ used to be failures: the group answered `resolves to no node — nothing to point
353
+ at` (a grouping node has no framing entry of its own), and naming a child was
354
+ reported as a miss whenever the ray met its sibling first, while the click had
355
+ already gone through the real `Clickable` and fired it. A click is wrong when it
356
+ reaches a different HANDLER — a wall in the way still says so.
357
+
304
358
  **A `click` naming a HUD widget PRESSES it** — `click: 'Hud/Shop/Buy'` is the
305
359
  whole verb for a menu, a shop, a settings screen, an idle game or a card game.
306
360
  There is no cursor involved: a widget lives in the DOM overlay, not the world,
@@ -1034,6 +1088,35 @@ Rules that make it hold (the engine's own rules anyway): use `engine.rng`
1034
1088
  never Math.random, dt/`engine.time` never Date.now. Gamepads replay through
1035
1089
  the ACTIONS they were bound to, not raw pad state.
1036
1090
 
1091
+ ### The same machinery is a REWIND
1092
+
1093
+ Determinism means a rewind needs no snapshot: cut the tape, load the scene
1094
+ again, and re-run the ticks that are left. A crate that was mid-air comes back
1095
+ mid-air, because it is the same simulation and not a recording of where things
1096
+ were.
1097
+
1098
+ ```ts
1099
+ const tape = startRecording(game.engine);
1100
+
1101
+ function takeBack(seconds: number): void {
1102
+ const upTo = tape.rewind(tape.take().ticks.length - Math.round(seconds * 60));
1103
+ game.engine.setScene(loadScene(structuredClone(sceneJson), { engine: game.engine }));
1104
+ replay(game.engine, upTo); // …and the tape keeps recording from the cut
1105
+ }
1106
+ ```
1107
+
1108
+ - `tape.take()` reads the recording WITHOUT ending it — `stop()` detaches the
1109
+ taps, so reading used to mean stopping, and the next recording's tick indices
1110
+ restarted at zero.
1111
+ - `tape.rewind(n)` drops everything after tick `n`, returns the tape up to it,
1112
+ and keeps recording from there — so a game can rewind, play on, and rewind
1113
+ again.
1114
+ - `replay(engine, recording, { until })` stops where you say, for a scrub or a
1115
+ mid-run assertion.
1116
+
1117
+ Measured in `examples/back-3d`: a 150-tick prefix replayed into a freshly
1118
+ loaded scene reproduces both crates and the walker to the millimetre.
1119
+
1037
1120
 
1038
1121
  ## Sound: did the right thing sound?
1039
1122
 
@@ -44,3 +44,15 @@ and `Walker` (CharacterBody3D + `Chase` on a child, `slopeLimitDeg 50`, `stepHei
44
44
  the west slope, `loseRange 25` so it never joins the quest. `verify.ts` measures the rebound
45
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
46
  terrain (0.97 m over `heightAt`).
47
+
48
+ ## Pausing, saving, continuing
49
+
50
+ ESC pauses and shows the pause menu; SAVE writes the run into a slot and
51
+ CONTINUE reloads the isle and hands every behaviour its state back — both are
52
+ `GameFlow` handlers (`save`, `continueFrom`) wired from buttons, so there is no
53
+ save code in this game at all.
54
+
55
+ ESC pauses and shows the pause menu; OPTIONS opens the settings over it and
56
+ BACK returns to the menu that asked. All of it is `GameFlow` (`pausePanelPath`,
57
+ `screen(path)`, `back()`) wired with `connections` — there is no TypeScript for
58
+ any screen in this game.
@@ -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.74.0",
17
+ "incanto": "^0.76.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {