castle-web-cli 0.4.171 → 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.
@@ -326,6 +326,15 @@ class DevSessionServerImpl {
326
326
  this.clearReadyWaiters();
327
327
  return;
328
328
  }
329
+ // A storage request is answered whenever it arrives. The session's `onStart`
330
+ // runs inside boot, before the child reports ready, and a read made there
331
+ // would otherwise never settle.
332
+ if (frame.t === 'storageRequest' &&
333
+ typeof frame.reqId === 'string' &&
334
+ typeof frame.op === 'string') {
335
+ void this.handleStorageRequest(frame.reqId, frame.op, asRecord(frame.args));
336
+ return;
337
+ }
329
338
  if (!this.runtimeReady)
330
339
  return;
331
340
  if (frame.t === 'sendTo' && typeof frame.playerId === 'string') {
@@ -359,11 +368,6 @@ class DevSessionServerImpl {
359
368
  else if (frame.t === 'kick' && typeof frame.playerId === 'string') {
360
369
  this.kick(frame.playerId, typeof frame.reason === 'string' ? frame.reason : undefined);
361
370
  }
362
- else if (frame.t === 'storageRequest' &&
363
- typeof frame.reqId === 'string' &&
364
- typeof frame.op === 'string') {
365
- void this.handleStorageRequest(frame.reqId, frame.op, asRecord(frame.args));
366
- }
367
371
  }
368
372
  async handleStorageRequest(reqId, op, args) {
369
373
  const deckId = readCastleJson(this.projectDir)?.deckId;
@@ -37,6 +37,10 @@ then `Smoothing.smooth(…)`. File names stay lowercase.
37
37
  - **What persists.** Object poses, each player's last pose by account, and
38
38
  whatever the optional `save(sim)` hook returns are written every ten seconds
39
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.
40
44
  - **A returning player starts where they left off.** A second connection from
41
45
  the same account while the first is still playing gets a new character at the
42
46
  spawn. The room is `session.sessionId`; every `public` shard shares one.
@@ -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-10T19:46:34.652Z"
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-10T20:10:36.784Z"
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
 
@@ -232,6 +232,7 @@ class MultiplayerSystem {
232
232
  }
233
233
  Avatars.showPlayer(this.avatars, scene, playerId, look.username ?? null, pose, {
234
234
  blueprint: look.blueprint,
235
+ userId: this.net.userId(playerId),
235
236
  });
236
237
  }
237
238
 
@@ -321,6 +322,7 @@ class MultiplayerSystem {
321
322
  if (local.components.Box) {
322
323
  local.components.Box.label = Avatars.shortName(this.net.status().you);
323
324
  }
325
+ local.runtime.avatar = Avatars.avatarCanvas(this.net.userId(this.net.selfId()));
324
326
  }
325
327
 
326
328
  // `[id, centre]` for every scene object the server has sent a pose for. The
@@ -66,6 +66,10 @@ File names stay lowercase.
66
66
  move report when it changes, capped at 4 KB, and comes back in the WORLD
67
67
  snapshot before the Player behavior runs, which is how the camera angle and
68
68
  score return.
69
+ - **Players wear their Castle avatar.** The name tag over every player,
70
+ the local one included, carries their profile photo and frame
71
+ (`castle.physics-2d`'s `engine/avatarArt.js`, through `User.get`). A player
72
+ with no photo gets the name alone.
69
73
  - **A returning player starts where they left off.** A second connection from
70
74
  the same account while the first is still playing gets a new character at the
71
75
  spawn. The room is `session.sessionId`; every `public` shard shares one.
@@ -14,7 +14,7 @@
14
14
  "imports": {
15
15
  "castle.real-time": {
16
16
  "deckId": "xFVr-afOLuUQ",
17
- "version": "2026-09-10T19:46:34.652Z"
17
+ "version": "2026-09-10T20:27:27.032Z"
18
18
  },
19
19
  "castle.physics-3d": {
20
20
  "deckId": "JH0SclbPVP0y",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "castle.physics-2d": {
24
24
  "deckId": "ckRZGFW4iPrx",
25
- "version": "2026-09-10T00:57:31.408Z",
25
+ "version": "2026-09-10T20:21:12.665Z",
26
26
  "via": "castle.physics-3d"
27
27
  },
28
28
  "castle.base": {
@@ -33,7 +33,7 @@
33
33
  "autoUpdateWhenImported": true,
34
34
  "deckId": "ZwA_P_-VO-Qh",
35
35
  "cardId": "bvRmCzyUpt_a",
36
- "publishedVersion": "2026-09-10T20:10:41.318Z",
36
+ "publishedVersion": "2026-09-10T20:28:53.356Z",
37
37
  "main": "main.jsx",
38
38
  "server": {
39
39
  "main": "code/server/index.js",
@@ -4,6 +4,7 @@
4
4
 
5
5
  import * as NameTags from './nameTags.js';
6
6
  import { applyPose } from './poses.js';
7
+ import { avatarCanvas } from '@imports/castle.physics-2d/engine/avatarArt.js';
7
8
 
8
9
  // The blueprint spawned when a caller names none. A deck's own file wins; a deck
9
10
  // scaffolded from this kit has no blueprints yet, so the kit's copy stands in
@@ -33,7 +34,8 @@ export function makeAvatars() {
33
34
  //
34
35
  // A `username` of null draws no label. Options are `blueprint`, the scene to
35
36
  // spawn from (default `blueprints/other-player.scene`, the deck's own or the kit's),
36
- // and `tagHeight`, how far over the body the label floats.
37
+ // `tagHeight`, how far over the body the label floats, and `userId`, whose
38
+ // avatar the label carries.
37
39
  export function showPlayer(avatars, scene, playerId, username, pose, options = {}) {
38
40
  // The actor keeps the blueprint used at creation; `ensureActor` only spawns
39
41
  // when this player has no live actor in the current scene.
@@ -46,19 +48,20 @@ export function showPlayer(avatars, scene, playerId, username, pose, options = {
46
48
  if (username === null) {
47
49
  dropTag(avatars, scene, playerId);
48
50
  } else {
49
- showTag(avatars, scene, playerId, username, transform, options.tagHeight);
51
+ showTag(avatars, scene, playerId, username, transform, options);
50
52
  }
51
53
  return actor;
52
54
  }
53
55
 
54
56
  // Update the local player's label over the engine's local Player actor. The SDK
55
57
  // roster can arrive several frames after connection, so an empty username
56
- // leaves the label absent until the roster supplies one.
57
- export function showSelf(avatars, scene, username, transform, tagHeight) {
58
+ // leaves the label absent until the roster supplies one. Options are
59
+ // `tagHeight` and `userId`, as for `showPlayer`.
60
+ export function showSelf(avatars, scene, username, transform, options = {}) {
58
61
  if (!username) {
59
62
  return;
60
63
  }
61
- showTag(avatars, scene, 'self', username, transform, tagHeight);
64
+ showTag(avatars, scene, 'self', username, transform, options);
62
65
  }
63
66
 
64
67
  // Despawn the actor and dispose the tag of every player absent from `present`.
@@ -106,20 +109,21 @@ function ensureActor(avatars, scene, playerId, pose, blueprint) {
106
109
  return spawned;
107
110
  }
108
111
 
109
- // Create a tag when the displayed text changes, then place it over the actor.
110
- // Each redraw allocates a canvas, texture, and sprite, so unchanged text reuses
111
- // the existing resources.
112
- function showTag(avatars, scene, key, username, transform, tagHeight) {
112
+ // Create a tag when the displayed text or the avatar changes, then place it over
113
+ // the actor. Each redraw allocates a canvas, texture, and sprite, so an unchanged
114
+ // tag reuses the existing resources.
115
+ function showTag(avatars, scene, key, username, transform, { tagHeight, userId } = {}) {
113
116
  const label = NameTags.shortName(username);
117
+ const avatar = avatarCanvas(userId);
114
118
  let tag = avatars.tags.get(key);
115
119
 
116
120
  // Compare against the shortened text stored on the tag. An
117
121
  // `anonymous-user-<uuid>` differs from the `anon-xxxxxx` string on the sprite.
118
- if (!tag || tag.text !== label) {
122
+ if (!tag || tag.text !== label || tag.avatar !== avatar) {
119
123
  if (tag) {
120
124
  NameTags.disposeNameTag(scene.three, tag);
121
125
  }
122
- tag = NameTags.makeNameTag(scene.three, label);
126
+ tag = NameTags.makeNameTag(scene.three, label, avatar);
123
127
  avatars.tags.set(key, tag);
124
128
  }
125
129
 
@@ -1,6 +1,8 @@
1
- // Floating username-label sprites for other players and the local player. This module
2
- // draws text to a canvas, attaches the resulting sprite directly to the engine's
3
- // three.js scene, scales it by camera distance, and disposes its GPU resources.
1
+ // Floating name tags for other players and the local player: the player's round
2
+ // avatar over their username. This module draws them to a canvas, attaches the
3
+ // resulting sprite directly to the engine's three.js scene, scales it by camera
4
+ // distance, and disposes its GPU resources. A sprite always faces the camera,
5
+ // so the avatar reads from every side.
4
6
 
5
7
  import * as THREE from 'three';
6
8
 
@@ -8,6 +10,11 @@ import * as THREE from 'three';
8
10
  const HEIGHT_ABOVE = 1.25;
9
11
  const PIXELS_PER_UNIT = 200;
10
12
 
13
+ // Canvas pixels of the name pill, and of the avatar drawn above it.
14
+ const LABEL_PX = 68;
15
+ const AVATAR_PX = 112;
16
+ const GAP_PX = 8;
17
+
11
18
  // The distance at which the label is the size it was drawn to be: 68 canvas
12
19
  // pixels over 200 per unit is 0.34m, which on a 500x700 card at fov 55 is 29px.
13
20
  // This reference distance matches the common camera placement in kit decks.
@@ -16,11 +23,12 @@ const SAME_SIZE_M = 8;
16
23
  // The minimum scale keeps a label readable when the camera is near the player.
17
24
  const MIN_SCALE = 0.35;
18
25
 
19
- // Create `{ sprite, text, width, height }` and add the sprite to the engine's
20
- // three.js scene. The engine actor-to-mesh sync does not update these directly
21
- // attached sprites, so callers move them each frame with `moveNameTag`.
22
- export function makeNameTag(world, text) {
23
- const canvas = drawLabel(text);
26
+ // Create `{ sprite, text, avatar, width, height, lift }` and add the sprite to
27
+ // the engine's three.js scene. `avatar` is the canvas from `avatarArt.js`, or
28
+ // null for a name alone. The engine actor-to-mesh sync does not update these
29
+ // directly attached sprites, so callers move them each frame with `moveNameTag`.
30
+ export function makeNameTag(world, text, avatar = null) {
31
+ const canvas = drawLabel(text, avatar);
24
32
  const texture = new THREE.CanvasTexture(canvas);
25
33
  texture.colorSpace = THREE.SRGBColorSpace;
26
34
  const sprite = new THREE.Sprite(
@@ -41,8 +49,11 @@ export function makeNameTag(world, text) {
41
49
  world.scene.add(sprite);
42
50
 
43
51
  // `text` is the string actually drawn, not the username it came from. The
44
- // caller compares against it to decide whether a name changed.
45
- return { sprite, text, width, height };
52
+ // caller compares against it, and against `avatar`, to decide whether to
53
+ // redraw. `lift` raises the sprite's centre so the name pill stays at
54
+ // `heightAbove` when an avatar is stacked over it.
55
+ const lift = (height - LABEL_PX / PIXELS_PER_UNIT) / 2;
56
+ return { sprite, text, avatar, width, height, lift };
46
57
  }
47
58
 
48
59
  // Move and scale a tag after its player pose is drawn for the current frame.
@@ -61,6 +72,7 @@ export function moveNameTag(tag, transform, camera, heightAbove = HEIGHT_ABOVE)
61
72
  // 0.8m 286px -> 100, 2m 114px -> 40, 4m 57px -> 29, 8m 29px -> 29, 20m 11 -> 11.
62
73
  const scale = Math.max(MIN_SCALE, Math.min(1, away / SAME_SIZE_M));
63
74
  tag.sprite.scale.set(tag.width * scale, tag.height * scale, 1);
75
+ tag.sprite.position.y += tag.lift * scale;
64
76
  }
65
77
 
66
78
  // Remove a tag from the three.js scene and release its texture and material.
@@ -78,24 +90,31 @@ export function shortName(username) {
78
90
  return anon ? `anon-${anon[1].replace(/-/g, '').slice(-6)}` : (username ?? 'player');
79
91
  }
80
92
 
81
- // Rasterize one display name into the canvas used as the sprite texture.
82
- function drawLabel(text) {
93
+ // Rasterize one display name, with the avatar centred above it, into the canvas
94
+ // used as the sprite texture.
95
+ function drawLabel(text, avatar) {
83
96
  const canvas = document.createElement('canvas');
84
97
  const ctx = canvas.getContext('2d');
85
98
  const font = '600 44px Inter, system-ui, sans-serif';
86
99
  ctx.font = font;
87
- const width = Math.ceil(ctx.measureText(text).width) + 40;
100
+ const pillWidth = Math.ceil(ctx.measureText(text).width) + 40;
101
+ const width = avatar ? Math.max(pillWidth, AVATAR_PX) : pillWidth;
102
+ const top = avatar ? AVATAR_PX + GAP_PX : 0;
88
103
 
89
104
  // Canvas resizing clears the context state, so measure before resizing and
90
105
  // restore the font before drawing.
91
106
  canvas.width = width;
92
- canvas.height = 68;
107
+ canvas.height = top + LABEL_PX;
93
108
  ctx.font = font;
109
+ if (avatar) {
110
+ ctx.drawImage(avatar, (width - AVATAR_PX) / 2, 0, AVATAR_PX, AVATAR_PX);
111
+ }
112
+ const left = (width - pillWidth) / 2;
94
113
  ctx.fillStyle = 'rgba(12,12,16,0.72)';
95
- ctx.roundRect(0, 0, width, 68, 16);
114
+ ctx.roundRect(left, top, pillWidth, LABEL_PX, 16);
96
115
  ctx.fill();
97
116
  ctx.fillStyle = '#f2f2f5';
98
117
  ctx.textBaseline = 'middle';
99
- ctx.fillText(text, 20, 36);
118
+ ctx.fillText(text, left + 20, top + LABEL_PX / 2);
100
119
  return canvas;
101
120
  }
@@ -262,6 +262,7 @@ class MultiplayerSystem {
262
262
  }
263
263
  Avatars.showPlayer(this.avatars, scene, playerId, look.username ?? null, pose, {
264
264
  blueprint: look.blueprint,
265
+ userId: this.net.userId(playerId),
265
266
  });
266
267
  }
267
268
  Avatars.dropMissing(this.avatars, scene, others);
@@ -350,7 +351,9 @@ class MultiplayerSystem {
350
351
  if (this.net.sendMove(Poses.packMove(me), owned, claims, releases, state) && state) {
351
352
  this.sentState = stateJson;
352
353
  }
353
- Avatars.showSelf(this.avatars, scene, this.net.status().you, me);
354
+ Avatars.showSelf(this.avatars, scene, this.net.status().you, me, {
355
+ userId: this.net.userId(this.net.selfId()),
356
+ });
354
357
  }
355
358
 
356
359
  // Return `[id, Transform]` for every scene object known from server snapshots.
@@ -72,7 +72,7 @@
72
72
  "main": "main.jsx",
73
73
  "autoUpdateWhenImported": true,
74
74
  "title": "physics-2d",
75
- "publishedVersion": "2026-09-10T20:09:53.271Z",
75
+ "publishedVersion": "2026-09-10T20:28:41.310Z",
76
76
  "imports": {
77
77
  "castle.base": {
78
78
  "deckId": "yRcmH4_aYllE",
@@ -0,0 +1,91 @@
1
+ // A player's round avatar for drawing in a scene: the photo clipped to a circle
2
+ // with their frame over it, as a canvas. Read through `User.get` once per user
3
+ // id and kept for the page. A player with no photo has no avatar, and a caller
4
+ // keeps its default look.
5
+ //
6
+ // `avatarCanvas` is for code that draws with a canvas context, like a name tag.
7
+ // `avatarSpritePath` registers the same picture as image art in `scene.sprites`,
8
+ // so a `Sprite` component can show it by file path.
9
+
10
+ import { User } from 'castle-web-sdk';
11
+ import { imageArt } from './art';
12
+
13
+ const SIZE = 128;
14
+
15
+ // A frame is drawn around a photo two thirds its size, the proportion Castle's
16
+ // own profile views use.
17
+ const FRAMED_INSET = SIZE / 6;
18
+
19
+ // userId -> { canvas }, present from the first ask; `canvas` fills in when the
20
+ // read completes.
21
+ const avatars = new Map();
22
+
23
+ // The avatar for one user id, or null until it is loaded or when there is none.
24
+ export function avatarCanvas(userId) {
25
+ if (!userId) {
26
+ return null;
27
+ }
28
+ let entry = avatars.get(userId);
29
+ if (!entry) {
30
+ entry = { canvas: null };
31
+ avatars.set(userId, entry);
32
+ void load(userId, entry);
33
+ }
34
+ return entry.canvas;
35
+ }
36
+
37
+ // The `scene.sprites` path holding this user's avatar, or null while there is
38
+ // none. A `Sprite` with this `file` draws it.
39
+ export function avatarSpritePath(scene, userId) {
40
+ const canvas = avatarCanvas(userId);
41
+ if (!canvas) {
42
+ return null;
43
+ }
44
+ const path = `avatars/${userId}.png`;
45
+ if (!scene.sprites[path]) {
46
+ scene.sprites[path] = imageArt(path, canvas.toDataURL());
47
+ }
48
+ return path;
49
+ }
50
+
51
+ async function load(userId, entry) {
52
+ try {
53
+ const user = await User.get(userId);
54
+ const [photo, frame] = await Promise.all([loadImage(user?.photoUrl), loadImage(user?.frameUrl)]);
55
+ if (photo) {
56
+ entry.canvas = compose(photo, frame);
57
+ }
58
+ } catch {
59
+ // A failed read leaves the entry without a canvas; the next page asks again.
60
+ }
61
+ }
62
+
63
+ function loadImage(url) {
64
+ if (!url) {
65
+ return Promise.resolve(null);
66
+ }
67
+ return new Promise((resolve) => {
68
+ const image = new Image();
69
+ image.onload = () => resolve(image);
70
+ image.onerror = () => resolve(null);
71
+ image.src = url;
72
+ });
73
+ }
74
+
75
+ function compose(photo, frame) {
76
+ const canvas = document.createElement('canvas');
77
+ canvas.width = SIZE;
78
+ canvas.height = SIZE;
79
+ const ctx = canvas.getContext('2d');
80
+ const inset = frame ? FRAMED_INSET : 0;
81
+ ctx.save();
82
+ ctx.beginPath();
83
+ ctx.arc(SIZE / 2, SIZE / 2, SIZE / 2 - inset, 0, Math.PI * 2);
84
+ ctx.clip();
85
+ ctx.drawImage(photo, inset, inset, SIZE - 2 * inset, SIZE - 2 * inset);
86
+ ctx.restore();
87
+ if (frame) {
88
+ ctx.drawImage(frame, 0, 0, SIZE, SIZE);
89
+ }
90
+ return canvas;
91
+ }
@@ -15,5 +15,5 @@
15
15
  "autoUpdateWhenImported": true,
16
16
  "deckId": "xFVr-afOLuUQ",
17
17
  "cardId": "w6G45sy3_P_m",
18
- "publishedVersion": "2026-09-10T20:10:30.302Z"
18
+ "publishedVersion": "2026-09-10T20:28:42.134Z"
19
19
  }
@@ -12,6 +12,7 @@ const SEND_MS = 1000 / Messages.MOVE_HZ;
12
12
  // connection the SDK connection
13
13
  // inbox arrived messages, each with `localMs` added
14
14
  // roster playerId -> username, as the platform last reported it
15
+ // userIds playerId -> userId, from the same roster
15
16
  // you the SDK's entry for the local player, null until a roster has it
16
17
  // sentAt `performance.now()` of the last move, so sends stay at MOVE_HZ
17
18
  // claims ids asked for since the last send, held until it goes
@@ -21,6 +22,7 @@ function makeNet(connection) {
21
22
  connection,
22
23
  inbox: [],
23
24
  roster: new Map(),
25
+ userIds: new Map(),
24
26
  you: null,
25
27
  sentAt: 0,
26
28
  claims: new Set(),
@@ -48,6 +50,7 @@ export async function connectSession({ onLog = () => {} } = {}) {
48
50
 
49
51
  // Replace the map from the complete SDK roster so departures disappear.
50
52
  net.roster = new Map(players.map((player) => [player.playerId, player.username]));
53
+ net.userIds = new Map(players.map((player) => [player.playerId, player.userId]));
51
54
  net.you = you;
52
55
  });
53
56
 
@@ -61,6 +64,9 @@ export async function connectSession({ onLog = () => {} } = {}) {
61
64
  // A stable fallback until the SDK roster includes this player.
62
65
  username: (playerId) => net.roster.get(playerId) ?? 'player',
63
66
 
67
+ // The account behind a player, for `User.get`; null until the roster has it.
68
+ userId: (playerId) => net.userIds.get(playerId) ?? null,
69
+
64
70
  // Every other player's roster entry. The local player is simulated by this client,
65
71
  // so the deck draws only these from network samples.
66
72
  others: () => new Map([...net.roster].filter(([id]) => id !== connection.playerId)),
@@ -80,12 +80,18 @@ function newServer(parts) {
80
80
  export function makeSession(parts) {
81
81
  const server = newServer(parts);
82
82
 
83
- // Castle's handlers remain available while `createSimulation` is pending.
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.
84
86
  server.building = server.world.createSimulation();
87
+ server.building.catch((err) => {
88
+ console.error(`[${server.label}] simulation failed to build: ${err?.stack ?? err}`);
89
+ });
85
90
  return {
86
- onStart(session) {
87
- attach(server, session);
88
- },
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() {},
89
95
 
90
96
  // Joins are logged here; the simulation's players are reconciled from the
91
97
  // full session roster on the next tick.
@@ -124,8 +130,8 @@ export function makeSession(parts) {
124
130
  };
125
131
  }
126
132
 
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.
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.
129
135
  function attach(server, session) {
130
136
  if (server.session) {
131
137
  return;
@@ -41,6 +41,10 @@ This kit adds no engine and no editors. What it holds:
41
41
  `{ table }`. One table per `session.sessionId`, made on demand. `joinAsync` is
42
42
  the join to use: it reads the saved table first, which the synchronous
43
43
  `room.join` cannot.
44
+ - **A claimed square shows its holder's avatar.** The state carries `users`,
45
+ seat -> userId, and `Board.jsx` swaps the square's `Sprite.file` for the
46
+ avatar from `castle.physics-2d`'s `engine/avatarArt.js`; a holder with no
47
+ photo keeps the seat tint.
44
48
  - **A room comes back.** `table.game`, the seat each account held, and the time
45
49
  of the last accepted move are saved to deck storage on every accepted move, on
46
50
  every leave, and from `room.save(session)` in `onShutdown`. Storage belongs to
@@ -17,7 +17,7 @@
17
17
  "imports": {
18
18
  "castle.physics-2d": {
19
19
  "deckId": "ckRZGFW4iPrx",
20
- "version": "2026-09-10T00:57:31.408Z"
20
+ "version": "2026-09-10T20:21:12.665Z"
21
21
  },
22
22
  "castle.base": {
23
23
  "deckId": "yRcmH4_aYllE",
@@ -28,7 +28,7 @@
28
28
  "autoUpdateWhenImported": true,
29
29
  "deckId": "K6-Xs7dCoMVi",
30
30
  "cardId": "Kh_08IYATpC3",
31
- "publishedVersion": "2026-09-10T20:10:44.202Z",
31
+ "publishedVersion": "2026-09-10T20:28:55.394Z",
32
32
  "server": {
33
33
  "main": "code/server/index.js",
34
34
  "maxPlayers": 8,
@@ -3,8 +3,10 @@
3
3
  // multiplayer session, send square claims, tint actors and format status text.
4
4
 
5
5
  import { Multiplayer } from 'castle-web-sdk';
6
+ import { avatarSpritePath } from '@imports/castle.physics-2d/engine/avatarArt';
6
7
 
7
- // Sprite tint multiplies these seat colors over each square's own artwork.
8
+ // A claimed square shows its holder's avatar. Until that has loaded, or when the
9
+ // holder has none, the seat's tint multiplies the square's own artwork instead.
8
10
  const SEAT_TINT = { A: '#ea323cff', B: '#0cf1ffff' };
9
11
  const UNCLAIMED_TINT = '#ffffffff';
10
12
 
@@ -130,10 +132,18 @@ function paintSquares(scene, state) {
130
132
  for (const squareActor of scene.actorsWith('Square')) {
131
133
  // Square.index selects the same slot in the server's sixteen-element array.
132
134
  const seat = state.squares[squareActor.components.Square.index];
133
- // Sprite tint multiplies the editable `drawings/square.sprite` pixels.
134
- // White preserves their colors; a seat tint displays the square's owner.
135
- // A game with separate pieces can spawn piece actors over these square actors.
136
- squareActor.components.Sprite.tint = seat ? SEAT_TINT[seat] : UNCLAIMED_TINT;
135
+ const sprite = squareActor.components.Sprite;
136
+
137
+ // The square's own art is remembered the first time, since `file` is
138
+ // swapped for an avatar below and back again when the square is cleared.
139
+ squareActor.runtime.squareFile ??= sprite.file;
140
+ const avatar = seat ? avatarSpritePath(scene, state.users?.[seat]) : null;
141
+ sprite.file = avatar ?? squareActor.runtime.squareFile;
142
+
143
+ // Sprite tint multiplies the pixels. White preserves their colors; a seat
144
+ // tint displays the owner of a square without an avatar. A game with
145
+ // separate pieces can spawn piece actors over these square actors.
146
+ sprite.tint = seat && !avatar ? SEAT_TINT[seat] : UNCLAIMED_TINT;
137
147
  }
138
148
  }
139
149
 
@@ -91,6 +91,7 @@ function stateFor(session, table, playerId) {
91
91
  turn: table.game.turn,
92
92
  winner: table.game.winner,
93
93
  names: table.seatNames(),
94
+ users: table.seatUsers(),
94
95
  watching: table.spectatorCount(session),
95
96
  you: table.seatOf(playerId),
96
97
  };
@@ -178,6 +178,8 @@ function createTable(settings, session) {
178
178
  playerAt: (seat) => table.seats[seat] ?? null,
179
179
  seatNames: () =>
180
180
  Object.fromEntries(settings.seats.map((seat) => [seat, table.seats[seat]?.name ?? null])),
181
+ seatUsers: () =>
182
+ Object.fromEntries(settings.seats.map((seat) => [seat, table.seats[seat]?.userId ?? null])),
181
183
  spectatorCount: (session) => spectatorCount(table, session),
182
184
 
183
185
  // Call this past every rejection, never before one. A refused message is not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.171",
3
+ "version": "0.4.172",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"