castle-web-cli 0.4.170 → 0.4.172

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 (58) 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/devSessionServer.js +9 -5
  6. package/dist/ide.d.ts +10 -0
  7. package/dist/ide.js +5 -5
  8. package/dist/init.js +1 -1
  9. package/dist/shell/assets/index-6odVZQSZ.css +1 -0
  10. package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
  11. package/dist/shell/index.html +3 -3
  12. package/kits/base/castle.json +1 -1
  13. package/kits/base/sdk/README.md +17 -1
  14. package/kits/base/sdk/commands.d.ts +14 -0
  15. package/kits/base/sdk/user.d.ts +4 -0
  16. package/kits/base/sdk/user.js +36 -1
  17. package/kits/multiplayer-2d/CLAUDE.md +24 -9
  18. package/kits/multiplayer-2d/behaviors/Box.jsx +32 -9
  19. package/kits/multiplayer-2d/castle.json +3 -3
  20. package/kits/multiplayer-2d/code/client/avatars.js +6 -1
  21. package/kits/multiplayer-2d/code/server/players.js +27 -6
  22. package/kits/multiplayer-2d/code/server/world.js +24 -1
  23. package/kits/multiplayer-2d/code/systems/multiplayer.js +47 -4
  24. package/kits/multiplayer-2d/package-lock.json +26 -1
  25. package/kits/multiplayer-2d/package.json +2 -0
  26. package/kits/multiplayer-3d/CLAUDE.md +32 -9
  27. package/kits/multiplayer-3d/castle.json +4 -4
  28. package/kits/multiplayer-3d/code/client/avatars.js +15 -11
  29. package/kits/multiplayer-3d/code/client/nameTags.js +35 -16
  30. package/kits/multiplayer-3d/code/server/players.js +54 -5
  31. package/kits/multiplayer-3d/code/server/world.js +45 -1
  32. package/kits/multiplayer-3d/code/systems/multiplayer.js +112 -7
  33. package/kits/multiplayer-3d/package-lock.json +26 -1
  34. package/kits/multiplayer-3d/package.json +2 -0
  35. package/kits/physics-2d/castle.json +1 -1
  36. package/kits/physics-2d/editors/PxArtEditor.jsx +2 -2
  37. package/kits/physics-2d/engine/avatarArt.js +91 -0
  38. package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
  39. package/kits/physics-3d/castle.json +1 -1
  40. package/kits/real-time/CLAUDE.md +23 -4
  41. package/kits/real-time/castle.json +1 -1
  42. package/kits/real-time/code/client/connection.js +15 -4
  43. package/kits/real-time/code/client/joinOverlay.js +46 -0
  44. package/kits/real-time/code/client/messages.js +4 -0
  45. package/kits/real-time/code/server/gameHooks.js +4 -0
  46. package/kits/real-time/code/server/persist.js +105 -0
  47. package/kits/real-time/code/server/session.js +295 -19
  48. package/kits/real-time/package-lock.json +1139 -0
  49. package/kits/turn-based/CLAUDE.md +80 -20
  50. package/kits/turn-based/castle.json +2 -2
  51. package/kits/turn-based/code/behaviors/Board.jsx +15 -5
  52. package/kits/turn-based/code/server/index.js +17 -5
  53. package/kits/turn-based/package.json +2 -1
  54. package/kits/turn-based/room.js +164 -13
  55. package/kits/turn-based/testing.js +276 -92
  56. package/package.json +1 -2
  57. package/dist/shell/assets/index-BvQmVwlO.css +0 -1
  58. package/dist/shell/assets/index-CV5sBby1.js +0 -445
@@ -0,0 +1,105 @@
1
+ // Deck storage for a real-time session: the world, the players, and the deck's
2
+ // own block.
3
+ //
4
+ // world:<room> { poses, gone }: object poses and the ids of scene
5
+ // actors removed for good
6
+ // game:<room> whatever the deck's `save` hook returned
7
+ // player:<room>:<userId> { pose, state }: where the player was and the
8
+ // block their client reported
9
+ //
10
+ // Storage rows are keyed by deck and not by session, so every key here carries
11
+ // the room the session belongs to. A named session and a party each have their
12
+ // own room; every public shard shares one.
13
+ //
14
+ // The server owns `deck` scope once a deck publishes a server. An unpublished
15
+ // deck has no storage at all, so a failure is reported and the session runs on
16
+ // without persistence.
17
+
18
+ // The room a session persists under.
19
+ export function roomOf(session) {
20
+ return session.mode === 'public' ? 'public' : session.sessionId;
21
+ }
22
+
23
+ // Read one room's world and deck block. A world saved before the gone list
24
+ // existed is a bare pose array.
25
+ export async function loadRoom(session, room) {
26
+ const saved = await read(session, [`world:${room}`, `game:${room}`], 'loadRoom');
27
+ const world = saved[`world:${room}`];
28
+ return {
29
+ world: Array.isArray(world) ? { poses: world, gone: [] } : (world ?? null),
30
+ game: saved[`game:${room}`] ?? null,
31
+ };
32
+ }
33
+
34
+ // Read one user's saved pose and state in this room, or null. A row saved
35
+ // before state existed is a bare pose array.
36
+ export async function loadPlayer(session, room, userId) {
37
+ const key = playerKey(room, userId);
38
+ const saved = await read(session, [key], 'loadPlayer');
39
+ const row = saved[key];
40
+ if (Array.isArray(row)) {
41
+ return { pose: row, state: null };
42
+ }
43
+ return Array.isArray(row?.pose) ? { pose: row.pose, state: row.state ?? null } : null;
44
+ }
45
+
46
+ // Write a room's world, deck block and present players in one request.
47
+ // `players` is a list of `[userId, { pose, state }]`.
48
+ export async function saveRoom(session, room, { world, game, players }) {
49
+ const values = { [`world:${room}`]: world };
50
+ if (game !== null && game !== undefined) {
51
+ values[`game:${room}`] = game;
52
+ }
53
+ for (const [userId, saved] of players) {
54
+ values[playerKey(room, userId)] = saved;
55
+ }
56
+ const stored = await write(session, values, 'saveRoom');
57
+
58
+ // Logged once; the same line every ten seconds says nothing more.
59
+ if (stored && !announced) {
60
+ announced = true;
61
+ console.log(`[persist] saving ${Object.keys(values).length} keys under ${room}`);
62
+ }
63
+ }
64
+
65
+ // Write one user's pose and state, which is where a later session starts them.
66
+ export async function savePlayer(session, room, userId, saved) {
67
+ await write(session, { [playerKey(room, userId)]: saved }, 'savePlayer');
68
+ }
69
+
70
+ function playerKey(room, userId) {
71
+ return `player:${room}:${userId}`;
72
+ }
73
+
74
+ async function read(session, keys, operation) {
75
+ try {
76
+ return await session.storage.deck.get(keys);
77
+ } catch (error) {
78
+ report(operation, error);
79
+ return {};
80
+ }
81
+ }
82
+
83
+ async function write(session, values, operation) {
84
+ try {
85
+ await session.storage.deck.set(values);
86
+ return true;
87
+ } catch (error) {
88
+ report(operation, error);
89
+ return false;
90
+ }
91
+ }
92
+
93
+ let announced = false;
94
+
95
+ // Operations that have already reported a failure, so a broken setup is named
96
+ // once.
97
+ const reported = new Set();
98
+
99
+ function report(operation, error) {
100
+ if (reported.has(operation)) {
101
+ return;
102
+ }
103
+ reported.add(operation);
104
+ console.error(`[persist] ${operation} failed: ${error?.message ?? error}`);
105
+ }
@@ -7,20 +7,44 @@
7
7
  // kit modules or through the callbacks the kit's pose codec exports.
8
8
 
9
9
  import * as Messages from '../client/messages.js';
10
+ import * as Persist from './persist.js';
10
11
 
11
12
  // Maximum wall-clock duration passed to physics for one session tick.
12
13
  const MAX_TICK_SECONDS = 0.25;
13
14
 
15
+ // How often a moved world and its players are written to storage.
16
+ const SAVE_INTERVAL_MS = 10000;
17
+
18
+ // How long a joining player waits for their saved pose before starting at the
19
+ // scene's spawn.
20
+ const RESUME_WAIT_MS = 3000;
21
+
22
+ // A player whose last report is older than this has gone: a closed tab whose
23
+ // connection the platform has not reaped yet.
24
+ const GHOST_MS = 2000;
25
+
14
26
  // The state one session process keeps, beside the modules it was built with:
15
27
  //
16
28
  // label prefix for this process in log messages
17
29
  // poses the kit's pose codec, for `PLAYER_GEOMETRY` and the
18
30
  // per-index `epsilonAt` the delta comparison reads
19
- // world createSimulation, step, objectPoses
31
+ // world createSimulation, step, objectPoses, restorePoses
20
32
  // players addPlayer, removePlayer, setPose, playerPoses
21
33
  // ownership claim, release, setObjectPoses, sweepOwners, ownerships
22
34
  // game the deck's hooks, from `gameHooks.js`
23
35
  // sim the simulation, null until it is built
36
+ // building the pending simulation, from `createSimulation`
37
+ // session the Castle session, from the first callback carrying one
38
+ // room the storage room this session persists under
39
+ // joining playerId -> { done, saved, userId, until } while a saved
40
+ // player row is being read
41
+ // gone ids of scene actors removed for good, by any client
42
+ // newlyGone ids removed since the last broadcast
43
+ // resumedUsers userId -> playerId, for bodies that started from a saved
44
+ // pose or took over a gone player's
45
+ // moved whether a pose moved since the last save
46
+ // savedAt wall clock of the last save
47
+ // saving the save in flight, or null
24
48
  // lastTickAt wall clock at the previous tick
25
49
  // sent the last pose broadcast for each id, so a later tick can
26
50
  // send only what changed
@@ -31,6 +55,16 @@ function newServer(parts) {
31
55
  return {
32
56
  ...parts,
33
57
  sim: null,
58
+ building: null,
59
+ session: null,
60
+ room: '',
61
+ joining: new Map(),
62
+ gone: new Set(),
63
+ newlyGone: [],
64
+ resumedUsers: new Map(),
65
+ moved: false,
66
+ savedAt: 0,
67
+ saving: null,
34
68
  lastTickAt: 0,
35
69
  sent: { players: new Map(), objects: new Map() },
36
70
  waitingForWorld: new Set(),
@@ -45,17 +79,35 @@ function newServer(parts) {
45
79
  // and messages between them.
46
80
  export function makeSession(parts) {
47
81
  const server = newServer(parts);
48
- buildWorld(server);
82
+
83
+ // Castle's handlers remain available while `createSimulation` is pending. A
84
+ // build that fails is logged here; `buildWorld` sees the same rejection when
85
+ // the first tick awaits it.
86
+ server.building = server.world.createSimulation();
87
+ server.building.catch((err) => {
88
+ console.error(`[${server.label}] simulation failed to build: ${err?.stack ?? err}`);
89
+ });
49
90
  return {
91
+ // The world is built from the first tick or join, not here: `onStart` runs
92
+ // while the process is still booting, and a storage read made then can go
93
+ // unanswered.
94
+ onStart() {},
95
+
50
96
  // Joins are logged here; the simulation's players are reconciled from the
51
97
  // full session roster on the next tick.
52
98
  onPlayerJoin(session, player) {
99
+ attach(server, session);
53
100
  console.log(`[${server.label}] join ${player.username} (${player.playerId})`);
101
+
102
+ // The saved pose is read now, so the body this player is given on a later
103
+ // tick can start there.
104
+ beginResume(server, player);
54
105
  },
55
106
 
56
107
  onPlayerLeave(session, player) {
57
108
  // The next roster reconciliation removes the simulation player and body.
58
109
  console.log(`[${server.label}] leave ${player.username}`);
110
+ savePlayer(server, player);
59
111
  },
60
112
 
61
113
  onMessage(session, player, data) {
@@ -63,6 +115,7 @@ export function makeSession(parts) {
63
115
  },
64
116
 
65
117
  onTick(session) {
118
+ attach(server, session);
66
119
  tick(server, session);
67
120
  },
68
121
 
@@ -70,27 +123,64 @@ export function makeSession(parts) {
70
123
  // Tell the connected clients when Castle replaces this session process.
71
124
  session.broadcast({ k: 'replaced' });
72
125
  },
126
+
127
+ async onShutdown() {
128
+ await saveRoom(server);
129
+ },
73
130
  };
74
131
  }
75
132
 
76
- // Start building the simulation when the handler object is created. Castle's
77
- // handlers remain available while `createSimulation` is pending.
78
- function buildWorld(server) {
79
- server.world.createSimulation().then(
80
- (created) => {
81
- server.sim = created;
133
+ // Keep the first session a tick or join carries and build the world with it.
134
+ // The room the session persists under is not known before this.
135
+ function attach(server, session) {
136
+ if (server.session) {
137
+ return;
138
+ }
139
+ server.session = session;
140
+ server.room = Persist.roomOf(session);
141
+ buildWorld(server);
142
+ }
82
143
 
83
- // Invoke the deck's ready hook with the built world before the first tick.
84
- server.game.ready?.(created);
85
- console.log(`[${server.label}] simulation ready`);
86
- },
87
- (err) => console.error(`[${server.label}] simulation failed: ${err?.message ?? err}`),
88
- );
144
+ // Finish the simulation: read the saved room, run the deck's restore and ready
145
+ // hooks, and apply the saved object poses. `server.sim` is assigned last, so the
146
+ // first WORLD snapshot anybody receives is the restored one.
147
+ async function buildWorld(server) {
148
+ try {
149
+ const created = await server.building;
150
+ const saved = await Persist.loadRoom(server.session, server.room);
151
+ server.game.restore?.(created, saved.game);
152
+
153
+ // Invoke the deck's ready hook with the built world before the first tick.
154
+ server.game.ready?.(created);
155
+
156
+ // Saved poses go on after both hooks, which is where a deck adds objects of
157
+ // its own. Ids the scene no longer has are skipped.
158
+ const applied = Array.isArray(saved.world?.poses)
159
+ ? server.world.restorePoses(created, saved.world.poses)
160
+ : 0;
161
+ for (const id of saved.world?.gone ?? []) {
162
+ if (typeof id === 'string') {
163
+ server.gone.add(id);
164
+ server.world.removeActor?.(created, id);
165
+ }
166
+ }
167
+ server.sim = created;
168
+ console.log(
169
+ `[${server.label}] simulation ready, ${applied} object poses restored, ${server.gone.size} actors gone`,
170
+ );
171
+ } catch (err) {
172
+ console.error(`[${server.label}] simulation failed: ${err?.message ?? err}`);
173
+ }
89
174
  }
90
175
 
91
176
  // Route one client message. Movement reports update the simulation, and other
92
177
  // message kinds go to the deck hook.
93
178
  function receive(server, session, player, data) {
179
+ if (data?.k === Messages.GONE) {
180
+ markGone(server, data.id);
181
+ return;
182
+ }
183
+
94
184
  // Deck control messages can run before simulation initialization completes.
95
185
  if (data?.k !== Messages.MOVE) {
96
186
  server.game.message?.(session, player, data);
@@ -112,6 +202,18 @@ function receive(server, session, player, data) {
112
202
  server.ownership.release(server.sim, player.playerId, data.r, server.handovers);
113
203
  }
114
204
 
205
+ // Remove a scene actor for everyone. The id is a scene actor id; anything else
206
+ // is ignored. The world drops the actor's body when it has one.
207
+ function markGone(server, id) {
208
+ if (!server.sim || typeof id !== 'string' || id.length > 128 || server.gone.has(id)) {
209
+ return;
210
+ }
211
+ server.gone.add(id);
212
+ server.newlyGone.push(id);
213
+ server.world.removeActor?.(server.sim, id);
214
+ server.moved = true;
215
+ }
216
+
115
217
  // One session tick: reconcile players, sweep ownership, run the deck's hooks
116
218
  // around physics, and send the join snapshots and the delta.
117
219
  function tick(server, session) {
@@ -144,16 +246,22 @@ function tick(server, session) {
144
246
  // A new player gets the whole world and the current ownerships. Sending the
145
247
  // ownerships with that first snapshot stops the client from simulating an
146
248
  // object somebody else already holds.
249
+ // A new player also gets every actor that is gone and their own saved block,
250
+ // which their client restores before it plays.
147
251
  for (const playerId of server.waitingForWorld) {
252
+ const state = server.players.stateOf?.(server.sim, playerId) ?? null;
148
253
  session.send(playerId, {
149
254
  k: Messages.WORLD,
150
255
  ...snapshot,
151
256
  own: server.ownership.ownerships(server.sim),
257
+ gone: [...server.gone],
258
+ ...(state === null ? {} : { me: state }),
152
259
  ...server.game.join?.(server.sim),
153
260
  });
154
261
  }
155
262
  server.waitingForWorld.clear();
156
263
  broadcastDelta(server, session, snapshot);
264
+ maybeSave(server);
157
265
  }
158
266
 
159
267
  // Make the simulation's player map match Castle's roster. The roster covers the
@@ -165,9 +273,11 @@ function reconcilePlayers(server, session) {
165
273
  continue;
166
274
  }
167
275
 
168
- // A newly observed roster id gets a body and a full WORLD on this tick.
169
- server.players.addPlayer(server.sim, playerId);
170
- server.waitingForWorld.add(playerId);
276
+ // A newly observed roster id gets a body and a full WORLD on the tick its
277
+ // saved pose has been read.
278
+ if (admit(server, session, playerId)) {
279
+ server.waitingForWorld.add(playerId);
280
+ }
171
281
  }
172
282
  for (const playerId of [...server.sim.players.keys()]) {
173
283
  if (present.has(playerId)) {
@@ -180,6 +290,159 @@ function reconcilePlayers(server, session) {
180
290
  }
181
291
  }
182
292
 
293
+ // Give one roster id a body. A gone player of the same account is taken over at
294
+ // once; otherwise the saved pose is waited for, up to `RESUME_WAIT_MS`.
295
+ function admit(server, session, playerId) {
296
+ const entry = server.joining.get(playerId);
297
+ const ghost = entry ? ghostOf(server, session, playerId, entry.userId) : null;
298
+ if (ghost === undefined) {
299
+ // Another connection of this account is still playing: a second character.
300
+ server.joining.delete(playerId);
301
+ server.players.addPlayer(server.sim, playerId, null);
302
+ return true;
303
+ }
304
+ if (ghost) {
305
+ server.joining.delete(playerId);
306
+ server.resumedUsers.set(entry.userId, playerId);
307
+ server.players.addPlayer(server.sim, playerId, poseOf(server, ghost));
308
+ server.players.setState?.(server.sim, playerId, server.players.stateOf?.(server.sim, ghost));
309
+ return true;
310
+ }
311
+ if (entry && !entry.done && Date.now() < entry.until) {
312
+ return false;
313
+ }
314
+ server.joining.delete(playerId);
315
+ const saved = resumeSaved(server, playerId, entry);
316
+ server.players.addPlayer(server.sim, playerId, saved?.pose ?? null);
317
+ server.players.setState?.(server.sim, playerId, saved?.state ?? null);
318
+ return true;
319
+ }
320
+
321
+ // The playerId of a present player of the same account whose reports have
322
+ // stopped: a tab that was closed and not yet reaped. `undefined` when such a
323
+ // player is still reporting, null when there is none.
324
+ function ghostOf(server, session, playerId, userId) {
325
+ for (const player of session.players) {
326
+ if (player.playerId === playerId || player.userId !== userId) {
327
+ continue;
328
+ }
329
+ const body = server.sim.players.get(player.playerId);
330
+ if (!body) {
331
+ continue;
332
+ }
333
+ return server.sim.timeMs - body.movedAt > GHOST_MS ? player.playerId : undefined;
334
+ }
335
+ return null;
336
+ }
337
+
338
+ // The saved row a joining player resumes from, or null for the scene's spawn
339
+ // with no state.
340
+ function resumeSaved(server, playerId, entry) {
341
+ if (!entry?.saved || server.resumedUsers.has(entry.userId)) {
342
+ return null;
343
+ }
344
+ server.resumedUsers.set(entry.userId, playerId);
345
+ return entry.saved;
346
+ }
347
+
348
+ // Start reading a joining player's saved row.
349
+ function beginResume(server, player) {
350
+ if (!player.userId) {
351
+ return;
352
+ }
353
+ const entry = {
354
+ done: false,
355
+ saved: null,
356
+ userId: player.userId,
357
+ until: Date.now() + RESUME_WAIT_MS,
358
+ };
359
+ server.joining.set(player.playerId, entry);
360
+ void Persist.loadPlayer(server.session, server.room, player.userId).then((saved) => {
361
+ entry.saved = saved;
362
+ entry.done = true;
363
+ });
364
+ }
365
+
366
+ // Write a leaving player's last pose. Their body is still in the simulation
367
+ // until the next reconciliation. A player another connection took over writes
368
+ // nothing: the newer body's pose is the one to keep.
369
+ function savePlayer(server, player) {
370
+ server.joining.delete(player.playerId);
371
+ const holder = server.resumedUsers.get(player.userId);
372
+ if (holder && holder !== player.playerId) {
373
+ return;
374
+ }
375
+ server.resumedUsers.delete(player.userId);
376
+ const pose = poseOf(server, player.playerId);
377
+ if (!pose || !player.userId) {
378
+ return;
379
+ }
380
+ const state = server.players.stateOf?.(server.sim, player.playerId) ?? null;
381
+ void Persist.savePlayer(server.session, server.room, player.userId, { pose, state });
382
+ }
383
+
384
+ // Write the world every `SAVE_INTERVAL_MS`, and only when a pose moved since the
385
+ // last write.
386
+ function maybeSave(server) {
387
+ const now = Date.now();
388
+ if (!server.moved || server.saving || now - server.savedAt < SAVE_INTERVAL_MS) {
389
+ return;
390
+ }
391
+ server.moved = false;
392
+ void saveRoom(server);
393
+ }
394
+
395
+ // Write the world, the deck's block and every present player. A save already in
396
+ // flight is waited for, so a shutdown during the timer's write still lands its
397
+ // own.
398
+ async function saveRoom(server) {
399
+ if (!server.sim || !server.session) {
400
+ return;
401
+ }
402
+ if (server.saving) {
403
+ await server.saving;
404
+ }
405
+ server.savedAt = Date.now();
406
+ server.saving = Persist.saveRoom(server.session, server.room, {
407
+ world: { poses: server.world.objectPoses(server.sim), gone: [...server.gone] },
408
+ game: server.game.save?.(server.sim) ?? null,
409
+ players: presentPlayers(server),
410
+ });
411
+ try {
412
+ await server.saving;
413
+ } finally {
414
+ server.saving = null;
415
+ }
416
+ }
417
+
418
+ // Every present player's pose and state, by user id.
419
+ function presentPlayers(server) {
420
+ const users = new Map(server.session.players.map((player) => [player.playerId, player.userId]));
421
+ const out = [];
422
+ for (const [playerId, ...pose] of server.players.playerPoses(server.sim)) {
423
+ const userId = users.get(playerId);
424
+ if (userId) {
425
+ const state = server.players.stateOf?.(server.sim, playerId) ?? null;
426
+ out.push([userId, { pose: pose.slice(0, server.poses.PLAYER_GEOMETRY), state }]);
427
+ }
428
+ }
429
+ return out;
430
+ }
431
+
432
+ // One player's current pose, without the `clamped` flag that belongs to a
433
+ // single tick.
434
+ function poseOf(server, playerId) {
435
+ if (!server.sim) {
436
+ return null;
437
+ }
438
+ for (const [id, ...pose] of server.players.playerPoses(server.sim)) {
439
+ if (id === playerId) {
440
+ return pose.slice(0, server.poses.PLAYER_GEOMETRY);
441
+ }
442
+ }
443
+ return null;
444
+ }
445
+
183
446
  // Broadcast what changed since the previous tick. A tick where nothing changed
184
447
  // sends nothing at all.
185
448
  function broadcastDelta(server, session, snapshot) {
@@ -202,14 +465,27 @@ function broadcastDelta(server, session, snapshot) {
202
465
  // a new list for the next broadcast.
203
466
  const own = server.handovers;
204
467
  server.handovers = [];
468
+ const gone = server.newlyGone;
469
+ server.newlyGone = [];
470
+
471
+ // Only a moved pose makes the next save worth making.
472
+ server.moved = server.moved || ps.length > 0 || os.length > 0;
205
473
 
206
474
  // A changed deck block is its own reason to broadcast, including on a tick
207
475
  // where no pose and no ownership moved.
208
476
  const block = server.game.delta?.(server.sim) ?? null;
209
- if (ps.length === 0 && os.length === 0 && own.length === 0 && !block) {
477
+ if (ps.length === 0 && os.length === 0 && own.length === 0 && gone.length === 0 && !block) {
210
478
  return;
211
479
  }
212
- session.broadcast({ k: Messages.STATE, t: snapshot.t, ps, os, own, ...block });
480
+ session.broadcast({
481
+ k: Messages.STATE,
482
+ t: snapshot.t,
483
+ ps,
484
+ os,
485
+ own,
486
+ ...(gone.length ? { gone } : {}),
487
+ ...block,
488
+ });
213
489
  }
214
490
 
215
491
  // Whether one player's pose belongs in this delta, recording it as the new