castle-web-cli 0.4.168 → 0.4.170

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 (87) hide show
  1. package/dist/agent-prompts.js +17 -0
  2. package/dist/agent.js +23 -13
  3. package/dist/atomicFile.d.ts +29 -0
  4. package/dist/atomicFile.js +120 -0
  5. package/dist/castle-host/host.js +37 -0
  6. package/dist/get-deck.js +2 -11
  7. package/dist/headlessCover.d.ts +4 -0
  8. package/dist/headlessCover.js +7 -5
  9. package/dist/ide.js +14 -6
  10. package/dist/index.js +10 -6
  11. package/dist/init.d.ts +1 -1
  12. package/dist/init.js +11 -1
  13. package/dist/remix.js +2 -14
  14. package/dist/save-deck.d.ts +3 -0
  15. package/dist/save-deck.js +51 -74
  16. package/dist/saveCover.d.ts +18 -0
  17. package/dist/saveCover.js +61 -0
  18. package/dist/serve.js +18 -5
  19. package/dist/serveSecurity.d.ts +11 -1
  20. package/dist/serveSecurity.js +38 -4
  21. package/dist/shell/assets/index-BvQmVwlO.css +1 -0
  22. package/dist/shell/assets/index-CV5sBby1.js +445 -0
  23. package/dist/shell/index.html +2 -2
  24. package/dist/versionStore.js +13 -4
  25. package/kits/base/CLAUDE.md +9 -0
  26. package/kits/base/castle.json +16 -11
  27. package/kits/base/sdk/README.md +66 -1
  28. package/kits/base/sdk/commands.d.ts +23 -1
  29. package/kits/base/sdk/commands.js +9 -0
  30. package/kits/base/sdk/lifecycle.d.ts +20 -0
  31. package/kits/base/sdk/lifecycle.js +7 -1
  32. package/kits/base/sdk/resumeState.d.ts +2 -0
  33. package/kits/base/sdk/resumeState.js +84 -0
  34. package/kits/base/sdk/runtime.js +65 -0
  35. package/kits/base/sdk/transport.d.ts +3 -0
  36. package/kits/base/sdk/transport.js +39 -5
  37. package/kits/base/sdk/unloadHandshake.d.ts +6 -0
  38. package/kits/base/sdk/unloadHandshake.js +47 -0
  39. package/kits/multiplayer-2d/castle.json +3 -3
  40. package/kits/multiplayer-2d/package-lock.json +0 -27
  41. package/kits/multiplayer-2d/package.json +0 -1
  42. package/kits/multiplayer-3d/castle.json +4 -4
  43. package/kits/multiplayer-3d/package-lock.json +0 -27
  44. package/kits/multiplayer-3d/package.json +0 -1
  45. package/kits/physics-2d/CLAUDE.md +75 -43
  46. package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
  47. package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
  48. package/kits/physics-2d/castle.json +6 -6
  49. package/kits/physics-2d/editors/PxArtEditor.jsx +83 -180
  50. package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
  51. package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
  52. package/kits/physics-2d/editors/overlayCanvas.js +77 -0
  53. package/kits/physics-2d/editors/pathOverlay.js +5 -13
  54. package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
  55. package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
  56. package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
  57. package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
  58. package/kits/physics-2d/editors/styleEditor.module.css +23 -4
  59. package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
  60. package/kits/physics-2d/engine/SceneUI.jsx +6 -12
  61. package/kits/physics-2d/engine/icons.js +56 -0
  62. package/kits/physics-2d/engine/panelSorting.jsx +7 -14
  63. package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
  64. package/kits/physics-2d/engine/physics/controls.js +76 -12
  65. package/kits/physics-2d/engine/scene.js +47 -9
  66. package/kits/physics-2d/engine/ui.jsx +13 -125
  67. package/kits/physics-2d/engine/ui.module.css +53 -28
  68. package/kits/physics-2d/package-lock.json +0 -29
  69. package/kits/physics-2d/package.json +3 -1
  70. package/kits/physics-2d/pnpm-lock.yaml +1 -17
  71. package/kits/physics-3d/castle.json +5 -5
  72. package/kits/physics-3d/package-lock.json +0 -29
  73. package/kits/physics-3d/package.json +0 -1
  74. package/kits/physics-3d/pnpm-lock.yaml +0 -17
  75. package/kits/turn-based/castle.json +3 -3
  76. package/kits/turn-based/package-lock.json +0 -27
  77. package/kits/turn-based/package.json +0 -1
  78. package/package.json +5 -4
  79. package/dist/shell/assets/index-CVHj503j.css +0 -1
  80. package/dist/shell/assets/index-D-qJ5VGp.js +0 -425
  81. package/kits/blank/.castle/agent/children.json +0 -1
  82. package/kits/blank/.castle/agent/messages.json +0 -9
  83. package/kits/blank/.castle/agent/pending-sends.json +0 -1
  84. package/kits/blank/.castle/agent/tasks/index.md +0 -2
  85. package/kits/blank/.castle/docs/platform.md +0 -36
  86. package/kits/blank/node_modules/.vite/deps/_metadata.json +0 -8
  87. package/kits/blank/node_modules/.vite/deps/package.json +0 -3
@@ -19,11 +19,11 @@
19
19
  },
20
20
  "castle.physics-2d": {
21
21
  "deckId": "ckRZGFW4iPrx",
22
- "version": "2026-09-01T01:02:46.041Z"
22
+ "version": "2026-09-10T00:57:31.408Z"
23
23
  },
24
24
  "castle.base": {
25
25
  "deckId": "yRcmH4_aYllE",
26
- "version": "2026-09-05T01:31:11.287Z"
26
+ "version": "2026-09-10T00:56:59.388Z"
27
27
  }
28
28
  },
29
29
  "autoUpdateWhenImported": true,
@@ -35,5 +35,5 @@
35
35
  },
36
36
  "deckId": "oiFu96LkwZ09",
37
37
  "cardId": "nkJgrvrCby3H",
38
- "publishedVersion": "2026-09-05T01:32:45.261Z"
38
+ "publishedVersion": "2026-09-10T00:58:09.384Z"
39
39
  }
@@ -15,7 +15,6 @@
15
15
  "@dnd-kit/modifiers": "^9.0.0",
16
16
  "@dnd-kit/sortable": "^10.0.0",
17
17
  "@dnd-kit/utilities": "^3.2.2",
18
- "@fortawesome/free-solid-svg-icons": "^5.15.4",
19
18
  "@lezer/highlight": "^1.2.3",
20
19
  "castle-web-fonts": "^1.0.0",
21
20
  "codemirror": "^6.0.2",
@@ -151,7 +150,6 @@
151
150
  "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
152
151
  "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
153
152
  "license": "MIT",
154
- "peer": true,
155
153
  "dependencies": {
156
154
  "@dnd-kit/accessibility": "^3.1.1",
157
155
  "@dnd-kit/utilities": "^3.2.2",
@@ -202,29 +200,6 @@
202
200
  "react": ">=16.8.0"
203
201
  }
204
202
  },
205
- "node_modules/@fortawesome/fontawesome-common-types": {
206
- "version": "0.2.36",
207
- "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
208
- "integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==",
209
- "hasInstallScript": true,
210
- "license": "MIT",
211
- "engines": {
212
- "node": ">=6"
213
- }
214
- },
215
- "node_modules/@fortawesome/free-solid-svg-icons": {
216
- "version": "5.15.4",
217
- "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
218
- "integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
219
- "hasInstallScript": true,
220
- "license": "(CC-BY-4.0 AND MIT)",
221
- "dependencies": {
222
- "@fortawesome/fontawesome-common-types": "^0.2.36"
223
- },
224
- "engines": {
225
- "node": ">=6"
226
- }
227
- },
228
203
  "node_modules/@lezer/common": {
229
204
  "version": "1.5.2",
230
205
  "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
@@ -308,7 +283,6 @@
308
283
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
309
284
  "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
310
285
  "license": "MIT",
311
- "peer": true,
312
286
  "engines": {
313
287
  "node": ">=0.10.0"
314
288
  }
@@ -318,7 +292,6 @@
318
292
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
319
293
  "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
320
294
  "license": "MIT",
321
- "peer": true,
322
295
  "dependencies": {
323
296
  "scheduler": "^0.27.0"
324
297
  },
@@ -16,7 +16,6 @@
16
16
  "@dnd-kit/modifiers": "^9.0.0",
17
17
  "@dnd-kit/sortable": "^10.0.0",
18
18
  "@dnd-kit/utilities": "^3.2.2",
19
- "@fortawesome/free-solid-svg-icons": "^5.15.4",
20
19
  "@lezer/highlight": "^1.2.3",
21
20
  "castle-web-fonts": "^1.0.0",
22
21
  "codemirror": "^6.0.2",
@@ -18,22 +18,22 @@
18
18
  },
19
19
  "castle.physics-3d": {
20
20
  "deckId": "JH0SclbPVP0y",
21
- "version": "2026-09-01T01:02:48.784Z"
21
+ "version": "2026-09-10T00:57:52.158Z"
22
22
  },
23
23
  "castle.physics-2d": {
24
24
  "deckId": "ckRZGFW4iPrx",
25
- "version": "2026-09-01T01:02:46.041Z",
25
+ "version": "2026-09-10T00:57:31.408Z",
26
26
  "via": "castle.physics-3d"
27
27
  },
28
28
  "castle.base": {
29
29
  "deckId": "yRcmH4_aYllE",
30
- "version": "2026-09-05T01:31:11.287Z"
30
+ "version": "2026-09-10T00:56:59.388Z"
31
31
  }
32
32
  },
33
33
  "autoUpdateWhenImported": true,
34
34
  "deckId": "ZwA_P_-VO-Qh",
35
35
  "cardId": "bvRmCzyUpt_a",
36
- "publishedVersion": "2026-09-05T01:32:53.625Z",
36
+ "publishedVersion": "2026-09-10T00:58:16.876Z",
37
37
  "main": "main.jsx",
38
38
  "server": {
39
39
  "main": "code/server/index.js",
@@ -16,7 +16,6 @@
16
16
  "@dnd-kit/modifiers": "^9.0.0",
17
17
  "@dnd-kit/sortable": "^10.0.0",
18
18
  "@dnd-kit/utilities": "^3.2.2",
19
- "@fortawesome/free-solid-svg-icons": "^5.15.4",
20
19
  "@lezer/highlight": "^1.2.3",
21
20
  "castle-web-fonts": "^1.0.0",
22
21
  "codemirror": "^6.0.2",
@@ -159,7 +158,6 @@
159
158
  "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
160
159
  "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
161
160
  "license": "MIT",
162
- "peer": true,
163
161
  "dependencies": {
164
162
  "@dnd-kit/accessibility": "^3.1.1",
165
163
  "@dnd-kit/utilities": "^3.2.2",
@@ -210,29 +208,6 @@
210
208
  "react": ">=16.8.0"
211
209
  }
212
210
  },
213
- "node_modules/@fortawesome/fontawesome-common-types": {
214
- "version": "0.2.36",
215
- "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
216
- "integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==",
217
- "hasInstallScript": true,
218
- "license": "MIT",
219
- "engines": {
220
- "node": ">=6"
221
- }
222
- },
223
- "node_modules/@fortawesome/free-solid-svg-icons": {
224
- "version": "5.15.4",
225
- "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
226
- "integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
227
- "hasInstallScript": true,
228
- "license": "(CC-BY-4.0 AND MIT)",
229
- "dependencies": {
230
- "@fortawesome/fontawesome-common-types": "^0.2.36"
231
- },
232
- "engines": {
233
- "node": ">=6"
234
- }
235
- },
236
211
  "node_modules/@lezer/common": {
237
212
  "version": "1.5.2",
238
213
  "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
@@ -316,7 +291,6 @@
316
291
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
317
292
  "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
318
293
  "license": "MIT",
319
- "peer": true,
320
294
  "engines": {
321
295
  "node": ">=0.10.0"
322
296
  }
@@ -326,7 +300,6 @@
326
300
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
327
301
  "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
328
302
  "license": "MIT",
329
- "peer": true,
330
303
  "dependencies": {
331
304
  "scheduler": "^0.27.0"
332
305
  },
@@ -17,7 +17,6 @@
17
17
  "@dnd-kit/modifiers": "^9.0.0",
18
18
  "@dnd-kit/sortable": "^10.0.0",
19
19
  "@dnd-kit/utilities": "^3.2.2",
20
- "@fortawesome/free-solid-svg-icons": "^5.15.4",
21
20
  "@lezer/highlight": "^1.2.3",
22
21
  "castle-web-fonts": "^1.0.0",
23
22
  "codemirror": "^6.0.2",
@@ -22,9 +22,10 @@ Do you already know what you want to make, or do you want to figure it out toget
22
22
  - Every actor is an instance of a **blueprint** (`blueprints/*.scene`) — see `## Blueprints` below. Always author the blueprint file yourself and reference it via `"blueprint"`. Don't write inline actors (full `components`, no `blueprint` field): the editor auto-migrates each one into its own new blueprint file on open, one per actor with no dedup, which litters the deck with junk blueprints.
23
23
  - Real game objects and scenery should usually be editable sprites: generate `.sprite` with `npm run draw -- name`, then place it via a `Sprite` component pointing at `drawings/name.sprite`. Dynamic UI/effects stay procedural.
24
24
  - A deck can also hold uploaded media (the Files panel's Upload button): `Sprite` draws image files as well as `.sprite`, and `Video` plays video files. Point their `file` at the deck path (`assets/logo.png`). For audio see `## Sound` below -- sound effects need no component at all.
25
- - The deck's body/UI font is picked in the Style editor (`theme.style`): HUD text inherits it, canvas text reads `scene.font`. A second display face is imported in the behavior that uses it. See `## Deck font` below.
25
+ - The deck's body/UI font is picked in the Style editor (`theme.style`): canvas text reads `scene.font`. A second display face is imported in the behavior that uses it. See `## Deck font` below.
26
26
  - This kit is plain JavaScript. Use `.jsx` for files with JSX, `.js` otherwise; do not add TypeScript files or a new build step.
27
27
  - Space is reserved by the editor for play/stop; do not bind Space to gameplay.
28
+ - Two render hooks: `draw(ctx)` is world space and scrolls with `Camera`; `drawUi(ctx)` is screen space (card units, camera-free). On-screen controls, gauges and HUD go in `drawUi`, never `draw`. The React `ui()` hook is deprecated — prefer `drawUi`.
28
29
  - Do not read `engine/`, `editors/`, or built-in behaviors (`Layout.jsx`, `Sprite.jsx`, `Collider.jsx`, `Camera.jsx`, and the physics ones — `RigidBody.jsx`, `Joints.jsx`, `Draggable.jsx`, `Slingshot.jsx`, `AnalogStick.jsx`) to build a game. Their public API is documented below. Forking is the exception: when you're deliberately copying a kit file into the deck to change it — an editor (see `## Editors`) or a behavior you're shadowing — read the original. `engine/` stays off-limits either way.
29
30
  - An editor is for content FILES the creator keeps coming back to (catalogs, dialogue, level tables) that nothing already opens. Scenes, sprites and styles have editors; per-actor settings belong on behavior props in the inspector — not in a new editor.
30
31
  - Details below: `## Behavior shape`, `## Scene file`, `## Blueprints`, `## Built-in behaviors`, `## Creating pixel art`, `## SceneRuntime API`, and `## Input shortcuts`.
@@ -266,11 +267,16 @@ export class MyThing {
266
267
  }
267
268
 
268
269
  // Optional. Custom drawing (you usually don't need this; use a Sprite
269
- // component instead). ctx is in card units already.
270
+ // component instead). ctx is world space: card units, translated by the
271
+ // camera when a `Camera` exists, then rotated by the actor's Layout.
270
272
  draw(actor, scene, ctx) {}
271
273
 
272
- // Optional. Return React nodes for game-time HUD. Coordinates are card
273
- // units. Read state your `update` set; do not start your own loops.
274
+ // Optional. Canvas HUD in screen-fixed card units; never moves with the
275
+ // camera. Prefer this for every on-screen control, gauge, and HUD label.
276
+ drawUi(actor, scene, ctx) {}
277
+
278
+ // Deprecated. Prefer `drawUi`. Still runs (React overlay, screen-fixed card
279
+ // units) but new HUD should not use it.
274
280
  ui(actor, scene) {
275
281
  return null;
276
282
  }
@@ -290,12 +296,6 @@ export class MyThing {
290
296
 
291
297
  A fresh `Behavior` instance is constructed per actor per frame from the actor's component props — DO NOT store per-actor state on `this`. Persist transient state on `actor.runtime` (a free-form object the framework will not serialize) or on the component props themselves.
292
298
 
293
- Interactive HUD from `ui` works by default for real controls (`button`, `a`,
294
- `input`, `select`, `textarea`, `[role="button"]`). A tap handler on anything
295
- else — a plain `div` — is click-through, so the HUD doesn't eat taps meant for
296
- the game: set `pointerEvents: 'auto'` on that element, never on a wrapper
297
- covering the card (every behavior's `ui` shares one layer).
298
-
299
299
  ## Scene file (`scenes/main.scene`, plain JSON)
300
300
 
301
301
  A scene is a background plus a list of actors. Every actor is an **instance of a blueprint** (see `## Blueprints`): a `blueprint` path plus sparse `components` overrides — usually just its `Layout` position:
@@ -400,7 +400,7 @@ Template `components` keys are behavior names (the `static behaviorName`); add a
400
400
  }
401
401
  ```
402
402
 
403
- - **Camera** — `{ target: actorId, followX, followY, roomWidth, roomHeight }`. Place on a dedicated actor; sets `scene.camera` clamped to the room. Omit entirely for a fixed view (no camera = no translation).
403
+ - **Camera** — `{ target: actorId, followX, followY, roomWidth, roomHeight }`. Place on a dedicated actor; sets `scene.camera` clamped to the room. Omit entirely for a fixed view (no camera = no translation). It translates everything drawn in `draw()`; `drawUi()` is unaffected. `scene.camera` is `{ x, y }` while a Camera actor runs, else `undefined`.
404
404
  - **SoundPlayer** — `{ file: "assets/music/theme.mp3", stream?: false, volume?: 1, playbackRate?: 1, pan?: 0, loop?: false, playOnStart?: true }`. Plays one audio file from an actor. `stream: false` (the default) fires it from memory — instant and overlapping, for effects; `stream: true` streams it from an `<audio>` element — for music and long ambience, whose decoded audio would be far too big to hold. `loop` applies to `stream: true` only. `playbackRate` retunes it (2 = an octave up and twice as fast, 0.5 = an octave down); `pan` places it left (−1) to right (+1). With `playOnStart` it starts with the scene; otherwise trigger it through the handle it puts on the actor:
405
405
 
406
406
  ```jsx
@@ -442,7 +442,7 @@ Name a sound by its filename without the extension, or by its full deck path. Tw
442
442
 
443
443
  A physics-2d deck picks its **deck font** in the Style editor — open
444
444
  `theme.style` and choose one of the nine Castle faces, or None. That is the
445
- deck's body and UI face: HUD text inherits it, and `scene.font` returns it.
445
+ deck's body and UI face: `scene.font` returns it for `draw` / `drawUi`.
446
446
 
447
447
  Picking writes two files, and both belong in the deck:
448
448
 
@@ -451,25 +451,19 @@ Picking writes two files, and both belong in the deck:
451
451
  - `fonts.generated.js` — one static re-export, and the only thing that carries
452
452
  that face's bytes into the published deck. Generated; don't edit it.
453
453
 
454
- Use it in the two places this kit renders text:
455
-
456
- - **HUD text** (a behavior's `ui` hook) inherits it already — plain React, no
457
- extra work. `--castle-deck-font` is on the UI layer if you want to name it in
458
- CSS.
459
- - **In-world text** (a behavior's `draw` hook) reads `scene.font`, which is
460
- always a usable CSS family — the deck's face, or a sans stack when it has
461
- none:
454
+ Use it from canvas text in `draw` / `drawUi` both read `scene.font`, which is
455
+ always a usable CSS family (the deck's face, or a sans stack when it has none):
462
456
 
463
- ```jsx
464
- draw(actor, scene, ctx) {
465
- ctx.font = `24px ${scene.font}`;
466
- ctx.fillText(`${this.props.score}`, 20, 40);
467
- }
468
- ```
457
+ ```jsx
458
+ drawUi(actor, scene, ctx) {
459
+ ctx.font = `24px ${scene.font}`;
460
+ ctx.fillText(`${this.props.score}`, 20, 40);
461
+ }
462
+ ```
469
463
 
470
- Don't hardcode the deck font's family name there: a `draw` hook naming
471
- `'Piazzolla'` outright keeps drawing in it after someone picks a different
472
- font, and draws in nothing at all once they pick None.
464
+ Don't hardcode the deck font's family name there: a hook naming `'Piazzolla'`
465
+ outright keeps drawing in it after someone picks a different font, and draws in
466
+ nothing at all once they pick None.
473
467
 
474
468
  ### A second face
475
469
 
@@ -498,8 +492,8 @@ behavior imported for itself — before the first frame, so `ctx.font` is safe t
498
492
  set from the very first `draw`. Each face costs its own 12–90 KB in the bundle,
499
493
  so two or three in total is the shape to aim for.
500
494
 
501
- There is no Text behavior — text is drawn by the deck's own code, through the
502
- two hooks above.
495
+ There is no Text behavior — text is drawn by the deck's own code via `draw`
496
+ (world) or `drawUi` (screen HUD).
503
497
 
504
498
  ## Physics
505
499
 
@@ -544,13 +538,21 @@ tilts it sideways. Omit for the default (`1`, downward).
544
538
 
545
539
  ### Driving physics from behaviors — `scene.physics`
546
540
 
547
- In a behavior's `update`, steer bodies through `scene.physics` (never write
548
- `Layout` directly for a dynamic body the sim owns its transform):
541
+ In a behavior's `update`, steer bodies through `scene.physics`. Writing
542
+ `Layout.x`/`y`/`rotation` on a dynamic body **teleports** it there before the
543
+ next step (velocity kept, no collision along the way). To move it *under*
544
+ physics use `setVelocity`/`applyImpulse`/`applyForce`:
549
545
 
550
546
  - `scene.physics.setVelocity(actor, { x, y })` — set velocity (px per fixed
551
547
  step; multiply by ~60 for px/s).
552
548
  - `scene.physics.applyImpulse(actor, { x, y })` — add to velocity this frame.
553
549
  - `scene.physics.applyForce(actor, { x, y })` — add a matter-space force.
550
+ - `scene.physics.setRotation(actor, deg)` — same as writing `Layout.rotation`.
551
+ - `scene.physics.setPosition(actor, { x, y })` — same as writing Layout; x/y
552
+ are the Layout top-left.
553
+ - `scene.physics.setAngularVelocity(actor, degPerStep)` /
554
+ `getAngularVelocity(actor)` — set to 0 to stop a spin;
555
+ `setVelocity(actor, {x:0,y:0})` stops linear motion.
554
556
  - `scene.physics.getVelocity(actor)` — current velocity `{ x, y }`.
555
557
  - `scene.physics.isColliding(a, b)` — are these two actors touching right now?
556
558
  - `scene.physics.actorAtPoint(x, y)` — topmost actor whose **collider** contains
@@ -578,6 +580,26 @@ export class Coin {
578
580
  }
579
581
  ```
580
582
 
583
+ ### Stick hooks
584
+
585
+ An `AnalogStick` on the same actor fires sibling hooks when the gesture begins
586
+ and ends — begin on the first nonzero deflection (not on a still press), end on
587
+ release or when the stick is disabled mid-hold. Signature `(actor, scene)`; the
588
+ stick itself receives them and ignores them.
589
+
590
+ ```jsx
591
+ export class StickSlowDownToggle {
592
+ static behaviorName = 'StickSlowDownToggle';
593
+ onAnalogStickBegin(actor, scene) {
594
+ // e.g. mute a sibling Slow Down while the stick is active
595
+ actor.runtime.stickHeld = true;
596
+ }
597
+ onAnalogStickEnd(actor, scene) {
598
+ actor.runtime.stickHeld = false;
599
+ }
600
+ }
601
+ ```
602
+
581
603
  ### Built-in controls (add these behaviors, no code needed)
582
604
 
583
605
  These read input and drive the actor through the physics system, so the actor
@@ -616,7 +638,13 @@ see the SceneRuntime API below.
616
638
  - **`Slingshot`** — press the actor, pull back, release to launch it the
617
639
  opposite way (angry-birds), with an aim arrow. `speed`, `maxDrag`.
618
640
  - **`AnalogStick`** — on-screen virtual joystick (press anywhere) that drives
619
- the actor. `speed`, `maxRadius`, `axes`. Best for top-down.
641
+ the actor with force-mode feel: ramp in, hard top speed, idle-only brake.
642
+ Screen-fixed (`drawUi`) so it works with a `Camera`. `speed` (top speed,
643
+ px/step), `rampTime` (seconds rest→speed at full deflection; `0` = instant),
644
+ `slowDown` (idle deceleration in speed units per second; `0` = coast),
645
+ `turnBoost` (extra accel when reversing), `maxRadius`, `axes`. Never clamps
646
+ or brakes an undriven axis (so horizontal + gravity still falls). Best for
647
+ top-down; see **Stick hooks** above for begin/end.
620
648
 
621
649
  ### Recipe: a launch-and-bounce game
622
650
 
@@ -712,10 +740,13 @@ draw(actor, scene, ctx) {
712
740
  Slingshot) can pass straight through it — matter-js has no continuous
713
741
  collision. Keep boundary walls thick (50px+) and/or cap launch power. The
714
742
  demo scene uses 50px walls with a ~20px/step launch cap.
715
- - **Don't move a dynamic body by writing `Layout`.** The simulation owns a
716
- dynamic body's transform and overwrites `Layout` every frameuse
717
- `scene.physics.setVelocity/applyImpulse/applyForce`. Writing `Layout`
718
- directly is only for `static`/`kinematic` bodies.
743
+ - **Writing `Layout` teleports.** A Layout write on a body snaps it there
744
+ before the next step with no collision response on the way a per-frame
745
+ `layout.x += speed*dt` on a dynamic body ghosts through walls and jitters
746
+ (use `setVelocity` for that). Momentum is kept, so to drive rotation every
747
+ frame pair it with `freezeRotation` or expect a small spin drift between
748
+ writes. Teleporting one end of a joint makes the constraint snap it back
749
+ next step.
719
750
  - **Editor vs play.** Physics only steps during play; in the editor actors sit
720
751
  where you place them. `RigidBody.velocityX/Y` apply once when play starts.
721
752
  - **No `Collider` means no physics at all.** A body only exists for an actor with
@@ -791,8 +822,9 @@ To generate path art, emit an svg of **shapes** rather than per-pixel rects and
791
822
 
792
823
  - `scene.time` — seconds since start.
793
824
  - `scene.keys` — `Set` of currently-held KeyboardEvent codes (e.g. `'ArrowLeft'`, `'KeyA'`, `'Space'`). Read in `update`.
794
- - `scene.pointer` — `{ x, y, down }` in world (card) coordinates, camera-adjusted. The **primary** pointer: the one held longest. Use this for anything single-touch.
795
- - `scene.pointers` — `Map` of every active pointer (finger / mouse / stylus) by id, each `{ id, x, y, down, justPressed, claimedBy }`, same coordinates. For multi-touch: latch the `id` a gesture began on and follow that entry, so another finger landing can't hijack it. `justPressed` is that pointer's press edge for this frame (a shared `down` flag would miss a finger landing while another is already held). Entries disappear on release.
825
+ - `scene.pointer` — `{ x, y, screenX, screenY, down }`. `x`/`y` are world coordinates (card units offset by the camera); `screenX`/`screenY` are card coords before the camera, for screen-fixed controls. The **primary** pointer: the one held longest. Use this for anything single-touch.
826
+ - `scene.pointers` — `Map` of every active pointer (finger / mouse / stylus) by id, each `{ id, x, y, screenX, screenY, down, justPressed, claimedBy }`, same coordinates. For multi-touch: latch the `id` a gesture began on and follow that entry, so another finger landing can't hijack it. `justPressed` is that pointer's press edge for this frame (a shared `down` flag would miss a finger landing while another is already held). Entries disappear on release.
827
+ - `scene.camera` — `{ x, y }` while a Camera actor runs, else `undefined`.
796
828
  - `scene.claimPointer(id, ownerId, priority?)` / `scene.ownsPointer(id, ownerId)` — take a finger for one gesture so two controls can't both act on it, and check you still hold it. A higher `priority` takes it from a lower one, which is how a control that was actually pressed (a `Draggable`'s collider) beats one that takes any press (an `AnalogStick`), regardless of actor order. Claims vanish with the pointer, so there's nothing to release. This is what lets a player work a stick with one thumb and drag with the other.
797
829
  - `scene.getActor(id)` / `scene.getActors()` (sorted by Layout.z) / `scene.getComponent(actor, name)`.
798
830
  - `scene.actorWith('GameController')` / `scene.actorsWith('Brick')` — find one / all actors carrying a given behavior. Prefer these to `getActors().find(a => a.components.X)`.
@@ -803,7 +835,7 @@ To generate path art, emit an svg of **shapes** rather than per-pixel rects and
803
835
  - `scene.spawnFromBlueprint('blueprints/enemy.scene', { components: { Layout: { x, y } } })` — spawn an instance of a blueprint, same merge semantics as a placed instance. Prefer this over `spawnActor` when you're spawning copies of something that has (or should have) a blueprint, e.g. `scene.spawnFromBlueprint(actor.blueprint, { components: { Layout: { x: actor.components.Layout.x, y: actor.components.Layout.y } } })` to spawn another of the same kind as an existing actor.
804
836
  - `scene.despawnActor(id)` — remove an actor at runtime. Use this; don't `splice` + `delete` by hand.
805
837
  - `scene.sound` — the deck's sound library: `play(name, opts)`, `stopAll()`, `isLoaded`, `whenLoaded`, `has(name)`, `voiceCount`. See `## Sound`.
806
- - `scene.font` — the deck's font as a CSS family, for `ctx.font` in `draw`. Always usable: the face picked in the Style editor, or a sans stack. See `## Deck font`.
838
+ - `scene.font` — the deck's font as a CSS family, for `ctx.font` in `draw` / `drawUi`. Always usable: the face picked in the Style editor, or a sans stack. See `## Deck font`.
807
839
  - `scene.status` — string you can set/read for game-state ('playing', 'gameover', ...).
808
840
  - `scene.load(sceneData)` — replace the running scene with the given scene data object.
809
841
  - `scene.readFromFile(name)` — read and parse a scene file (`'gameover.scene'`, `'levels/2.scene'`), returning its scene data. Use this instead of importing a `.scene` file.
@@ -820,14 +852,14 @@ if (scene.keys.has('KeyX')) /* launch ball */ ;
820
852
 
821
853
  **Space is reserved** — the editor binds it to the play/stop toggle, so don't bind Space to a gameplay action (jump / shoot / launch / ...). Use arrows, WASD, letter keys, or on-screen buttons instead.
822
854
 
823
- For HUD text use a behavior's `ui` hook (returns React); for in-world text or shapes, draw with `ctx` from `draw`. Both render in the deck's font — see `## Deck font`.
855
+ For HUD text and shapes use `drawUi` (screen-fixed canvas). Only in-world text or shapes go in `draw`. Both read `scene.font` — see `## Deck font`. The React `ui()` hook is deprecated.
824
856
 
825
857
  ## Common breakout-shaped recipe (sketch)
826
858
 
827
859
  - `Paddle` behavior: read keys, clamp x to `[0, 500 - layout.width]`.
828
860
  - `Ball` behavior: store `vx, vy` on `actor.runtime`; integrate; bounce off wall edges (`x<0`, `x+w>500`, `y<0`); on `scene.overlaps(actor, paddle)`, flip `vy`; for each `brick` in `scene.actorsWith('Brick')` check `scene.overlaps(actor, brick)` → flip `vy` and `scene.despawnActor(brick.id)`; if `y > 700` lose a life.
829
861
  - `Brick` behavior: typically just a marker — `kind: 'solid'` collider is enough. State (hit count) goes on `actor.runtime` or the brick's own props.
830
- - `GameController` (no Layout needed if you don't draw it): tracks score / lives / status; expose HUD via `ui()`.
862
+ - `GameController` (no Layout needed if you don't draw it): tracks score / lives / status; expose HUD via `drawUi()`.
831
863
 
832
864
  ## Don't
833
865
 
@@ -4,19 +4,39 @@ import { AutoFields, overrideProps } from '../engine/autoInspector';
4
4
  import {
5
5
  GREEDY_CLAIM,
6
6
  acquirePress,
7
- analogVelocity,
8
- inActorWorldSpace,
7
+ length,
8
+ screenPoint,
9
+ stickBrake,
10
+ stickDrive,
9
11
  stickVector,
10
12
  } from '../engine/physics/controls';
11
13
 
12
- // AnalogStick: an on-screen virtual joystick that drives the actor it's on --
13
- // the look and feel of castle-client's Analog Stick. Press anywhere to place
14
- // the stick; drag to steer. Draws the stick base ring + knob while held. Best
15
- // for top-down movement; needs a RigidBody (dynamic) + Collider.
14
+ // AnalogStick: on-screen virtual joystick (press anywhere) that drives the
15
+ // actor with OG-style force mode ramp in, hard top speed, idle-only brake.
16
+ // Screen-fixed: the ring stays under the thumb while a Camera scrolls, and does
17
+ // not rotate with the actor. Needs a RigidBody (dynamic) + Collider. Never
18
+ // touches an undriven axis.
16
19
  //
17
- // speed -> velocity at full deflection (px per fixed step).
20
+ // speed -> top speed at full deflection (px per fixed step).
21
+ // rampTime -> seconds from rest to speed at full deflection (0 = instant).
22
+ // slowDown -> deceleration while idle, speed units lost per second (0 = coast).
23
+ // turnBoost -> extra accel when velocity points away from the stick (OG turnFriction).
18
24
  // maxRadius -> stick throw radius in pixels.
19
- // axes -> restrict to 'horizontal' / 'vertical' / 'both'.
25
+ // axes -> 'horizontal' / 'vertical' / 'both'.
26
+ //
27
+ // Sibling hooks (fired on this actor only): onAnalogStickBegin / onAnalogStickEnd
28
+ // (actor, scene) — begin on first nonzero deflection, end on release or disable.
29
+ function fireStickHook(actor, scene, hook) {
30
+ scene.forEachBehavior(actor, (inst) => inst[hook]?.(actor, scene));
31
+ }
32
+
33
+ function clearStickRuntime(rt) {
34
+ rt._stickBegan = false;
35
+ rt._stickPointerId = null;
36
+ rt._stickOrigin = null;
37
+ rt._stickKnob = null;
38
+ }
39
+
20
40
  export class AnalogStick {
21
41
  static behaviorName = 'AnalogStick';
22
42
  // Drives a body, so it needs one: Collider + dynamic RigidBody.
@@ -24,14 +44,20 @@ export class AnalogStick {
24
44
 
25
45
  static defaultProps = {
26
46
  speed: 6,
47
+ rampTime: 0.2,
48
+ slowDown: 20,
49
+ turnBoost: 3,
27
50
  maxRadius: 60,
28
51
  axes: 'both',
29
52
  };
30
53
 
31
54
  // `maxRadius` is the stick's travel in px; below ~10 the knob has no usable
32
- // throw and the control reads as broken.
55
+ // throw and the control reads as broken. slowDown humanizes to "Slow Down".
33
56
  static propertyMeta = {
34
57
  speed: { min: 0, step: 0.5 },
58
+ rampTime: { min: 0, step: 0.05 },
59
+ slowDown: { min: 0, step: 1 },
60
+ turnBoost: { min: 0, step: 0.5 },
35
61
  maxRadius: { min: 10, step: 5 },
36
62
  };
37
63
 
@@ -55,59 +81,63 @@ export class AnalogStick {
55
81
  const press = acquirePress(scene, actor.id, () => GREEDY_CLAIM);
56
82
  if (!press) return null;
57
83
  rt._stickPointerId = press.id;
58
- rt._stickOrigin = { x: press.x, y: press.y };
84
+ rt._stickOrigin = { x: press.screenX, y: press.screenY };
59
85
  return press;
60
86
  }
61
87
 
62
- update(actor, scene) {
88
+ update(actor, scene, dt) {
63
89
  const rt = actor.runtime;
64
90
  const p = this.trackedPointer(actor, scene);
65
- // Only drive velocity while the stick is actually held.
66
91
  if (p?.down && rt._stickOrigin) {
67
- rt._stickKnob = stickVector(p, rt._stickOrigin, this.props.maxRadius ?? 60);
68
- scene.physics.setVelocity(actor, analogVelocity(p, rt._stickOrigin, this.props));
69
- rt._stickActive = true;
92
+ const maxRadius = this.props.maxRadius ?? 60;
93
+ const sp = screenPoint(p);
94
+ const knob = stickVector(sp, rt._stickOrigin, maxRadius);
95
+ rt._stickKnob = knob;
96
+ const deflection = { x: knob.x / maxRadius, y: knob.y / maxRadius };
97
+ const vel = scene.physics.getVelocity(actor);
98
+ scene.physics.applyImpulse(actor, stickDrive(vel, deflection, this.props, dt));
99
+ // OG fires on first movement, not on a still press.
100
+ if (!rt._stickBegan && length(deflection) > 0) {
101
+ rt._stickBegan = true;
102
+ fireStickHook(actor, scene, 'onAnalogStickBegin');
103
+ }
70
104
  return;
71
105
  }
72
106
 
73
- rt._stickPointerId = null;
74
- rt._stickOrigin = null;
75
- rt._stickKnob = null;
76
- // Stop ONCE on release, and only on the driven axes (so a top-down mover
77
- // doesn't drift). Critically, do NOT touch velocity while idle -- doing that
78
- // every frame zeroes the velocity gravity builds up, so the actor barely
79
- // falls. An idle AnalogStick must leave physics (gravity, momentum) alone.
80
- if (rt._stickActive) {
81
- rt._stickActive = false;
82
- const axes = this.props.axes ?? 'both';
83
- const cur = scene.physics.getVelocity(actor);
84
- scene.physics.setVelocity(actor, {
85
- x: axes === 'vertical' ? cur.x : 0,
86
- y: axes === 'horizontal' ? cur.y : 0,
87
- });
107
+ if (rt._stickBegan) {
108
+ fireStickHook(actor, scene, 'onAnalogStickEnd');
88
109
  }
110
+ clearStickRuntime(rt);
111
+ // Idle every frame: brake on driven axes only (gravity/momentum elsewhere stay).
112
+ const vel = scene.physics.getVelocity(actor);
113
+ scene.physics.applyImpulse(actor, stickBrake(vel, this.props, dt));
114
+ }
115
+
116
+ // Switched off mid-hold: fire end and drop runtime state. `update` stops
117
+ // running once disabled, so this is the only chance to release the gesture.
118
+ onDisable(actor, scene) {
119
+ const rt = actor.runtime;
120
+ if (rt._stickBegan) fireStickHook(actor, scene, 'onAnalogStickEnd');
121
+ clearStickRuntime(rt);
89
122
  }
90
123
 
91
- // Base ring anchored where the user pressed + knob at the current (clamped)
92
- // deflection. Drawn in world space so it stays put instead of rotating/sliding
93
- // with the actor.
94
- draw(actor, scene, ctx) {
124
+ // Drawn in screen space (drawUi) so the ring stays under the thumb while the
125
+ // camera scrolls, and doesn't rotate with the actor.
126
+ drawUi(actor, scene, ctx) {
95
127
  const rt = actor.runtime;
96
128
  if (!rt._stickOrigin) return;
97
129
  const o = rt._stickOrigin;
98
130
  const k = rt._stickKnob ?? { x: 0, y: 0 };
99
131
  const radius = this.props.maxRadius ?? 60;
100
- inActorWorldSpace(ctx, actor.components.Layout, (c) => {
101
- c.strokeStyle = 'rgba(255, 255, 255, 0.5)';
102
- c.lineWidth = 3;
103
- c.beginPath();
104
- c.arc(o.x, o.y, radius, 0, Math.PI * 2);
105
- c.stroke();
106
- c.fillStyle = 'rgba(255, 255, 255, 0.85)';
107
- c.beginPath();
108
- c.arc(o.x + k.x, o.y + k.y, 20, 0, Math.PI * 2);
109
- c.fill();
110
- });
132
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
133
+ ctx.lineWidth = 3;
134
+ ctx.beginPath();
135
+ ctx.arc(o.x, o.y, radius, 0, Math.PI * 2);
136
+ ctx.stroke();
137
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
138
+ ctx.beginPath();
139
+ ctx.arc(o.x + k.x, o.y + k.y, 20, 0, Math.PI * 2);
140
+ ctx.fill();
111
141
  }
112
142
 
113
143
  static Inspector({ component, setComponent, override }) {
@@ -100,8 +100,7 @@ export class Slingshot {
100
100
  const dir = { x: -pull.x / len, y: -pull.y / len }; // launch direction (opposite pull)
101
101
  const end = { x: a.x - pull.x * 0.8, y: a.y - pull.y * 0.8 };
102
102
 
103
- // The launch is absolute, so draw the aim in world space too (castle-client
104
- // draws the sling as a screen overlay for the same reason).
103
+ // Aim is drawn in world space because the anchor is on the actor.
105
104
  inActorWorldSpace(ctx, actor.components.Layout, (c) => drawSling(c, a, end, dir));
106
105
  }
107
106