incanto 0.50.0 → 0.52.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 (57) hide show
  1. package/dist/2d.d.ts +38 -2
  2. package/dist/2d.js +3 -3
  3. package/dist/3d.d.ts +4 -3
  4. package/dist/3d.js +4 -4
  5. package/dist/{behavior-62q0HWBO.d.ts → behavior-uPEuZrUB.d.ts} +47 -3
  6. package/dist/{create-game-BLDjy_PW.js → create-game-B_e9hJf7.js} +92 -10
  7. package/dist/{create-game-DqqxEax1.js → create-game-CGnoypjL.js} +6 -6
  8. package/dist/debug.d.ts +1 -1
  9. package/dist/debug.js +1 -1
  10. package/dist/{duplicate-CI9WF_bg.js → duplicate-B-OtSRFL.js} +1 -1
  11. package/dist/editor.js +1917 -1579
  12. package/dist/{environment-presets-XFuqu5jv.js → environment-presets-DSZwsKPs.js} +3 -3
  13. package/dist/{gameplay-DbaI313d.js → gameplay-B6jqvYeM.js} +163 -6
  14. package/dist/gameplay.d.ts +79 -2
  15. package/dist/gameplay.js +2 -2
  16. package/dist/index.d.ts +74 -5
  17. package/dist/index.js +7 -7
  18. package/dist/{loader-DwazzlQb.js → loader-B-Gft32x.js} +372 -23
  19. package/dist/{loader-CeyU_bm1.d.ts → loader-B9iTqs27.d.ts} +1 -1
  20. package/dist/net.d.ts +15 -410
  21. package/dist/net.js +1 -822
  22. package/dist/{pathfinding-C49JSNNq.d.ts → pathfinding-CAR9DjQQ.d.ts} +1 -1
  23. package/dist/{physics-2d-_9VBOHn6.js → physics-2d-Dns-oZlE.js} +7 -2
  24. package/dist/{physics-3d-DrpF5hcG.js → physics-3d-BhI0ehpe.js} +8 -3
  25. package/dist/react.d.ts +1 -1
  26. package/dist/react.js +1 -1
  27. package/dist/{register-uvaZj1KX.js → register-DVwlnZAZ.js} +20 -266
  28. package/dist/{register-BNPZYJmd.js → register-Trx7WHnD.js} +3 -3
  29. package/dist/{registry-IyWCGe4q.js → registry-C7u42TID.js} +23 -1
  30. package/dist/{replay-DYdy1wb0.d.ts → replay-BU1CCM15.d.ts} +1 -1
  31. package/dist/{replay-j-m6lJ4W.js → replay-BicPOMX0.js} +214 -2
  32. package/dist/split-screen-CZ9ccBBQ.js +1267 -0
  33. package/dist/split-screen-paxkQs_q.d.ts +441 -0
  34. package/dist/{src-B3HrKuAi.js → src-5gbZO47I.js} +1 -1
  35. package/dist/{teardown-BKTCzLek.js → teardown-ks3d5W9n.js} +2 -1
  36. package/dist/{test-D16igj4C.js → test-DAojuFdb.js} +119 -22
  37. package/dist/test.d.ts +60 -5
  38. package/dist/test.js +3 -3
  39. package/dist/vite.js +2 -2
  40. package/editor/assets/{agent8-t3kl5q9K.js → agent8-CCvckvbw.js} +1 -1
  41. package/editor/assets/{debug-Bu3eeAlO.js → debug-CLNCOnbc.js} +1 -1
  42. package/editor/assets/{index-Df5g8ofT.js → index-Cb5Brupb.js} +99 -92
  43. package/editor/index.html +1 -1
  44. package/package.json +1 -1
  45. package/skills/incanto-assets.md +4 -1
  46. package/skills/incanto-building-2d-games.md +6 -1
  47. package/skills/incanto-editor.md +48 -11
  48. package/skills/incanto-gameplay-behaviors.md +14 -5
  49. package/skills/incanto-multiplayer.md +39 -3
  50. package/skills/incanto-node-reference.md +13 -0
  51. package/skills/incanto-save-slots.md +134 -12
  52. package/skills/incanto-verifying-your-game.md +56 -0
  53. package/templates/agent8-server.ts +79 -2
  54. package/templates-app/beacon-isle-3d/package.json +1 -1
  55. package/templates-app/tps-3d/package.json +1 -1
  56. package/templates-app/village-quest-3d/package.json +1 -1
  57. package/dist/register-CB11yp21.js +0 -374
package/editor/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Incanto Scene Editor</title>
7
7
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%236ee7dc'/><text x='8' y='12' text-anchor='middle' font-size='11' font-family='monospace' fill='%230e1018'>i</text></svg>" />
8
- <script type="module" crossorigin src="./assets/index-Df5g8ofT.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-Cb5Brupb.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="./assets/GameServer-C56iOUgF.js">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incanto",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
5
5
  "keywords": [
6
6
  "game-engine",
@@ -164,4 +164,7 @@ Two consequences worth knowing:
164
164
 
165
165
  A texture that 404s now shows up in `game.assetErrors()` alongside models, by the
166
166
  URL you wrote — so "why is my sprite invisible" is answerable without opening the
167
- network tab.
167
+ network tab. In **2D** the same question is `renderer.assets.errors()`
168
+ (`$ref`, url and reason per failed entry), and the scene EDITOR reads it: a
169
+ failed asset is red in the explorer with the url in its tooltip and the
170
+ consequence in its inspector.
@@ -213,8 +213,13 @@ listing the valid set. With a viewport design, UI coordinates are design px.
213
213
  ```
214
214
  `setScene` frees the old root, clears + redeclares the input map from the new
215
215
  scene's `input{}`, and emits `sceneChanged` — `createGame2D`'s touch overlay
216
- rebuilds itself on that signal, and the renderer loads the new scene's assets
216
+ rebuilds itself on that signal, the PHYSICS world registers the new scene's
217
+ bodies before its first frame, and the renderer loads the new scene's assets
217
218
  on demand. Register any extra behaviors/node types BEFORE the `loadScene` call.
219
+
220
+ The input map is CLEARED by the swap, which matters for a headless drive: an
221
+ injected `setActionVector` does not carry into the next level — set it again
222
+ after the transition, the same way a player's held key is re-read.
218
223
  - **Game over / restart**: swap to a fresh load of the SAME JSON —
219
224
  `engine.setScene(loadScene(levelJson))`. `loadScene` treats the JSON as
220
225
  read-only (everything it keeps is cloned), so reloading the same imported
@@ -39,13 +39,17 @@ The `scenes` button opens the project as a tree, not a list of paths:
39
39
  - **The scene you are editing** is marked and revealed, and the cursor starts on it.
40
40
  - **A filter box** — type any part of a path; matches show wherever they are hiding.
41
41
  Keyboard from that box: `↑↓` move · `→` open a folder · `←` close it · `⏎` load ·
42
- `Esc` close (backdrop and ✕ work too).
42
+ `Esc` close (backdrop and ✕ work too). The cursor always sits on a SCENE —
43
+ the first match while you filter, the scene you are editing when you open the
44
+ panel — so `⏎` loads something without arrowing first.
43
45
  - Each row carries **when it was last written and how big it is**, which is usually
44
46
  how you recognise the file you were just in.
45
47
  - **create** makes a new scene at the path in the box — left empty it uses the
46
48
  placeholder, which tracks the folder you are standing in, so a scene lands beside
47
49
  its siblings rather than at the project root. Parent dirs are created.
48
- - Loading another scene while you have unsaved EDITS asks first.
50
+ - Loading another scene while you have unsaved EDITS asks first, and the answer
51
+ is not only *discard*: **save & open** writes the scene you are leaving and
52
+ then opens the other one.
49
53
 
50
54
  **The same browser opens inside a running game.** When the game's dev server serves
51
55
  the project's scenes, `☰ debug ▸ ✎ edit this scene` gives you the whole project: the
@@ -66,7 +70,12 @@ is not there. The editor still edits the scene the game booted with meanwhile.
66
70
  Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
67
71
 
68
72
  - **Explorer** — two collapsible sections: **ASSETS** on top (icon rows by
69
- type; keys with a `group/` prefix nest under collapsible folders (any depth),
73
+ type; **an asset the renderer could not FETCH turns red**, with the url in its
74
+ tooltip and the consequence spelled out when you select it — *"failed to
75
+ load — every node using $fx/coin draws nothing"* — plus a banner naming all of
76
+ them. A 404'd texture is otherwise the one failure with no symptom: the scene
77
+ is structurally perfect, the tree is full, and the viewport draws nothing where
78
+ the art should be; keys with a `group/` prefix nest under collapsible folders (any depth),
70
79
  each showing its recursive asset count — refs are `$group/key`; click the
71
80
  icon for a blurb, the row to edit in the inspector; DRAG asset rows onto a
72
81
  folder (or the section background = root) to move them — references rewrite
@@ -79,7 +88,14 @@ Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
79
88
  to reorder before/after. Dragging a selected row moves the whole selection.
80
89
  Illegal drops (engine rules — e.g. a CharacterController2D outside a
81
90
  CharacterBody2D) are ROLLED BACK entirely with the error in the banner; the
82
- tree never shows a state the engine would reject. Right-click for
91
+ tree never shows a state the engine would reject.
92
+ **A reparent does not MOVE anything**: the node keeps its world transform and
93
+ the editor rewrites the local `position`/`rotation`/`scale` to match (drop a
94
+ ball at `[3, 0.6, 0]` onto a crate at `[-3, 0.5, 0]` and the ball stays put,
95
+ holding `[6, 0.1, 0]`). Rotated and scaled parents are handled the same way,
96
+ and the values that are no longer needed disappear rather than being written
97
+ as defaults. This is the Godot/Unity/Blender behaviour, and it is what keeps a
98
+ tree edit from changing the picture. Right-click for
83
99
  duplicate / rename (or double-click the name) / cut / copy / paste-as-child /
84
100
  delete — all act on the multi-selection. **Rename REPAIRS references**: every
85
101
  `connections[].from/to` and every node-path prop pointing at the node (or into
@@ -121,6 +137,12 @@ Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
121
137
  scales to 0.25 steps (the readout shows the snapped value).
122
138
  2D additionally supports click-pick, body drag, wheel zoom-at-cursor,
123
139
  right/middle/Shift-drag pan, Alt+wheel scale, and collider wireframes.
140
+ **HUD nodes are editable like anything else**: a `UILayer` subtree is posed in
141
+ screen space rather than world space, and the viewport now picks, outlines and
142
+ drags it there — click the widget where you SEE it, and its position moves 1:1
143
+ with the cursor whatever the world zoom is. (Picking used to look only at the
144
+ world pass, so a HUD could not be selected in the viewport at all, and its
145
+ outline was drawn wherever the game camera happened to be looking.)
124
146
  **`F` frames the SELECTED node** (its whole subtree; a light or empty node has
125
147
  no bounds, so the camera goes to it at a readable distance) — and the whole
126
148
  scene when nothing is selected, which is the Maya/Unity/Unreal meaning of the
@@ -136,9 +158,25 @@ Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
136
158
  - **Inspector** — schema-driven from the node registry, with STRUCTURED editors for
137
159
  the hard parts: `collider` (shape dropdown + per-shape dimensions, mirrored by
138
160
  the wireframe), `network` (mode dropdown + sync-key chips + throttle),
139
- `script` (attach/detach + name + props, with copy-paste Behavior boilerplate in
140
- its help), `groups` (tag chips). Every one has a `?` help popover with examples.
141
- Values equal to the default are removed (delta-only, like the serializer).
161
+ `script` — **the behaviors the engine SHIPS are a dropdown**, and picking one
162
+ builds a form from its own prop schema (a `Health` gets `max`/`regenPerSec`/
163
+ `invulnerableFor`/`freeOnDeath` with their defaults; a `Patrol`'s `mode` is a
164
+ `loop`/`pingpong` menu), delta-only like every other field. A name that is NOT
165
+ built in is your game's TypeScript: it keeps the raw-JSON props box and says
166
+ so, with copy-paste Behavior boilerplate in its help — and `groups` (tag
167
+ chips). Every one has a `?` help popover with examples. Values equal to the
168
+ default are removed (delta-only, like the serializer).
169
+ **A prop that holds a NODE PATH** (`Chase.target`, `Camera2D.follow`,
170
+ `Spawner.prefab`, `Joint3D.target`, `skinPath`, `terrain`…) offers every node
171
+ in the scene as a list — `%Name` where the name is unique, the absolute path
172
+ where it repeats — and free text still works for the forms a list cannot
173
+ enumerate (`../Skin`, a path into a subtree). A value that resolves to NOTHING
174
+ is marked red with the reason: `'%Playerr' matches no node in this scene. The
175
+ scene still loads — the prop just does nothing.` That last sentence is the
176
+ point: unlike a connection, a dangling path prop is not a load error, so
177
+ nothing else would ever have told you. A behavior's path props are checked
178
+ exactly like the node's own, and `incanto-check` reports the same thing from
179
+ the file.
142
180
 
143
181
  **3D scenes** get full camera navigation: drag orbits, right/middle/Shift-drag pans,
144
182
  wheel zooms, `F` frames the contents, and **`0` / the `game cam` button** returns to the
@@ -353,8 +391,7 @@ get normal click-select back.
353
391
  The **✦ button** beside the add-node controls opens the Generate dialog — the
354
392
  `incanto/env` generators inside the editor, driven by the same `GENERATORS`
355
393
  catalog as the `incanto-env` CLI and filtered to the open scene's dimension
356
- (3D: arena, terrain, meadow, forest, maze, rocks, clouds, island; 2D:
357
- platforms2d, maze2d, dungeon2d). The param form is built from the catalog
394
+ (3D: arena, terrain, maze; 2D: platforms2d, maze2d, dungeon2d). The param form is built from the catalog
358
395
  metadata — numbers clamp to their min/max, option lists become dropdowns — so
359
396
  a new generator needs zero editor changes. The seed starts random (↻ rerolls);
360
397
  the same seed always generates the same level. **insert** runs the generator
@@ -395,7 +432,7 @@ trusts the surrounding network — use it only inside containers.
395
432
  The EDIT view freezes game time — nothing falls or fires until you press play;
396
433
  only ambient visuals (model animations, particles, water, foliage sway) keep
397
434
  moving. The PLAY view simulates everything engine-native but cannot execute the
398
- game's TypeScript behaviors. 3D scenes render and edit via the inspector; direct
399
- viewport manipulation is 2D-only for now. Runtime-injected textures (asset URLs
435
+ game's TypeScript behaviors. Both dimensions have the W/E/R gizmos in the
436
+ viewport; 2D additionally has click-pick, body drag and wheel zoom-at-cursor. Runtime-injected textures (asset URLs
400
437
  like `"GENERATED_AT_RUNTIME"`) render as a magenta checkerboard — position/size
401
438
  stay visible; the real art appears in the running game.
@@ -58,11 +58,20 @@ Two kinds of node path resolve from DIFFERENT origins — mixing them up is the
58
58
  enemy (a child of the Spawner) reaches the player with the ABSOLUTE
59
59
  `/root/Player`, never a bare `Player` (which would look under the enemy).
60
60
 
61
- Renaming a node in the editor now REWRITES both kinds for you (and reports what
62
- it changed), so the paths above stay valid across a rename. Hand-edited JSON
63
- still has to be kept in sync yourself — `bunx incanto-check` catches the
64
- connections, but a behavior prop pointing at a name that no longer exists
65
- resolves to null in silence.
61
+ Renaming a node in the editor REWRITES both kinds for you (behavior props
62
+ included) and reports what it changed, and deleting a node lists every reference
63
+ that still points at it before it goes. Hand-edited JSON is covered too:
64
+ **`bunx incanto-check` now reports a node-path prop that leads nowhere** —
65
+
66
+ ```
67
+ warn: World/Enemy: Chase.target — '%Playerr' matches no node in this scene.
68
+ The scene loads and the prop does nothing.
69
+ ```
70
+
71
+ — which is the one failure that used to be completely silent (a prop path
72
+ resolves with `getNodeOrNull`, so the scene opens and the enemy just never
73
+ chases). `Chase.target` and `FollowCamera.target` are `required`: an EMPTY one
74
+ is a load error naming the node, not a surprise in the browser.
66
75
 
67
76
  **Never write `/root/<RootName>/...`.** If your root node is named `Game`, the
68
77
  path is `/root/Player` — NOT `/root/Game/Player`: `/root/` already *is* the
@@ -58,13 +58,22 @@ Authority rules:
58
58
  applies onto the spawned scene's root; `position` lerps when `interpolate: true`
59
59
  (remote entities render slightly in the past — that's correct). Emits
60
60
  `spawned(node, key)` / `despawned(node, key)`. `source: "collection:<id>"` mirrors a
61
- room collection by `__id`.
61
+ room collection by `__id` — a collection ENTITY applies as your server wrote it
62
+ (`addCollectionItem('coins', { position: [x, y] })` lands on the spawned node;
63
+ the `{sync: {…}}` envelope an owner state uses is accepted too). A key whose
64
+ node PATH resolves to nothing (`Skin.animation` on a scene with no `Skin`) is
65
+ reported once rather than dropped — that value replicates to nowhere.
62
66
 
63
67
  ## Boot
64
68
 
65
69
  ```ts
70
+ import { registerNodes2D } from 'incanto/2d'; // or registerNodes3D for a 3D game
66
71
  import { NetworkManager, registerNodesNet, LoopbackHub } from 'incanto/net';
67
72
 
73
+ // REGISTRARS COMPOSE and you need BOTH: `registerNodesNet()` adds NetworkSpawner
74
+ // and nothing else, so a 2D scene loaded after it alone dies on its own root
75
+ // node (`Unknown node type 'Node2D'`).
76
+ registerNodes2D();
68
77
  registerNodesNet();
69
78
  const scene = loadScene(json);
70
79
  engine.setScene(scene);
@@ -147,8 +156,12 @@ the whole N-panel harness — one LocalGameServer, one engine + NetworkManager
147
156
  per player — so you only supply the per-panel renderer/input:
148
157
 
149
158
  ```ts
159
+ import { registerNodes2D } from 'incanto/2d'; // the dimension your scene uses
150
160
  import { createSplitScreen, registerNodesNet } from 'incanto/net';
151
161
 
162
+ registerNodes2D();
163
+ registerNodesNet();
164
+
152
165
  const canvases = [document.getElementById('p1'), document.getElementById('p2')];
153
166
  const { players, server, dispose } = await createSplitScreen({
154
167
  scene: gameJson, // shared scene (each panel gets its own copy)
@@ -163,8 +176,13 @@ const { players, server, dispose } = await createSplitScreen({
163
176
  });
164
177
  ```
165
178
 
166
- The first panel's clock pumps `server.tick` (so `$roomTick` runs) — don't add
167
- your own. `dispose()` tears every panel down. Going live is unchanged: ONE
179
+ Each panel gets physics on the same terms `createGame2D` gives it (`'auto'`:
180
+ Rapier when the scene has bodies; `physics: false` opts out) — without it a
181
+ `CharacterBody2D` never moves. The first panel's clock pumps `server.tick` (so
182
+ `$roomTick` runs) — don't add your own. Every panel joins ONE room: a shared scene usually says
183
+ `multiplayer: { room: "auto" }`, and "auto" means a server-ASSIGNED room, so the
184
+ harness pins panels 1..N to the room panel 0 got (`room: 'lobby'` overrides).
185
+ `dispose()` tears every panel down. Going live is unchanged: ONE
168
186
  client per browser with `createAgent8Server()` as the transport.
169
187
 
170
188
  It runs the SAME class body the cloud runs: the v2 globals (`$sender`/`$global`/
@@ -172,6 +190,24 @@ It runs the SAME class body the cloud runs: the v2 globals (`$sender`/`$global`/
172
190
  (so `this.*` never persists), and calls are serialized (no global leaks across
173
191
  `await`s). `$roomTick(deltaMS, roomId)` runs only while a room has users.
174
192
 
193
+ **Verifying a whole match**: `playMultiplayer` from `incanto/test` runs N clients
194
+ against one in-memory server for a fixed number of simulated seconds and reports
195
+ 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`.
198
+
199
+ **Driving one by hand**: server calls are QUEUED and only run when the event loop
200
+ turns, so a synchronous frame loop enqueues a thousand ticks that never execute —
201
+ the match clock stands still and every `call()` result arrives after your
202
+ assertions.
203
+
204
+ ```ts
205
+ for (let f = 0; f * 16.7 < ms; f++) {
206
+ for (const p of players) p.engine.tick(f * 16.7);
207
+ if (f % 6 === 0) await new Promise((r) => setTimeout(r, 0)); // let the server run
208
+ }
209
+ ```
210
+
175
211
  It is a FUNCTIONAL emulator, NOT the platform: no isolated-vm sandbox, no rate
176
212
  limits, and no DURABLE persistence (global state lives only for the preview process
177
213
  — it is not saved across runs, and rooms still clear when empty). It proves your
@@ -1422,6 +1422,19 @@ Signals: `collected(value, other)`
1422
1422
  | `direction` | `null` | null |
1423
1423
  | `gravity` | `0` | number |
1424
1424
 
1425
+ ### `SavePoint`
1426
+
1427
+ | Prop | Default | Kind |
1428
+ |---|---|---|
1429
+ | `game` | `"game"` | string |
1430
+ | `slot` | `"1"` | string |
1431
+ | `label` | `""` | string |
1432
+ | `scene` | `""` | string |
1433
+ | `restoreOnReady` | `false` | boolean |
1434
+ | `probeOnReady` | `false` | boolean |
1435
+
1436
+ Signals: `saved` · `restored` · `noSave` · `hasSave`
1437
+
1425
1438
  ### `ScoreKeeper`
1426
1439
 
1427
1440
  | Prop | Default | Kind |
@@ -47,6 +47,17 @@ restored.** You resume at the scene's start with stats, inventory, unlocks and
47
47
  quest flags intact — a checkpoint save. If your game needs a position, save it:
48
48
  `serialize()` returns anything.
49
49
 
50
+ **What the run CONSUMED is remembered.** Reloading from the file brings back
51
+ every gem you already picked up, which would let a collect-five-to-win run
52
+ resume at four with five gems on the map. So the save also carries the authored
53
+ uids that are no longer in the tree, under `#freed`, and the restore frees them
54
+ again — collectibles, opened chests, destroyed crates, a named boss. Spawned
55
+ clones never have uids (`duplicateNode` drops them on purpose), so the ledger is
56
+ exactly the authored world.
57
+
58
+ Those nodes go on the **next frame** (`queueFree`, not an immediate detach), so
59
+ read `report.freed` rather than counting children the instant restore returns.
60
+
50
61
  ## Making a behavior saveable
51
62
 
52
63
  Two optional hooks, exactly like the other five:
@@ -82,8 +93,17 @@ lives, won/lost), `Collector` (total).
82
93
 
83
94
  The uid is the join key, because it is the one identifier that survives a rename
84
95
  or a reparent. The editor assigns one to every node it touches. A hand-written
85
- scene may not have them — `engine.captureState()` logs any node that has state to
86
- save and no uid to key it under.
96
+ scene may not have them, and then the save is silently empty — so
97
+ **`incanto-check` warns about it**, naming each node, long before you write a
98
+ save:
99
+
100
+ ```
101
+ warn: these carry state a save keeps and have no uid to key it under, so the
102
+ save comes back EMPTY and the load reports no problem: Game (ScoreKeeper),
103
+ Game/Player (Health). Give each one a "uid" from newUid().
104
+ ```
105
+
106
+ At runtime `engine.captureState()` logs the same thing per node.
87
107
 
88
108
  Never hand-craft a uid. Use `newUid()`.
89
109
 
@@ -120,7 +140,68 @@ It never throws. A save naming a uid this build deleted reports it in
120
140
  `report.missing` and restores everything else; refusing to load would mean a
121
141
  patch that moves one node deletes everyone's progress.
122
142
 
123
- ## A load menu
143
+ ## Many levels: the router is three lines, and they are yours
144
+
145
+ The engine does not route scenes — deliberately, because only your game knows
146
+ what a key means. What it does is record the key, so the routing is a lookup:
147
+
148
+ ```ts
149
+ import { createGame2D, loadScene } from 'incanto';
150
+ import level1 from './level1.scene.json';
151
+ import level2 from './level2.scene.json';
152
+
153
+ const SCENES: Record<string, unknown> = { level1, level2 }; // key → scene JSON
154
+
155
+ // New game
156
+ const game = await createGame2D({ canvas, scene: SCENES.level1 });
157
+
158
+ // Next level — the SavePoint in the new scene writes `level2` from here on
159
+ (game.scene.root.getNode('Flow').behavior as GameFlow).goToScene(SCENES.level2);
160
+
161
+ // Continue
162
+ const slot = new SaveSlots('chapters').read('1');
163
+ const scene = SCENES[slot?.scene ?? 'level1'];
164
+ const game = await createGame2D({ canvas, scene });
165
+ // then `restoreOnReady: true` on that scene's SavePoint, or call restore()
166
+ ```
167
+
168
+ A `SavePoint` records `scene` as the scene's own `name` unless you set the prop,
169
+ so `level2.scene.json` named `level2` needs no wiring at all. Set `scene`
170
+ explicitly when one file is entered more than one way (`"chapter-2-rescue"`).
171
+
172
+ **The swap clears the input map** (the new scene declares its own `input{}`), and
173
+ physics registers the new bodies before that scene's first frame — so a
174
+ character walks in level two exactly as it did in level one.
175
+
176
+ ## A title screen, in JSON
177
+
178
+ A `SavePoint` can ASK without loading. `probeOnReady` fires on the first frame
179
+ and emits `hasSave(label, playtime, scene)` or `noSave`, so the menu wires
180
+ itself:
181
+
182
+ ```json
183
+ { "name": "Save", "type": "Node", "uid": "n_…",
184
+ "script": { "name": "SavePoint",
185
+ "props": { "game": "chapters", "slot": "1", "probeOnReady": true } } }
186
+ ```
187
+ ```json
188
+ { "signal": "noSave", "from": "Save", "to": "HUD/Menu/Continue", "handler": "hide" },
189
+ { "signal": "noSave", "from": "Save", "to": "HUD/Menu/SlotInfo", "handler": "hide" },
190
+ { "signal": "hasSave", "from": "Save", "to": "HUD/Menu/SlotInfo", "handler": "setText" }
191
+ ```
192
+
193
+ With `"format": "Continue: {}"` on that `UiText`, a fresh install shows a menu
194
+ with no Continue button and a save shows `Continue: Chapter 2`. Every HUD widget
195
+ takes `show`/`hide` from a wire (`visible` is a prop, and a connection needs a
196
+ method — the same wall `setText` broke through).
197
+
198
+ The label leads because that is what a menu shows; an unlabelled slot falls back
199
+ to its scene key, so the line is never blank.
200
+
201
+ **The button's press is still yours**, and rightly: `pressed → your router`. See
202
+ the three lines above.
203
+
204
+ ## Several slots
124
205
 
125
206
  ```ts
126
207
  for (const slot of slots.all()) { // newest first
@@ -130,23 +211,64 @@ slots.remove('2');
130
211
  slots.clear(); // "delete all data"
131
212
  ```
132
213
 
214
+ One `SavePoint` per slot is the declarative version: three nodes with
215
+ `slot: "1" | "2" | "3"`, each probing into its own row of the menu.
216
+
133
217
  ## Checking your coverage
134
218
 
135
219
  ```ts
136
- import { behaviorsWithoutSave } from 'incanto';
137
- console.log(behaviorsWithoutSave(game.engine.scene.root));
220
+ import { behaviorsWithoutSave, savesWithoutUid } from 'incanto';
221
+ console.log(behaviorsWithoutSave(game.engine.scene.root)); // forgot serialize?
222
+ console.log(savesWithoutUid(game.engine.scene.root)); // forgot the uid?
138
223
  ```
139
224
 
140
- Names every behavior in the tree that has props and no `serialize`. Not all of
141
- them are wrong — a behavior that derives everything from time has nothing to
225
+ `behaviorsWithoutSave` names every behavior with props and no `serialize`. Not
226
+ all of them are wrong — one that derives everything from time has nothing to
142
227
  save — but it is the list to read before shipping.
143
228
 
144
- ## Autosave
229
+ `savesWithoutUid` is the other half, and none of it is debatable: a behavior
230
+ that DOES serialize, on a node with no uid, is state that goes nowhere.
231
+ `incanto-check` catches the built-ins it can recognise from the JSON
232
+ (`Health`, `ScoreKeeper`, `Collector`); a scene file cannot be asked whether
233
+ YOUR behavior serializes, so this walks the live tree and names those too.
145
234
 
146
- There is no autosave node, on purpose: *when* to save is a design decision
147
- (checkpoint, level end, every 60s, on quit) and only your game knows. Wire it to
148
- whatever signal marks the moment:
235
+ ## Saving from the scene — `SavePoint`
149
236
 
237
+ *When* to save is a design decision (checkpoint, level end, on quit) and only
238
+ your game knows — so the scene still chooses, by picking which signal to wire.
239
+ What it does not need any more is a method of your own to wire it to:
240
+
241
+ ```json
242
+ { "name": "Save", "type": "Node", "uid": "n_…",
243
+ "script": { "name": "SavePoint",
244
+ "props": { "game": "vault", "slot": "1", "label": "Chapter 2" } } }
245
+ ```
150
246
  ```json
151
- { "from": "Level/Exit", "signal": "triggerEnter", "to": "Game", "handler": "onCheckpoint" }
247
+ { "signal": "triggerEnter", "from": "Level/Exit", "to": "Save", "handler": "save" },
248
+ { "signal": "collected", "from": "Gems/Gem1", "to": "Save", "handler": "save" },
249
+ { "signal": "won", "from": ".", "to": "Save", "handler": "save" }
152
250
  ```
251
+
252
+ | prop | default | meaning |
253
+ | --- | --- | --- |
254
+ | `game` | `"game"` | slot namespace — keeps two games on one origin apart |
255
+ | `slot` | `"1"` | which slot this node reads and writes |
256
+ | `label` | `""` | shown in a load menu |
257
+ | `scene` | `""` | the key a loader routes back to (empty = this scene's `name`) |
258
+ | `restoreOnReady` | `false` | read the slot on the first frame — a "Continue" boot |
259
+
260
+ Methods: `save()` · `restore()` · `clear()` — and `playtime` / `slotScene()` to
261
+ read. Signals: `saved(slot)` · `restored(count)` · **`noSave`**, which is what
262
+ greys out a Continue button.
263
+
264
+ `restoreOnReady` lands on the **first frame**, not in `onReady`: `onReady` runs
265
+ children-first, so restoring there would hand the score keeper its state back
266
+ and then watch the root's own `onReady` set it to zero.
267
+
268
+ **A checkpoint wired to `collected` counts that pickup.** `Pickup` queues its
269
+ free before it announces, so a save taken from the handler records the world
270
+ without it — otherwise the gem came back on the next run with the score that
271
+ counted it already banked.
272
+
273
+ Still your code when the moment is not a signal — every 60 s, on `visibilitychange`,
274
+ on a quit button: call `engine.captureState()` and `slots.write()` yourself.
@@ -619,6 +619,23 @@ rows until the orange box lands on the thing you're hunting.
619
619
  inside a `static` subtree, HUD widgets outside a HudLayer. Programmatic:
620
620
  `auditScene(json)` from `incanto` or `incanto/test` returns the warnings.
621
621
 
622
+ **Including a node path that points at nothing.** `Chase.target`,
623
+ `Camera2D.follow`, `Spawner.prefab`, `Joint3D.target`, `skinPath`, `terrain` —
624
+ these hold a path to another node, and a wrong one is SILENT: a connection that
625
+ dangles is a hard load error, but a prop resolves with `getNodeOrNull`, so the
626
+ scene opens and the enemy simply never chases.
627
+
628
+ ```
629
+ warn: World/Enemy: Chase.target — '%Playerr' matches no node in this scene.
630
+ The scene loads and the prop does nothing.
631
+ warn: World/Cam: follow — '/Level/Player' starts at 'Level', but the scene
632
+ root is 'World' (or write '/root/…'). The scene loads and the prop does nothing.
633
+ ```
634
+
635
+ An empty value is never reported — `""` is the default of most of these and
636
+ means "not set". A behavior of YOUR OWN is not reported either: the checker
637
+ never loads your TypeScript, so it cannot know which of its props are paths.
638
+
622
639
  ## Deterministic replay (record once, regression-test forever)
623
640
 
624
641
  The engine is fully deterministic under a seed + injected clock, so a
@@ -644,6 +661,45 @@ never Math.random, dt/`engine.time` never Date.now. Gamepads replay through
644
661
  the ACTIONS they were bound to, not raw pad state.
645
662
 
646
663
 
664
+ ## Multiplayer: do the players end up in the SAME world?
665
+
666
+ Every rung above asks about one player. A multiplayer game's first question is
667
+ whether there are two of them in the same room at all — and when there are not,
668
+ nothing throws, nothing logs, and each browser looks perfectly fine on its own.
669
+
670
+ ```ts
671
+ import { playMultiplayer, multiplayText } from 'incanto/test';
672
+ import { Server } from '../server/src/server'; // your real server class
673
+
674
+ const report = await playMultiplayer({
675
+ scene: gameJson,
676
+ server: Server, // optional: runs YOUR rules
677
+ scenes: { 'remote-player': remoteJson },
678
+ players: 2,
679
+ seconds: 5,
680
+ drive: ({ engine, manager, account }, frame) => {
681
+ engine.input.setActionVector('move', 1, 0); // steer each client
682
+ },
683
+ });
684
+ console.log(multiplayText(report));
685
+ ```
686
+
687
+ ```
688
+ multiplayer: 2 clients, 5s, room room1
689
+ p1: sees [p2] spawned Remotes=1
690
+ p2: sees [p1] spawned Remotes=1
691
+ ✓ every client in one room, seeing the others
692
+ ```
693
+
694
+ `report.problems` names the silent ones: clients in different rooms, a client
695
+ that never saw another account's state, a `NetworkSpawner` that materialised
696
+ nothing, frame errors per client. `report.roomState` is the shared state at the
697
+ end, so a `$roomTick` match clock is checkable too.
698
+
699
+ It runs the whole match on ONE in-memory server (`LocalGameServer`), so it also
700
+ pays the trap a hand-rolled harness has to know about: those server calls are
701
+ QUEUED, and a synchronous frame loop enqueues a thousand ticks that never run.
702
+
647
703
  ## The fifth question: is it a GAME?
648
704
 
649
705
  The four signals above answer *will it load*, *did something throw*, *did the art
@@ -32,27 +32,104 @@
32
32
  */
33
33
 
34
34
  // These globals are injected by the agent8 isolated-vm runtime (see gameserver-sdk-v2).
35
- declare const $sender: { account: string; roomId: string };
35
+ declare const $sender: { account: string; roomId: string; isGuest?: boolean };
36
+
37
+ /**
38
+ * Room membership, PERSISTENT global state, global collections, room management
39
+ * and global messaging. Room data is ephemeral — anything that must outlive an
40
+ * empty room is written here.
41
+ */
36
42
  declare const $global: {
37
43
  joinRoom(roomId?: string): Promise<string>;
38
44
  leaveRoom(): Promise<string>;
45
+ getGlobalState(): Promise<Record<string, unknown>>;
46
+ updateGlobalState(patch: Record<string, unknown>): Promise<Record<string, unknown>>;
47
+ getMyState(): Promise<Record<string, unknown>>;
48
+ updateMyState(patch: Record<string, unknown>): Promise<Record<string, unknown>>;
49
+ getUserState(account: string): Promise<Record<string, unknown>>;
50
+ updateUserState(account: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
51
+ addCollectionItem(collectionId: string, item: Record<string, unknown>): Promise<{ __id: string }>;
52
+ updateCollectionItem(
53
+ collectionId: string,
54
+ item: Record<string, unknown>,
55
+ ): Promise<{ __id: string }>;
56
+ deleteCollectionItem(collectionId: string, itemId: string): Promise<{ __id: string }>;
57
+ deleteCollection(collectionId: string): Promise<string>;
58
+ getCollectionItem(collectionId: string, itemId: string): Promise<Record<string, unknown>>;
59
+ getCollectionItems(
60
+ collectionId: string,
61
+ options?: CollectionQuery,
62
+ ): Promise<Record<string, unknown>[]>;
63
+ countCollectionItems(collectionId: string, options?: CollectionQuery): Promise<number>;
64
+ countRooms(): Promise<number>;
65
+ getAllRoomIds(): Promise<string[]>;
66
+ getAllRoomStates(): Promise<Record<string, unknown>[]>;
67
+ getRoomUserAccounts(roomId: string): Promise<string[]>;
68
+ countRoomUsers(roomId: string): Promise<number>;
69
+ getRoomState(roomId: string): Promise<Record<string, unknown>>;
70
+ updateRoomState(roomId: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
71
+ getRoomUserState(roomId: string, account: string): Promise<Record<string, unknown>>;
72
+ updateRoomUserState(
73
+ roomId: string,
74
+ account: string,
75
+ patch: Record<string, unknown>,
76
+ ): Promise<Record<string, unknown>>;
77
+ broadcastToAll(type: string, message: unknown): void;
78
+ sendMessageToUser(account: string, type: string, message: unknown): void;
39
79
  };
80
+
81
+ /** Query options for a collection read (`filters` / `orderBy` / `limit`). */
82
+ interface CollectionQuery {
83
+ filters?: Record<string, unknown>;
84
+ orderBy?: { field: string; direction?: 'asc' | 'desc' };
85
+ limit?: number;
86
+ }
87
+
88
+ /** The CURRENT room: shared state, per-user state, collections, messaging. */
40
89
  declare const $room: {
90
+ getMyState(): Promise<Record<string, unknown>>;
41
91
  updateMyState(patch: Record<string, unknown>): Promise<Record<string, unknown>>;
92
+ getRoomState(): Promise<Record<string, unknown>>;
42
93
  updateRoomState(patch: Record<string, unknown>): Promise<Record<string, unknown>>;
43
94
  getUserState(account: string): Promise<Record<string, unknown>>;
44
95
  updateUserState(account: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
96
+ getAllUserStates(): Promise<Record<string, unknown>[]>;
97
+ countUsers(): Promise<number>;
45
98
  addCollectionItem(collectionId: string, item: Record<string, unknown>): Promise<{ __id: string }>;
46
99
  updateCollectionItem(
47
100
  collectionId: string,
48
101
  item: Record<string, unknown>,
49
102
  ): Promise<{ __id: string }>;
50
103
  deleteCollectionItem(collectionId: string, itemId: string): Promise<{ __id: string }>;
104
+ deleteCollection(collectionId: string): Promise<string>;
105
+ getCollectionItem(collectionId: string, itemId: string): Promise<Record<string, unknown>>;
106
+ getCollectionItems(
107
+ collectionId: string,
108
+ options?: CollectionQuery,
109
+ ): Promise<Record<string, unknown>[]>;
110
+ countCollectionItems(collectionId: string, options?: CollectionQuery): Promise<number>;
51
111
  broadcastToRoom(type: string, message: unknown): void;
52
- countUsers(): Promise<number>;
112
+ sendMessageToUser(account: string, type: string, message: unknown): void;
53
113
  };
114
+
115
+ /**
116
+ * Serialize a read-modify-write against concurrent requests.
117
+ *
118
+ * The preview runs calls one at a time, so a FORGOTTEN lock still passes
119
+ * locally — live, parallel requests race (double-award, last-write-wins).
120
+ */
54
121
  declare function $lock<T>(key: string, fn: () => T | Promise<T>): Promise<T>;
55
122
 
123
+ /** Per-account currency ledger. `burn`/`transfer` throw on an insufficient balance. */
124
+ declare const $asset: {
125
+ mint(assetId: string, amount: number): Promise<Record<string, unknown>>;
126
+ burn(assetId: string, amount: number): Promise<Record<string, unknown>>;
127
+ has(assetId: string, amount: number): Promise<boolean>;
128
+ get(assetId: string): Promise<number>;
129
+ getAll(): Promise<Record<string, number>>;
130
+ transfer(toAccount: string, assetId: string, amount: number): Promise<Record<string, unknown>>;
131
+ };
132
+
56
133
  export class Server {
57
134
  // ---- rooms -----------------------------------------------------------------
58
135