castle-web-cli 0.4.171 → 0.4.173

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 (65) hide show
  1. package/dist/assetCompression.d.ts +3 -0
  2. package/dist/assetCompression.js +30 -0
  3. package/dist/castleJson.d.ts +1 -1
  4. package/dist/castleJson.js +2 -2
  5. package/dist/devSessionServer.js +9 -5
  6. package/dist/imports.js +2 -2
  7. package/dist/init.js +4 -4
  8. package/dist/serve.js +2 -0
  9. package/dist/serveSecurity.d.ts +2 -0
  10. package/dist/serveSecurity.js +17 -3
  11. package/kits/multiplayer-2d/CLAUDE.md +4 -0
  12. package/kits/multiplayer-2d/behaviors/Box.jsx +32 -9
  13. package/kits/multiplayer-2d/castle.json +3 -3
  14. package/kits/multiplayer-2d/code/client/avatars.js +6 -1
  15. package/kits/multiplayer-2d/code/server/sceneBodies.js +1 -1
  16. package/kits/multiplayer-2d/code/server/world.js +2 -2
  17. package/kits/multiplayer-2d/code/systems/multiplayer.js +14 -0
  18. package/kits/multiplayer-2d/fonts.generated.js +1 -1
  19. package/kits/multiplayer-2d/package-lock.json +1 -1
  20. package/kits/multiplayer-3d/CLAUDE.md +5 -0
  21. package/kits/multiplayer-3d/castle.json +3 -3
  22. package/kits/multiplayer-3d/code/client/avatars.js +42 -9
  23. package/kits/multiplayer-3d/code/server/sceneBodies.js +1 -1
  24. package/kits/multiplayer-3d/code/systems/multiplayer.js +17 -1
  25. package/kits/multiplayer-3d/fonts.generated.js +1 -1
  26. package/kits/multiplayer-3d/package-lock.json +1 -1
  27. package/kits/physics-2d/CLAUDE.md +7 -6
  28. package/kits/physics-2d/behaviors/Collider.jsx +7 -1
  29. package/kits/physics-2d/behaviors/RigidBody.jsx +6 -3
  30. package/kits/physics-2d/castle.json +2 -2
  31. package/kits/physics-2d/editors/SceneEditor.jsx +1 -0
  32. package/kits/physics-2d/editors/SingleEditor.jsx +4 -4
  33. package/kits/physics-2d/editors/{StyleEditor.jsx → ThemeEditor.jsx} +8 -8
  34. package/kits/physics-2d/editors/deckFont.js +2 -2
  35. package/kits/physics-2d/editors/{styleEditor.module.css → themeEditor.module.css} +2 -2
  36. package/kits/physics-2d/engine/avatarArt.js +185 -0
  37. package/kits/physics-2d/engine/files.js +2 -2
  38. package/kits/physics-2d/engine/physics/matterBridge.js +2 -2
  39. package/kits/physics-2d/engine/scene.js +1 -1
  40. package/kits/physics-2d/fonts.generated.js +1 -1
  41. package/kits/physics-2d/package-lock.json +1 -1
  42. package/kits/physics-3d/behaviors/Body.jsx +10 -1
  43. package/kits/physics-3d/behaviors/Door.jsx +5 -0
  44. package/kits/physics-3d/behaviors/Lookable.jsx +3 -0
  45. package/kits/physics-3d/behaviors/Model.jsx +4 -0
  46. package/kits/physics-3d/behaviors/Pickup.jsx +4 -0
  47. package/kits/physics-3d/behaviors/Player.jsx +7 -0
  48. package/kits/physics-3d/behaviors/Shape.jsx +13 -0
  49. package/kits/physics-3d/castle.json +1 -1
  50. package/kits/physics-3d/engine3d/editor3dInspector.jsx +1 -0
  51. package/kits/physics-3d/fonts.generated.js +1 -1
  52. package/kits/physics-3d/package-lock.json +1 -1
  53. package/kits/real-time/castle.json +1 -1
  54. package/kits/real-time/code/client/connection.js +17 -2
  55. package/kits/real-time/code/server/session.js +12 -6
  56. package/kits/real-time/package-lock.json +1 -1
  57. package/kits/turn-based/CLAUDE.md +4 -0
  58. package/kits/turn-based/castle.json +2 -2
  59. package/kits/turn-based/code/behaviors/Board.jsx +15 -5
  60. package/kits/turn-based/code/server/index.js +1 -0
  61. package/kits/turn-based/fonts.generated.js +1 -1
  62. package/kits/turn-based/package-lock.json +1 -1
  63. package/kits/turn-based/room.js +2 -0
  64. package/package.json +3 -1
  65. /package/kits/physics-2d/editors/{styleTheme.js → theme.js} +0 -0
@@ -0,0 +1,3 @@
1
+ import type { Connect } from 'vite';
2
+ /** Compress assets, never the token-bearing shell bootstrap or control API. */
3
+ export declare function createAssetCompression(): Connect.NextHandleFunction;
@@ -0,0 +1,30 @@
1
+ import compression from 'compression';
2
+ /** Compress assets, never the token-bearing shell bootstrap or control API. */
3
+ export function createAssetCompression() {
4
+ // compression supports plain Node HTTP/Connect; its DefinitelyTyped signature
5
+ // unnecessarily requires Express request/response extensions.
6
+ return compression({
7
+ threshold: 1024,
8
+ filter(req, res) {
9
+ const requestPath = (req.url || '/').split('?')[0];
10
+ if ((req.method !== 'GET' && req.method !== 'HEAD') ||
11
+ requestPath === '/' ||
12
+ (requestPath.startsWith('/__castle/') &&
13
+ !requestPath.startsWith('/__castle/ide/assets/')) ||
14
+ req.headers.range ||
15
+ res.hasHeader('Content-Range')) {
16
+ return false;
17
+ }
18
+ // Conditional responses must retain the representation's Vary header,
19
+ // but must never start a compressor or acquire a body.
20
+ if (res.statusCode === 304) {
21
+ if (res.getHeader('Vary') !== '*')
22
+ res.appendHeader('Vary', 'Accept-Encoding');
23
+ return false;
24
+ }
25
+ return (res.statusCode === 200 &&
26
+ !String(res.getHeader('Content-Type')).startsWith('text/event-stream') &&
27
+ compression.filter(req, res));
28
+ },
29
+ });
30
+ }
@@ -1,4 +1,4 @@
1
- export declare const STARTER_STYLE_FILES: string[];
1
+ export declare const STARTER_THEME_FILES: string[];
2
2
  export declare const IMPORTS_DIR = "imports";
3
3
  export interface DeckImport {
4
4
  deckId?: string;
@@ -5,7 +5,7 @@ import picomatch from 'picomatch';
5
5
  // module with no imports of its own, because both the import machinery and the
6
6
  // fork overlay need it and importing it from either of those would close a
7
7
  // cycle.
8
- // Root-level style files a kit ships as a deck's starting point, seeded into a
8
+ // Root-level theme files a kit ships as a deck's starting point, seeded into a
9
9
  // deck at scaffold (init.ts `writeStarterTheme`) and when an import newly brings
10
10
  // them (imports.ts `seedKitTheme`).
11
11
  //
@@ -18,7 +18,7 @@ import picomatch from 'picomatch';
18
18
  // `theme.style` names the deck's font, `fonts.generated.js` is what carries that
19
19
  // face's bytes into the bundle, and a deck given the first without the second
20
20
  // renders in the fallback while claiming otherwise.
21
- export const STARTER_STYLE_FILES = ['theme.style', 'fonts.generated.js'];
21
+ export const STARTER_THEME_FILES = ['theme.style', 'fonts.generated.js'];
22
22
  export const IMPORTS_DIR = 'imports';
23
23
  export const DEFAULT_STARTER_SCENE = 'scenes/main.scene';
24
24
  export const DEFAULT_SERVER_TICK_RATE = 20;
@@ -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;
package/dist/imports.js CHANGED
@@ -6,7 +6,7 @@ import { runTar } from './save-deck.js';
6
6
  import { normalizeDeckPackageJson } from './normalize.js';
7
7
  import { getKitsDir } from './localPaths.js';
8
8
  import { writePlatformDocs } from './platformDoc.js';
9
- import { ensureVisibleGlob, IMPORTS_DIR, STARTER_STYLE_FILES, readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
9
+ import { ensureVisibleGlob, IMPORTS_DIR, STARTER_THEME_FILES, readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
10
10
  // Adding another deck as a dependency (`castle-web add-import`; removing one is a
11
11
  // later command). Deliberately NOT `get-deck`: that one
12
12
  // replaces THIS deck's own source from the server (and carries guards for the
@@ -614,7 +614,7 @@ function seedKitTheme(deckDir, alias, log) {
614
614
  if (typeof main !== 'string' || !main.trim())
615
615
  return;
616
616
  const seeded = [];
617
- for (const name of STARTER_STYLE_FILES) {
617
+ for (const name of STARTER_THEME_FILES) {
618
618
  const src = path.join(importDir, name);
619
619
  const dest = path.join(deckDir, name);
620
620
  if (!fs.existsSync(src) || fs.existsSync(dest))
package/dist/init.js CHANGED
@@ -2,7 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { CLAUDE_MD_PLATFORM_SECTION } from './platformDoc.js';
4
4
  import { installDeps } from './install.js';
5
- import { deckMainFile, deckStarterScene, STARTER_STYLE_FILES } from './castleJson.js';
5
+ import { deckMainFile, deckStarterScene, STARTER_THEME_FILES } from './castleJson.js';
6
6
  import { addImportTo, IMPORTS_DIR, lockImportTree, restoreMissingImports, syncImportDependencies, } from './imports.js';
7
7
  import { discardParentSource, fetchParentSource, unpackParentSource, writeRemixIdentity, } from './remix.js';
8
8
  import { getCliEntryPath, getKitsDir, getSdkPackagePath, toPosixPath } from './localPaths.js';
@@ -309,7 +309,7 @@ export function writeStarterScene(projectDir, kitDir, alias) {
309
309
  fs.mkdirSync(path.join(projectDir, 'scenes'), { recursive: true });
310
310
  writeJsonFile(path.join(projectDir, 'scenes', 'main.scene'), rewrite(scene));
311
311
  }
312
- // A kit may ship starter style files (deck-level styling: the color palette,
312
+ // A kit may ship starter theme files (deck-level theme: the color palette,
313
313
  // the deck font). Like the starter scene, they are deck content: the engine
314
314
  // reads them from the DECK root, and they are the creator's to edit -- so they
315
315
  // get seeded once at scaffold time rather than living read-only in the import.
@@ -317,11 +317,11 @@ export function writeStarterScene(projectDir, kitDir, alias) {
317
317
  // kit file paths, so no ref rewriting). Exported for the same two scaffold paths
318
318
  // as writeStarterScene.
319
319
  //
320
- // The set is STARTER_STYLE_FILES (castleJson.ts), which says why these two
320
+ // The set is STARTER_THEME_FILES (castleJson.ts), which says why these two
321
321
  // travel together: a deck given the record without the generated module renders
322
322
  // in the fallback while claiming otherwise.
323
323
  export function writeStarterTheme(projectDir, kitDir) {
324
- for (const name of STARTER_STYLE_FILES) {
324
+ for (const name of STARTER_THEME_FILES) {
325
325
  const src = path.join(kitDir, name);
326
326
  const dest = path.join(projectDir, name);
327
327
  if (!fs.existsSync(src) || fs.existsSync(dest))
package/dist/serve.js CHANGED
@@ -19,6 +19,7 @@ import { graphql as castleGraphql } from './api.js';
19
19
  import { executeCommand } from './castle-host/host.js';
20
20
  import { ServeSecurity } from './serveSecurity.js';
21
21
  import { isSameOriginUpgrade } from './wsOrigin.js';
22
+ import { createAssetCompression } from './assetCompression.js';
22
23
  import { resolveServerConfig } from './castleJson.js';
23
24
  import { startDevSessionServer, } from './devSessionServer.js';
24
25
  // The log is a tail, not an archive: append, and once it passes the cap drop
@@ -315,6 +316,7 @@ window.__castleShellOrigin=(function(){
315
316
  },
316
317
  },
317
318
  configureServer(server) {
319
+ server.middlewares.use(createAssetCompression());
318
320
  server.middlewares.use((req, res, next) => {
319
321
  const reqPath = (req.url || '/').split('?')[0];
320
322
  const trustedSurface = req.headers['x-castle-sandbox-surface'];
@@ -18,6 +18,7 @@ export declare class ServeSecurity {
18
18
  private readonly contentProxyToken;
19
19
  private readonly signingKey;
20
20
  private readonly serveNonce;
21
+ private contentGrantExpiresAtSeconds;
21
22
  private readonly pathKey;
22
23
  private readonly tickets;
23
24
  private localContentPorts;
@@ -50,6 +51,7 @@ export declare class ServeSecurity {
50
51
  editOrigin: string;
51
52
  playOrigin: string;
52
53
  };
54
+ private contentGrantExpiry;
53
55
  /**
54
56
  * Origin a browser should use for a local content role.
55
57
  *
@@ -6,6 +6,7 @@ const CROCKFORD = '0123456789abcdefghjkmnpqrstvwxyz';
6
6
  const CONTENT_LABEL_RE = /^1([ep])([0-9a-z]{1,4})-([a-z0-9]{14})-([0-9a-z]{1,8})-([0-9a-hjkmnp-tv-z]{13})-([0-9a-hjkmnp-tv-z]{20})$/;
7
7
  const MAX_GRANT_SECONDS = 12 * 60 * 60;
8
8
  const GRANT_CLOCK_SKEW_SECONDS = 60;
9
+ const MIN_CONTENT_GRANT_REMAINING_SECONDS = 11 * 60 * 60;
9
10
  const BROWSER_GRANT_SECONDS = 3 * 60;
10
11
  export const MOBILE_HOST_PROTOCOL = '2';
11
12
  // A signature covers the request path and nothing else, so every path it can
@@ -79,6 +80,7 @@ export class ServeSecurity {
79
80
  contentProxyToken = randomBytes(32).toString('base64url');
80
81
  signingKey = process.env.CASTLE_AGENT_TOKEN || randomBytes(32).toString('base64url');
81
82
  serveNonce = base32Lower(randomBytes(8));
83
+ contentGrantExpiresAtSeconds = 0;
82
84
  pathKey = randomBytes(32).toString('base64url');
83
85
  tickets = new Map();
84
86
  localContentPorts;
@@ -284,9 +286,7 @@ window.fetch=function(input,init){
284
286
  : cloud
285
287
  ? 'sandboxes.castlexyz.com'
286
288
  : null;
287
- // Leave room for small positive clock skew at the control plane. Ghost still
288
- // enforces the hard 12-hour maximum when it resolves the capability.
289
- const expiresAtSeconds = Math.floor(Date.now() / 1000) + MAX_GRANT_SECONDS - GRANT_CLOCK_SKEW_SECONDS;
289
+ const expiresAtSeconds = cloud && contentDomain ? this.contentGrantExpiry() : 0;
290
290
  const originFor = (role) => {
291
291
  // Safari on macOS 15 asks DNS to resolve `*.localhost`, where it fails.
292
292
  // Dedicated loopback ports are still distinct browser origins and need no DNS.
@@ -297,6 +297,20 @@ window.fetch=function(input,init){
297
297
  };
298
298
  return { editOrigin: originFor('edit'), playOrigin: originFor('play') };
299
299
  }
300
+ contentGrantExpiry() {
301
+ const nowSeconds = Math.floor(Date.now() / 1000);
302
+ const latestExpiry = nowSeconds + MAX_GRANT_SECONDS - GRANT_CLOCK_SKEW_SECONDS;
303
+ // Expiry is part of the hostname: reuse it so reopening the shell can reuse
304
+ // the browser's module cache. Until open shells support automatic refresh,
305
+ // keep at least 11 hours for every new shell (about an hour of cache reuse).
306
+ // Already-open shells keep their old, still-valid grants until expiry.
307
+ // Also remint after a backwards clock jump rather than exceed Ghost's cap.
308
+ if (this.contentGrantExpiresAtSeconds - nowSeconds < MIN_CONTENT_GRANT_REMAINING_SECONDS ||
309
+ this.contentGrantExpiresAtSeconds > latestExpiry) {
310
+ this.contentGrantExpiresAtSeconds = latestExpiry;
311
+ }
312
+ return this.contentGrantExpiresAtSeconds;
313
+ }
300
314
  /**
301
315
  * Origin a browser should use for a local content role.
302
316
  *
@@ -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:32:33.371Z"
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-10T22:05:16.080Z"
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
 
@@ -34,7 +34,7 @@ const DEFAULTS = {
34
34
  Collider: { friction: 0.1, frictionStatic: 0.5, bounciness: 0, density: 0.001, isTrigger: false },
35
35
  RigidBody: {
36
36
  bodyType: 'dynamic',
37
- gravityScale: 1,
37
+ gravityScale: 0,
38
38
  drag: 0.01,
39
39
  angularDrag: 0,
40
40
  freezeRotation: false,
@@ -190,7 +190,7 @@ function patchBody(body, actor) {
190
190
  body.frictionStatic = collider.frictionStatic ?? 0.5;
191
191
  body.isSensor = Boolean(collider.isTrigger);
192
192
  body.frictionAir = rigid?.drag ?? 0.01;
193
- body.plugin.gravityScale = rigid?.gravityScale ?? 1;
193
+ body.plugin.gravityScale = rigid?.gravityScale ?? 0;
194
194
  body.plugin.angularDrag = rigid?.angularDrag ?? 0;
195
195
 
196
196
  // Density sets mass, and only a dynamic body may have it: on a static one it
@@ -261,7 +261,7 @@ function applyGravity(sim) {
261
261
  if (body.isStatic) {
262
262
  continue;
263
263
  }
264
- const scale = (body.plugin.gravityScale ?? 1) * GRAVITY_SCALE;
264
+ const scale = (body.plugin.gravityScale ?? 0) * GRAVITY_SCALE;
265
265
  body.force.x += body.mass * sim.gravity.x * scale;
266
266
  body.force.y += body.mass * sim.gravity.y * scale;
267
267
  }
@@ -96,6 +96,18 @@ class MultiplayerSystem {
96
96
  return void this.start();
97
97
  }
98
98
 
99
+ // The session ended under this connection (the server was replaced, or the
100
+ // platform closed it). Everything drawn from it goes, and the next frame
101
+ // joins afresh behind the overlay.
102
+ if (this.net.closed()) {
103
+ this.reset(scene);
104
+ this.net = null;
105
+ this.joined = false;
106
+ this.joinStartedAt = 0;
107
+ this.starting = false;
108
+ return;
109
+ }
110
+
99
111
  // Player is a behavior component on the actor controlled by this browser.
100
112
  const local = scene.actorWith('Player');
101
113
 
@@ -232,6 +244,7 @@ class MultiplayerSystem {
232
244
  }
233
245
  Avatars.showPlayer(this.avatars, scene, playerId, look.username ?? null, pose, {
234
246
  blueprint: look.blueprint,
247
+ userId: this.net.userId(playerId),
235
248
  });
236
249
  }
237
250
 
@@ -321,6 +334,7 @@ class MultiplayerSystem {
321
334
  if (local.components.Box) {
322
335
  local.components.Box.label = Avatars.shortName(this.net.status().you);
323
336
  }
337
+ local.runtime.avatar = Avatars.avatarCanvas(this.net.userId(this.net.selfId()));
324
338
  }
325
339
 
326
340
  // `[id, centre]` for every scene object the server has sent a pose for. The
@@ -1,4 +1,4 @@
1
- // Generated by the Style editor. Do not edit -- picking a font rewrites it.
1
+ // Generated by the Theme editor. Do not edit -- picking a font rewrites it.
2
2
  //
3
3
  // `theme.style` records that this deck's font is DMSans; this module is what
4
4
  // puts DMSans's bytes in the published bundle. It has to name the face in a
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "../../sdk": {
32
32
  "name": "castle-web-sdk",
33
- "version": "0.4.27",
33
+ "version": "0.4.28",
34
34
  "dev": true,
35
35
  "devDependencies": {
36
36
  "eslint": "^9.0.0",
@@ -66,6 +66,11 @@ 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.** Every player's profile photo, the
70
+ local one included, is the texture on the four side faces of their model's
71
+ `head` part (`castle.physics-2d`'s `engine/avatarArt.js`, through
72
+ `User.get`). A player with no photo, or a model with no `head`, keeps the
73
+ model's own face.
69
74
  - **A returning player starts where they left off.** A second connection from
70
75
  the same account while the first is still playing gets a new character at the
71
76
  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:32:33.371Z"
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:37:41.733Z",
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-10T22:05:24.002Z",
37
37
  "main": "main.jsx",
38
38
  "server": {
39
39
  "main": "code/server/index.js",
@@ -1,9 +1,12 @@
1
- // Other-player actors and their floating name tags. This module creates actors
2
- // from deck blueprints, updates their network poses, and removes their scene
3
- // resources when players leave.
1
+ // Other-player actors, their floating name tags, and the avatar on their head.
2
+ // This module creates actors from deck blueprints, updates their network poses,
3
+ // and removes their scene resources when players leave.
4
4
 
5
5
  import * as NameTags from './nameTags.js';
6
6
  import { applyPose } from './poses.js';
7
+ import { artReady } from '@imports/castle.physics-2d/engine/art.js';
8
+ import { avatarFacePath } from '@imports/castle.physics-2d/engine/avatarArt.js';
9
+ import { makeShapeMesh } from '@imports/castle.physics-3d/engine3d/materials.js';
7
10
 
8
11
  // The blueprint spawned when a caller names none. A deck's own file wins; a deck
9
12
  // scaffolded from this kit has no blueprints yet, so the kit's copy stands in
@@ -33,7 +36,8 @@ export function makeAvatars() {
33
36
  //
34
37
  // A `username` of null draws no label. Options are `blueprint`, the scene to
35
38
  // 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.
39
+ // `tagHeight`, how far over the body the label floats, and `userId`, whose
40
+ // avatar goes on the model's head.
37
41
  export function showPlayer(avatars, scene, playerId, username, pose, options = {}) {
38
42
  // The actor keeps the blueprint used at creation; `ensureActor` only spawns
39
43
  // when this player has no live actor in the current scene.
@@ -48,17 +52,20 @@ export function showPlayer(avatars, scene, playerId, username, pose, options = {
48
52
  } else {
49
53
  showTag(avatars, scene, playerId, username, transform, options.tagHeight);
50
54
  }
55
+ dressHead(scene, actor.id, options.userId);
51
56
  return actor;
52
57
  }
53
58
 
54
- // Update the local player's label over the engine's local Player actor. The SDK
55
- // 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) {
59
+ // Update the local player's label and head over the engine's local Player
60
+ // actor. The SDK roster can arrive several frames after connection, so an empty
61
+ // username leaves the label absent until the roster supplies one. Options are
62
+ // `tagHeight` and `userId`, as for `showPlayer`.
63
+ export function showSelf(avatars, scene, local, username, transform, options = {}) {
64
+ dressHead(scene, local?.id, options.userId);
58
65
  if (!username) {
59
66
  return;
60
67
  }
61
- showTag(avatars, scene, 'self', username, transform, tagHeight);
68
+ showTag(avatars, scene, 'self', username, transform, options.tagHeight);
62
69
  }
63
70
 
64
71
  // Despawn the actor and dispose the tag of every player absent from `present`.
@@ -127,6 +134,32 @@ function showTag(avatars, scene, key, username, transform, tagHeight) {
127
134
  NameTags.moveNameTag(tag, transform, scene.three?.camera, tagHeight);
128
135
  }
129
136
 
137
+ // Put the player's photo, square and edge to edge, on the four side faces of
138
+ // their model's head. The part is the one named `head`, the same convention
139
+ // `pxmodel.js` animates by; a model without one shows no avatar. The materials
140
+ // are rebuilt once per picture, when its art has loaded.
141
+ function dressHead(scene, actorId, userId) {
142
+ const path = avatarFacePath(scene, userId);
143
+ const head = scene.three?.nodes?.get(actorId)?.node.userData.modelParts?.get('head');
144
+ const mesh = head?.children.find((child) => child.userData.partMesh === 'head');
145
+ if (!path || !mesh || mesh.userData.avatarPath === path || !artReady(scene.sprites[path])) {
146
+ return;
147
+ }
148
+
149
+ // White under the picture, so the head's skin color does not tint it.
150
+ const { width, height, depth } = mesh.geometry.parameters;
151
+ const dressed = makeShapeMesh(
152
+ { kind: 'box', width, height, depth, color: '#ffffff', art: { sides: path, front: path } },
153
+ scene.sprites,
154
+ );
155
+ for (const material of [].concat(mesh.material)) {
156
+ material.dispose();
157
+ }
158
+ mesh.material = dressed.material;
159
+ dressed.geometry.dispose();
160
+ mesh.userData.avatarPath = path;
161
+ }
162
+
130
163
  function dropTag(avatars, scene, key) {
131
164
  const tag = avatars.tags.get(key);
132
165
  if (tag) {
@@ -31,7 +31,7 @@ const blueprintTexts = import.meta.glob(['/blueprints/*.scene', '/imports/*/blue
31
31
  const DEFAULTS = {
32
32
  Transform: { x: 0, y: 0, z: 0, rotationX: 0, rotationY: 0, rotationZ: 0 },
33
33
  Shape: { kind: 'box', width: 1, height: 1, depth: 1, radius: 0.5, solid: false },
34
- Body: { type: 'fixed', friction: 0.6, restitution: 0, gravityScale: 1, lockRotations: false },
34
+ Body: { type: 'fixed', friction: 0.6, restitution: 0, gravityScale: 0, lockRotations: false },
35
35
  Door: { range: 3, slide: 2.1, speed: 2.5 },
36
36
 
37
37
  // `Ball` marks a fast-moving actor. `world.js` enables continuous collision
@@ -98,6 +98,19 @@ class MultiplayerSystem {
98
98
  showJoinOverlay(this.joiningOverlayWanted());
99
99
  return void this.start();
100
100
  }
101
+
102
+ // The session ended under this connection (the server was replaced, or the
103
+ // platform closed it). Everything drawn from it goes, and the next frame
104
+ // joins afresh behind the overlay.
105
+ if (this.net.closed()) {
106
+ this.reset(scene);
107
+ this.net = null;
108
+ this.joined = false;
109
+ this.joinStartedAt = 0;
110
+ this.starting = false;
111
+ this.sentState = '';
112
+ return;
113
+ }
101
114
  const local = scene.actorWith('Player');
102
115
 
103
116
  // Drain all queued messages before choosing the render time for this frame.
@@ -262,6 +275,7 @@ class MultiplayerSystem {
262
275
  }
263
276
  Avatars.showPlayer(this.avatars, scene, playerId, look.username ?? null, pose, {
264
277
  blueprint: look.blueprint,
278
+ userId: this.net.userId(playerId),
265
279
  });
266
280
  }
267
281
  Avatars.dropMissing(this.avatars, scene, others);
@@ -350,7 +364,9 @@ class MultiplayerSystem {
350
364
  if (this.net.sendMove(Poses.packMove(me), owned, claims, releases, state) && state) {
351
365
  this.sentState = stateJson;
352
366
  }
353
- Avatars.showSelf(this.avatars, scene, this.net.status().you, me);
367
+ Avatars.showSelf(this.avatars, scene, local, this.net.status().you, me, {
368
+ userId: this.net.userId(this.net.selfId()),
369
+ });
354
370
  }
355
371
 
356
372
  // Return `[id, Transform]` for every scene object known from server snapshots.
@@ -1,4 +1,4 @@
1
- // Generated by the Style editor. Do not edit -- picking a font rewrites it.
1
+ // Generated by the Theme editor. Do not edit -- picking a font rewrites it.
2
2
  //
3
3
  // `theme.style` records that this deck's font is DMSans; this module is what
4
4
  // puts DMSans's bytes in the published bundle. It has to name the face in a
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "../../sdk": {
34
34
  "name": "castle-web-sdk",
35
- "version": "0.4.27",
35
+ "version": "0.4.28",
36
36
  "dev": true,
37
37
  "devDependencies": {
38
38
  "eslint": "^9.0.0",