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
@@ -15,6 +15,10 @@ export const MOVE = 'i';
15
15
  export const WORLD = 'w';
16
16
  export const STATE = 's';
17
17
 
18
+ // A client reports that it removed a scene actor: `{ k: GONE, id }`. The
19
+ // session removes it for everyone, for good.
20
+ export const GONE = 'g';
21
+
18
22
  // Movement reports run below the render rate and below the platform's roughly
19
23
  // 30-message-per-second client cap. A second report inside one server tick would
20
24
  // be overwritten before the simulation read it.
@@ -21,8 +21,12 @@ const bodyModules = {
21
21
  // `session.js` receives this value from the dimensional kit's server entry and
22
22
  // calls the hooks at the stages listed below.
23
23
  //
24
+ // restore(sim, saved) the value the last `save` returned for this
25
+ // room, or null. Runs before `ready`
24
26
  // ready(sim) after the world and its scene bodies exist,
25
27
  // before the first server tick
28
+ // save(sim) JSON kept for this room, written with the
29
+ // world every ten seconds and at shutdown
26
30
  // message(session, player, d) client data that is not a pose report. It
27
31
  // receives no handover list, so a world change
28
32
  // is kept and applied later by `place` or `step`
@@ -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,29 @@ 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.
84
+ server.building = server.world.createSimulation();
49
85
  return {
86
+ onStart(session) {
87
+ attach(server, session);
88
+ },
89
+
50
90
  // Joins are logged here; the simulation's players are reconciled from the
51
91
  // full session roster on the next tick.
52
92
  onPlayerJoin(session, player) {
93
+ attach(server, session);
53
94
  console.log(`[${server.label}] join ${player.username} (${player.playerId})`);
95
+
96
+ // The saved pose is read now, so the body this player is given on a later
97
+ // tick can start there.
98
+ beginResume(server, player);
54
99
  },
55
100
 
56
101
  onPlayerLeave(session, player) {
57
102
  // The next roster reconciliation removes the simulation player and body.
58
103
  console.log(`[${server.label}] leave ${player.username}`);
104
+ savePlayer(server, player);
59
105
  },
60
106
 
61
107
  onMessage(session, player, data) {
@@ -63,6 +109,7 @@ export function makeSession(parts) {
63
109
  },
64
110
 
65
111
  onTick(session) {
112
+ attach(server, session);
66
113
  tick(server, session);
67
114
  },
68
115
 
@@ -70,27 +117,64 @@ export function makeSession(parts) {
70
117
  // Tell the connected clients when Castle replaces this session process.
71
118
  session.broadcast({ k: 'replaced' });
72
119
  },
120
+
121
+ async onShutdown() {
122
+ await saveRoom(server);
123
+ },
73
124
  };
74
125
  }
75
126
 
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;
127
+ // Keep the first session a callback carries and build the world with it. The
128
+ // room the session persists under is not known before this.
129
+ function attach(server, session) {
130
+ if (server.session) {
131
+ return;
132
+ }
133
+ server.session = session;
134
+ server.room = Persist.roomOf(session);
135
+ buildWorld(server);
136
+ }
82
137
 
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
- );
138
+ // Finish the simulation: read the saved room, run the deck's restore and ready
139
+ // hooks, and apply the saved object poses. `server.sim` is assigned last, so the
140
+ // first WORLD snapshot anybody receives is the restored one.
141
+ async function buildWorld(server) {
142
+ try {
143
+ const created = await server.building;
144
+ const saved = await Persist.loadRoom(server.session, server.room);
145
+ server.game.restore?.(created, saved.game);
146
+
147
+ // Invoke the deck's ready hook with the built world before the first tick.
148
+ server.game.ready?.(created);
149
+
150
+ // Saved poses go on after both hooks, which is where a deck adds objects of
151
+ // its own. Ids the scene no longer has are skipped.
152
+ const applied = Array.isArray(saved.world?.poses)
153
+ ? server.world.restorePoses(created, saved.world.poses)
154
+ : 0;
155
+ for (const id of saved.world?.gone ?? []) {
156
+ if (typeof id === 'string') {
157
+ server.gone.add(id);
158
+ server.world.removeActor?.(created, id);
159
+ }
160
+ }
161
+ server.sim = created;
162
+ console.log(
163
+ `[${server.label}] simulation ready, ${applied} object poses restored, ${server.gone.size} actors gone`,
164
+ );
165
+ } catch (err) {
166
+ console.error(`[${server.label}] simulation failed: ${err?.message ?? err}`);
167
+ }
89
168
  }
90
169
 
91
170
  // Route one client message. Movement reports update the simulation, and other
92
171
  // message kinds go to the deck hook.
93
172
  function receive(server, session, player, data) {
173
+ if (data?.k === Messages.GONE) {
174
+ markGone(server, data.id);
175
+ return;
176
+ }
177
+
94
178
  // Deck control messages can run before simulation initialization completes.
95
179
  if (data?.k !== Messages.MOVE) {
96
180
  server.game.message?.(session, player, data);
@@ -112,6 +196,18 @@ function receive(server, session, player, data) {
112
196
  server.ownership.release(server.sim, player.playerId, data.r, server.handovers);
113
197
  }
114
198
 
199
+ // Remove a scene actor for everyone. The id is a scene actor id; anything else
200
+ // is ignored. The world drops the actor's body when it has one.
201
+ function markGone(server, id) {
202
+ if (!server.sim || typeof id !== 'string' || id.length > 128 || server.gone.has(id)) {
203
+ return;
204
+ }
205
+ server.gone.add(id);
206
+ server.newlyGone.push(id);
207
+ server.world.removeActor?.(server.sim, id);
208
+ server.moved = true;
209
+ }
210
+
115
211
  // One session tick: reconcile players, sweep ownership, run the deck's hooks
116
212
  // around physics, and send the join snapshots and the delta.
117
213
  function tick(server, session) {
@@ -144,16 +240,22 @@ function tick(server, session) {
144
240
  // A new player gets the whole world and the current ownerships. Sending the
145
241
  // ownerships with that first snapshot stops the client from simulating an
146
242
  // object somebody else already holds.
243
+ // A new player also gets every actor that is gone and their own saved block,
244
+ // which their client restores before it plays.
147
245
  for (const playerId of server.waitingForWorld) {
246
+ const state = server.players.stateOf?.(server.sim, playerId) ?? null;
148
247
  session.send(playerId, {
149
248
  k: Messages.WORLD,
150
249
  ...snapshot,
151
250
  own: server.ownership.ownerships(server.sim),
251
+ gone: [...server.gone],
252
+ ...(state === null ? {} : { me: state }),
152
253
  ...server.game.join?.(server.sim),
153
254
  });
154
255
  }
155
256
  server.waitingForWorld.clear();
156
257
  broadcastDelta(server, session, snapshot);
258
+ maybeSave(server);
157
259
  }
158
260
 
159
261
  // Make the simulation's player map match Castle's roster. The roster covers the
@@ -165,9 +267,11 @@ function reconcilePlayers(server, session) {
165
267
  continue;
166
268
  }
167
269
 
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);
270
+ // A newly observed roster id gets a body and a full WORLD on the tick its
271
+ // saved pose has been read.
272
+ if (admit(server, session, playerId)) {
273
+ server.waitingForWorld.add(playerId);
274
+ }
171
275
  }
172
276
  for (const playerId of [...server.sim.players.keys()]) {
173
277
  if (present.has(playerId)) {
@@ -180,6 +284,159 @@ function reconcilePlayers(server, session) {
180
284
  }
181
285
  }
182
286
 
287
+ // Give one roster id a body. A gone player of the same account is taken over at
288
+ // once; otherwise the saved pose is waited for, up to `RESUME_WAIT_MS`.
289
+ function admit(server, session, playerId) {
290
+ const entry = server.joining.get(playerId);
291
+ const ghost = entry ? ghostOf(server, session, playerId, entry.userId) : null;
292
+ if (ghost === undefined) {
293
+ // Another connection of this account is still playing: a second character.
294
+ server.joining.delete(playerId);
295
+ server.players.addPlayer(server.sim, playerId, null);
296
+ return true;
297
+ }
298
+ if (ghost) {
299
+ server.joining.delete(playerId);
300
+ server.resumedUsers.set(entry.userId, playerId);
301
+ server.players.addPlayer(server.sim, playerId, poseOf(server, ghost));
302
+ server.players.setState?.(server.sim, playerId, server.players.stateOf?.(server.sim, ghost));
303
+ return true;
304
+ }
305
+ if (entry && !entry.done && Date.now() < entry.until) {
306
+ return false;
307
+ }
308
+ server.joining.delete(playerId);
309
+ const saved = resumeSaved(server, playerId, entry);
310
+ server.players.addPlayer(server.sim, playerId, saved?.pose ?? null);
311
+ server.players.setState?.(server.sim, playerId, saved?.state ?? null);
312
+ return true;
313
+ }
314
+
315
+ // The playerId of a present player of the same account whose reports have
316
+ // stopped: a tab that was closed and not yet reaped. `undefined` when such a
317
+ // player is still reporting, null when there is none.
318
+ function ghostOf(server, session, playerId, userId) {
319
+ for (const player of session.players) {
320
+ if (player.playerId === playerId || player.userId !== userId) {
321
+ continue;
322
+ }
323
+ const body = server.sim.players.get(player.playerId);
324
+ if (!body) {
325
+ continue;
326
+ }
327
+ return server.sim.timeMs - body.movedAt > GHOST_MS ? player.playerId : undefined;
328
+ }
329
+ return null;
330
+ }
331
+
332
+ // The saved row a joining player resumes from, or null for the scene's spawn
333
+ // with no state.
334
+ function resumeSaved(server, playerId, entry) {
335
+ if (!entry?.saved || server.resumedUsers.has(entry.userId)) {
336
+ return null;
337
+ }
338
+ server.resumedUsers.set(entry.userId, playerId);
339
+ return entry.saved;
340
+ }
341
+
342
+ // Start reading a joining player's saved row.
343
+ function beginResume(server, player) {
344
+ if (!player.userId) {
345
+ return;
346
+ }
347
+ const entry = {
348
+ done: false,
349
+ saved: null,
350
+ userId: player.userId,
351
+ until: Date.now() + RESUME_WAIT_MS,
352
+ };
353
+ server.joining.set(player.playerId, entry);
354
+ void Persist.loadPlayer(server.session, server.room, player.userId).then((saved) => {
355
+ entry.saved = saved;
356
+ entry.done = true;
357
+ });
358
+ }
359
+
360
+ // Write a leaving player's last pose. Their body is still in the simulation
361
+ // until the next reconciliation. A player another connection took over writes
362
+ // nothing: the newer body's pose is the one to keep.
363
+ function savePlayer(server, player) {
364
+ server.joining.delete(player.playerId);
365
+ const holder = server.resumedUsers.get(player.userId);
366
+ if (holder && holder !== player.playerId) {
367
+ return;
368
+ }
369
+ server.resumedUsers.delete(player.userId);
370
+ const pose = poseOf(server, player.playerId);
371
+ if (!pose || !player.userId) {
372
+ return;
373
+ }
374
+ const state = server.players.stateOf?.(server.sim, player.playerId) ?? null;
375
+ void Persist.savePlayer(server.session, server.room, player.userId, { pose, state });
376
+ }
377
+
378
+ // Write the world every `SAVE_INTERVAL_MS`, and only when a pose moved since the
379
+ // last write.
380
+ function maybeSave(server) {
381
+ const now = Date.now();
382
+ if (!server.moved || server.saving || now - server.savedAt < SAVE_INTERVAL_MS) {
383
+ return;
384
+ }
385
+ server.moved = false;
386
+ void saveRoom(server);
387
+ }
388
+
389
+ // Write the world, the deck's block and every present player. A save already in
390
+ // flight is waited for, so a shutdown during the timer's write still lands its
391
+ // own.
392
+ async function saveRoom(server) {
393
+ if (!server.sim || !server.session) {
394
+ return;
395
+ }
396
+ if (server.saving) {
397
+ await server.saving;
398
+ }
399
+ server.savedAt = Date.now();
400
+ server.saving = Persist.saveRoom(server.session, server.room, {
401
+ world: { poses: server.world.objectPoses(server.sim), gone: [...server.gone] },
402
+ game: server.game.save?.(server.sim) ?? null,
403
+ players: presentPlayers(server),
404
+ });
405
+ try {
406
+ await server.saving;
407
+ } finally {
408
+ server.saving = null;
409
+ }
410
+ }
411
+
412
+ // Every present player's pose and state, by user id.
413
+ function presentPlayers(server) {
414
+ const users = new Map(server.session.players.map((player) => [player.playerId, player.userId]));
415
+ const out = [];
416
+ for (const [playerId, ...pose] of server.players.playerPoses(server.sim)) {
417
+ const userId = users.get(playerId);
418
+ if (userId) {
419
+ const state = server.players.stateOf?.(server.sim, playerId) ?? null;
420
+ out.push([userId, { pose: pose.slice(0, server.poses.PLAYER_GEOMETRY), state }]);
421
+ }
422
+ }
423
+ return out;
424
+ }
425
+
426
+ // One player's current pose, without the `clamped` flag that belongs to a
427
+ // single tick.
428
+ function poseOf(server, playerId) {
429
+ if (!server.sim) {
430
+ return null;
431
+ }
432
+ for (const [id, ...pose] of server.players.playerPoses(server.sim)) {
433
+ if (id === playerId) {
434
+ return pose.slice(0, server.poses.PLAYER_GEOMETRY);
435
+ }
436
+ }
437
+ return null;
438
+ }
439
+
183
440
  // Broadcast what changed since the previous tick. A tick where nothing changed
184
441
  // sends nothing at all.
185
442
  function broadcastDelta(server, session, snapshot) {
@@ -202,14 +459,27 @@ function broadcastDelta(server, session, snapshot) {
202
459
  // a new list for the next broadcast.
203
460
  const own = server.handovers;
204
461
  server.handovers = [];
462
+ const gone = server.newlyGone;
463
+ server.newlyGone = [];
464
+
465
+ // Only a moved pose makes the next save worth making.
466
+ server.moved = server.moved || ps.length > 0 || os.length > 0;
205
467
 
206
468
  // A changed deck block is its own reason to broadcast, including on a tick
207
469
  // where no pose and no ownership moved.
208
470
  const block = server.game.delta?.(server.sim) ?? null;
209
- if (ps.length === 0 && os.length === 0 && own.length === 0 && !block) {
471
+ if (ps.length === 0 && os.length === 0 && own.length === 0 && gone.length === 0 && !block) {
210
472
  return;
211
473
  }
212
- session.broadcast({ k: Messages.STATE, t: snapshot.t, ps, os, own, ...block });
474
+ session.broadcast({
475
+ k: Messages.STATE,
476
+ t: snapshot.t,
477
+ ps,
478
+ os,
479
+ own,
480
+ ...(gone.length ? { gone } : {}),
481
+ ...block,
482
+ });
213
483
  }
214
484
 
215
485
  // Whether one player's pose belongs in this delta, recording it as the new