incanto 0.69.0 → 0.71.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 (147) hide show
  1. package/bin/_behaviors-loader.mjs +22 -0
  2. package/bin/_read-json.mjs +28 -0
  3. package/bin/incanto-assets.mjs +5 -4
  4. package/bin/incanto-check.mjs +58 -31
  5. package/bin/incanto-editor.mjs +128 -5
  6. package/bin/incanto-env.mjs +3 -2
  7. package/bin/incanto-feel.mjs +24 -13
  8. package/bin/incanto-frame.mjs +8 -1
  9. package/bin/incanto-multiplay.mjs +11 -9
  10. package/bin/incanto-new.mjs +105 -10
  11. package/bin/incanto-play.mjs +158 -16
  12. package/bin/incanto-playtest.mjs +66 -23
  13. package/bin/incanto-skills.mjs +14 -2
  14. package/bin/incanto-verify.mjs +121 -102
  15. package/bin/incanto.mjs +3 -2
  16. package/dist/2d.d.ts +296 -34
  17. package/dist/2d.js +4 -4
  18. package/dist/3d.d.ts +157 -9
  19. package/dist/3d.js +8 -8
  20. package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
  21. package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
  22. package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
  23. package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
  24. package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
  25. package/dist/debug.d.ts +1 -1
  26. package/dist/debug.js +2 -3
  27. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  28. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  29. package/dist/editor.js +1088 -882
  30. package/dist/env.d.ts +1 -1
  31. package/dist/env.js +5 -3
  32. package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
  33. package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
  34. package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
  35. package/dist/gameplay.d.ts +254 -10
  36. package/dist/gameplay.js +2 -2
  37. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  38. package/dist/index.d.ts +179 -13
  39. package/dist/index.js +10 -12
  40. package/dist/json-CfTjpvW8.js +67 -0
  41. package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
  42. package/dist/net.d.ts +27 -3
  43. package/dist/net.js +2 -2
  44. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  45. package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
  46. package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
  47. package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
  48. package/dist/react.d.ts +2 -2
  49. package/dist/react.js +2 -2
  50. package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
  51. package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
  52. package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
  53. package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
  54. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  55. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  56. package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
  57. package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
  58. package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
  59. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  60. package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
  61. package/dist/test-CmN54kWv.js +4264 -0
  62. package/dist/test.d.ts +639 -30
  63. package/dist/test.js +3 -3
  64. package/dist/touch-BnCyPA0G.js +519 -0
  65. package/dist/vite.d.ts +20 -37
  66. package/dist/vite.js +177 -7
  67. package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
  68. package/editor/assets/agent8-yLIEYHbd.js +1 -0
  69. package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
  70. package/editor/assets/index-DK9xMGpW.js +11046 -0
  71. package/editor/index.html +1 -1
  72. package/package.json +4 -14
  73. package/schemas/scene.schema.json +34 -3
  74. package/skills/incanto-3d-character.md +14 -1
  75. package/skills/incanto-3d-models.md +12 -0
  76. package/skills/incanto-assets.md +9 -1
  77. package/skills/incanto-audio.md +8 -2
  78. package/skills/incanto-behaviors-and-scripts.md +105 -7
  79. package/skills/incanto-building-2d-games.md +97 -10
  80. package/skills/incanto-building-3d-games.md +58 -4
  81. package/skills/incanto-editor.md +46 -7
  82. package/skills/incanto-environment.md +19 -1
  83. package/skills/incanto-game-feel.md +70 -0
  84. package/skills/incanto-gameplay-behaviors.md +172 -20
  85. package/skills/incanto-hud.md +142 -7
  86. package/skills/incanto-localization.md +13 -5
  87. package/skills/incanto-multiplayer.md +83 -3
  88. package/skills/incanto-node-reference.md +239 -59
  89. package/skills/incanto-performance.md +52 -0
  90. package/skills/incanto-physics-and-input.md +123 -24
  91. package/skills/incanto-playtesting.md +112 -2
  92. package/skills/incanto-save-slots.md +217 -7
  93. package/skills/incanto-scene-json-authoring.md +69 -12
  94. package/skills/incanto-verifying-your-game.md +207 -10
  95. package/skills/incanto-web-integration.md +28 -0
  96. package/skills/incanto-your-first-game.md +3 -1
  97. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  98. package/templates-app/beacon-isle-3d/package.json +2 -2
  99. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  100. package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
  101. package/templates-app/beacon-isle-3d/src/main.ts +11 -1
  102. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  103. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  104. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  105. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  106. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  107. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  108. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  109. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  110. package/templates-app/molehill-2d/index.html +73 -0
  111. package/templates-app/molehill-2d/package.json +23 -0
  112. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  113. package/templates-app/molehill-2d/src/game.scene.json +1205 -0
  114. package/templates-app/molehill-2d/src/main.ts +41 -0
  115. package/templates-app/molehill-2d/tsconfig.json +13 -0
  116. package/templates-app/molehill-2d/verify.ts +247 -0
  117. package/templates-app/molehill-2d/vite.config.ts +12 -0
  118. package/templates-app/platformer-2d/package.json +2 -2
  119. package/templates-app/platformer-2d/src/game.scene.json +178 -986
  120. package/templates-app/platformer-2d/src/main.ts +11 -1
  121. package/templates-app/platformer-2d/tsconfig.json +1 -1
  122. package/templates-app/star-survivor/package.json +2 -2
  123. package/templates-app/star-survivor/src/game.scene.json +43 -216
  124. package/templates-app/star-survivor/src/main.ts +11 -1
  125. package/templates-app/star-survivor/tsconfig.json +1 -1
  126. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  127. package/templates-app/tps-3d/package.json +2 -2
  128. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  129. package/templates-app/tps-3d/src/game.scene.json +66 -263
  130. package/templates-app/tps-3d/src/main.ts +13 -2
  131. package/templates-app/tps-3d/tsconfig.json +1 -1
  132. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  133. package/templates-app/village-quest-3d/package.json +2 -2
  134. package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
  135. package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
  136. package/templates-app/village-quest-3d/src/main.ts +11 -1
  137. package/templates-app/village-quest-3d/src/village.scene.json +202 -863
  138. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  139. package/templates-app/village-quest-3d/verify.ts +14 -1
  140. package/dist/duplicate-Dj4L-5AA.js +0 -22
  141. package/dist/json-CwwhxQgb.js +0 -36
  142. package/dist/registry-CF70EArN.js +0 -212
  143. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  144. package/dist/test-D6ISmuFE.js +0 -2383
  145. package/dist/touch-DEAmqGdf.js +0 -225
  146. package/editor/assets/agent8-BDvaZMQJ.js +0 -1
  147. package/editor/assets/index-CZIRr5EL.js +0 -10958
@@ -25,6 +25,11 @@ Declare your HUD in the scene JSON — no `index.html` markup, no CSS, no
25
25
  works identically over the 2D and 3D renderers and is a silent no-op in
26
26
  headless tests (core nodes, no three.js). Widgets pick one of 9 anchors:
27
27
  `topLeft top topRight left center right bottomLeft bottom bottomRight`.
28
+
29
+ **Camel case here, hyphens in `UILayer`.** The 2D world-space layer spells the
30
+ same nine `top-left`/`bottom-right`, and neither prop accepts the other's
31
+ spelling — both fail at load listing their own set, so you are one edit from
32
+ right. Worth knowing before you spend the edit.
28
33
  Widgets stacked on the same anchor form a column.
29
34
 
30
35
  ## Driving widgets from the scene JSON (no TypeScript)
@@ -36,10 +41,15 @@ are `connections[]` entries — not a behavior whose whole job is one assignment
36
41
  "connections": [
37
42
  { "signal": "scoreChanged", "from": ".", "to": "HUD/Score", "handler": "setText" },
38
43
  { "signal": "healthChanged", "from": "Player", "to": "HUD/Hp", "handler": "setValue" },
39
- { "signal": "lifeLost", "from": ".", "to": "HUD/Lives", "handler": "setText" }
44
+ { "signal": "livesChanged", "from": ".", "to": "HUD/Lives", "handler": "setText" }
40
45
  ]
41
46
  ```
42
47
 
48
+ **`livesChanged`, not `lifeLost`, for the counter.** `lifeLost` is the EVENT — a
49
+ life was just spent, flash the screen, respawn — and it does not fire when a
50
+ SAVE is loaded, so a counter wired to it still read `3` after a Continue at one
51
+ life left. `livesChanged` is the COUNT, and fires on both.
52
+
43
53
  ```json
44
54
  { "name": "Score", "type": "UiText",
45
55
  "props": { "anchor": "topRight", "text": "Gems 0 / 8", "format": "Gems {} / 8" } }
@@ -93,9 +103,58 @@ banner.show('YOU DIED', { color: '#ef4444', seconds: 0 }); // sticky until next
93
103
  the queue; signal `bannerShown(text)`. Props: `size` (font px), `seconds`
94
104
  (default duration).
95
105
 
106
+ **Showing and hiding a widget is `visible`, not a method.** There is no
107
+ `hide()`/`show()` pair on a widget: `UiBanner.show(text)` announces a LINE, and
108
+ every widget's own visibility is the ordinary `visible` prop it inherits from
109
+ every other node —
110
+
111
+ ```ts
112
+ (this.node.getNode('%Pause') as UiPanel).visible = false; // a whole menu, and
113
+ // everything under it
114
+ ```
115
+
116
+ A panel hides its children with it, so one write takes a whole screen away.
117
+ (HUD widgets are screen-space, so `framing()` leaves them out entirely — asking
118
+ whether the camera can see one is a category error.)
119
+
96
120
  Pair with the `Health` / `ScoreKeeper` gameplay behaviors: listen to their
97
121
  signals and write the widget props — that is the whole HUD wiring.
98
122
 
123
+ ## On a phone
124
+
125
+ The nine anchors keep out of the device's own furniture: each is 16 px from its
126
+ edge **plus** that edge's `env(safe-area-inset-*)`. Every scaffolded
127
+ `index.html` carries `viewport-fit=cover` — which is what makes a game fill a
128
+ phone edge to edge, and what would otherwise put `anchor: "bottom"` under the
129
+ home indicator, where iOS takes the gesture and your button never hears the tap.
130
+ `topRight` behind the notch and `left`/`right` under the rounded corners are the
131
+ same story in landscape, which is how a phone is held for a game.
132
+
133
+ The insets are ADDED, and every desktop browser reports 0 for all four, so
134
+ nothing moves where there is nothing to avoid. (The touch-control overlay has
135
+ done this since 0.42; the HUD, which is the overlay with the buttons you
136
+ actually tap, joined it much later.)
137
+
138
+ **A widget authored in pixels still fits the screen.** `UiPanel`, `UiBar`,
139
+ `UiImage`, `UiSlider` and `UiDialogue` all cap at `calc(100vw - 32px)` — a pause
140
+ panel at `width: 520` on a 390 px phone would otherwise hang 130 px off the
141
+ edge, taking its buttons with it, and a panel too TALL scrolls rather than
142
+ hiding the row that says Resume.
143
+
144
+ **Every tappable widget is at least 44x44.** Measured in Chrome, a `UiButton`
145
+ used to come out 131x37 and a dialogue choice ~50x29, while the engine's own
146
+ touch buttons have been 64x64 and its stick 120x120 for years — the surface a
147
+ finger actually lands on was the one nobody had sized for a finger. Apple asks
148
+ 44, Material 48, WCAG 2.5.5 44. It is a MINIMUM: a button with more text stays
149
+ as wide as its text, and an authored `width` still wins.
150
+
151
+ The layer also takes the GESTURES over itself: every widget that receives taps
152
+ sets `touch-action: none`, and the layer turns off text selection, the iOS
153
+ long-press callout and the tap flash. `claimCanvasGestures` has done that for
154
+ the canvas for a while — but the HUD is a separate overlay ABOVE it, and a
155
+ swipe that starts on the score at the top of the screen never reaches the
156
+ canvas at all. It used to scroll the page or pull-to-refresh.
157
+
99
158
  ## Interactive widgets: UiButton & UiDialogue
100
159
 
101
160
  ```json
@@ -120,6 +179,18 @@ reveals the line then advances; choice lines render buttons and wait for
120
179
  `choose(i)`. Buttons opt into pointer events — the rest of the HUD stays
121
180
  click-through.
122
181
 
182
+ **Driving them with no screen** — a HUD widget is DOM, so a headless harness
183
+ cannot click one. Both take the gesture directly:
184
+
185
+ ```ts
186
+ (hud.getNode('Start') as UiButton).press(); // emits `pressed`, as a click would
187
+ (hud.getNode('Talk') as UiDialogue).advance(); // reveal the rest, then next line
188
+ ```
189
+
190
+ That is how a title screen, a pause menu or a conversation gets tested in
191
+ `runScript` — `click` reaches world nodes through the picker, and a widget is
192
+ not one of those.
193
+
123
194
  ## Menus, options and inventories (UiPanel + the value widgets)
124
195
 
125
196
  The HUD widgets can SAY things. These arrange them and take a value back — which
@@ -151,13 +222,34 @@ so structure in the tree becomes structure on screen. Panels nest.
151
222
  | `UiSlider` | `label`, `value`, `min`, `max`, `step`, `width`, `color` | `changed(value)` |
152
223
  | `UiToggle` | `label`, `value` | `changed(bool)` |
153
224
  | `UiSelect` | `label`, `options` (`"low,medium,high"`), `value` | `changed(value)` |
154
- | `UiVolumeSlider` | `bus` (`master`/`sfx`/`music`), plus every `UiSlider` prop | `changed(value)` |
155
- | `UiMuteToggle` | `label` | `changed(bool)` |
156
225
 
157
- The last two are **already wired**: they read and write `engine.audio`, which
158
- persists itself. An audio menu is three nodes and no TypeScript see
159
- `incanto-audio.md`. The graphics ones (`UiQualitySelect`, `UiFrameCapSelect`,
160
- `UiRenderScaleSelect`) and `UiLanguageSelect` work the same way.
226
+ `UiSelect.value` must be one of its `options` a value the list cannot show is
227
+ a hard load error naming them, because the browser would show the FIRST option
228
+ while the node went on answering with the value it was given, and `changed`
229
+ only fires when a person moves it. (Writing one at runtime is reported rather
230
+ than refused: a behaviour may widen `options` a frame later, and it goes quiet
231
+ when it does.) `""` is "nothing chosen yet", which is a choice.
232
+ ### The ones that need no TypeScript at all
233
+
234
+ | node | props | follows |
235
+ | --- | --- | --- |
236
+ | `UiVolumeSlider` | `bus` (`master`/`sfx`/`music`), plus every `UiSlider` prop | `engine.audio[bus]` |
237
+ | `UiMuteToggle` | `label` | `engine.audio.muted` |
238
+ | `UiQualitySelect` | — | `engine.settings` `quality` |
239
+ | `UiFrameCapSelect` | — | `engine.settings` `maxFps` |
240
+ | `UiRenderScaleSelect` | — | `engine.settings` `renderScale` |
241
+ | `UiLanguageSelect` | — | `engine.locale` (options are the scene's own locales) |
242
+
243
+ These are **already wired** in both directions: they show what the game is set
244
+ to, and setting one changes the game. A whole settings screen is those six nodes
245
+ and no TypeScript.
246
+
247
+ **They follow the engine HEADLESS too**, so a harness can drive one: set
248
+ `slider.value`, step, and assert `engine.audio.master` moved. These used to live
249
+ on the painting side of the widget, below `update`'s early
250
+ return for "no DOM element" — so every one of them was inert in `runScript`,
251
+ `incanto-playtest` and `incanto-verify`, showing a `UiSlider`'s 0.5 default next
252
+ to a master volume of 1.
161
253
 
162
254
  **An inventory is a grid panel**: `"layout": "grid", "columns": 5`, one child per
163
255
  slot, each a small `UiPanel` holding a `UiImage` (`tint` greys out what you
@@ -167,6 +259,20 @@ widget use `UiButton`'s `pressed`.
167
259
  Setting `.value` from a behavior updates the control and does **NOT** re-emit
168
260
  `changed` — restoring a saved setting must not fire the handler that saved it.
169
261
 
262
+ **`choose(value)` is the other half**: set it as a PERSON would, and say so.
263
+ `UiButton` has always had `press()`; the value widgets had nothing, so a preset
264
+ button, a "reset to defaults", a tutorial that moves a slider for you, or a
265
+ harness checking its own options screen had no public route at all.
266
+
267
+ ```ts
268
+ (hud.getNode('%Volume') as UiSlider).choose(0.25); // clamped, emits changed
269
+ (hud.getNode('%Invert') as UiToggle).choose(true);
270
+ (hud.getNode('%Quality') as UiSelect).choose('high'); // an option it does not
271
+ // offer is refused
272
+ ```
273
+
274
+ Both are silent when nothing changes.
275
+
170
276
  **Every prop is live.** `color`, `size`, `width`, `background`, `label` and
171
277
  `anchor` are re-read each frame, so flashing the score red, growing a health bar
172
278
  or moving a widget to another corner all work from a behavior — and from the
@@ -204,12 +310,25 @@ What activation MEANS is per widget: a button presses, a toggle flips (left/righ
204
310
  sets it explicitly, which reads better on a pad), a select walks its list, a
205
311
  slider nudges by one `step`.
206
312
 
313
+ **A `GameFlow` pause panel arms it for you** — that screen IS a menu, it knows
314
+ exactly when it opens and closes, and it puts the layer's own value back on
315
+ resume. Every game with a pause menu was otherwise writing the same two lines,
316
+ or shipping a menu that answered only to a mouse.
317
+
207
318
  **It is OFF by default**, deliberately: a game whose HUD happens to contain a
208
319
  button must not lose its arrow keys the moment one exists. Turn it on for the
209
320
  screens that ARE menus and off again when play resumes —
210
321
  `hud.focusNavigation = false`. `hud.focus(widget)` sets the starting item, since
211
322
  opening a menu should land somewhere rather than nowhere.
212
323
 
324
+ **With two layers, the TOPMOST armed one takes the key** — last in the tree,
325
+ which is the one drawn on top, and the one a modal is. Keyboard focus is
326
+ singular by nature (a browser has one focused element); when both a HUD and a
327
+ modal were armed, a single Enter activated the focused widget in EACH, so the
328
+ pause menu's RESUME and whatever the screen underneath had focused both fired.
329
+ A HIDDEN layer is not armed at all, so a closed menu never eats the key from the
330
+ HUD it covers.
331
+
213
332
  The gamepad codes are the ones the engine already produces from a pad
214
333
  (`Pad12`–`Pad15` d-pad, `Pad0` = A), so nothing extra is declared.
215
334
 
@@ -244,6 +363,22 @@ A drop on nothing cancels, and so does putting a thing back into the slot it
244
363
  came from: that is not a move, and firing `dropped` for it would make every
245
364
  mis-grab look like a transfer.
246
365
 
366
+ **A harness can make the gesture.** This is the one screen the paragraph above
367
+ says a click cannot express, and for a long time it was also the one screen no
368
+ headless check could reach at all — every one of the four signals lived on a
369
+ DOM listener, so `runScript`, `incanto-playtest` and every rung of the ladder
370
+ were blind to an inventory.
371
+
372
+ ```ts
373
+ { atMs: 200, do: (ctx) => (ctx.getNode('%Potion') as UiImage)
374
+ .dropOnto(ctx.getNode('%SlotB') as UiPanel) }
375
+ ```
376
+
377
+ `dropOnto(target)` is to a drag what `press()` is to a click: the same rules as
378
+ the pointer path — a non-`draggable` source does nothing, a target that is not a
379
+ `dropTarget` cancels, and putting a thing back where it came from is not a move
380
+ — and it returns whether the drop was taken.
381
+
247
382
 
248
383
 
249
384
  ## More than one language
@@ -64,7 +64,7 @@ A text prop becomes translatable by naming a key with `@t:` — the same shape a
64
64
  the `"$assetKey"` references you already write:
65
65
 
66
66
  ```json
67
- { "name": "Start", "type": "UiButton", "props": { "text": "@t:menu.start" } }
67
+ { "name": "Start", "type": "UiButton", "props": { "text": "@t:menu.start" } },
68
68
  { "name": "Wave", "type": "UiText", "props": { "format": "@t:hud.wave" } }
69
69
  ```
70
70
 
@@ -101,10 +101,18 @@ param is left alone rather than blanked.
101
101
 
102
102
  ### Authoring the table
103
103
 
104
- The scene header's **advanced** section has a `strings` field the same
105
- raw-JSON editor `input` and `multiplayer` use. Until 0.61 it was the one of
106
- those three the panel did not show, so a localized game could be read by the
107
- editor and never authored in it.
104
+ The scene header has a **strings** table: one row per key, one column per
105
+ language, `+ key` and `+ language`. Two things it shows that the JSON cannot:
106
+
107
+ - an **untranslated cell** shows the English it will silently fall back to,
108
+ greyed — that fallback is the design, not an error, and this is where you see
109
+ it happening;
110
+ - a key the **base locale is missing** is marked red, because that is the one
111
+ case that really breaks: nothing to fall back to, so the raw key paints.
112
+
113
+ Deleting a key a prop still says asks first, and tells you how many props will
114
+ start painting the key. The raw-JSON `strings` field is still in **advanced**
115
+ for pasting a whole table in at once.
108
116
 
109
117
  ## Checking a translation without a browser
110
118
 
@@ -34,6 +34,16 @@ Authority rules:
34
34
  - movement/cosmetics → **owner-authoritative** (the replication below)
35
35
  - score/economy/inventory → **server-function-authoritative** (extend `server.js`;
36
36
  `$sender.account` is trustworthy, args are NOT; guard with `$lock`)
37
+
38
+ > **`$lock` is NOT reentrant.** Taking one key inside another's callback —
39
+ > `$lock('clock')` inside `$lock('match')`, one string apart in a
40
+ > read-modify-write — DEADLOCKS. The preview server runs every client's calls
41
+ > one at a time, so one stuck call wedges the whole match: every
42
+ > `setMyState` stops, new players can never join, and `local.tick()` never
43
+ > resolves. It says so now (`a call has been running for over 5s and every
44
+ > other client is blocked behind it`) instead of hanging in silence, and
45
+ > `incanto-multiplay` prints its verdict instead of exiting 13 with nothing.
46
+ > Take one lock, do the whole read-modify-write inside it, and release.
37
47
  - physics/AI run client-side; one-simulator needs (NPC waves) use the host-client pattern
38
48
 
39
49
  ## Scene JSON
@@ -61,7 +71,9 @@ working game's. All three are hard `BAD_FORMAT` errors now, and
61
71
  — write ["position"] (on 'Player')
62
72
  ```
63
73
 
64
- Valid keys: `mode` (`owner` | `observer`), `sync` (array of prop names),
74
+ Valid keys: `mode` (`owner` the only one the engine reads; `observer` was a
75
+ sketch that shipped in the validator's list and was never implemented, so it is
76
+ now refused by name), `sync` (array of prop names),
65
77
  `throttleMs` (number). An `owner` with an empty `sync` is an error too — it is
66
78
  a half-finished edit that behaves exactly like a broken one.
67
79
 
@@ -76,7 +88,11 @@ a half-finished edit that behaves exactly like a broken one.
76
88
  keyframe (`keyframeMs`, default 2 s) comes round, which is also what heals a
77
89
  send that never reached the wire. Spawned entities (bullets,
78
90
  pickups) go through **collections**, never extra owner nodes.
79
- - **`NetworkSpawner`** (register with `registerNodesNet()`): `source: "users"` spawns one
91
+ - **`NetworkSpawner`** (register with `registerNodesNet()`): **`scene` is REQUIRED**
92
+ the key you registered the remote's scene under. Without one the spawner used to
93
+ return from every frame and no remote ever appeared, on a scene that loaded,
94
+ audited and played clean. A spawner in a game with no multiplayer at all says so
95
+ after two seconds, by name. `source: "users"` spawns one
80
96
  instance of the registered scene per OTHER account (self skipped); the flat `sync` patch
81
97
  applies onto the spawned scene's root; `position` lerps when `interpolate: true`
82
98
  (remote entities render slightly in the past — that's correct). Emits
@@ -98,7 +114,10 @@ import { NetworkManager, registerNodesNet, LoopbackHub } from 'incanto/net';
98
114
  // node (`Unknown node type 'Node2D'`).
99
115
  registerNodes2D();
100
116
  registerNodesNet();
101
- const scene = loadScene(json);
117
+ // `{ engine }` on a MANUAL boot, always: `onReady` runs during the load, and a
118
+ // behaviour that reads `this.engine`/`this.rng` there (Wander draws its first
119
+ // heading in it) throws TREE_VIOLATION without it.
120
+ const scene = loadScene(json, { engine });
102
121
  engine.setScene(scene);
103
122
 
104
123
  // DEV / offline / split-screen: zero infrastructure, built-in protocol only
@@ -142,6 +161,31 @@ delta-merged ARRAY `[{...state, account, __updated}]` → an account-keyed Recor
142
161
  `subscribeRoomCollection` arrives as `{items:[{__id,…}], changes}` → a `__id`-keyed
143
162
  Record. It also re-issues every subscription after a reconnect.
144
163
 
164
+ **When the socket drops it keeps trying** — 0.5s, 1, 2, 4, then 8 up to eight
165
+ attempts — and SAYS so on the first failure and again if it gives up:
166
+
167
+ ```
168
+ [incanto] the multiplayer connection dropped and the server would not take it
169
+ back (ECONNREFUSED). Retrying — other players cannot see this one until it
170
+ comes back.
171
+ ```
172
+
173
+ There used to be one attempt and no catch, so a server that was still
174
+ restarting rejected it, the rejection escaped an unawaited `void reconnect()`,
175
+ the re-issue loop never ran and nothing scheduled a second try: **the session
176
+ never came back, and nothing said a word.** A `connect()` that resolved `false`
177
+ was taken for success and re-issued every subscription onto a dead socket.
178
+
179
+ **A replication send that fails now reaches `stats().errors`.** It was
180
+ `void this.setMyState(…)`, so the rejection went to the host's
181
+ unhandled-rejection channel — not `engine.log`, not the error count. A client
182
+ whose socket died kept playing perfectly on its own screen while everyone else
183
+ watched it frozen, and the run reported `errors: 0`.
184
+
185
+ **A transport that REFUSES the connection now throws** `NETWORK_UNAVAILABLE`
186
+ out of `NetworkManager.create`, instead of returning a fully-formed manager
187
+ with `connected === false` that had joined nothing.
188
+
145
189
  ## Writing a custom transport (any backend)
146
190
 
147
191
  Implement `NetworkTransport` (exported from `incanto/net`): `connect`/`disconnect`,
@@ -354,6 +398,22 @@ Kernel constraints (agent8 platform facts — don't fight them):
354
398
  - room data is ephemeral — persist to `$global` before rooms empty
355
399
  - never expose unthrottled per-frame remoteFunction (rapid calls are rejected); guard score/economy with `$lock`
356
400
 
401
+ ## Changing scene in a multiplayer game
402
+
403
+ A manager is bound to the ROOM the scene it was created for declares.
404
+
405
+ - **Same room, new scene** (level 2 of one match, a map vote): nothing to do.
406
+ The manager rebinds to the incoming tree and keeps broadcasting; the new
407
+ owner node re-sends its full state.
408
+ - **Different room** (lobby → match, leaving for another arena): create a NEW
409
+ manager for the new room. Broadcasting stops on purpose — leaking one room's
410
+ state into another is exactly what the binding prevents — and the engine logs
411
+ an error saying which room it is bound to and which the scene asked for.
412
+
413
+ Dispose the old handle whenever you like, before or after creating the new one:
414
+ disposing a handle that a later `create()` already superseded leaves the live
415
+ manager alone.
416
+
357
417
  ## State rules
358
418
 
359
419
  - JSON-safe by construction: vectors as arrays, no `undefined`/`NaN`/typed arrays
@@ -392,3 +452,23 @@ changed nothing — a ✓ that could not have been a ✗.
392
452
 
393
453
  If your server cannot be loaded the run REFUSES rather than quietly measuring a
394
454
  game without its own rules.
455
+
456
+ **Pass `--behaviors src/behaviors.ts` too.** A sync key is very often written by
457
+ game TypeScript (`player.firing = input.isPressed('fire')`), and without the
458
+ behaviors the harness cannot tell a DEAD sync key from one whose writer was
459
+ never loaded — so it says nothing about them at all. With the behaviors there,
460
+ a key the owner cannot read is reported as what it is:
461
+
462
+ ```
463
+ ✗ p1 declares `firing` in its `network.sync` and cannot READ it — it is never
464
+ sent, so no client will ever see it change.
465
+ ```
466
+
467
+ That is the most complete failure of replication there is, and the rung used to
468
+ grade it PASS: an unreadable key was dropped from the comparison, so
469
+ every client agreed perfectly about a value nobody ever sent.
470
+
471
+ **`collection:` spawners are compared too.** The account-keyed half of the check
472
+ cannot see them — a bullet has no account — so a spawner that took a hundred
473
+ entities off the wire and materialised none of them used to be entirely silent.
474
+ Now it says which spawner, and how many entities it dropped.