castle-web-cli 0.4.94 → 0.4.96

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.
package/dist/agent.js CHANGED
@@ -24,7 +24,7 @@ import { rawDataToString } from "./ide.js";
24
24
  import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from "./agent-prompts.js";
25
25
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from "./openrouter-catalog.js";
26
26
  import { classifyProviderError, failureCopy, } from "./agent-failures.js";
27
- import { meteringHeaders, newAgentSessionId, withCustomHeaders, } from "./metering.js";
27
+ import { meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from "./metering.js";
28
28
  import { runAgentNative } from "./native/loop.js";
29
29
  import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
30
30
  import { createPlaywrightPlaytestExecutor } from "./native/playtest-executor.js";
@@ -356,6 +356,9 @@ function handleModelCaps(req, res) {
356
356
  // Single `=` token because --allowedTools is variadic and would otherwise
357
357
  // swallow the trailing prompt positional.
358
358
  const OPENROUTER_ALLOWED_TOOLS = "--allowedTools=Edit,Write,NotebookEdit,Bash";
359
+ // Cursor's proprietary model. Also the slug reported to the metering ledger, so
360
+ // the two can never drift into disagreeing about what a cursor row ran on.
361
+ const CURSOR_MODEL = "composer-2.5-fast";
359
362
  function buildAgentInvocation(backend, role, prompt, claudeModel,
360
363
  // Already resolved for this role by the caller (router turns pass
361
364
  // settings.routerOpenrouterModel, task spawns settings.tasksOpenrouterModel).
@@ -426,7 +429,7 @@ metering) {
426
429
  "--stream-partial-output",
427
430
  "--trust",
428
431
  "--model",
429
- "composer-2.5-fast",
432
+ CURSOR_MODEL,
430
433
  ...(role === "router" ? ["--mode", "ask"] : ["--force"]),
431
434
  prompt,
432
435
  ],
@@ -1226,67 +1229,6 @@ function logAgentUsage(label, backend, usage) {
1226
1229
  const output = formatTokenCount(usage.output_tokens);
1227
1230
  console.error(`[agent usage] ${label} ${backend}: input=${input} cache_read=${read} cache_created=${created} output=${output}`);
1228
1231
  }
1229
- // Per-deck machine-readable usage ledger, appended to <deckDir>/.castle/agent/,
1230
- // harvested out-of-band (by the cloud launcher) for rough per-user token
1231
- // metering. Distinct from logAgentUsage's stderr line, which is lossy
1232
- // (rounds to "3.2k") and gets truncated when the serve restarts.
1233
- const USAGE_LEDGER_FILE = "usage.jsonl";
1234
- // The ledger is only useful where the cloud launcher harvests it, so the managed
1235
- // sandbox environments set CASTLE_USAGE_LEDGER=1 (E2B via cloudSandbox.serveOnPort,
1236
- // castle-sandboxes via its image). A local `castle-web serve` leaves it unset, so
1237
- // dev deck dirs don't accumulate a ledger nothing reads.
1238
- const USAGE_LEDGER_ENABLED = process.env.CASTLE_USAGE_LEDGER === "1";
1239
- // Concrete model behind a finished run: smith and claude-via-OpenRouter both
1240
- // bill the OpenRouter slug; plain claude bills its own slug; cursor has no
1241
- // per-model split tracked here.
1242
- function resolveRunModel(backend, claudeModel, openrouterModel) {
1243
- if (backend === "smith")
1244
- return openrouterModel;
1245
- if (backend === "claude") {
1246
- return claudeModel === "openrouter" ? openrouterModel : claudeModel;
1247
- }
1248
- return backend;
1249
- }
1250
- // One record per finished run: the human-readable stderr line PLUS a precise
1251
- // append-only JSONL line in the deck's usage ledger. Precise counts (not the
1252
- // stderr line's rounded values) and self-describing (id/role/backend/model) so
1253
- // the harvester can attribute and de-dup. Best-effort: a metering write must
1254
- // never fail an agent run.
1255
- function reportRunUsage(agentDir, role, backend, claudeModel, openrouterModel, result, taskId) {
1256
- const usage = result.usage;
1257
- logAgentUsage(taskId ? `task ${taskId}` : "router", backend, usage);
1258
- if (!USAGE_LEDGER_ENABLED)
1259
- return;
1260
- // cursor-agent's stream-json doesn't report token usage, so a cursor run has no
1261
- // `usage`; still record it (zero counts, tokens_reported:false) for run-count
1262
- // visibility. Other backends only write once they actually produced usage.
1263
- if (!usage && backend !== "cursor")
1264
- return;
1265
- try {
1266
- const line = JSON.stringify({
1267
- at: nowIso(),
1268
- id: nanoid(),
1269
- role,
1270
- backend,
1271
- model: resolveRunModel(backend, claudeModel, openrouterModel),
1272
- ...(taskId ? { taskId } : {}),
1273
- // A failed run still resolves with a usage object of all zeros (an auth
1274
- // error emits a result event with zeroed counts), so its zeros would
1275
- // otherwise be indistinguishable downstream from a real "measured zero".
1276
- ...(result.ok ? {} : { failed: true }),
1277
- tokens_reported: usage !== undefined,
1278
- input_tokens: usage?.input_tokens ?? 0,
1279
- output_tokens: usage?.output_tokens ?? 0,
1280
- cache_read_input_tokens: usage?.cache_read_input_tokens ?? 0,
1281
- cache_creation_input_tokens: usage?.cache_creation_input_tokens ?? 0,
1282
- });
1283
- fs.mkdirSync(agentDir, { recursive: true });
1284
- fs.appendFileSync(path.join(agentDir, USAGE_LEDGER_FILE), line + "\n");
1285
- }
1286
- catch {
1287
- /* best-effort: a metering write must never fail an agent run */
1288
- }
1289
- }
1290
1232
  // Build the per-run stdout event handler over a shared mutable parser state.
1291
1233
  // Splitting the cursor + claude stream decoding out of runAgentCli keeps each
1292
1234
  // within the max-lines budget; behavior is identical (same delta/activity/
@@ -1735,7 +1677,8 @@ async function runAgentTurn(opts) {
1735
1677
  });
1736
1678
  }
1737
1679
  const invocation = buildAgentInvocation(opts.backend, opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel, { sessionId, deckDir: opts.cwd });
1738
- return runAgentCli({
1680
+ const startedMs = Date.now();
1681
+ const run = runAgentCli({
1739
1682
  cwd: opts.cwd,
1740
1683
  command: invocation.command,
1741
1684
  args: invocation.args,
@@ -1753,6 +1696,21 @@ async function runAgentTurn(opts) {
1753
1696
  ? opts.openrouterModel
1754
1697
  : undefined,
1755
1698
  });
1699
+ // Cursor is the only backend whose traffic never reaches the llm-proxy, so its
1700
+ // run is reported from here. Every other backend is already recorded upstream,
1701
+ // and reporting them here too would double-count them in the same table.
1702
+ if (opts.backend !== "cursor")
1703
+ return run;
1704
+ return run.then((result) => {
1705
+ reportCursorRun({
1706
+ deckDir: opts.cwd,
1707
+ sessionId,
1708
+ model: CURSOR_MODEL,
1709
+ durationMs: Date.now() - startedMs,
1710
+ ok: result.ok,
1711
+ });
1712
+ return result;
1713
+ });
1756
1714
  }
1757
1715
  // -- task store ---------------------------------------------------------------
1758
1716
  function persistTaskFile(tasksDir, task) {
@@ -1999,7 +1957,7 @@ async function runTaskAgentIn(ctx, task) {
1999
1957
  ctx.onFeed(`[${activity}]`);
2000
1958
  },
2001
1959
  });
2002
- reportRunUsage(path.dirname(ctx.tasksDir), "task", ctx.backend, ctx.claudeModel, ctx.openrouterModel, result, task.id);
1960
+ logAgentUsage(`task ${task.id}`, ctx.backend, result.usage);
2003
1961
  if (ctx.stopRequested.has(task.id))
2004
1962
  return result;
2005
1963
  if (!result.crashed)
@@ -2794,7 +2752,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2794
2752
  },
2795
2753
  })
2796
2754
  .then((result) => {
2797
- reportRunUsage(ctx.agentDir, "router", backend, ctx.claudeModel(), ctx.openrouterModel(), result);
2755
+ logAgentUsage("router", backend, result.usage);
2798
2756
  // Signals the finally -> onSettled(retryable): the turn failed cleanly
2799
2757
  // enough (transient, nothing salvaged) that the queue may re-run it.
2800
2758
  let retryable = false;
package/dist/init.js CHANGED
@@ -36,7 +36,7 @@ const DEFAULT_KIT = "basic-2d";
36
36
  // Registry version of castle-web-sdk to inject when scaffolding from a
37
37
  // globally-installed castle-web (not from inside the workspace). Bumped
38
38
  // alongside cli/sdk version bumps.
39
- const PUBLISHED_SDK_VERSION = "0.4.10";
39
+ const PUBLISHED_SDK_VERSION = "0.4.11";
40
40
  // Never copied into a fresh deck: build/dependency junk. castle.json IS copied
41
41
  // (the kit ships a config-only one with the editor layout / file filters), but
42
42
  // `scaffoldFromKit` strips any identity fields off it first -- a fresh deck has
@@ -145,6 +145,23 @@ function tryMakeAgentsSymlink(agentsPath) {
145
145
  // symlink already exists / unsupported FS — non-fatal
146
146
  }
147
147
  }
148
+ // Scripts for a deck whose kit is an import. The same deck commands every
149
+ // scaffold gets, plus the kit's own authoring commands re-pointed at where the
150
+ // kit now lives -- a kit's CLAUDE.md tells the agent to run `npm run draw` and
151
+ // `npm run restart`, and those have to exist in the deck for that to be true.
152
+ function makeKitDeckScripts(kitDir, alias, cliCommand) {
153
+ const scripts = {
154
+ restart: `${cliCommand} restart .`,
155
+ screenshot: `${cliCommand} screenshot .`,
156
+ "save-deck": `${cliCommand} save-deck .`,
157
+ };
158
+ // `draw` is a kit script, not a CLI one, so it only exists if this kit ships
159
+ // it -- and it runs from the deck root, where the sprites belong.
160
+ if (fs.existsSync(path.join(kitDir, "scripts", "draw.mjs"))) {
161
+ scripts.draw = `node ${IMPORTS_DIR}/${alias}/scripts/draw.mjs`;
162
+ }
163
+ return scripts;
164
+ }
148
165
  function makePackageJson(projectDir) {
149
166
  const { sdkRef, cliCommand } = resolveScaffoldRefs();
150
167
  return {
@@ -291,6 +308,23 @@ something in the kit behaves, define your own behavior of the same name in
291
308
  \`behaviors/\` -- the deck's own files win over an import's.
292
309
  `;
293
310
  }
311
+ // How the deck names the kit it was built on. `scripts/publish-kits.mjs` stamps
312
+ // a published kit's castle.json with the deckId it lives at and the version it
313
+ // was published as, and both have to be present to pin it that way: a deckId
314
+ // with no version would read as "you have some unknown version", which the
315
+ // update check would report as an update available forever.
316
+ //
317
+ // Falling back to the CLI's copy is what makes a kit usable before it is ever
318
+ // published -- a new kit works from the day it exists, and starts offering
319
+ // updates the day it is published, with nothing in between to migrate.
320
+ function makeKitPin(kitConfig, kit) {
321
+ const deckId = kitConfig.deckId;
322
+ const version = kitConfig.publishedVersion;
323
+ if (typeof deckId === "string" && typeof version === "string") {
324
+ return { deckId, version };
325
+ }
326
+ return { source: "builtin", kit, version: getCliVersion() };
327
+ }
294
328
  // Scaffold a deck that IMPORTS its kit instead of copying it. The kit's files
295
329
  // land in `imports/<kit>/` (read-only, like any dependency) and the deck itself
296
330
  // holds only what is genuinely its own: an entry point, its config, and its
@@ -298,10 +332,11 @@ something in the kit behaves, define your own behavior of the same name in
298
332
  // rather than being frozen into the deck at scaffold time -- which is what
299
333
  // copying the kit in meant.
300
334
  //
301
- // The kit comes from the copy shipped with this CLI, so `init` works offline and
302
- // needs no account. The pin records that origin; once kits are published as
303
- // decks it becomes a deckId + version and the files are fetched instead, with
304
- // the same deck shape either way.
335
+ // The files always come from the copy shipped with this CLI, so `init` works
336
+ // offline and needs no account. What the pin SAYS depends on whether the kit has
337
+ // been published: an unpublished one can only be named as the CLI's own copy,
338
+ // while a published one is pinned by deckId so the deck can be told about kit
339
+ // releases and take them, without waiting for a CLI release.
305
340
  function scaffoldFromKitImport(kit, projectDir) {
306
341
  const kitDir = requireKitDir(kit);
307
342
  // Qualified like any other import (`<author>.<deck>`), and qualified NOW even
@@ -326,12 +361,12 @@ function scaffoldFromKitImport(kit, projectDir) {
326
361
  // the import pin.
327
362
  writeJsonFile(path.join(projectDir, "castle.json"), {
328
363
  ...(kitConfig.editor ? { editor: kitConfig.editor } : {}),
329
- imports: { [alias]: { source: "builtin", kit, version: getCliVersion() } },
364
+ imports: { [alias]: makeKitPin(kitConfig, kit) },
330
365
  });
331
366
  // The kit's code runs from THIS deck's node_modules, so its dependencies are
332
367
  // declared here (see syncImportDependencies, which does the same for imports
333
368
  // added later).
334
- const { sdkRef } = resolveScaffoldRefs();
369
+ const { sdkRef, cliCommand } = resolveScaffoldRefs();
335
370
  const dependencies = {};
336
371
  for (const [name, range] of Object.entries(kitPkg.dependencies ?? {})) {
337
372
  dependencies[name] =
@@ -343,6 +378,7 @@ function scaffoldFromKitImport(kit, projectDir) {
343
378
  name: path.basename(projectDir),
344
379
  private: true,
345
380
  type: "module",
381
+ scripts: makeKitDeckScripts(kitDir, alias, cliCommand),
346
382
  dependencies,
347
383
  });
348
384
  fs.writeFileSync(path.join(projectDir, "CLAUDE.md"), makeImportedKitClaudeMd(alias));
@@ -7,6 +7,29 @@ export type MeteringRoute = "anthropic" | "openrouter";
7
7
  * background-task spend" a prefix match with no extra column.
8
8
  */
9
9
  export declare function newAgentSessionId(role: "router" | "task"): string;
10
+ /**
11
+ * Report a cursor run to the llm-proxy, which stamps the user + sandbox from
12
+ * our token and forwards it to the ledger alongside the anthropic/openrouter
13
+ * rows the proxy records itself.
14
+ *
15
+ * Cursor needs this because it is the one backend the proxy cannot see:
16
+ * cursor-agent has no base-URL override, so it talks to Cursor directly on its
17
+ * own key. There are no token counts to send -- cursor-agent's stream-json
18
+ * reports none -- so a row is a run count with its deck, agent run, model, and
19
+ * outcome, and the ledger's NULL tokens read as "not measured".
20
+ *
21
+ * Fire-and-forget by contract: the run is already over and its output already
22
+ * reached the user, so every failure here is swallowed. A dropped post loses
23
+ * one row rather than retrying, which is the right trade for a signal whose
24
+ * value is aggregate.
25
+ */
26
+ export declare function reportCursorRun(opts: {
27
+ deckDir: string;
28
+ sessionId: string;
29
+ model: string;
30
+ durationMs: number;
31
+ ok: boolean;
32
+ }): void;
10
33
  export declare function meteringHeaders(opts: {
11
34
  deckDir: string;
12
35
  sessionId: string;
package/dist/metering.js CHANGED
@@ -48,6 +48,58 @@ function deckIdFor(deckDir) {
48
48
  return null;
49
49
  return parsed.deckId.replace(/[\r\n]/g, "") || null;
50
50
  }
51
+ // Path on the proxy that accepts a run the proxy never routed. Must match
52
+ // CASTLE_USAGE_PATH in castle-sandboxes/shared/src/index.ts.
53
+ const CASTLE_USAGE_PATH = "/castle/usage";
54
+ // A metering post must never delay a turn that has already finished, and its
55
+ // result is never awaited -- so a proxy that has gone away costs one socket
56
+ // timeout in the background, not a hung run.
57
+ const USAGE_POST_TIMEOUT_MS = 5_000;
58
+ /**
59
+ * Report a cursor run to the llm-proxy, which stamps the user + sandbox from
60
+ * our token and forwards it to the ledger alongside the anthropic/openrouter
61
+ * rows the proxy records itself.
62
+ *
63
+ * Cursor needs this because it is the one backend the proxy cannot see:
64
+ * cursor-agent has no base-URL override, so it talks to Cursor directly on its
65
+ * own key. There are no token counts to send -- cursor-agent's stream-json
66
+ * reports none -- so a row is a run count with its deck, agent run, model, and
67
+ * outcome, and the ledger's NULL tokens read as "not measured".
68
+ *
69
+ * Fire-and-forget by contract: the run is already over and its output already
70
+ * reached the user, so every failure here is swallowed. A dropped post loses
71
+ * one row rather than retrying, which is the right trade for a signal whose
72
+ * value is aggregate.
73
+ */
74
+ export function reportCursorRun(opts) {
75
+ const base = process.env.CASTLE_LLM_PROXY_URL;
76
+ const token = process.env.CASTLE_LLM_PROXY_TOKEN;
77
+ // Absent outside a sandbox, and on a host whose proxy predates this route --
78
+ // both cases correctly report nothing rather than posting into the void.
79
+ if (!base || !token)
80
+ return;
81
+ const deckId = deckIdFor(opts.deckDir);
82
+ void fetch(`${base}${CASTLE_USAGE_PATH}`, {
83
+ method: "POST",
84
+ headers: {
85
+ "content-type": "application/json",
86
+ authorization: `Bearer ${token}`,
87
+ },
88
+ body: JSON.stringify({
89
+ provider: "cursor",
90
+ model: opts.model,
91
+ ...(deckId ? { deckId } : {}),
92
+ agentSessionId: opts.sessionId,
93
+ durationMs: opts.durationMs,
94
+ // A cursor run has no HTTP status; the ledger's column is one, and
95
+ // `>= 400 means it failed` is the reading every other row already gets.
96
+ status: opts.ok ? 200 : 500,
97
+ }),
98
+ signal: AbortSignal.timeout(USAGE_POST_TIMEOUT_MS),
99
+ }).catch(() => {
100
+ /* best-effort: metering must never surface in a finished run */
101
+ });
102
+ }
51
103
  export function meteringHeaders(opts) {
52
104
  if (opts.direct || !proxyInjected(opts.route))
53
105
  return {};
@@ -3,11 +3,30 @@
3
3
  "initialPanels": [
4
4
  {
5
5
  "width": 400,
6
- "column": [{ "type": "playtest" }, { "type": "files" }]
6
+ "column": [
7
+ {
8
+ "type": "playtest"
9
+ },
10
+ {
11
+ "type": "files"
12
+ }
13
+ ]
7
14
  },
8
- { "type": "editor", "file": "scenes/main.scene" }
15
+ {
16
+ "type": "editor",
17
+ "file": "scenes/main.scene"
18
+ }
9
19
  ],
10
20
  "hiddenPaths": [],
11
- "visiblePaths": ["drawings/**", "scenes/**", "blueprints/**", "behaviors/**"]
12
- }
21
+ "visiblePaths": [
22
+ "drawings/**",
23
+ "scenes/**",
24
+ "blueprints/**",
25
+ "behaviors/**"
26
+ ]
27
+ },
28
+ "deckId": "5R39TxaRfLLQ",
29
+ "cardId": "AZk0PWHTrFcT",
30
+ "title": "basic-2d",
31
+ "publishedVersion": "2026-07-28T17:52:37.298Z"
13
32
  }
@@ -3,8 +3,9 @@
3
3
  This is the `basic-2d` actor/behavior/scene framework plus a built-in 2D
4
4
  **physics** system (matter-js). Everything in `basic-2d` works the same; physics
5
5
  adds `RigidBody`, physics fields on `Collider`, a world-gravity scene setting,
6
- collision callbacks, and ready-made touch-first controls (`Draggable`,
7
- `Slingshot`, `AnalogStick`). See `## Physics` below.
6
+ collision callbacks, `Joints` that link actors (spring/rod/pin/weld/rope, several
7
+ per actor), and ready-made touch-first controls (`Draggable`, `Slingshot`, `AnalogStick`).
8
+ See `## Physics` below.
8
9
 
9
10
  ## Welcome message
10
11
 
@@ -27,6 +28,25 @@ Do you already know what you want to make, or do you want to figure it out toget
27
28
  - Do not read `engine/`, `editors/`, or built-in behaviors (`Layout.jsx`, `Sprite.jsx`, `Collider.jsx`, `Camera.jsx`) to build a game. Their public API is documented below.
28
29
  - Details below: `## Behavior shape`, `## Scene file`, `## Blueprints`, `## Built-in behaviors`, `## Creating pixel art`, `## SceneRuntime API`, and `## Input shortcuts`.
29
30
 
31
+ ## Files and imports
32
+
33
+ One rule wherever a file is named -- JS imports, `"blueprint"` refs, `Sprite.file`,
34
+ a joint's `sprite`, anything a behavior invents:
35
+
36
+ - `drawings/ship.pxart` -- a file of the deck the reference is WRITTEN IN. In this
37
+ deck's own files that means this deck; in a file belonging to an import, that
38
+ import. So a kit's blueprint saying `drawings/cauldron.pxart` keeps meaning the
39
+ kit's drawing once the kit is imported by someone else.
40
+ - `@imports/<alias>/drawings/ship.pxart` -- a file of the deck imported under
41
+ `<alias>`. This is the only way to name another deck's file, so cross-deck
42
+ references are visible as such, and it means the same thing from any file at
43
+ any depth.
44
+
45
+ Imports are read-only: their files belong to the deck they came from. Use them,
46
+ don't edit them. `castle-web add-import <deckId>` adds one, `update-import`
47
+ re-fetches it. `resolveDeckFile` from `castle-web-sdk` is the rule itself, if a
48
+ behavior needs to resolve a path it was handed.
49
+
30
50
  ## Scope
31
51
 
32
52
  Write the smallest game that satisfies what the user asked for. No sound, particles, menus, multi-level progression, or visual polish unless they specifically asked for it. A typical behavior is 30–80 lines — if yours is hitting 200, you're over-engineering: cut feel-good extras, fewer fields on props, fewer edge cases, fewer comments. Ship the core loop first; the user can ask for more.
@@ -152,6 +172,10 @@ make it move.**
152
172
  (`mode`, `width`/`height`, `offsetX`/`offsetY`, `debug`) it has:
153
173
  - `shape: 'box' | 'circle'` — the physics shape (default `box`). A circle uses
154
174
  `radius`, or half the smaller collider dimension if `radius` is 0.
175
+ - A collider can hold **multiple shapes** (a compound body) — edit them in the
176
+ inspector's shape list. A **box shape takes an `angle`** (degrees), so one
177
+ actor can have an angled collider (e.g. a skateboard: a flat deck box plus an
178
+ upturned nose and tail box) instead of gluing separate actors together.
155
179
  - `isTrigger` — a **sensor**: detects overlaps (fires collision callbacks) but
156
180
  does **not** block. Use for pickups, goals, zones.
157
181
  - `bounciness` — restitution, 0 (dead) to ~1 (very bouncy), can exceed 1.
@@ -228,6 +252,83 @@ on-screen control -- never be the only way to play.
228
252
  `onCollisionEnter` that scores when `other` is the ball.
229
253
  - Scene: set `"physics": { "gravity": 1 }`, place one ball, walls, a goal.
230
254
 
255
+ ### Joints (link actors together)
256
+
257
+ Add a **`Joints`** behavior to connect an actor to one or more `target` actors
258
+ with physics links. The component holds a **list**, so one actor can carry
259
+ several links at once (a ragdoll pelvis pinned to both thighs, a ball slung
260
+ between two anchors, a body both sprung and roped). Both actors of a link need a
261
+ `Collider` (to have a body); the owner is usually a dynamic `RigidBody` and each
262
+ target either dynamic or a static anchor (a lone `Collider`). Each list entry:
263
+
264
+ - `type` — the link's feel:
265
+ - `spring` — soft elastic tether (`springiness` 0..1 + `damping`).
266
+ - `rod` — rigid fixed-distance stick; the ends still rotate freely. This is also
267
+ your **hinge**: a `rod` to a static anchor is a pendulum / swinging door.
268
+ - `weld` — fused rigid: a pivot link plus frozen rotation, so the two bodies
269
+ can't move or turn relative to each other. Place them slightly apart.
270
+ - `rope` — slack, taut only at `length` (max); the bob free-falls then catches.
271
+ - (`pin`, a free-rotating coincident-pivot hinge, was removed — a length-0
272
+ revolute between two dynamic bodies is matter's most unstable case. Use `rod`.)
273
+ - `target` — the other actor's id. **Author it per instance** (pick it on the
274
+ canvas — see below), not on a blueprint, unless every instance really should
275
+ link to the same actor (e.g. many things roped to one anchor).
276
+ - `length` — rest length (spring/rod) or max length (rope) in px. In the
277
+ inspector, turn **Auto length** on to lock it to the actors' distance when play
278
+ starts (stored as `-1`). Not used by `pin`/`weld`.
279
+ - `springiness` (0..1) — for `spring`, how bouncy the tether is; for `rope`, how
280
+ it behaves *when taut*: `0` = a dead rope (holds firm at max length), `1` = a
281
+ bungee (stretches well past and springs back).
282
+ - `damping` (0..1, spring only) — how fast the spring's oscillation settles.
283
+ - `anchorX/anchorY` (+ `targetAnchorX/targetAnchorY` for spring/rod/rope) —
284
+ attach-point offsets in px, under the inspector's **Anchor offsets** toggle. For
285
+ `pin`/`weld` the owner offset moves the shared pivot.
286
+
287
+ **Author a joint:** add the `Joints` behavior to the owner, then in the inspector
288
+ click **Pick target** and click the other actor on the canvas (Esc / empty click
289
+ cancels). **+ Add joint** appends another link; each has its own Remove. Links
290
+ draw as colored overlays (coil = spring, dashed = rope, double line = weld, ring =
291
+ pin, plain line = rod) so you can see what's connected. In the scene file it's a
292
+ list: `"Joints": { "list": [ { "type": "rod", "target": "anchor" }, … ] }`.
293
+
294
+ `target` is instance-local (a blueprint-level target points every instance at the
295
+ same actor — only what you want for a shared anchor). You rarely need multiple
296
+ entries: a chain A–B–C just puts one link on B→A and one on C→B. Reach for a
297
+ multi-entry list when an actor genuinely connects to two+ others at once.
298
+
299
+ Recipe — pendulum: a static `anchor` actor (Collider, no RigidBody) at top, a
300
+ dynamic ball below with `Joints { list: [{ type: 'rod', target: 'anchor' }] }`.
301
+ The ball swings at a fixed radius. Swap `rod`→`rope` for a slack tether,
302
+ `rod`→`spring` for a bouncy one; add a second entry to sling it between two
303
+ anchors.
304
+
305
+ **How a joint looks in play** — each entry has a `render`:
306
+
307
+ - `line` (default) — the schematic overlay (coil/dashed/etc.). Good for
308
+ prototyping; you see your joints working.
309
+ - `hidden` — nothing drawn in play (the joint is pure mechanics). It still shows
310
+ as a faint line in the *editor* so you don't lose track of it.
311
+ - `sprite` — draw art along the joint. Set `sprite` to a `.pxart`
312
+ (defaults to a built-in rope), `thickness` (px), and `fit`: `tile` (repeat a
313
+ segment — rope, chain) or `stretch` (one image end-to-end — stick, beam). The
314
+ art rotates to the joint's angle and scales to its live length, so a rope
315
+ visibly stretches. The editor previews it WYSIWYG.
316
+
317
+ **Custom joint visuals (draw your own):** for anything beyond the built-in art,
318
+ set the joint's `render: 'hidden'` and draw it yourself from a behavior's
319
+ `draw()` using `scene.physics.getJoints()`. It returns one entry per link with
320
+ world-space endpoints so your art lines up with the sprites:
321
+
322
+ ```jsx
323
+ draw(actor, scene, ctx) {
324
+ for (const j of scene.physics.getJoints()) {
325
+ // j = { ownerId, targetId, type, a:{x,y}, b:{x,y}, length, angle }
326
+ ctx.strokeStyle = '#0cf1ff';
327
+ ctx.beginPath(); ctx.moveTo(j.a.x, j.a.y); ctx.lineTo(j.b.x, j.b.y); ctx.stroke();
328
+ }
329
+ }
330
+ ```
331
+
231
332
  ### Gotchas
232
333
 
233
334
  - **Fast bodies + thin walls tunnel.** A small body moving faster than a static
@@ -241,6 +342,12 @@ on-screen control -- never be the only way to play.
241
342
  directly is only for `static`/`kinematic` bodies.
242
343
  - **Editor vs play.** Physics only steps during play; in the editor actors sit
243
344
  where you place them. `RigidBody.velocityX/Y` apply once when play starts.
345
+ - **A joint binds only when both actors have a body.** If a `Joints` entry's
346
+ owner or `target` has no `Collider` (or the target id is wrong/missing), that
347
+ link is silently skipped until both bodies exist. `weld`/`pin` want the two
348
+ actors placed a little apart, not overlapping.
349
+ - **A global max-speed clamp (`MAX_SPEED` in matterBridge)** backstops any joint
350
+ blow-up so a body can't be flung off screen — normal motion never reaches it.
244
351
 
245
352
  ## Adding physics to another kit
246
353
 
@@ -250,10 +357,13 @@ different kit:
250
357
  1. Copy the `physics/` folder into the kit.
251
358
  2. Add `matter-js` to the kit's `package.json` dependencies.
252
359
  3. In the kit's `engine/scene.js`: add a systems registry to `SceneRuntime`
253
- (`this.systems = []`, a `registerSystem(system)` method, and, at the end of
254
- `update(dt)`, `for (const s of this.systems) s.afterBehaviors?.(this, dt);`),
255
- then call `installPhysics(runtime)` from `makeScene` (and route `clone()`
256
- through `makeScene` so clones get it too).
360
+ (`this.systems = []`, a `registerSystem(system)` method, at the end of
361
+ `update(dt)` a `for (const s of this.systems) s.afterBehaviors?.(this, dt);`,
362
+ and at the START of `load(sceneData)` a
363
+ `for (const s of this.systems) s.reset?.(this);` so a reload/restart/scene
364
+ transition resets the simulation to the authored layout instead of carrying
365
+ over body positions + joints), then call `installPhysics(runtime)` from
366
+ `makeScene` (and route `clone()` through `makeScene` so clones get it too).
257
367
  4. In the kit's `editors/behaviorRegistry`, also glob
258
368
  `../physics/behaviors/*.jsx` so the physics behaviors auto-register.
259
369
 
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react';
1
+ import React, { useEffect, useState } from 'react';
2
2
  import { NumberField, Panel, SelectField } from '../engine/ui';
3
3
  import { AutoFields } from '../engine/autoInspector';
4
4
  import { computeAutoFit, getColliderRect, getColliderShapes, intersects } from '../engine/collider';
@@ -61,6 +61,13 @@ function drawShape(ctx, s) {
61
61
  for (let k = 1; k < s.points.length; k++) ctx.lineTo(s.points[k].x, s.points[k].y);
62
62
  ctx.closePath();
63
63
  ctx.stroke();
64
+ } else if (s.angle) {
65
+ // Rotate the outline about the box center to match the (angled) matter part.
66
+ ctx.save();
67
+ ctx.translate(s.cx, s.cy);
68
+ ctx.rotate((s.angle * Math.PI) / 180);
69
+ ctx.strokeRect(-s.width / 2 + 1, -s.height / 2 + 1, s.width - 2, s.height - 2);
70
+ ctx.restore();
64
71
  } else {
65
72
  ctx.strokeRect(s.x + 1, s.y + 1, s.width - 2, s.height - 2);
66
73
  }
@@ -113,14 +120,21 @@ export class Collider {
113
120
  const shapes = getColliderShapes(actor);
114
121
  if (!shapes) return;
115
122
  const isSensor = Boolean(this.props.isTrigger) || this.props.kind === 'pickup';
123
+ // When editing this blueprint (hotbar), highlight the shape the inspector has
124
+ // selected so you can tell which chip is which on its lit instances -- the
125
+ // selected-instance path does this via the SelectionOverlay instead.
126
+ const highlight = blueprintSelected ? options.colliderShapeSel ?? -1 : -1;
116
127
  ctx.save();
117
- ctx.strokeStyle = isSensor ? '#ffe17a' : '#8db7ff';
118
- ctx.lineWidth = 2;
119
- for (const s of shapes) drawShape(ctx, s);
128
+ shapes.forEach((s, k) => {
129
+ const on = k === highlight;
130
+ ctx.strokeStyle = on ? '#ffd24a' : isSensor ? '#ffe17a' : '#8db7ff';
131
+ ctx.lineWidth = on ? 3.5 : 2;
132
+ drawShape(ctx, s);
133
+ });
120
134
  ctx.restore();
121
135
  }
122
136
 
123
- static Inspector({ actor, component, sprites, setComponent, override }) {
137
+ static Inspector({ actor, component, sprites, setComponent, override, onSelectShape }) {
124
138
  const [sel, setSel] = useState(0);
125
139
  const [addOpen, setAddOpen] = useState(false);
126
140
  const layout = actor?.components?.Layout ?? {};
@@ -129,6 +143,9 @@ export class Collider {
129
143
 
130
144
  const shapes = currentShapes(component);
131
145
  const i = Math.min(sel, shapes.length - 1);
146
+ // Report the selected shape index up so the on-canvas overlay can highlight
147
+ // it (fires on mount with 0, and on every chip click / add / remove).
148
+ useEffect(() => onSelectShape?.(i), [i, onSelectShape]);
132
149
  const shape = shapes[i];
133
150
  const isCircle = shape.type === 'circle';
134
151
  const isPoly = shape.type === 'triangle' || shape.type === 'polygon';
@@ -160,6 +177,22 @@ export class Collider {
160
177
  const offXPx = Math.round(((shape.x ?? 0.5) - 0.5) * bw);
161
178
  const offYPx = Math.round(((shape.y ?? 0.5) - 0.5) * bh);
162
179
 
180
+ // Per-subfield override state vs the blueprint's version of THIS shape. The
181
+ // shape fields don't go through AutoFields, so without this an instance that
182
+ // changed e.g. box 3's offset X wouldn't show the tint / Default / Reset.
183
+ // Null override (editing the blueprint template itself) shows nothing.
184
+ const bpShapes = override?.baseline?.('shapes');
185
+ const bShape = Array.isArray(bpShapes) ? bpShapes[i] : null;
186
+ const shapeOv = (key, dflt, toPx) => {
187
+ if (!bShape || bShape.type !== shape.type) return {};
188
+ const bv = bShape[key] ?? dflt;
189
+ return {
190
+ overridden: Math.abs((shape[key] ?? dflt) - bv) > 1e-4,
191
+ defaultValue: toPx(bv),
192
+ onReset: () => patchSel({ [key]: bv }),
193
+ };
194
+ };
195
+
163
196
  const density = component.density > 0 ? component.density : 0.001;
164
197
 
165
198
  // Auto-fit (box/circle) for the selected shape.
@@ -205,17 +238,50 @@ export class Collider {
205
238
  <>
206
239
  <SelectField label="Shape" value={shape.type} onChange={setType} options={['box', 'circle']} />
207
240
  {isCircle ? (
208
- <NumberField label="Radius" value={radiusPx} onChange={(v) => patchSel({ r: v / Math.min(bw, bh) })} />
241
+ <NumberField
242
+ label="Radius"
243
+ value={radiusPx}
244
+ onChange={(v) => patchSel({ r: v / Math.min(bw, bh) })}
245
+ {...shapeOv('r', 0.5, (f) => Math.round(f * Math.min(bw, bh)))}
246
+ />
209
247
  ) : (
210
248
  <>
211
- <NumberField label="Width" value={widthPx} onChange={(v) => patchSel({ w: v / bw })} />
212
- <NumberField label="Height" value={heightPx} onChange={(v) => patchSel({ h: v / bh })} />
249
+ <NumberField
250
+ label="Width"
251
+ value={widthPx}
252
+ onChange={(v) => patchSel({ w: v / bw })}
253
+ {...shapeOv('w', 1, (f) => Math.round(f * bw))}
254
+ />
255
+ <NumberField
256
+ label="Height"
257
+ value={heightPx}
258
+ onChange={(v) => patchSel({ h: v / bh })}
259
+ {...shapeOv('h', 1, (f) => Math.round(f * bh))}
260
+ />
261
+ <NumberField
262
+ label="Angle"
263
+ value={Math.round(shape.angle ?? 0)}
264
+ min={-180}
265
+ max={180}
266
+ onChange={(v) => patchSel({ angle: v })}
267
+ {...shapeOv('angle', 0, (f) => Math.round(f))}
268
+ />
213
269
  </>
214
270
  )}
215
271
  </>
216
272
  )}
217
- <NumberField label="Offset X" value={offXPx} onChange={(v) => patchSel({ x: 0.5 + v / bw })} />
218
- <NumberField label="Offset Y" value={offYPx} onChange={(v) => patchSel({ y: 0.5 + v / bh })} />
273
+ <NumberField
274
+ label="Offset X"
275
+ value={offXPx}
276
+ onChange={(v) => patchSel({ x: 0.5 + v / bw })}
277
+ {...shapeOv('x', 0.5, (f) => Math.round((f - 0.5) * bw))}
278
+ />
279
+ <NumberField
280
+ label="Offset Y"
281
+ value={offYPx}
282
+ onChange={(v) => patchSel({ y: 0.5 + v / bh })}
283
+ {...shapeOv('y', 0.5, (f) => Math.round((f - 0.5) * bh))}
284
+ />
219
285
  <div style={actionRow}>
220
286
  {autoFit && !fitted ? (
221
287
  <button type="button" onClick={() => writeShapes(shapes.map((s, k) => (k === i ? autoFit : s)))} style={linkBtn}>