castle-web-cli 0.4.170 → 0.4.171

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 (51) hide show
  1. package/dist/agent-prompts.d.ts +1 -0
  2. package/dist/agent-prompts.js +3 -0
  3. package/dist/agent.js +47 -11
  4. package/dist/castle-host/host.js +71 -0
  5. package/dist/ide.d.ts +10 -0
  6. package/dist/ide.js +5 -5
  7. package/dist/init.js +1 -1
  8. package/dist/shell/assets/index-6odVZQSZ.css +1 -0
  9. package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
  10. package/dist/shell/index.html +3 -3
  11. package/kits/base/castle.json +1 -1
  12. package/kits/base/sdk/README.md +17 -1
  13. package/kits/base/sdk/commands.d.ts +14 -0
  14. package/kits/base/sdk/user.d.ts +4 -0
  15. package/kits/base/sdk/user.js +36 -1
  16. package/kits/multiplayer-2d/CLAUDE.md +20 -9
  17. package/kits/multiplayer-2d/castle.json +2 -2
  18. package/kits/multiplayer-2d/code/server/players.js +27 -6
  19. package/kits/multiplayer-2d/code/server/world.js +24 -1
  20. package/kits/multiplayer-2d/code/systems/multiplayer.js +45 -4
  21. package/kits/multiplayer-2d/package-lock.json +26 -1
  22. package/kits/multiplayer-2d/package.json +2 -0
  23. package/kits/multiplayer-3d/CLAUDE.md +28 -9
  24. package/kits/multiplayer-3d/castle.json +3 -3
  25. package/kits/multiplayer-3d/code/server/players.js +54 -5
  26. package/kits/multiplayer-3d/code/server/world.js +45 -1
  27. package/kits/multiplayer-3d/code/systems/multiplayer.js +108 -6
  28. package/kits/multiplayer-3d/package-lock.json +26 -1
  29. package/kits/multiplayer-3d/package.json +2 -0
  30. package/kits/physics-2d/castle.json +1 -1
  31. package/kits/physics-2d/editors/PxArtEditor.jsx +2 -2
  32. package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
  33. package/kits/physics-3d/castle.json +1 -1
  34. package/kits/real-time/CLAUDE.md +23 -4
  35. package/kits/real-time/castle.json +1 -1
  36. package/kits/real-time/code/client/connection.js +9 -4
  37. package/kits/real-time/code/client/joinOverlay.js +46 -0
  38. package/kits/real-time/code/client/messages.js +4 -0
  39. package/kits/real-time/code/server/gameHooks.js +4 -0
  40. package/kits/real-time/code/server/persist.js +105 -0
  41. package/kits/real-time/code/server/session.js +289 -19
  42. package/kits/real-time/package-lock.json +1139 -0
  43. package/kits/turn-based/CLAUDE.md +76 -20
  44. package/kits/turn-based/castle.json +1 -1
  45. package/kits/turn-based/code/server/index.js +16 -5
  46. package/kits/turn-based/package.json +2 -1
  47. package/kits/turn-based/room.js +162 -13
  48. package/kits/turn-based/testing.js +276 -92
  49. package/package.json +1 -2
  50. package/dist/shell/assets/index-BvQmVwlO.css +0 -1
  51. package/dist/shell/assets/index-CV5sBby1.js +0 -445
@@ -7,6 +7,9 @@ import { earned, number } from '@imports/castle.real-time/code/server/limits.js'
7
7
  import * as Limits from './limits.js';
8
8
  import { createBody } from './world.js';
9
9
 
10
+ // The largest state block a client may report, as JSON text.
11
+ const STATE_BYTES = 4096;
12
+
10
13
  // An entry in `sim.players`:
11
14
  //
12
15
  // body the kinematic capsule in the rapier world
@@ -14,18 +17,26 @@ import { createBody } from './world.js';
14
17
  // facing radians, as the client sent them
15
18
  // clamped true only on the tick a move was refused
16
19
  // movedAt simulation time of that acceptance
17
- function newPlayer(body, at) {
18
- return { body, at, facing: 0, clamped: false, movedAt: 0 };
20
+ // settled whether this connection has ever reported a pose the server took
21
+ // state the block the client last reported: the engine's `scene.persistent`
22
+ // (score, camera angle, collected pickups). Saved with the pose and
23
+ // handed back on rejoin. The server never reads inside it.
24
+ function newPlayer(body, at, movedAt) {
25
+ return { body, at, facing: 0, clamped: false, movedAt, settled: false, state: null };
19
26
  }
20
27
 
21
28
  // Add a kinematic capsule for a session player. `world.step` places it at
22
29
  // `player.at` before physics; zero friction prevents a capsule leaning against a
23
30
  // wall from remaining attached to it.
24
- export function addPlayer(sim, playerId) {
31
+ //
32
+ // `pose` is where a returning player left off, as `[x, y, z, facing]`. Without
33
+ // one the capsule starts on the spawn ring.
34
+ export function addPlayer(sim, playerId, pose) {
25
35
  // The shape comes off the scene's own player actor, so the server collides
26
36
  // with the same body the browser draws. The default is a person-sized capsule.
27
37
  const shape = sim.template?.shape ?? { kind: 'capsule', radius: 0.35, height: 1.4 };
28
- const at = spawnPoint(sim);
38
+ const resumed = pose ? Limits.readPoint(pose) : null;
39
+ const at = resumed ?? spawnPoint(sim);
29
40
  const body = createBody(
30
41
  sim,
31
42
  { components: { Solid: {} } },
@@ -33,7 +44,15 @@ export function addPlayer(sim, playerId) {
33
44
  shape,
34
45
  { type: 'kinematic', friction: 0 },
35
46
  );
36
- sim.players.set(playerId, newPlayer(body, at));
47
+
48
+ // The movement allowance runs from now. A body that starts where a player left
49
+ // off would otherwise let that player's first report cover half a second of
50
+ // travel back to wherever their own copy of the scene put them.
51
+ const player = newPlayer(body, at, sim.timeMs);
52
+ if (resumed) {
53
+ player.facing = number(pose[3]);
54
+ }
55
+ sim.players.set(playerId, player);
37
56
  }
38
57
 
39
58
  // Remove a departing player's Rapier body and simulation entry.
@@ -64,6 +83,9 @@ export function setPose(sim, playerId, message) {
64
83
  // changes the rendered model orientation and has no effect on the upright
65
84
  // capsule's movement or collision shape.
66
85
  player.facing = number(message?.p?.[3]);
86
+ if (message?.s !== undefined) {
87
+ setState(sim, playerId, message.s);
88
+ }
67
89
 
68
90
  // Combine elapsed-time movement allowance with fixed delivery slack.
69
91
  const reach = Limits.MAX_SPEED * earned(sim, player.movedAt) + Limits.MOVE_SLACK;
@@ -77,6 +99,15 @@ export function setPose(sim, playerId, message) {
77
99
  if (distance <= reach) {
78
100
  player.at = asked;
79
101
  player.clamped = false;
102
+ player.settled = true;
103
+ return;
104
+ }
105
+
106
+ // The first report moves nothing. A client starts its own body from the scene
107
+ // and does not know yet where the session put this player, so the server
108
+ // position stands until the client has agreed with it once.
109
+ if (!player.settled) {
110
+ player.clamped = true;
80
111
  return;
81
112
  }
82
113
 
@@ -92,6 +123,24 @@ export function setPose(sim, playerId, message) {
92
123
  player.clamped = true;
93
124
  }
94
125
 
126
+ // Keep a player's state block. Only a plain object under `STATE_BYTES` of JSON
127
+ // is kept; anything else leaves the block as it was.
128
+ export function setState(sim, playerId, state) {
129
+ const player = sim.players.get(playerId);
130
+ if (!player || typeof state !== 'object' || Array.isArray(state)) {
131
+ return;
132
+ }
133
+ if (state !== null && JSON.stringify(state).length > STATE_BYTES) {
134
+ return;
135
+ }
136
+ player.state = state;
137
+ }
138
+
139
+ // A player's state block, or null.
140
+ export function stateOf(sim, playerId) {
141
+ return sim.players.get(playerId)?.state ?? null;
142
+ }
143
+
95
144
  // Pack current Rapier body poses for a WORLD or STATE message. Physics contacts
96
145
  // can make the body position differ from the last accepted target in `player.at`.
97
146
  export function playerPoses(sim) {
@@ -4,7 +4,12 @@
4
4
 
5
5
  import RAPIER from '@dimforge/rapier3d-compat';
6
6
  import { packPose } from '../client/poses.js';
7
- import { MAX_STEPS_PER_TICK, STEP, clamp } from '@imports/castle.real-time/code/server/limits.js';
7
+ import {
8
+ MAX_STEPS_PER_TICK,
9
+ STEP,
10
+ clamp,
11
+ number,
12
+ } from '@imports/castle.real-time/code/server/limits.js';
8
13
  import { readScene } from './sceneBodies.js';
9
14
  import * as Limits from './limits.js';
10
15
 
@@ -13,6 +18,7 @@ const DEG = Math.PI / 180;
13
18
  // The simulation every hook is handed as `sim`:
14
19
  //
15
20
  // world the rapier world
21
+ // bodies actor id -> rapier body, for every scene actor with a shape
16
22
  // objects actor id -> { body, home, claimable, fast, owner, reportedAt,
17
23
  // claimedAt }
18
24
  // doors [{ body, props, baseY, openT }]
@@ -27,6 +33,7 @@ function emptySim() {
27
33
  return {
28
34
  world: new RAPIER.World({ x: 0, y: -9.81, z: 0 }),
29
35
  objects: new Map(),
36
+ bodies: new Map(),
30
37
  doors: [],
31
38
  players: new Map(),
32
39
  spawn: { x: 0, y: 1, z: 0 },
@@ -76,6 +83,7 @@ function buildScene(sim) {
76
83
  continue;
77
84
  }
78
85
  const rb = createBody(sim, actor, t, shape, body);
86
+ sim.bodies.set(actor.id, rb);
79
87
  if (door) {
80
88
  sim.doors.push({ body: rb, props: door, baseY: t.y, openT: 0 });
81
89
  }
@@ -102,6 +110,19 @@ function buildScene(sim) {
102
110
  }
103
111
  }
104
112
 
113
+ // Take a scene actor out of the simulation for good. An id with no body here
114
+ // (a gem, say, that the engine handles as a sensor) is a no-op.
115
+ export function removeActor(sim, id) {
116
+ const rb = sim.bodies.get(id);
117
+ if (!rb) {
118
+ return;
119
+ }
120
+ sim.world.removeRigidBody(rb);
121
+ sim.bodies.delete(id);
122
+ sim.objects.delete(id);
123
+ sim.doors = sim.doors.filter((door) => door.body !== rb);
124
+ }
125
+
105
126
  // Create one Rapier body and collider from engine component data. These mappings
106
127
  // mirror the engine's `bodyDescFor` and `colliderDescFor`, so server and browser
107
128
  // worlds use the same body type, material values, shape, and sensor state.
@@ -289,6 +310,29 @@ export function objectPoses(sim) {
289
310
  return out;
290
311
  }
291
312
 
313
+ // Put saved poses back on the bodies they were read from. An id the scene no
314
+ // longer has is skipped. Velocities are cleared: a restored body starts at rest.
315
+ // Returns how many poses were applied.
316
+ export function restorePoses(sim, poses) {
317
+ let applied = 0;
318
+ for (const pose of poses) {
319
+ const entry = sim.objects.get(pose?.[0]);
320
+ const at = entry ? Limits.readPoint(pose.slice(1)) : null;
321
+ if (!at) {
322
+ continue;
323
+ }
324
+ entry.body.setTranslation(at, true);
325
+ entry.body.setRotation(
326
+ { x: number(pose[4]), y: number(pose[5]), z: number(pose[6]), w: number(pose[7]) },
327
+ true,
328
+ );
329
+ entry.body.setLinvel({ x: 0, y: 0, z: 0 }, true);
330
+ entry.body.setAngvel({ x: 0, y: 0, z: 0 }, true);
331
+ applied += 1;
332
+ }
333
+ return applied;
334
+ }
335
+
292
336
  // Convert the engine Transform's degree Euler angles to a Rapier quaternion.
293
337
  function quatFromEuler(t) {
294
338
  const cx = Math.cos((t.rotationX ?? 0) * DEG * 0.5);
@@ -10,7 +10,11 @@ import * as Smoothing from '@imports/castle.real-time/code/client/smooth.js';
10
10
  import * as Poses from '../client/poses.js';
11
11
  import { connectSession } from '@imports/castle.real-time/code/client/connection.js';
12
12
  import { showSoloBadge, updateSoloBadge } from '@imports/castle.real-time/code/client/soloBadge.js';
13
- import { STATE, WORLD } from '@imports/castle.real-time/code/client/messages.js';
13
+ import { showJoinOverlay } from '@imports/castle.real-time/code/client/joinOverlay.js';
14
+ import { GONE, STATE, WORLD } from '@imports/castle.real-time/code/client/messages.js';
15
+
16
+ // How long the joining overlay may hold the card before giving up.
17
+ const JOIN_WAIT_MS = 10000;
14
18
  import { game } from '@imports/castle.real-time/code/client/gameHooks.js';
15
19
 
16
20
  // Apply this fraction of a server player correction per frame. Corrections over
@@ -46,6 +50,11 @@ class MultiplayerSystem {
46
50
  constructor() {
47
51
  // The Castle transport is created on the first stepped frame.
48
52
  this.net = null;
53
+ // False until a server snapshot carries this player's pose. The overlay
54
+ // stays up from the first stepped frame until then, or until JOIN_WAIT_MS
55
+ // after the join began if the session never answers.
56
+ this.joined = false;
57
+ this.joinStartedAt = 0;
49
58
 
50
59
  // `starting` prevents concurrent join attempts while the promise is pending.
51
60
  this.starting = false;
@@ -68,6 +77,16 @@ class MultiplayerSystem {
68
77
 
69
78
  // A clamp stores the last server position that rejected a local move.
70
79
  this.clampedTo = null;
80
+
81
+ // Scene actors the session has removed for good, and the actor ids present
82
+ // at the end of the last frame. An id that was there and is not now was
83
+ // despawned by a behavior this frame, and is reported.
84
+ this.gone = new Set();
85
+ this.seen = null;
86
+
87
+ // The JSON of the last state block sent, so a block goes out only when it
88
+ // changes.
89
+ this.sentState = '';
71
90
  }
72
91
 
73
92
  // Run the network frame after engine behaviors and local physics. Connection,
@@ -76,12 +95,15 @@ class MultiplayerSystem {
76
95
  // Connecting on the first stepped frame keeps editor-only runtimes out of
77
96
  // multiplayer sessions because the scene editor constructs but never steps them.
78
97
  if (!this.net) {
98
+ showJoinOverlay(this.joiningOverlayWanted());
79
99
  return void this.start();
80
100
  }
81
101
  const local = scene.actorWith('Player');
82
102
 
83
103
  // Drain all queued messages before choosing the render time for this frame.
84
104
  this.receive(scene);
105
+ this.applyGone(scene);
106
+ this.reportGone(scene);
85
107
 
86
108
  // Draw at a fixed delay behind the server clock so interpolation normally has
87
109
  // samples on both sides. The local engine keeps stepping before the first
@@ -98,7 +120,9 @@ class MultiplayerSystem {
98
120
  // Deck code can make a final local adjustment after engine Player behavior
99
121
  // and physics, then optionally replace the kit's standard report.
100
122
  game.beforeReport?.(scene, local, this);
101
- if (game.report) {
123
+ if (!this.joined) {
124
+ // Nothing reported until the session has placed this player.
125
+ } else if (game.report) {
102
126
  game.report(scene, local, this);
103
127
  } else {
104
128
  this.report(scene, local);
@@ -117,6 +141,7 @@ class MultiplayerSystem {
117
141
  }
118
142
  this.publish(scene, local);
119
143
  updateSoloBadge(this.net.status(), game);
144
+ showJoinOverlay(this.joiningOverlayWanted());
120
145
  }
121
146
 
122
147
  // Join the Castle public session once and retain the transport across scene loads.
@@ -125,6 +150,7 @@ class MultiplayerSystem {
125
150
  return;
126
151
  }
127
152
  this.starting = true;
153
+ this.joinStartedAt ||= performance.now();
128
154
  try {
129
155
  this.net = await connectSession({ onLog: (text) => console.log(`[multiplayer] ${text}`) });
130
156
  } catch (err) {
@@ -145,6 +171,21 @@ class MultiplayerSystem {
145
171
 
146
172
  // Pair simulation time with the page arrival time before filing samples.
147
173
  Clock.noteArrival(this.clock, msg.t, msg.localMs);
174
+ if (!this.joined && (msg.k === WORLD || (msg.ps ?? []).some((pose) => pose[0] === selfId))) {
175
+ this.joined = true;
176
+ console.log('[multiplayer] placed by the session');
177
+ }
178
+ for (const id of msg.gone ?? []) {
179
+ this.gone.add(id);
180
+ }
181
+
182
+ // The WORLD snapshot carries this player's own saved block. It goes back
183
+ // on `scene.persistent`, which is where the engine's Player behavior keeps
184
+ // the camera angle and score, before that behavior runs again.
185
+ if (msg.k === WORLD && msg.me) {
186
+ Object.assign((scene.persistent ??= {}), msg.me);
187
+ this.sentState = JSON.stringify(scene.persistent);
188
+ }
148
189
 
149
190
  // Apply ownership first so an object granted this tick stops receiving
150
191
  // network poses on the same frame local simulation begins.
@@ -152,9 +193,11 @@ class MultiplayerSystem {
152
193
  for (const [id, x, y, z, facing, clamped] of msg.ps ?? []) {
153
194
  if (id !== selfId) {
154
195
  sample(this.players, msg, id, [x, y, z, facing]);
155
- } else if (clamped) {
156
- // The local player's returned pose is used only when the server marks
157
- // it clamped; ordinary local movement remains client-simulated.
196
+ } else if (clamped || msg.k === WORLD) {
197
+ // The local player's returned pose is used when the server marks it
198
+ // clamped, and on the WORLD snapshot that starts a session: that one
199
+ // carries where this player left off. Ordinary local movement remains
200
+ // client-simulated.
158
201
  this.clampedTo = { x, y, z };
159
202
  }
160
203
  }
@@ -300,7 +343,13 @@ class MultiplayerSystem {
300
343
 
301
344
  // `sendMove` only goes out at MOVE_HZ; claims are held until it does. The
302
345
  // transport takes a packed pose, so the Transform is encoded here.
303
- this.net.sendMove(Poses.packMove(me), owned, claims, releases);
346
+ // The state block rides in a move packet when it changed since the last one
347
+ // that went out.
348
+ const stateJson = JSON.stringify(scene.persistent ?? {});
349
+ const state = stateJson === this.sentState ? undefined : (scene.persistent ?? {});
350
+ if (this.net.sendMove(Poses.packMove(me), owned, claims, releases, state) && state) {
351
+ this.sentState = stateJson;
352
+ }
304
353
  Avatars.showSelf(this.avatars, scene, this.net.status().you, me);
305
354
  }
306
355
 
@@ -388,6 +437,56 @@ class MultiplayerSystem {
388
437
  this.own = Ownership.makeOwnership();
389
438
  this.shown = Smoothing.makeSmoothing();
390
439
  this.clampedTo = null;
440
+ this.seen = null;
441
+ }
442
+
443
+ //
444
+ // Actors removed for good
445
+ //
446
+
447
+ // Despawn every actor the session says is gone. Runs each frame, so a scene
448
+ // load that brings an actor back from the file loses it again at once.
449
+ applyGone(scene) {
450
+ for (const id of this.gone) {
451
+ if (scene.actors.has(id)) {
452
+ scene.despawnActor(id);
453
+ }
454
+ }
455
+ }
456
+
457
+ // Report actors a behavior despawned this frame (a collected pickup, say).
458
+ // Other players' avatars are the kit's own and are not reported. Nothing is
459
+ // reported before the session has placed this player.
460
+ reportGone(scene) {
461
+ const ids = new Set(scene.actors.keys());
462
+ if (this.seen && this.joined) {
463
+ const avatars = new Set(this.avatars.actors.values());
464
+ for (const id of this.seen) {
465
+ if (ids.has(id) || this.gone.has(id) || avatars.has(id)) {
466
+ continue;
467
+ }
468
+ this.gone.add(id);
469
+ this.net.send({ k: GONE, id });
470
+ }
471
+ }
472
+ this.seen = ids;
473
+ }
474
+
475
+ // Whether the overlay should be up: joining or connected, not solo, and not
476
+ // yet placed. A session that never answers releases it after JOIN_WAIT_MS so
477
+ // the deck is not stuck behind it.
478
+ joiningOverlayWanted() {
479
+ if (this.joined || this.net?.status().solo) {
480
+ return false;
481
+ }
482
+ if (this.joinStartedAt && performance.now() - this.joinStartedAt > JOIN_WAIT_MS) {
483
+ if (!this.joinWarned) {
484
+ this.joinWarned = true;
485
+ console.warn('[multiplayer] no snapshot from the session yet; playing unplaced');
486
+ }
487
+ return false;
488
+ }
489
+ return true;
391
490
  }
392
491
 
393
492
  // Release scene resources and leave the session when the engine runtime ends.
@@ -396,6 +495,9 @@ class MultiplayerSystem {
396
495
  this.reset(scene);
397
496
  this.net?.close();
398
497
  this.net = null;
498
+ this.joined = false;
499
+ this.joinStartedAt = 0;
500
+ showJoinOverlay(false);
399
501
  // The badge is a fixed element outside the scene, so a disposed runtime
400
502
  // (an editor preview closing, say) has to take it down explicitly.
401
503
  showSoloBadge(null);
@@ -16,8 +16,10 @@
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",
19
20
  "@lezer/highlight": "^1.2.3",
20
21
  "castle-web-fonts": "^1.0.0",
22
+ "castle-web-sdk": "file:../../sdk",
21
23
  "codemirror": "^6.0.2",
22
24
  "matter-js": "^0.20.0",
23
25
  "react": "^19.2.4",
@@ -30,7 +32,7 @@
30
32
  },
31
33
  "../../sdk": {
32
34
  "name": "castle-web-sdk",
33
- "version": "0.4.26",
35
+ "version": "0.4.27",
34
36
  "dev": true,
35
37
  "devDependencies": {
36
38
  "eslint": "^9.0.0",
@@ -208,6 +210,29 @@
208
210
  "react": ">=16.8.0"
209
211
  }
210
212
  },
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
+ },
211
236
  "node_modules/@lezer/common": {
212
237
  "version": "1.5.2",
213
238
  "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
@@ -17,8 +17,10 @@
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",
20
21
  "@lezer/highlight": "^1.2.3",
21
22
  "castle-web-fonts": "^1.0.0",
23
+ "castle-web-sdk": "file:../../sdk",
22
24
  "codemirror": "^6.0.2",
23
25
  "matter-js": "^0.20.0",
24
26
  "react": "^19.2.4",
@@ -72,7 +72,7 @@
72
72
  "main": "main.jsx",
73
73
  "autoUpdateWhenImported": true,
74
74
  "title": "physics-2d",
75
- "publishedVersion": "2026-09-10T00:57:31.408Z",
75
+ "publishedVersion": "2026-09-10T20:09:53.271Z",
76
76
  "imports": {
77
77
  "castle.base": {
78
78
  "deckId": "yRcmH4_aYllE",
@@ -419,7 +419,7 @@ export function PxArtEditor({ path, text, onChange, files, readOnly = false, ...
419
419
  const artboardCanvasStyle = canvasSize ?? canvasStyle;
420
420
  // `canvasStyle`'s width is the artboard's CURRENT display size (recomputed
421
421
  // by useArtboardFit's own ResizeObserver whenever the wrap resizes — e.g. a
422
- // dockview panel drag). Feeding it in here is what keeps the supersampled
422
+ // panel resize). Feeding it in here is what keeps the supersampled
423
423
  // canvas's resolution in sync with that display size: without it, this
424
424
  // effect has no way to know the wrap resized (nothing about `text`,
425
425
  // `frameIndex`, or `cornerRadius` changes on a pure container resize), so
@@ -2458,7 +2458,7 @@ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing
2458
2458
  // the `sprite` object (a fresh object every render) so it only redraws on an
2459
2459
  // actual content/frame/radius change, not on every hover-driven re-render of
2460
2460
  // the tool preview — PLUS `displayWidth` (the artboard's current CSS width
2461
- // from useArtboardFit), so a wrap resize (e.g. a dockview panel drag) also
2461
+ // from useArtboardFit), so a wrap resize (e.g. a panel drag) also
2462
2462
  // triggers a re-render at the new supersample scale, not just a content
2463
2463
  // change. The supersample factor auto-fits to the displayed size (capped
2464
2464
  // between 4x and 16x).
@@ -4,7 +4,9 @@
4
4
  //
5
5
  // Collection is remembered in the persistent store keyed by scene + actor id,
6
6
  // so re-entering a scene doesn't respawn (and double-count) shards
7
- // -- scene loads otherwise reset the world to its authored state.
7
+ // -- scene loads otherwise reset the world to its authored state. The store is
8
+ // a plain object, not a Set: `scene.persistent` has to survive JSON, since a
9
+ // multiplayer kit saves it per player.
8
10
  function pickupKey(scene, actor) {
9
11
  return `${scene.currentScenePath ?? ''}:${actor.id}`;
10
12
  }
@@ -18,7 +20,7 @@ export class Pickup {
18
20
  }
19
21
 
20
22
  update(actor, scene, dt) {
21
- if (scene.persistent?.collected?.has(pickupKey(scene, actor))) {
23
+ if (scene.persistent?.collected?.[pickupKey(scene, actor)]) {
22
24
  scene.despawnActor(actor.id);
23
25
  return;
24
26
  }
@@ -33,7 +35,7 @@ export class Pickup {
33
35
  if (!other.components.Player) return;
34
36
  const per = (scene.persistent ??= {});
35
37
  per.score = (per.score ?? 0) + 1;
36
- (per.collected ??= new Set()).add(pickupKey(scene, actor));
38
+ (per.collected ??= {})[pickupKey(scene, actor)] = true;
37
39
  scene.despawnActor(actor.id);
38
40
  }
39
41
  }
@@ -69,5 +69,5 @@
69
69
  "autoUpdateWhenImported": true,
70
70
  "deckId": "JH0SclbPVP0y",
71
71
  "cardId": "eXfAaUdbSU5A",
72
- "publishedVersion": "2026-09-10T00:57:52.158Z"
72
+ "publishedVersion": "2026-09-10T20:10:28.916Z"
73
73
  }
@@ -20,6 +20,16 @@ imports these modules from here directly.
20
20
  - **A deck normally writes nothing against it.** The multiplayer kit it came with
21
21
  already drives all of this; a deck's own code goes in `code/server/game.js` and
22
22
  `code/client/game.js`, which that kit documents.
23
+ - **What persists.** Object poses, the ids of scene actors a client despawned,
24
+ each player's last pose and state block by account, and whatever the optional
25
+ `save(sim)` hook returns. Written every ten seconds when something moved and
26
+ at a clean shutdown. Nothing to configure.
27
+ - **A returning player starts where they left off.** A second connection from
28
+ the same account while the first is still playing gets a new character at the
29
+ spawn. The room is `session.sessionId`; every `public` shard shares one.
30
+ - `restore(sim, saved)` receives the last `save` result before `ready`. Storage
31
+ is keyed by deck, so the deck must be published; `castle-web serve` brokers it
32
+ as the deck's creator.
23
33
  - A pose is an opaque array of numbers here, so anything reading inside one takes
24
34
  the kit's `code/client/poses.js` as an argument. That is the seam to use when
25
35
  something genuinely needs a lower level.
@@ -59,10 +69,19 @@ never learns the format.
59
69
  values of a player pose are geometry before the `clamped` flag, and `epsilonAt`,
60
70
  which a kit whose pose values are all one unit leaves undefined.
61
71
 
62
- `soloBadge.js` is the one file here that touches the DOM: a fixed badge that
63
- names why a session fell back to solo (`status().soloReason` from
64
- `connection.js`), drawn by both kits each frame through `updateSoloBadge`. It
65
- stays dependency-free and hides itself when a deck's `soloBadge` hook says so.
72
+ Persistence adds to that contract: `world.restorePoses(sim, poses)` puts saved
73
+ poses back on the bodies the scene still has, and `players.addPlayer` takes the
74
+ saved pose a returning player starts from. Optional beside those:
75
+ `world.removeActor(sim, id)` drops a despawned actor's body, and
76
+ `players.setState` / `players.stateOf` keep the block a client reports.
77
+
78
+ Two files here touch the DOM, both dependency-free. `soloBadge.js` is a fixed
79
+ badge that names why a session fell back to solo (`status().soloReason` from
80
+ `connection.js`), drawn by both kits each frame through `updateSoloBadge`; a
81
+ deck's `soloBadge` hook hides it. `joinOverlay.js` covers the card with
82
+ "Joining…" from the first frame until the session's first snapshot places the
83
+ player, so nobody plays from the scene's spawn before the saved spot arrives;
84
+ it gives up after ten seconds with a console warning.
66
85
 
67
86
  ## Rules for editing this kit
68
87
 
@@ -15,5 +15,5 @@
15
15
  "autoUpdateWhenImported": true,
16
16
  "deckId": "xFVr-afOLuUQ",
17
17
  "cardId": "w6G45sy3_P_m",
18
- "publishedVersion": "2026-09-05T01:31:28.578Z"
18
+ "publishedVersion": "2026-09-10T20:10:30.302Z"
19
19
  }
@@ -81,7 +81,8 @@ export async function connectSession({ onLog = () => {} } = {}) {
81
81
  // the transport handles every dimensional format in the same way.
82
82
  // The client system calls this every frame; most calls only accumulate the
83
83
  // ownership requests, which are held until the next rate-limited send.
84
- sendMove: (pose, owned, claims, releases) => sendMove(net, pose, owned, claims, releases),
84
+ sendMove: (pose, owned, claims, releases, state) =>
85
+ sendMove(net, pose, owned, claims, releases, state),
85
86
 
86
87
  // Send deck-specific data. The server routes these to `code/server/game.js`.
87
88
  send: (data) => net.connection.send(data),
@@ -106,8 +107,10 @@ export async function connectSession({ onLog = () => {} } = {}) {
106
107
  };
107
108
  }
108
109
 
109
- // Collect ownership requests from every frame and send at `MOVE_HZ`.
110
- function sendMove(net, pose, owned, claims, releases) {
110
+ // Collect ownership requests from every frame and send at `MOVE_HZ`. `state` is
111
+ // the player's own block, sent along when the caller has one to send. Returns
112
+ // whether a packet went out.
113
+ function sendMove(net, pose, owned, claims, releases, state) {
111
114
  // Gathered on every frame, including the ones that send nothing, so a claim
112
115
  // made in between is still in the next message. The sets also collapse
113
116
  // repeated requests for the same object over that interval.
@@ -119,7 +122,7 @@ function sendMove(net, pose, owned, claims, releases) {
119
122
  }
120
123
  const now = performance.now();
121
124
  if (now - net.sentAt < SEND_MS) {
122
- return;
125
+ return false;
123
126
  }
124
127
  net.sentAt = now;
125
128
 
@@ -130,9 +133,11 @@ function sendMove(net, pose, owned, claims, releases) {
130
133
  c: [...net.claims],
131
134
  r: [...net.releases],
132
135
  o: owned,
136
+ ...(state === undefined ? {} : { s: state }),
133
137
  });
134
138
 
135
139
  // Clear only after a packet; rate-limited returns above retain the requests.
136
140
  net.claims.clear();
137
141
  net.releases.clear();
142
+ return true;
138
143
  }
@@ -0,0 +1,46 @@
1
+ // A full-card overlay shown from connecting until the server's first snapshot
2
+ // places this player. Under it the local character may still be standing at the
3
+ // scene's spawn; the overlay hides that and takes the pointer, so nothing is
4
+ // played until the session says where things are.
5
+ //
6
+ // It mounts inside the card when the sdk has marked one, so a capture of the
7
+ // card (a cover, a playtest frame) includes it; otherwise it covers the page.
8
+ //
9
+ // Dependency-free DOM, like the solo badge: the kits draw their scenes
10
+ // differently and share no HUD.
11
+
12
+ let element = null;
13
+
14
+ // Show or hide the overlay. Idempotent; cheap enough to call every frame.
15
+ export function showJoinOverlay(visible) {
16
+ if (typeof document === 'undefined') {
17
+ return;
18
+ }
19
+ if (!visible) {
20
+ element?.remove();
21
+ element = null;
22
+ return;
23
+ }
24
+ if (element) {
25
+ return;
26
+ }
27
+ const card = document.querySelector('#castle-card, [data-castle-card]');
28
+ if (card && getComputedStyle(card).position === 'static') {
29
+ card.style.position = 'relative';
30
+ }
31
+ element = document.createElement('div');
32
+ element.setAttribute('data-castle-join-overlay', '');
33
+ element.style.cssText = [
34
+ card ? 'position:absolute' : 'position:fixed',
35
+ 'inset:0',
36
+ 'display:flex',
37
+ 'align-items:center',
38
+ 'justify-content:center',
39
+ 'background:#0a0a0a',
40
+ 'color:#fff',
41
+ 'font:500 15px/1.4 system-ui,sans-serif',
42
+ 'z-index:2147483646',
43
+ ].join(';');
44
+ element.textContent = 'Joining…';
45
+ (card ?? document.body).appendChild(element);
46
+ }