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
@@ -3,15 +3,15 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Castle Editor</title>
6
+ <title>Untitled deck - Castle</title>
7
7
  <!-- The same favicon castle.xyz uses (copied into `public/`, so vite emits
8
8
  them next to the bundle and the published package ships them). Vite
9
9
  rewrites these onto the build `base`, i.e. `/__castle/ide/...`. -->
10
10
  <link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
11
11
  <link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
12
12
  <link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
13
- <script type="module" crossorigin src="/__castle/ide/assets/index-CV5sBby1.js"></script>
14
- <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-BvQmVwlO.css">
13
+ <script type="module" crossorigin src="/__castle/ide/assets/index-Ws0WrCbi.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-6odVZQSZ.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -74,7 +74,7 @@
74
74
  "title": "base",
75
75
  "deckId": "yRcmH4_aYllE",
76
76
  "cardId": "zkFdingmIm3m",
77
- "publishedVersion": "2026-09-10T00:56:59.388Z",
77
+ "publishedVersion": "2026-09-10T20:10:25.416Z",
78
78
  "provides": [
79
79
  "castle-web-sdk"
80
80
  ]
@@ -416,7 +416,10 @@ const dailyPuzzle = (date.daysSinceCastleEpoch % 30) + 1;
416
416
  Returns the signed-in player. Throws `CastleError`
417
417
  (`LOGIN_REQUIRED`) when nobody is signed in.
418
418
 
419
- The returned `CastleUser` has `userId`, `username`, `isAnonymous`, and `isActive`.
419
+ A `CastleUser` has `userId`, `username`, `isAnonymous`, `isActive`, `photoUrl`
420
+ (their 256px avatar), `frameUrl` (the 96px frame drawn around it) and `color`
421
+ (the hex their username is shown in); the last three are `null` when unset. The
422
+ urls are `data:` urls, so they go straight into an `<img>` or a texture.
420
423
  Anonymous accounts are real logins with generated `anonymous-user-...` names;
421
424
  check `isAnonymous` before accepting writes into anything other players see,
422
425
  such as a shared gallery.
@@ -426,6 +429,19 @@ const me = await User.getCurrent();
426
429
  greet(me.username);
427
430
  ```
428
431
 
432
+ ### `User.get(userId): Promise<CastleUser | null>`
433
+
434
+ Another player as anyone may see them, or `null` when no such user exists.
435
+ Results are cached per id for the life of the page. The ids come from the
436
+ session's player list or from `Store.user(id)` rows.
437
+
438
+ ```js
439
+ const them = await User.get(player.userId);
440
+ if (them?.photoUrl) {
441
+ avatar.src = them.photoUrl;
442
+ }
443
+ ```
444
+
429
445
  ## Pass
430
446
 
431
447
  A pass is something a creator sells to players for Castle bricks (the
@@ -120,6 +120,9 @@ export interface CommandParams {
120
120
  score?: number | null;
121
121
  };
122
122
  "user.getCurrent": Record<string, never>;
123
+ "user.get": {
124
+ userId: string;
125
+ };
123
126
  "time.getServerTime": Record<string, never>;
124
127
  "pass.has": {
125
128
  passId: string;
@@ -214,6 +217,9 @@ export interface CommandResult {
214
217
  isAnonymous?: boolean;
215
218
  } | null;
216
219
  };
220
+ "user.get": {
221
+ user: UserPayload | null;
222
+ };
217
223
  "time.getServerTime": {
218
224
  timestamp: number;
219
225
  timezoneOffset: number;
@@ -254,6 +260,14 @@ export interface CommandResult {
254
260
  };
255
261
  "lifecycle.restoreState": LifecycleRestoreStateResult;
256
262
  }
263
+ export interface UserPayload {
264
+ userId: string;
265
+ username: string;
266
+ isAnonymous: boolean;
267
+ photoUrl: string | null;
268
+ frameUrl: string | null;
269
+ color: string | null;
270
+ }
257
271
  export type CommandName = keyof CommandParams;
258
272
  export interface SerializedCommandError {
259
273
  code: string;
@@ -3,8 +3,12 @@ export interface CastleUser {
3
3
  username: string;
4
4
  isAnonymous: boolean;
5
5
  isActive: boolean;
6
+ photoUrl: string | null;
7
+ frameUrl: string | null;
8
+ color: string | null;
6
9
  }
7
10
  export interface CastleUserApi {
8
11
  getCurrent(): Promise<CastleUser>;
12
+ get(userId: string): Promise<CastleUser | null>;
9
13
  }
10
14
  export declare const User: CastleUserApi;
@@ -2,8 +2,12 @@ import { CastleError } from "./errors";
2
2
  import { hostRequest } from "./transport";
3
3
  let currentUser = null;
4
4
  let currentUserPromise = null;
5
+ // Profiles by user id. A profile changes rarely, and a multiplayer deck asks for
6
+ // the same few ids every time a player joins.
7
+ const profiles = new Map();
5
8
  export const User = {
6
9
  getCurrent,
10
+ get,
7
11
  };
8
12
  async function getCurrent() {
9
13
  if (currentUser)
@@ -25,15 +29,46 @@ async function fetchCurrentUser() {
25
29
  });
26
30
  }
27
31
  const username = requiredString(user.username, "user.username", operation);
32
+ const userId = requiredString(user.userId, "user.userId", operation);
33
+ // The host answers identity from what it already knows. The profile fields
34
+ // come from the same read `get` does; a host that predates that command
35
+ // leaves them null.
36
+ const profile = await get(userId).catch(() => null);
28
37
  return {
29
- userId: requiredString(user.userId, "user.userId", operation),
38
+ userId,
30
39
  username,
31
40
  // A host that predates the flag omits it; anonymous account usernames are
32
41
  // always minted as `anonymous-user-<uuid>`, so the prefix is the fallback.
33
42
  isAnonymous: user.isAnonymous === true || username.toLowerCase().startsWith("anonymous-user-"),
34
43
  isActive: true,
44
+ photoUrl: profile?.photoUrl ?? null,
45
+ frameUrl: profile?.frameUrl ?? null,
46
+ color: profile?.color ?? null,
35
47
  };
36
48
  }
49
+ // One user by id, or null when no such user exists. Throws `CastleError` on a
50
+ // host that predates the command (`UNKNOWN_COMMAND`).
51
+ async function get(userId) {
52
+ if (typeof userId !== "string" || userId.length === 0) {
53
+ throw new CastleError({
54
+ code: "INVALID_ARGUMENT",
55
+ message: "User.get(userId) needs a user id.",
56
+ operation: "User.get",
57
+ });
58
+ }
59
+ let pending = profiles.get(userId);
60
+ if (!pending) {
61
+ pending = fetchProfile(userId);
62
+ profiles.set(userId, pending);
63
+ // A failed read is not kept, so the next call asks again.
64
+ pending.catch(() => profiles.delete(userId));
65
+ }
66
+ return pending;
67
+ }
68
+ async function fetchProfile(userId) {
69
+ const { user } = await hostRequest("user.get", { userId });
70
+ return user ? { ...user, isActive: true } : null;
71
+ }
37
72
  function requiredString(value, field, operation) {
38
73
  if (typeof value === "string" && value.length > 0)
39
74
  return value;
@@ -30,10 +30,19 @@ then `Smoothing.smooth(…)`. File names stay lowercase.
30
30
  builds a matter world from the deck's own `scenes/main.scene` and steps it at
31
31
  `tickRate` (30). Never fork it.
32
32
  - **A game is added as hooks, not by replacing anything.** Write
33
- `code/server/game.js` with a default export of up to six functions --
33
+ `code/server/game.js` with a default export of up to six gameplay functions --
34
34
  `ready(sim)`, `message(session, player, data)`, `place(sim, handovers)`,
35
35
  `step(sim, handovers)`, `join(sim)`, `delta(sim)` -- and it is found by glob.
36
36
  `code/client/game.js` is the browser half. Both optional.
37
+ - **What persists.** Object poses, each player's last pose by account, and
38
+ whatever the optional `save(sim)` hook returns are written every ten seconds
39
+ when something moved and at a clean shutdown. Nothing to configure.
40
+ - **A returning player starts where they left off.** A second connection from
41
+ the same account while the first is still playing gets a new character at the
42
+ spawn. The room is `session.sessionId`; every `public` shard shares one.
43
+ - `restore(sim, saved)` receives the last `save` result before `ready`. Storage
44
+ is keyed by deck, so the deck must be published; `castle-web serve` brokers it
45
+ as the deck's creator.
37
46
  - `place` runs BEFORE the physics step; a write that only reaches a body at the
38
47
  next step belongs there. `delta` returning news is its own reason to broadcast,
39
48
  so a tick where nothing moved but a round ended still reaches players.
@@ -55,8 +64,9 @@ then `Smoothing.smooth(…)`. File names stay lowercase.
55
64
  - The kit ships `code/systems/multiplayer.js`, the frame loop. A deck that writes
56
65
  its own file of that path gets its own instead; most decks should not.
57
66
  - Two players is the test that matters: `castle-web serve`, then the play URL in
58
- two tabs. The session process holds the room, so restart the serve for a fresh
59
- one.
67
+ two tabs. The room persists in deck storage across serve restarts; a fresh one
68
+ means removing its `world:<room>`, `game:<room>` and `player:<room>:*` rows
69
+ (the local serve's room is `dev`).
60
70
 
61
71
  ## Layout
62
72
 
@@ -137,11 +147,13 @@ and then, all optional, all found by root-anchored glob in
137
147
  | `code/server/game.js` | the game, as six hooks on a default export |
138
148
  | `code/server/deckComponents.js` | `export const componentDefaults = { Cargo: {...} }` |
139
149
 
140
- The kit draws no HUD, with one exception: when the join fell back to solo,
150
+ The kit draws no HUD, with two exceptions. When the join fell back to solo,
141
151
  `castle.real-time`'s `soloBadge.js` shows a small fixed badge naming why
142
152
  (`status().soloReason`: a private deck, no published server, or plain
143
153
  unavailable), so a creator testing their own deck is told rather than left to
144
- guess. A deck's `soloBadge(status)` client hook hides (`false`) or rewords it.
154
+ guess; a deck's `soloBadge(status)` client hook hides (`false`) or rewords it.
155
+ While joining, its `joinOverlay.js` covers the card with "Joining…" until the
156
+ session's first snapshot places the player, ten seconds at most.
145
157
 
146
158
  The six server hooks: `ready(sim)`, `message(session, player, data)`,
147
159
  `place(sim, handovers)`, `step(sim, handovers)`, `join(sim)`, `delta(sim)`.
@@ -156,10 +168,9 @@ node ../../cli/dist/index.js serve . --port 5762 --detach
156
168
  node test/two-client-test.mjs
157
169
  ```
158
170
 
159
- The session process holds the room's state, so crates stay where the last run
160
- pushed them. Restart the serve for a fresh room. Each client publishes what it
161
- believes about the room on `globalThis.__multiplayer`, which is what the test
162
- reads.
171
+ The room's state persists, so crates stay where the last run pushed them. Each
172
+ client publishes what it believes about the room on `globalThis.__multiplayer`,
173
+ which is what the test reads.
163
174
 
164
175
  ## Rules for editing this kit
165
176
 
@@ -15,7 +15,7 @@
15
15
  "imports": {
16
16
  "castle.real-time": {
17
17
  "deckId": "xFVr-afOLuUQ",
18
- "version": "2026-09-05T01:31:28.578Z"
18
+ "version": "2026-09-10T19:46:34.652Z"
19
19
  },
20
20
  "castle.physics-2d": {
21
21
  "deckId": "ckRZGFW4iPrx",
@@ -35,5 +35,5 @@
35
35
  },
36
36
  "deckId": "oiFu96LkwZ09",
37
37
  "cardId": "nkJgrvrCby3H",
38
- "publishedVersion": "2026-09-10T00:58:09.384Z"
38
+ "publishedVersion": "2026-09-10T20:10:36.784Z"
39
39
  }
@@ -17,16 +17,21 @@ import { addBody } from './world.js';
17
17
  // facing radians, as the client sent them
18
18
  // clamped true only on the tick a move was refused
19
19
  // movedAt the simulation time of that acceptance
20
- function newPlayer(entry, at) {
20
+ // settled whether this connection has ever reported a pose the server took
21
+ function newPlayer(entry, at, movedAt) {
21
22
  // Spread the body's offset data beside the report-validation state.
22
- return { ...entry, at, facing: 0, clamped: false, movedAt: 0 };
23
+ return { ...entry, at, facing: 0, clamped: false, movedAt, settled: false };
23
24
  }
24
25
 
25
26
  // Add a character body for a session player. world.step writes `player.at`
26
27
  // before each physics step. The engine represents a kinematic body as a static
27
28
  // Matter body moved through explicit position writes.
28
- export function addPlayer(sim, playerId) {
29
- const at = spawnPoint(sim);
29
+ //
30
+ // `pose` is where a returning player left off, as `[x, y, facing]`. Without one
31
+ // the character starts on the spawn ring.
32
+ export function addPlayer(sim, playerId, pose) {
33
+ const resumed = pose ? Limits.readPoint(pose) : null;
34
+ const at = resumed ?? spawnPoint(sim);
30
35
  const template = sim.template?.components;
31
36
 
32
37
  // Reuse the authored Player shape and material while forcing the server body
@@ -40,8 +45,15 @@ export function addPlayer(sim, playerId) {
40
45
  },
41
46
  };
42
47
 
43
- // addBody returns the centre offset needed to translate Layout reports.
44
- sim.players.set(playerId, newPlayer(addBody(sim, actor), at));
48
+ // addBody returns the centre offset needed to translate Layout reports. The
49
+ // movement allowance runs from now: a body that starts where a player left off
50
+ // would otherwise let that player's first report cover half a second of travel
51
+ // back to wherever their own copy of the scene put them.
52
+ const player = newPlayer(addBody(sim, actor), at, sim.timeMs);
53
+ if (resumed) {
54
+ player.facing = number(pose[2]);
55
+ }
56
+ sim.players.set(playerId, player);
45
57
  }
46
58
 
47
59
  // Remove a departing player's body and its entry.
@@ -82,6 +94,15 @@ export function setPose(sim, playerId, message) {
82
94
  // An accepted move becomes the target written before the next Matter step.
83
95
  player.at = asked;
84
96
  player.clamped = false;
97
+ player.settled = true;
98
+ return;
99
+ }
100
+
101
+ // The first report moves nothing. A client starts its own character from the
102
+ // scene and does not know yet where the session put this player, so the server
103
+ // position stands until the client has agreed with it once.
104
+ if (!player.settled) {
105
+ player.clamped = true;
85
106
  return;
86
107
  }
87
108
 
@@ -9,7 +9,7 @@ import {
9
9
  pointsBounds,
10
10
  } from '@imports/castle.physics-2d/engine/collider.js';
11
11
  import { packPose } from '../client/poses.js';
12
- import { MAX_STEPS_PER_TICK, STEP } from '@imports/castle.real-time/code/server/limits.js';
12
+ import { MAX_STEPS_PER_TICK, STEP, number } from '@imports/castle.real-time/code/server/limits.js';
13
13
  import { readScene } from './sceneBodies.js';
14
14
  import * as Limits from './limits.js';
15
15
 
@@ -326,6 +326,29 @@ export function objectPoses(sim) {
326
326
  return out;
327
327
  }
328
328
 
329
+ // Put saved poses back on the bodies they were read from. An id the scene no
330
+ // longer has is skipped. Velocities are cleared: a restored body starts at rest.
331
+ // Returns how many poses were applied.
332
+ export function restorePoses(sim, poses) {
333
+ let applied = 0;
334
+ for (const pose of poses) {
335
+ const entry = sim.objects.get(pose?.[0]);
336
+ const at = entry ? Limits.readPoint(pose.slice(1)) : null;
337
+ if (!at) {
338
+ continue;
339
+ }
340
+ Matter.Body.setPosition(entry.body, {
341
+ x: at.x + entry.offset.dx,
342
+ y: at.y + entry.offset.dy,
343
+ });
344
+ Matter.Body.setAngle(entry.body, number(pose[3]));
345
+ Matter.Body.setVelocity(entry.body, { x: 0, y: 0 });
346
+ Matter.Body.setAngularVelocity(entry.body, 0);
347
+ applied += 1;
348
+ }
349
+ return applied;
350
+ }
351
+
329
352
  // A body's pose in the terms the client writes onto a Layout box. The offset is
330
353
  // not rotated with the body, matching how the engine writes its own bodies back.
331
354
  export function layoutOf(entry) {
@@ -15,7 +15,11 @@ import * as Smoothing from '@imports/castle.real-time/code/client/smooth.js';
15
15
  import * as Poses from '../client/poses.js';
16
16
  import { connectSession } from '@imports/castle.real-time/code/client/connection.js';
17
17
  import { showSoloBadge, updateSoloBadge } from '@imports/castle.real-time/code/client/soloBadge.js';
18
+ import { showJoinOverlay } from '@imports/castle.real-time/code/client/joinOverlay.js';
18
19
  import { STATE, WORLD } from '@imports/castle.real-time/code/client/messages.js';
20
+
21
+ // How long the joining overlay may hold the card before giving up.
22
+ const JOIN_WAIT_MS = 10000;
19
23
  import { game } from '@imports/castle.real-time/code/client/gameHooks.js';
20
24
 
21
25
  // How much of a refused move is taken back per frame, and the distance past
@@ -51,6 +55,11 @@ class MultiplayerSystem {
51
55
  constructor() {
52
56
  // The transport is created on the first stepped frame.
53
57
  this.net = null;
58
+ // False until a server snapshot carries this player's pose. The overlay
59
+ // stays up from the first stepped frame until then, or until JOIN_WAIT_MS
60
+ // after the join began if the session never answers.
61
+ this.joined = false;
62
+ this.joinStartedAt = 0;
54
63
 
55
64
  // `starting` keeps a second join attempt out while the first is pending.
56
65
  this.starting = false;
@@ -83,6 +92,7 @@ class MultiplayerSystem {
83
92
  // Connecting on the first stepped frame keeps the editor's own runtimes out
84
93
  // of the session: it builds them and never steps them.
85
94
  if (!this.net) {
95
+ showJoinOverlay(this.joiningOverlayWanted());
86
96
  return void this.start();
87
97
  }
88
98
 
@@ -105,7 +115,9 @@ class MultiplayerSystem {
105
115
  this.acceptClamp(scene, local);
106
116
 
107
117
  game.beforeReport?.(scene, local, this);
108
- if (game.report) {
118
+ if (!this.joined) {
119
+ // Nothing reported until the session has placed this player.
120
+ } else if (game.report) {
109
121
  game.report(scene, local, this);
110
122
  } else {
111
123
  this.report(scene, local);
@@ -113,6 +125,7 @@ class MultiplayerSystem {
113
125
  game.afterFrame?.(scene, dt, this);
114
126
  this.publish(scene, local);
115
127
  updateSoloBadge(this.net.status(), game);
128
+ showJoinOverlay(this.joiningOverlayWanted());
116
129
  }
117
130
 
118
131
  // Join the public session once. Scene resets retain this transport.
@@ -121,6 +134,7 @@ class MultiplayerSystem {
121
134
  return;
122
135
  }
123
136
  this.starting = true;
137
+ this.joinStartedAt ||= performance.now();
124
138
  try {
125
139
  this.net = await connectSession({ onLog: (text) => console.log(`[multiplayer] ${text}`) });
126
140
  } catch (err) {
@@ -141,6 +155,10 @@ class MultiplayerSystem {
141
155
 
142
156
  // Pair server simulation time with page arrival time before filing samples.
143
157
  Clock.noteArrival(this.clock, msg.t, msg.localMs);
158
+ if (!this.joined && (msg.k === WORLD || (msg.ps ?? []).some((pose) => pose[0] === selfId))) {
159
+ this.joined = true;
160
+ console.log('[multiplayer] placed by the session');
161
+ }
144
162
 
145
163
  // Apply ownership before poses so a newly granted object stops consuming
146
164
  // network poses on the first frame of local simulation.
@@ -148,9 +166,11 @@ class MultiplayerSystem {
148
166
  for (const [id, x, y, facing, clamped] of msg.ps ?? []) {
149
167
  if (id !== selfId) {
150
168
  sample(this.players, msg, id, [x, y, facing]);
151
- } else if (clamped) {
152
- // Store this client's returned pose only when the server marks the
153
- // report clamped. Accepted movement remains client-simulated.
169
+ } else if (clamped || msg.k === WORLD) {
170
+ // Store this client's returned pose when the server marks the report
171
+ // clamped, and on the WORLD snapshot that starts a session: that one
172
+ // carries where this player left off. Accepted movement remains
173
+ // client-simulated.
154
174
  this.clampedTo = { x, y };
155
175
  }
156
176
  }
@@ -374,12 +394,33 @@ class MultiplayerSystem {
374
394
  this.clampedTo = null;
375
395
  }
376
396
 
397
+ // Whether the overlay should be up: joining or connected, not solo, and not
398
+ // yet placed. A session that never answers releases it after JOIN_WAIT_MS so
399
+ // the deck is not stuck behind it.
400
+ joiningOverlayWanted() {
401
+ if (this.joined || this.net?.status().solo) {
402
+ return false;
403
+ }
404
+ if (this.joinStartedAt && performance.now() - this.joinStartedAt > JOIN_WAIT_MS) {
405
+ if (!this.joinWarned) {
406
+ this.joinWarned = true;
407
+ console.warn('[multiplayer] no snapshot from the session yet; playing unplaced');
408
+ }
409
+ return false;
410
+ }
411
+ return true;
412
+ }
413
+
414
+ // Release scene resources and leave the session when the engine runtime ends.
377
415
  dispose(scene) {
378
416
  // Runtime disposal also closes transport resources retained across resets.
379
417
  game.dispose?.(scene, this);
380
418
  this.reset(scene);
381
419
  this.net?.close();
382
420
  this.net = null;
421
+ this.joined = false;
422
+ this.joinStartedAt = 0;
423
+ showJoinOverlay(false);
383
424
  // The badge is a fixed element outside the scene, so a disposed runtime
384
425
  // (an editor preview closing, say) has to take it down explicitly.
385
426
  showSoloBadge(null);
@@ -15,8 +15,10 @@
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",
18
19
  "@lezer/highlight": "^1.2.3",
19
20
  "castle-web-fonts": "^1.0.0",
21
+ "castle-web-sdk": "file:../../sdk",
20
22
  "codemirror": "^6.0.2",
21
23
  "matter-js": "^0.20.0",
22
24
  "react": "^19.2.4",
@@ -28,7 +30,7 @@
28
30
  },
29
31
  "../../sdk": {
30
32
  "name": "castle-web-sdk",
31
- "version": "0.4.26",
33
+ "version": "0.4.27",
32
34
  "dev": true,
33
35
  "devDependencies": {
34
36
  "eslint": "^9.0.0",
@@ -200,6 +202,29 @@
200
202
  "react": ">=16.8.0"
201
203
  }
202
204
  },
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
+ },
203
228
  "node_modules/@lezer/common": {
204
229
  "version": "1.5.2",
205
230
  "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
@@ -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",
@@ -51,10 +51,27 @@ File names stay lowercase.
51
51
  builds a rapier world from the deck's own `scenes/main.scene` and steps it at
52
52
  `tickRate` (30). Never fork it.
53
53
  - **A game is added as hooks, not by replacing anything.** Write
54
- `code/server/game.js` with a default export of up to six functions --
54
+ `code/server/game.js` with a default export of up to six gameplay functions --
55
55
  `ready(sim)`, `message(session, player, data)`, `place(sim, handovers)`,
56
56
  `step(sim, handovers)`, `join(sim)`, `delta(sim)` -- and it is found by glob.
57
57
  Optional, as is `code/server/deckComponents.js` for component defaults.
58
+ - **What persists.** Object poses, the ids of despawned scene actors, each
59
+ player's last pose and `scene.persistent` block by account, and whatever the
60
+ optional `save(sim)` hook returns. Written every ten seconds when something
61
+ moved and at a clean shutdown. Nothing to configure.
62
+ - **A despawn is for everyone.** The client system reports a scene actor a
63
+ behavior despawned (a collected pickup); the session removes it for every
64
+ player, now and on every later visit.
65
+ - **`scene.persistent` must stay JSON** -- no Sets, no actors. It rides in the
66
+ move report when it changes, capped at 4 KB, and comes back in the WORLD
67
+ snapshot before the Player behavior runs, which is how the camera angle and
68
+ score return.
69
+ - **A returning player starts where they left off.** A second connection from
70
+ the same account while the first is still playing gets a new character at the
71
+ spawn. The room is `session.sessionId`; every `public` shard shares one.
72
+ - `restore(sim, saved)` receives the last `save` result before `ready`. Storage
73
+ is keyed by deck, so the deck must be published; `castle-web serve` brokers it
74
+ as the deck's creator.
58
75
  - `place` runs BEFORE the physics step, which is where a write through a deferred
59
76
  channel (`player.at`, `setNextKinematicTranslation`) has to happen to reach the
60
77
  body this tick. `message` is dispatched before the world exists, so a lobby
@@ -78,8 +95,9 @@ File names stay lowercase.
78
95
  - The kit ships `code/systems/multiplayer.js`, the frame loop. A deck that writes
79
96
  its own file of that path gets its own instead; most decks should not.
80
97
  - Two players is the test that matters: `castle-web serve`, then the play URL in
81
- two tabs. The session process holds the room, so restart the serve for a fresh
82
- one.
98
+ two tabs. The room persists in deck storage across serve restarts; a fresh one
99
+ means removing its `world:<room>`, `game:<room>` and `player:<room>:*` rows
100
+ (the local serve's room is `dev`).
83
101
 
84
102
  ## Layout
85
103
 
@@ -238,12 +256,13 @@ Map())` — and the next `showPlayer` respawns it.
238
256
  - **The player blueprint itself.** The kit spawns a scene from the deck's own
239
257
  root; the deck ships it. `blueprints/other-player.scene` is only the name it
240
258
  looks for when the deck names none.
241
- - **Any HUD.** Five decks wrote five HUDs with no shared lines. The one
242
- on-screen element the kit does draw is the solo badge
243
- (`castle.real-time`'s `soloBadge.js`): when the join fell back to solo it
244
- names why (`status().soloReason`), so a creator testing a private or
245
- unpublished deck is told rather than left to guess. A deck's
246
- `soloBadge(status)` client hook hides or rewords it.
259
+ - **Any HUD.** Five decks wrote five HUDs with no shared lines. The kit draws
260
+ two on-screen elements, both `castle.real-time`'s: the solo badge
261
+ (`soloBadge.js`) names why a join fell back to solo (`status().soloReason`),
262
+ so a creator testing a private or unpublished deck is told rather than left
263
+ to guess -- a deck's `soloBadge(status)` client hook hides or rewords it; and
264
+ the join overlay (`joinOverlay.js`) covers the card with "Joining…" until the
265
+ session's first snapshot places the player, ten seconds at most.
247
266
  - **Gameplay verbs** — carry, vehicle, round, lobby. Measured across six decks
248
267
  and none of them survived contact; see the audit.
249
268
 
@@ -14,11 +14,11 @@
14
14
  "imports": {
15
15
  "castle.real-time": {
16
16
  "deckId": "xFVr-afOLuUQ",
17
- "version": "2026-09-05T01:31:28.578Z"
17
+ "version": "2026-09-10T19:46:34.652Z"
18
18
  },
19
19
  "castle.physics-3d": {
20
20
  "deckId": "JH0SclbPVP0y",
21
- "version": "2026-09-10T00:57:52.158Z"
21
+ "version": "2026-09-10T19:46:37.323Z"
22
22
  },
23
23
  "castle.physics-2d": {
24
24
  "deckId": "ckRZGFW4iPrx",
@@ -33,7 +33,7 @@
33
33
  "autoUpdateWhenImported": true,
34
34
  "deckId": "ZwA_P_-VO-Qh",
35
35
  "cardId": "bvRmCzyUpt_a",
36
- "publishedVersion": "2026-09-10T00:58:16.876Z",
36
+ "publishedVersion": "2026-09-10T20:10:41.318Z",
37
37
  "main": "main.jsx",
38
38
  "server": {
39
39
  "main": "code/server/index.js",