incanto 0.57.0 → 0.59.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 (96) hide show
  1. package/README.md +6 -4
  2. package/bin/incanto-check.mjs +27 -0
  3. package/bin/incanto-multiplay.mjs +170 -0
  4. package/bin/incanto-new.mjs +29 -7
  5. package/bin/incanto-playtest.mjs +28 -2
  6. package/bin/incanto-verify.mjs +155 -19
  7. package/bin/incanto.mjs +107 -0
  8. package/dist/2d.d.ts +8 -2
  9. package/dist/2d.js +3 -3
  10. package/dist/3d.d.ts +9 -3
  11. package/dist/3d.js +4 -4
  12. package/dist/{behavior-l08AEbq9.d.ts → behavior-DWKTUzKI.d.ts} +10 -0
  13. package/dist/{create-game-C5jQYPah.js → create-game-BiW8Men_.js} +61 -13
  14. package/dist/{create-game-DpbUrMOQ.js → create-game-CHDLDQsQ.js} +6 -6
  15. package/dist/debug.d.ts +1 -1
  16. package/dist/debug.js +2 -2
  17. package/dist/{duplicate-BPLZDZpd.js → duplicate-DJQd44CD.js} +1 -1
  18. package/dist/{environment-presets-CvvQr_bJ.js → environment-presets-DRAz5EV9.js} +12 -10
  19. package/dist/{gameplay-BVphcxmE.js → gameplay-BBEjPFsR.js} +62 -34
  20. package/dist/gameplay.d.ts +1 -1
  21. package/dist/gameplay.js +1 -1
  22. package/dist/index.d.ts +36 -8
  23. package/dist/index.js +8 -8
  24. package/dist/{json-BLk7H2Qa.js → json-CwwhxQgb.js} +7 -1
  25. package/dist/{loader-BcrRSjxB.js → loader-D8n7TU8W.js} +142 -5
  26. package/dist/{loader-BbEMTuWg.d.ts → loader-TvkRFbyL.d.ts} +1 -1
  27. package/dist/net.d.ts +2 -2
  28. package/dist/net.js +1 -1
  29. package/dist/{pathfinding-mEN4V1CU.d.ts → pathfinding-BqWBb0kh.d.ts} +1 -1
  30. package/dist/{physics-2d-BLcvEFDR.js → physics-2d-BaRSRrrZ.js} +14 -3
  31. package/dist/{physics-3d-QBrfIT2Y.js → physics-3d-CYxjh-HW.js} +15 -4
  32. package/dist/quiet-rapier-BAJ4K94N.js +46 -0
  33. package/dist/react.d.ts +1 -1
  34. package/dist/react.js +2 -2
  35. package/dist/{register-C6ZBFRjd.js → register-BpFcgdcL.js} +57 -27
  36. package/dist/{register-Ch70uByv.js → register-CDrAQqPp.js} +90 -41
  37. package/dist/{registry-C7u42TID.js → registry-WWcQcfMr.js} +1 -1
  38. package/dist/{replay-s7I2GstT.js → replay-CEPyQtF_.js} +31 -7
  39. package/dist/{replay-Dw6gMlYA.d.ts → replay-O-yAGM76.d.ts} +1 -1
  40. package/dist/{split-screen-B0baBwxI.d.ts → split-screen-BQ3tAsf-.d.ts} +38 -1
  41. package/dist/{split-screen-DLsUrleX.js → split-screen-DDMZutQ6.js} +56 -13
  42. package/dist/{sprite-animation-C0wXLBZJ.js → sprite-animation-CY-mrr1L.js} +1 -1
  43. package/dist/{src-CGjmPw65.js → src-CY21B462.js} +1 -1
  44. package/dist/{teardown-Cs113S9F.js → teardown-RApWnM1G.js} +1 -1
  45. package/dist/{test-it1VekWs.js → test-DHYuFyAu.js} +292 -31
  46. package/dist/test.d.ts +104 -6
  47. package/dist/test.js +3 -3
  48. package/dist/vite.js +2 -2
  49. package/editor/assets/{agent8-CGT7r3Mb.js → agent8-BoRGtVxK.js} +1 -1
  50. package/editor/assets/{debug-BxWSIHG3.js → debug-CzdyCg75.js} +1 -1
  51. package/editor/assets/{index-CV1m-aX5.js → index-VesuVEhe.js} +91 -91
  52. package/editor/index.html +1 -1
  53. package/package.json +3 -1
  54. package/schemas/scene.schema.json +1174 -70
  55. package/skills/incanto-building-2d-games.md +13 -0
  56. package/skills/incanto-building-3d-games.md +3 -3
  57. package/skills/incanto-localization.md +40 -8
  58. package/skills/incanto-multiplayer.md +57 -4
  59. package/skills/incanto-node-reference.md +18 -0
  60. package/skills/incanto-physics-and-input.md +26 -0
  61. package/skills/incanto-playtesting.md +19 -5
  62. package/skills/incanto-verifying-your-game.md +33 -4
  63. package/templates-app/beacon-isle-3d/package.json +1 -1
  64. package/templates-app/beacon-isle-3d/src/game.scene.json +7 -6
  65. package/templates-app/platformer-2d/PROJECT/Context.md +70 -0
  66. package/templates-app/platformer-2d/PROJECT/Requirements.md +63 -0
  67. package/templates-app/platformer-2d/PROJECT/Status.md +60 -0
  68. package/templates-app/platformer-2d/PROJECT/Structure.md +77 -0
  69. package/templates-app/platformer-2d/docs/project-2d-rules.md +61 -0
  70. package/templates-app/platformer-2d/index.html +99 -0
  71. package/templates-app/platformer-2d/package.json +23 -0
  72. package/templates-app/platformer-2d/src/behaviors.ts +541 -0
  73. package/templates-app/platformer-2d/src/game.scene.json +2061 -0
  74. package/templates-app/platformer-2d/src/main.ts +68 -0
  75. package/templates-app/platformer-2d/tsconfig.json +13 -0
  76. package/templates-app/platformer-2d/verify.ts +275 -0
  77. package/templates-app/platformer-2d/vite.config.ts +12 -0
  78. package/templates-app/star-survivor/PROJECT/Context.md +55 -0
  79. package/templates-app/star-survivor/PROJECT/Requirements.md +47 -0
  80. package/templates-app/star-survivor/PROJECT/Status.md +44 -0
  81. package/templates-app/star-survivor/PROJECT/Structure.md +63 -0
  82. package/templates-app/star-survivor/docs/project-2d-rules.md +53 -0
  83. package/templates-app/star-survivor/index.html +232 -0
  84. package/templates-app/star-survivor/package.json +23 -0
  85. package/templates-app/star-survivor/src/behaviors.ts +624 -0
  86. package/templates-app/star-survivor/src/game.scene.json +464 -0
  87. package/templates-app/star-survivor/src/main.ts +49 -0
  88. package/templates-app/star-survivor/tsconfig.json +13 -0
  89. package/templates-app/star-survivor/verify.ts +193 -0
  90. package/templates-app/star-survivor/vite.config.ts +12 -0
  91. package/templates-app/tps-3d/package.json +1 -1
  92. package/templates-app/tps-3d/src/game.scene.json +6 -3
  93. package/templates-app/tps-3d/verify.ts +17 -1
  94. package/templates-app/village-quest-3d/package.json +1 -1
  95. package/templates-app/village-quest-3d/src/grove.scene.json +14 -13
  96. package/templates-app/village-quest-3d/src/village.scene.json +5 -5
@@ -10,6 +10,19 @@ description: Build 2D web games with Incanto — y-down pixel coordinates, strin
10
10
 
11
11
  Prerequisite: `incanto-scene-json-authoring.md` (this directory) for the file format. This skill covers the 2D taxonomy.
12
12
 
13
+ ## Fastest start: scaffold a whole game
14
+
15
+ ```bash
16
+ bunx incanto new my-game --template platformer-2d # tilemap, jump feel, follow cam, coins
17
+ bunx incanto new my-game --template star-survivor # endless waves, auto-attack, upgrades
18
+ bunx incanto new --list # every starter, 3D and 2D
19
+ ```
20
+
21
+ Templates are COMPLETE games — level, HUD, sound, and a `verify.ts` harness
22
+ that plays them end to end — meant to be reshaped rather than read. Prefer
23
+ starting from one over wiring from scratch; `bun run verify` in the new project
24
+ tells you the moment a change breaks the game.
25
+
13
26
  ## Coordinate convention (Phaser/Godot prior)
14
27
 
15
28
  **1 unit = 1 px · (0,0) top-left · +y DOWN · positive rotation = clockwise (degrees).**
@@ -20,9 +20,9 @@ reports a file's real bounding box and animation names.
20
20
  ## Fastest start: scaffold a whole game
21
21
 
22
22
  ```bash
23
- bunx incanto-new my-game # Beacon Isle — 3D flagship template
24
- bunx incanto-new my-game --template tps-3d # third-person shooter starter
25
- bunx incanto-new --list
23
+ bunx incanto new my-game # Beacon Isle — 3D flagship template
24
+ bunx incanto new my-game --template tps-3d # third-person shooter starter
25
+ bunx incanto new --list
26
26
  ```
27
27
 
28
28
  Templates are COMPLETE games (world generation, quest, enemies, verify
@@ -65,12 +65,29 @@ the `"$assetKey"` references you already write:
65
65
 
66
66
  ```json
67
67
  { "name": "Start", "type": "UiButton", "props": { "text": "@t:menu.start" } }
68
- { "name": "Wave", "type": "UiText", "props": { "text": "@t:hud.wave" } }
68
+ { "name": "Wave", "type": "UiText", "props": { "format": "@t:hud.wave" } }
69
69
  ```
70
70
 
71
- Works on every text-bearing widget: `UiText.text`, `UiButton.text`,
72
- `UiBar.label`, `UiSelect.label`, `UiBanner.show()`, and `UiDialogue` lines,
73
- speakers and choices.
71
+ Works on every text-bearing widget: `UiText.text` and `.format`,
72
+ `UiButton.text`, `UiBar.label`, `UiSelect.label` **and `.options`**,
73
+ `UiBanner.show()`, `UiDialogue` lines/speakers/choices — and `Label3D` / 2D
74
+ `Label`, the text that lives in the world.
75
+
76
+ **A key with a `{}` slot needs `format`, not `text`.** A prop resolves the key
77
+ and paints the result verbatim: `"hud.wave": "Wave {n}"` in `text` puts the
78
+ characters `Wave {n}` on screen. `format` is the template `setText` fills:
79
+
80
+ ```json
81
+ { "name": "Wave", "type": "UiText", "props": { "format": "@t:hud.wave" } }
82
+ ```
83
+ ```ts
84
+ wave.setText(String(n)); // "Wave 3" — and it re-reads on a language switch
85
+ ```
86
+ ```json
87
+ "strings": { "en": { "hud.wave": "Wave {}" }, "ko": { "hud.wave": "{} 웨이브" } }
88
+ ```
89
+
90
+ The slot moves with the language, which is the whole reason it is a slot.
74
91
 
75
92
  From a behavior, `engine.t(key, params)`:
76
93
 
@@ -78,8 +95,9 @@ From a behavior, `engine.t(key, params)`:
78
95
  banner.show(this.engine.t('hud.wave', { n: this.wave }));
79
96
  ```
80
97
 
81
- `{n}` slots are filled from `params`; a slot with no matching param is left
82
- alone rather than blanked.
98
+ `{n}` slots are filled from `params` — this is the ONLY path that fills a NAMED
99
+ slot; a scene-JSON prop has no params to fill it from. A slot with no matching
100
+ param is left alone rather than blanked.
83
101
 
84
102
  ## The language picker
85
103
 
@@ -96,10 +114,24 @@ because a player who cannot read the language on screen still has to find theirs
96
114
  Picking one switches the game **live** and persists the choice through
97
115
  `engine.settings`, so the next visit opens in it.
98
116
 
117
+ ### Text that lives in the world
118
+
119
+ `Label3D` and the 2D `Label` resolve `@t:` too, and re-bake their texture when
120
+ the language changes — a sign over a shop door, a nameplate, a damage number.
121
+ The prop keeps the marker; only the painted words change.
122
+
123
+ ```json
124
+ { "name": "Sign", "type": "Label3D",
125
+ "props": { "text": "@t:sign.welcome", "height": 0.4 } }
126
+ ```
127
+
99
128
  ## What NOT to localize
100
129
 
101
- - **Values, not labels.** `UiSelect.options` are values the game compares
102
- against (`"low,medium,high"`); translating them breaks the comparison.
130
+ - **Option VALUES stay identifiers — but you can still translate what is shown.**
131
+ `UiSelect.options` are what the game compares against, and `@t:` in an option
132
+ changes only the words on screen: the value `changed` emits is exactly what
133
+ you wrote. So `"options": "@t:diff.easy,@t:diff.hard"` displays 쉬움/어려움
134
+ and still hands your handler `@t:diff.easy`.
103
135
  - **Node names, group names, asset keys, signal names, action names.** These are
104
136
  identifiers.
105
137
  - **Anything a behavior parses.** If code does `if (value === 'start')`, that
@@ -49,9 +49,32 @@ Authority rules:
49
49
  ] }
50
50
  ```
51
51
 
52
+ **The `network` block is validated at LOAD.** It used to be the one node-level
53
+ block the loader cloned without looking at, and its typos are invisible at
54
+ runtime — a string `sync`, a capitalised `mode`, the plural `syncs` each mean
55
+ "nothing replicates and nothing says so", and produce a report identical to a
56
+ working game's. All three are hard `BAD_FORMAT` errors now, and
57
+ `bunx incanto check` catches them before you ever open a browser:
58
+
59
+ ```
60
+ [BAD_FORMAT] "network.sync" must be an ARRAY of prop names, not "position"
61
+ — write ["position"] (on 'Player')
62
+ ```
63
+
64
+ Valid keys: `mode` (`owner` | `observer`), `sync` (array of prop names),
65
+ `throttleMs` (number). An `owner` with an empty `sync` is an error too — it is
66
+ a half-finished edit that behaves exactly like a broken one.
67
+
52
68
  - **ONE owner node per player.** Its `sync` keys are relative to ITSELF
53
- (`position` = own prop, `Skin.animation` = child path + prop). Changed values coalesce
54
- into one throttled `setMyState({sync: {...}})` per window. Spawned entities (bullets,
69
+ (`position` = own prop, `Skin.animation` = child path + prop). A change to ANY
70
+ of them sends ALL of them, once per throttle window — the payload is the whole
71
+ set, deliberately: backends shallow-merge one level down, so a partial patch
72
+ would REPLACE the stored `sync` object and erase every key that had stopped
73
+ changing (a team colour, a skin, a name). Players already in the room would
74
+ never notice — they applied it once and kept it — while the next joiner
75
+ rendered the default forever. An idle owner still sends nothing until the
76
+ keyframe (`keyframeMs`, default 2 s) comes round, which is also what heals a
77
+ send that never reached the wire. Spawned entities (bullets,
55
78
  pickups) go through **collections**, never extra owner nodes.
56
79
  - **`NetworkSpawner`** (register with `registerNodesNet()`): `source: "users"` spawns one
57
80
  instance of the registered scene per OTHER account (self skipped); the flat `sync` patch
@@ -193,8 +216,38 @@ It runs the SAME class body the cloud runs: the v2 globals (`$sender`/`$global`/
193
216
  **Verifying a whole match**: `playMultiplayer` from `incanto/test` runs N clients
194
217
  against one in-memory server for a fixed number of simulated seconds and reports
195
218
  what they ended up sharing — rooms, who saw whom, what each `NetworkSpawner`
196
- materialised, per-client frame errors, the final room state. See
197
- `incanto-verifying-your-game.md`.
219
+ materialised, per-client frame errors, the final room state, and **whether the
220
+ clients hold the same VALUES**.
221
+
222
+ That last part is the one that matters and the one that used to be missing.
223
+ "Saw p2" only ever meant "p2 is in the room" — the kernel puts an empty entry
224
+ there at join — so a game whose replication was completely dead reported exactly
225
+ what a working one did. After the match quiesces, every key in every owner's
226
+ `network.sync` is read on the sender and on each other client's spawned copy and
227
+ compared:
228
+
229
+ | | |
230
+ |---|---|
231
+ | `missing` | that client never materialised the account at all |
232
+ | `absent` | the key path does not resolve on the spawned scene — a renamed child |
233
+ | `shape` | different array lengths; a componentwise lerp calls that "already correct" |
234
+ | `mismatch` | the values differ beyond the interpolation tolerance |
235
+ | `erased` | the live clients agree, and a LATE JOINER never got it |
236
+
237
+ `erased` is why the harness brings one more client in after everything settles.
238
+ Players already in the room applied a value once and kept it on their node, so
239
+ they agree with each other while the authoritative snapshot is already wrong —
240
+ a two-browser test cannot see it, and the next person to join sees the default
241
+ forever. Set `lateJoin: false` to skip that half; `seed` makes the whole match
242
+ reproducible.
243
+
244
+ **What it does not prove.** One in-memory server means no latency, no loss, no
245
+ reordering: this measures the apply path and the protocol shape, never the live
246
+ wire. A reconnect that never re-joins, a throttle that discards a payload, a
247
+ batch dropped on a closed socket — none of those are in reach, and the report
248
+ says so on its own summary line.
249
+
250
+ See `incanto-verifying-your-game.md`.
198
251
 
199
252
  **Driving one by hand**: server calls are QUEUED and only run when the event loop
200
253
  turns, so a synchronous frame loop enqueues a thousand ticks that never execute —
@@ -81,6 +81,7 @@ Signals: `animationFinished(name)`
81
81
  | `orderGroup` | `"default"` | one of: `background` `terrain` `default` `characters` `effects` `overlay` |
82
82
  | `visible` | `true` | boolean |
83
83
  | `collider` | `{}` | object |
84
+ | `enabled` | `true` | boolean |
84
85
 
85
86
  Signals: `triggerEnter(other)` · `triggerExit(other)`
86
87
 
@@ -97,6 +98,7 @@ Signals: `triggerEnter(other)` · `triggerExit(other)`
97
98
  | `orderGroup` | `"default"` | one of: `background` `terrain` `default` `characters` `effects` `overlay` |
98
99
  | `snapToGround` | `null` | null |
99
100
  | `collider` | `{}` | object |
101
+ | `enabled` | `true` | boolean |
100
102
 
101
103
  Signals: `triggerEnter(other)` · `triggerExit(other)`
102
104
 
@@ -213,6 +215,7 @@ Signals: `finished`
213
215
  | `orderGroup` | `"default"` | one of: `background` `terrain` `default` `characters` `effects` `overlay` |
214
216
  | `visible` | `true` | boolean |
215
217
  | `collider` | `{}` | object |
218
+ | `enabled` | `true` | boolean |
216
219
  | `velocity` | `[0,0]` | array |
217
220
  | `stickToGround` | `true` | boolean |
218
221
  | `slopeLimitDeg` | `45` | number |
@@ -232,6 +235,7 @@ Signals: `triggerEnter(other)` · `triggerExit(other)`
232
235
  | `orderGroup` | `"default"` | one of: `background` `terrain` `default` `characters` `effects` `overlay` |
233
236
  | `snapToGround` | `null` | null |
234
237
  | `collider` | `{}` | object |
238
+ | `enabled` | `true` | boolean |
235
239
  | `velocity` | `[0,0,0]` | array |
236
240
  | `stickToGround` | `true` | boolean |
237
241
  | `slopeLimitDeg` | `45` | number |
@@ -713,6 +717,7 @@ Signals: `finished`
713
717
  | `orderGroup` | `"default"` | one of: `background` `terrain` `default` `characters` `effects` `overlay` |
714
718
  | `visible` | `true` | boolean |
715
719
  | `collider` | `{}` | object |
720
+ | `enabled` | `true` | boolean |
716
721
  | `mass` | `1` | number |
717
722
  | `gravityScale` | `1` | number |
718
723
  | `fixedRotation` | `false` | boolean |
@@ -735,6 +740,7 @@ Signals: `triggerEnter(other)` · `triggerExit(other)`
735
740
  | `orderGroup` | `"default"` | one of: `background` `terrain` `default` `characters` `effects` `overlay` |
736
741
  | `snapToGround` | `null` | null |
737
742
  | `collider` | `{}` | object |
743
+ | `enabled` | `true` | boolean |
738
744
  | `mass` | `1` | number |
739
745
  | `gravityScale` | `1` | number |
740
746
  | `fixedRotation` | `false` | boolean |
@@ -828,6 +834,7 @@ Signals: `triggerEnter(other)` · `triggerExit(other)`
828
834
  | `orderGroup` | `"default"` | one of: `background` `terrain` `default` `characters` `effects` `overlay` |
829
835
  | `visible` | `true` | boolean |
830
836
  | `collider` | `{}` | object |
837
+ | `enabled` | `true` | boolean |
831
838
 
832
839
  Signals: `triggerEnter(other)` · `triggerExit(other)`
833
840
 
@@ -844,6 +851,7 @@ Signals: `triggerEnter(other)` · `triggerExit(other)`
844
851
  | `orderGroup` | `"default"` | one of: `background` `terrain` `default` `characters` `effects` `overlay` |
845
852
  | `snapToGround` | `null` | null |
846
853
  | `collider` | `{}` | object |
854
+ | `enabled` | `true` | boolean |
847
855
 
848
856
  Signals: `triggerEnter(other)` · `triggerExit(other)`
849
857
 
@@ -1377,6 +1385,16 @@ Signals: `dealtDamage(amount, target)`
1377
1385
 
1378
1386
  Signals: `dayPhaseChanged`
1379
1387
 
1388
+ ### `FloatAway`
1389
+
1390
+ | Prop | Default | Kind |
1391
+ |---|---|---|
1392
+ | `rise` | `1` | number |
1393
+ | `seconds` | `0.7` | number |
1394
+ | `hold` | `0.3` | number |
1395
+ | `drift` | `0` | number |
1396
+ | `freeOnEnd` | `true` | boolean |
1397
+
1380
1398
  ### `FollowCamera`
1381
1399
 
1382
1400
  | Prop | Default | Kind |
@@ -79,6 +79,32 @@ mesh or one model and warns when the body carries more.
79
79
  (capsule recommended), `velocity`, `stickToGround true`, `slopeLimitDeg 45`.
80
80
  API: `moveAndSlide()` (call from `fixedUpdate`), `isOnFloor()`.
81
81
 
82
+ ### `enabled` — a collider that is off for now
83
+
84
+ Every physics body takes **`enabled`** (default `true`). Off means no contacts
85
+ and no `triggerEnter`/`triggerExit`; the body stays, so nothing is rebuilt and
86
+ re-arming is one write.
87
+
88
+ ```jsonc
89
+ // a melee hitbox: real collider, authored OFF
90
+ { "name": "Sword", "type": "Area2D",
91
+ "props": { "collider": { "shape": "circle", "radius": 30 }, "enabled": false } }
92
+ ```
93
+ ```ts
94
+ sword.enabled = true; // the active window of the swing
95
+ sword.enabled = false; // …and done
96
+ ```
97
+
98
+ **Do not arm a hitbox by swapping `collider` in and out.** Replacing the
99
+ collider prop tears the rigid body down and builds a new one — twice per swing —
100
+ and a scene authored `"collider": {}` warns `has no collider — physics skips it`
101
+ on every boot, which is true and unactionable and teaches its reader to ignore
102
+ warnings.
103
+
104
+ Good for anything that is sometimes solid: a door that opens, a platform that
105
+ phases, a shield that is only up while blocking, a trigger that fires once and
106
+ retires.
107
+
82
108
  Physics simulates WORLD positions: bodies under offset parents work (offsets compose),
83
109
  but ancestor ROTATION/SCALE are not supported for physics bodies — keep body ancestors
84
110
  untransformed or translation-only.
@@ -50,7 +50,8 @@ two.
50
50
  | `lost` | `GameFlow` `'gameover'`, a `lost` signal, or the player's `Health.died` |
51
51
  | `fell` | the player left the world past `--fall-below` (default: 50 m under the spawn in 3D, 1000 px under it in 2D) |
52
52
  | `error` | `stats().errors` went above zero — a behavior threw |
53
- | `stuck` | none of the above before the clock ran out |
53
+ | `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 |
54
+ | `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 |
54
55
  | `never reached` | destinations the bot never came within `--reach-radius` of (default 2 m in 3D, 32 px in 2D) |
55
56
  | `never fired` | signals a `connections[]` entry listens to that never happened |
56
57
  | `danger` | how many times the player's `Health` emitted `damaged` |
@@ -91,9 +92,20 @@ unreachable or the connection is wrong.
91
92
  is exactly right (a walking simulator). Usually it means the hazards are not
92
93
  wired up.
93
94
 
94
- **`stuck` in every run** with no `won` at all usually means the scene declares
95
- no win condition — add a `GameFlow` or a `ScoreKeeper` and the report becomes
96
- far more useful.
95
+ **`stuck` in every run** means the player is not going anywhere: wedged in
96
+ geometry, spawned inside a collider, or missing the input map that moves it.
97
+ That is a real bug and worth chasing.
98
+
99
+ **`unfinished` in every run** is the normal report for a quest or story game
100
+ and means nothing is wrong. Random play cannot perform a sequence. Judge those
101
+ games with a SCRIPTED harness — `runScript` from `incanto/test`, which every
102
+ template's `verify.ts` is built on — and let the playtest tell you about
103
+ crashes, falls and dead hazards instead. `incanto-verify` marks the rung
104
+ `unmeasured` rather than failed for exactly this reason.
105
+
106
+ (These were one word until 0.58: `stuck` was the default verdict, so a quest
107
+ game that visited every landmark and fired two dozen signals was reported the
108
+ same as a player stuck in a wall.)
97
109
 
98
110
  ## Replays
99
111
 
@@ -116,7 +128,9 @@ Then read where it ended. The player above left a walled 40×40 arena at
116
128
  read straight off the replay.
117
129
 
118
130
  One file per *kind* of failure — twenty identical "stuck" replays teach nothing
119
- the first one does not.
131
+ the first one does not. `unfinished` runs write no replay at all: a replay is
132
+ for reproducing a failure, and eight of them per verify bury the one that is
133
+ real.
120
134
 
121
135
  ## Your behaviors
122
136
 
@@ -29,16 +29,39 @@ failures — but `framing` and `assetErrors()` you have to ASK for.
29
29
  ## 0. The whole ladder, one command: `incanto-verify`
30
30
 
31
31
  ```
32
- $ bunx incanto-verify # finds your scene
32
+ $ bunx incanto verify # finds your scene AND your behaviours
33
+ · behaviours: src/behaviors.ts (found, not named)
33
34
  ✓ loads — the scene is legal and its assets resolve
34
- ? plays — nothing declares a win — 8 runs played without error, and there was no end to reach
35
+ ? plays — 8 runs played without reaching a win (4 lost, 4 ran out the clock)
35
36
  ✓ feels — 5 of 7 fired — silent: /Game/Boss/Roar, /Game/Boss/Boom
37
+ · agrees — not run — this scene has no `multiplayer` header
36
38
  ? draws — the dev server is running on :5173, but no page answered
37
39
 
38
40
  passes what was measured — plays, draws not measured.
39
- next: if it is meant to be finishable, emit `won` (GameFlow, ScoreKeeper, or your own behaviour)
41
+ 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`
40
42
  ```
41
43
 
44
+ ### `agrees` — do two clients hold the same values?
45
+
46
+ Skipped outright unless the scene has a `multiplayer` header. When it does,
47
+ `incanto multiplay` runs a real match headlessly, lets it QUIESCE, then reads
48
+ every key in the owner's `network.sync` on the sender and on each other
49
+ client's spawned copy and compares them — plus one client that joins AFTER
50
+ everything settles, which is the only observer that reads the authoritative
51
+ snapshot fresh.
52
+
53
+ ```
54
+ ✓ agrees — 8 replicated value(s) match across clients and a late joiner
55
+ — over one in-memory server, not a live wire
56
+ ✗ agrees — p2 has no `Skin.rotation` for p1 — the key path does not resolve on
57
+ the spawned scene (a renamed child?), or it was never sent.
58
+ ```
59
+
60
+ That trailing clause is not modesty, it is the measurement's boundary: one
61
+ in-memory server has no latency, no loss and no reordering, so this proves the
62
+ apply path and says nothing about a reconnect that never re-joins or a batch
63
+ dropped on a closed socket.
64
+
42
65
  Runs the rungs below in order and says the ONE thing to do next. Three rules it
43
66
  encodes so you do not have to remember them:
44
67
 
@@ -773,7 +796,13 @@ bunx incanto-playtest src/game.scene.json --runs 20 --seconds 60
773
796
  A seeded bot plays it headlessly — reading the scene's own `input{}` for its
774
797
  controls — and reports the win rate, destinations it could never reach, wires
775
798
  that never fired, falls out of the world, and whether anything can hurt the
776
- player at all. Failing runs come back as replays. Exits 1 when nothing ever won.
799
+ player at all. Failing runs come back as replays.
800
+
801
+ **`won`, `lost` and `unfinished` are all gameplay; `error`, `fell` and `stuck`
802
+ are defects.** A random bot cannot perform a sequence, so a quest or story game
803
+ reports `unfinished` in every run and that means nothing is wrong — judge those
804
+ with a scripted `runScript` harness and let this one tell you about crashes,
805
+ falls and dead hazards.
777
806
 
778
807
  Read `incanto-playtesting.md` before shipping a level.
779
808
 
@@ -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.57.0",
17
+ "incanto": "^0.59.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {
@@ -2713,7 +2713,8 @@
2713
2713
  "volume": 0.6
2714
2714
  }
2715
2715
  }
2716
- ]
2716
+ ],
2717
+ "uid": "n_klf0y16b0ct0deyk"
2717
2718
  },
2718
2719
  {
2719
2720
  "name": "Sky",
@@ -2917,7 +2918,7 @@
2917
2918
  {
2918
2919
  "name": "Quest",
2919
2920
  "type": "UiText",
2920
- "uid": "n_bi_quest",
2921
+ "uid": "n_ysul3v903v7r9bqw",
2921
2922
  "props": {
2922
2923
  "text": "Find the lighthouse keeper [E]",
2923
2924
  "size": 16,
@@ -2928,7 +2929,7 @@
2928
2929
  {
2929
2930
  "name": "HP",
2930
2931
  "type": "UiBar",
2931
- "uid": "n_bi_hp",
2932
+ "uid": "n_x49lxq49wej0w332",
2932
2933
  "props": {
2933
2934
  "anchor": "topRight",
2934
2935
  "value": 100,
@@ -2939,7 +2940,7 @@
2939
2940
  {
2940
2941
  "name": "Banner",
2941
2942
  "type": "UiBanner",
2942
- "uid": "n_bi_banner",
2943
+ "uid": "n_7kqv8wsefrki826x",
2943
2944
  "props": {
2944
2945
  "anchor": "center"
2945
2946
  }
@@ -2947,7 +2948,7 @@
2947
2948
  {
2948
2949
  "name": "Dialogue",
2949
2950
  "type": "UiDialogue",
2950
- "uid": "n_bi_dialogue",
2951
+ "uid": "n_y0bq7vt5tewf88ny",
2951
2952
  "props": {
2952
2953
  "anchor": "bottom",
2953
2954
  "charsPerSecond": 45
@@ -2956,7 +2957,7 @@
2956
2957
  {
2957
2958
  "name": "Hint",
2958
2959
  "type": "UiText",
2959
- "uid": "n_bi_hint",
2960
+ "uid": "n_osy8mwjnmfp9zicd",
2960
2961
  "props": {
2961
2962
  "text": "WASD move · Shift sprint · E interact · click/F strike",
2962
2963
  "size": 12,
@@ -0,0 +1,70 @@
1
+ # Context — platformer-2d (Incanto)
2
+
3
+ ## Project Overview
4
+
5
+ **Castle Run** — a polished 2D adventure PLATFORMER, the clone-and-modify
6
+ starter for the platformer genre. You run a medieval knight through a castle:
7
+ solid ground with gaps, floating platforms, a horizontal patrolling platform and
8
+ a vertical bobbing lift you RIDE, spinning coins + gems to grab, spikes and a
9
+ bottomless pit to avoid, patrolling goblins you STOMP from above (and chain-
10
+ bounce off), a checkpoint flag, and a gold goal flag. Three hearts per life,
11
+ three lives; touch the goal to clear the castle, run out of lives for GAME OVER.
12
+
13
+ The level is BUILT-IN gameplay wired in scene JSON. What's custom is the
14
+ platformer GAME FEEL the renderer-agnostic library leaves open: a hand-tuned
15
+ `PlayerController` (coyote-time, jump-buffer, double-jump, variable jump height,
16
+ stomp, knockback, hearts+lives, checkpoint respawn, moving-platform carry) plus
17
+ presentation glue (`GoblinSkin`, `FollowCam` with screen-shake, `ParallaxLayer`,
18
+ `HudUpdater`).
19
+
20
+ ## Tech Stack
21
+
22
+ _Exact versions are in `package.json`._
23
+
24
+ - **Game engine**: `incanto` (scene-JSON-first, three.js-rendered) +
25
+ `incanto/2d` (createGame2D, 2D nodes: CharacterBody2D/StaticBody2D/Area2D,
26
+ AnimatedSprite2D, ColorRect2D, Camera2D, UILayer/Label, Particles2D,
27
+ AudioPlayer) + `incanto/gameplay` (auto-registered Pickup, Patrol, Oscillate,
28
+ ScoreKeeper, …).
29
+ - **Art**: built-in animated sheets `medieval-knight` (player) + `goblin`
30
+ (enemy), and `coin` + `gem` item textures — all bundler-imported from the
31
+ package and injected into the scene asset urls in `main.ts`. Ground, platforms,
32
+ spikes, flags and parallax castle are styled `ColorRect2D`.
33
+ - **Audio**: zero-asset procedural SFX presets (jump/coin/hit/hurt/powerup/
34
+ win/lose); an OPTIONAL `engine.music` hook for a looping track.
35
+ - **Build / Lang**: Vite, TypeScript. **Headless verify**: `incanto/test`
36
+ (`runScript`) — see `verify.ts`. No React — a single full-window canvas.
37
+
38
+ ## Critical Memory
39
+
40
+ - READ THE SKILLS FIRST: `node_modules/incanto/skills/` —
41
+ `incanto-gameplay-behaviors.md`, `incanto-physics-and-input.md` (2D bodies +
42
+ input + units), `incanto-building-2d-games.md`, `incanto-audio.md`.
43
+ - MOVEMENT is the custom `PlayerController` (NOT the built-in
44
+ `CharacterController2D`, which is frame-perfect/stiff). It integrates velocity
45
+ on the `CharacterBody2D` and adds the forgiveness a good platformer needs:
46
+ coyote-time, jump-buffer, double-jump, variable jump height (release early =
47
+ short hop), stomp-to-kill + bounce, side-hit knockback + i-frames.
48
+ - DAMAGE IS CENTRALISED + GROUP-DRIVEN: the player has NO built-in `Health`.
49
+ `PlayerController` owns hearts (3) + reads `ScoreKeeper.lives`, and resolves
50
+ ALL contact each frame by AABB against GROUPS — `enemy` (stomp from above /
51
+ hurt on the side), `hazard` (spikes), `pit` (death plane), `checkpoint`,
52
+ `goal`, `platform` (rideable). One authority → no stomp-vs-damage double-hit.
53
+ - RESPAWN is IN-LEVEL: `Health` can't revive (dies once, `heal` is a no-op when
54
+ dead), so the player uses hearts/lives instead and `PlayerController` teleports
55
+ to the last `checkpoint` on death (no scene reload). `ScoreKeeper.loseLife` at
56
+ 0 lives emits `lost`.
57
+ - WIN: touching the `goal` group sets the score to `scoreToWin` (100000) →
58
+ `won`. Coins (10) + gems (50) are score flavour only; they can't reach the
59
+ threshold, so only the flag wins.
60
+ - MOVING-PLATFORM CARRY: the kinematic controller doesn't inherit platform
61
+ velocity, so the player has a `Feet` Area sensor that remembers the `platform`
62
+ it stands on; `PlayerController` adds that platform's per-frame delta so you
63
+ ride it.
64
+ - ENGINE ACCESS IS DEFERRED: `this.engine` throws in `onReady` during
65
+ `loadScene` (scene not attached yet) — read scene config (gravity) lazily on
66
+ the first `fixedUpdate`. `getNode`/signal wiring is fine in `onReady`.
67
+ - `FollowCam` does follow + screen-shake in ONE behavior (one script per node):
68
+ `PlayerController.shake()` calls into it on stomps/landings/hits.
69
+ - Node uids are omitted in the JSON — the loader generates them. `window.game`
70
+ exposes the Game handle in the console.
@@ -0,0 +1,63 @@
1
+ # Requirements — platformer-2d (Incanto)
2
+
3
+ ## Coding Patterns
4
+
5
+ - Game STRUCTURE belongs in `src/game.scene.json` (nodes, props, input map,
6
+ assets, scripts, connections, viewport, physics). Prefer a BUILT-IN gameplay
7
+ behavior over hand-writing logic — check
8
+ `node_modules/incanto/skills/incanto-gameplay-behaviors.md` and
9
+ `incanto-physics-and-input.md` BEFORE writing a Behavior class.
10
+ - MOVEMENT is the custom `PlayerController` on the player `CharacterBody2D` (the
11
+ built-in `CharacterController2D` is intentionally stiff; a great platformer
12
+ needs coyote-time/jump-buffer/double-jump/variable height/stomp). It integrates
13
+ `velocity` + `moveAndSlide()` + `isOnFloor()` itself. Tune feel with the
14
+ constants at the top of `behaviors.ts` (RUN_SPEED, JUMP_V, COYOTE, BUFFER,
15
+ STOMP_BOUNCE, …), not by editing the loop.
16
+ - Custom logic in TypeScript (`src/behaviors.ts`):
17
+ - `PlayerController` — run/gravity/coyote/buffer/double-jump/variable height,
18
+ stomp + knockback, hearts+lives, checkpoint respawn, moving-platform carry,
19
+ and the idle/run/jump sprite + facing + i-frame blink.
20
+ - `GoblinSkin` — face the patrol heading + play the goblin walk clip (it lives
21
+ on the goblin's `AI` child, so it reads the PARENT's position and `../Skin`).
22
+ - `FollowCam` — follow the knight (look-ahead + smoothing + world clamp) AND
23
+ screen-shake on demand (one script per node).
24
+ - `ParallaxLayer` — scroll a castle backdrop slower than the camera.
25
+ - `HudUpdater` — paint score/hearts/lives into the HUD + win/lose banner.
26
+ - DAMAGE + INTERACTIONS are GROUP-DRIVEN, not per-node wiring: tag a node with a
27
+ group and `PlayerController` handles it by AABB each frame — `enemy`, `hazard`,
28
+ `pit`, `checkpoint`, `goal`, `platform`. Adding an enemy/hazard = add a node in
29
+ the right group. (Pickups are the exception: coins/gems use the built-in
30
+ `Pickup` + `connections` `collected → ScoreKeeper.addScore` + `→ AudioPlayer.play`.)
31
+ - Moving platforms: `Patrol` (horizontal ferry) / `Oscillate` (vertical lift) on
32
+ a `StaticBody2D` in the `platform` group — the `Feet` sensor + carry logic ride
33
+ them. Bobbing pickups: `Oscillate` on the sprite child.
34
+ - Keep `main.ts` thin: inject the four built-in asset urls (knight/goblin/coin/
35
+ gem), boot `createGame2D` with the scene + behaviors, optional music, the
36
+ on-screen JUMP button, remove the loader, expose `window.game`. There is NO
37
+ respawn-via-scene-reload (respawn is in-level in `PlayerController`).
38
+ - Omit node `uid`s (the loader generates them).
39
+ - VERIFY headlessly first: `bun run verify` drives the real scene through
40
+ `incanto/test`'s `runScript` and asserts run+jump, coin scoring, STOMP, side-
41
+ hit hearts, checkpoint respawn, the win path, and the lose path.
42
+
43
+ ## Known Issues / Constraints
44
+
45
+ - No collision LAYERS in v0 — gameplay collisions are resolved by `PlayerController`
46
+ AABB against groups, so PLACE content in the right group; physics solids
47
+ (`StaticBody2D`) are only for standing/colliding.
48
+ - `Health` dies once and exposes no revive, so the PLAYER deliberately does NOT
49
+ use it — hearts/lives live in `PlayerController` + `ScoreKeeper`, and respawn is
50
+ an in-level teleport to the last checkpoint (no scene reload, no lost progress
51
+ jump-cut). Goblins are removed with `queueFree()` on stomp (no `Health` needed).
52
+ - Score is flavour: coins 10, gems 50; `scoreToWin` 100000 so ONLY the goal flag
53
+ wins (collecting everything can't reach the threshold).
54
+ - Built on `incanto/2d` physics (auto-enabled — the scene has bodies). Player =
55
+ `CharacterBody2D` (kinematic KCC), platforms/ground = `StaticBody2D`, pickups/
56
+ hazards/checkpoint/goal/feet-sensor = `Area2D`.
57
+ - Sprites: `medieval-knight` (idle 0–5, move 6–11, attack 12–17; jump reuses a
58
+ move frame) and `goblin` (idle 0–6, move 7–12). Swap a sheet by changing only
59
+ the asset entry + the import in `main.ts`.
60
+ - The parallax "castle" is styled `ColorRect2D` bands + towers (no tilemap node
61
+ in v0). Background art is the obvious next polish pass; the hero sprites carry
62
+ the look. Music is OPTIONAL (`MUSIC_URL` empty by default; SFX presets need no
63
+ files).