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
@@ -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,23 @@ 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
+ - **Players wear their Castle avatar.** A player's `Box` draws their profile
41
+ photo in the circle, turned to their facing (`castle.physics-2d`'s
42
+ `engine/avatarArt.js`, through `User.get`). A player with no photo keeps the
43
+ box's color and facing dot.
44
+ - **A returning player starts where they left off.** A second connection from
45
+ the same account while the first is still playing gets a new character at the
46
+ spawn. The room is `session.sessionId`; every `public` shard shares one.
47
+ - `restore(sim, saved)` receives the last `save` result before `ready`. Storage
48
+ is keyed by deck, so the deck must be published; `castle-web serve` brokers it
49
+ as the deck's creator.
37
50
  - `place` runs BEFORE the physics step; a write that only reaches a body at the
38
51
  next step belongs there. `delta` returning news is its own reason to broadcast,
39
52
  so a tick where nothing moved but a round ended still reaches players.
@@ -55,8 +68,9 @@ then `Smoothing.smooth(…)`. File names stay lowercase.
55
68
  - The kit ships `code/systems/multiplayer.js`, the frame loop. A deck that writes
56
69
  its own file of that path gets its own instead; most decks should not.
57
70
  - 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.
71
+ two tabs. The room persists in deck storage across serve restarts; a fresh one
72
+ means removing its `world:<room>`, `game:<room>` and `player:<room>:*` rows
73
+ (the local serve's room is `dev`).
60
74
 
61
75
  ## Layout
62
76
 
@@ -137,11 +151,13 @@ and then, all optional, all found by root-anchored glob in
137
151
  | `code/server/game.js` | the game, as six hooks on a default export |
138
152
  | `code/server/deckComponents.js` | `export const componentDefaults = { Cargo: {...} }` |
139
153
 
140
- The kit draws no HUD, with one exception: when the join fell back to solo,
154
+ The kit draws no HUD, with two exceptions. When the join fell back to solo,
141
155
  `castle.real-time`'s `soloBadge.js` shows a small fixed badge naming why
142
156
  (`status().soloReason`: a private deck, no published server, or plain
143
157
  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.
158
+ guess; a deck's `soloBadge(status)` client hook hides (`false`) or rewords it.
159
+ While joining, its `joinOverlay.js` covers the card with "Joining…" until the
160
+ session's first snapshot places the player, ten seconds at most.
145
161
 
146
162
  The six server hooks: `ready(sim)`, `message(session, player, data)`,
147
163
  `place(sim, handovers)`, `step(sim, handovers)`, `join(sim)`, `delta(sim)`.
@@ -156,10 +172,9 @@ node ../../cli/dist/index.js serve . --port 5762 --detach
156
172
  node test/two-client-test.mjs
157
173
  ```
158
174
 
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.
175
+ The room's state persists, so crates stay where the last run pushed them. Each
176
+ client publishes what it believes about the room on `globalThis.__multiplayer`,
177
+ which is what the test reads.
163
178
 
164
179
  ## Rules for editing this kit
165
180
 
@@ -1,6 +1,9 @@
1
1
  // The Box behavior draws an actor as a filled rectangle with an optional facing
2
- // marker and label. The class declares editable properties first, then provides
3
- // the engine drawing methods and local canvas helpers.
2
+ // marker and label. A player's actor carries their avatar on
3
+ // `actor.runtime.avatar` (a canvas from the engine's `avatarArt.js`), and then
4
+ // the box is that picture in a circle, turned to the facing. The class declares
5
+ // editable properties first, then provides the engine drawing methods and local
6
+ // canvas helpers.
4
7
 
5
8
  export class Box {
6
9
  // The engine registers behavior classes by this stable scene component name.
@@ -36,15 +39,19 @@ export class Box {
36
39
  draw(actor, scene, ctx) {
37
40
  const { x, y, width, height } = actor.components.Layout;
38
41
  ctx.save();
39
- ctx.fillStyle = this.props.color;
40
42
  ctx.strokeStyle = this.props.edge;
41
43
  ctx.lineWidth = 2;
42
- roundRect(ctx, x, y, width, height, this.props.radius);
43
- ctx.fill();
44
- ctx.stroke();
45
-
46
- if (this.props.showFacing) {
47
- this.drawFacing(ctx, x + width / 2, y + height / 2, Math.min(width, height) / 2);
44
+ const avatar = actor.runtime?.avatar;
45
+ if (avatar) {
46
+ this.drawAvatar(ctx, avatar, x + width / 2, y + height / 2, Math.min(width, height) / 2);
47
+ } else {
48
+ ctx.fillStyle = this.props.color;
49
+ roundRect(ctx, x, y, width, height, this.props.radius);
50
+ ctx.fill();
51
+ ctx.stroke();
52
+ if (this.props.showFacing) {
53
+ this.drawFacing(ctx, x + width / 2, y + height / 2, Math.min(width, height) / 2);
54
+ }
48
55
  }
49
56
  if (this.props.label) {
50
57
  this.drawLabel(ctx, scene, x + width / 2, y);
@@ -52,6 +59,22 @@ export class Box {
52
59
  ctx.restore();
53
60
  }
54
61
 
62
+ // Draw the avatar in a circle, rotated to the facing so the picture itself
63
+ // shows which way the player is moving.
64
+ drawAvatar(ctx, avatar, cx, cy, radius) {
65
+ ctx.save();
66
+ ctx.translate(cx, cy);
67
+ ctx.rotate(this.props.facing ?? 0);
68
+ ctx.beginPath();
69
+ ctx.arc(0, 0, radius, 0, Math.PI * 2);
70
+ ctx.clip();
71
+ ctx.drawImage(avatar, -radius, -radius, radius * 2, radius * 2);
72
+ ctx.restore();
73
+ ctx.beginPath();
74
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
75
+ ctx.stroke();
76
+ }
77
+
55
78
  // Draw a marker toward the edge in the movement direction. The square body
56
79
  // has no other directional feature.
57
80
  drawFacing(ctx, cx, cy, reach) {
@@ -15,11 +15,11 @@
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-10T20:27:27.032Z"
19
19
  },
20
20
  "castle.physics-2d": {
21
21
  "deckId": "ckRZGFW4iPrx",
22
- "version": "2026-09-10T00:57:31.408Z"
22
+ "version": "2026-09-10T20:21:12.665Z"
23
23
  },
24
24
  "castle.base": {
25
25
  "deckId": "yRcmH4_aYllE",
@@ -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:28:48.461Z"
39
39
  }
@@ -4,6 +4,9 @@
4
4
  // player behind them.
5
5
 
6
6
  import { applyCharacterPose } from './poses.js';
7
+ import { avatarCanvas } from '@imports/castle.physics-2d/engine/avatarArt.js';
8
+
9
+ export { avatarCanvas };
7
10
 
8
11
  // The blueprint spawned when the caller names none.
9
12
  const BLUEPRINT = 'blueprints/other-player.scene';
@@ -29,7 +32,8 @@ export function makeAvatars() {
29
32
 
30
33
  // Create or update one other player and return its actor. The multiplayer
31
34
  // system calls this once per other player per frame with the smoothed network
32
- // pose. A `username` of null draws no label.
35
+ // pose. A `username` of null draws no label. Options are `blueprint`, the scene
36
+ // to spawn from, and `userId`, whose avatar the Box draws.
33
37
  export function showPlayer(avatars, scene, playerId, username, pose, options = {}) {
34
38
  const blueprint = options.blueprint ?? defaultBlueprint(scene);
35
39
  const actor = ensureActor(avatars, scene, playerId, pose, blueprint);
@@ -40,6 +44,7 @@ export function showPlayer(avatars, scene, playerId, username, pose, options = {
40
44
  // A blueprint may omit Box; such an avatar still receives its Layout pose.
41
45
  actor.components.Box.label = username === null ? '' : shortName(username);
42
46
  }
47
+ actor.runtime.avatar = avatarCanvas(options.userId);
43
48
  return actor;
44
49
  }
45
50
 
@@ -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
  }
@@ -212,6 +232,7 @@ class MultiplayerSystem {
212
232
  }
213
233
  Avatars.showPlayer(this.avatars, scene, playerId, look.username ?? null, pose, {
214
234
  blueprint: look.blueprint,
235
+ userId: this.net.userId(playerId),
215
236
  });
216
237
  }
217
238
 
@@ -301,6 +322,7 @@ class MultiplayerSystem {
301
322
  if (local.components.Box) {
302
323
  local.components.Box.label = Avatars.shortName(this.net.status().you);
303
324
  }
325
+ local.runtime.avatar = Avatars.avatarCanvas(this.net.userId(this.net.selfId()));
304
326
  }
305
327
 
306
328
  // `[id, centre]` for every scene object the server has sent a pose for. The
@@ -374,12 +396,33 @@ class MultiplayerSystem {
374
396
  this.clampedTo = null;
375
397
  }
376
398
 
399
+ // Whether the overlay should be up: joining or connected, not solo, and not
400
+ // yet placed. A session that never answers releases it after JOIN_WAIT_MS so
401
+ // the deck is not stuck behind it.
402
+ joiningOverlayWanted() {
403
+ if (this.joined || this.net?.status().solo) {
404
+ return false;
405
+ }
406
+ if (this.joinStartedAt && performance.now() - this.joinStartedAt > JOIN_WAIT_MS) {
407
+ if (!this.joinWarned) {
408
+ this.joinWarned = true;
409
+ console.warn('[multiplayer] no snapshot from the session yet; playing unplaced');
410
+ }
411
+ return false;
412
+ }
413
+ return true;
414
+ }
415
+
416
+ // Release scene resources and leave the session when the engine runtime ends.
377
417
  dispose(scene) {
378
418
  // Runtime disposal also closes transport resources retained across resets.
379
419
  game.dispose?.(scene, this);
380
420
  this.reset(scene);
381
421
  this.net?.close();
382
422
  this.net = null;
423
+ this.joined = false;
424
+ this.joinStartedAt = 0;
425
+ showJoinOverlay(false);
383
426
  // The badge is a fixed element outside the scene, so a disposed runtime
384
427
  // (an editor preview closing, say) has to take it down explicitly.
385
428
  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",