castle-web-sdk 0.4.45 → 0.4.47

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.
package/README.md CHANGED
@@ -21,9 +21,12 @@ import { setup, initCard, Store, Leaderboard } from "castle-web-sdk";
21
21
  - [Leaderboard](#leaderboard)
22
22
  - [Time](#time)
23
23
  - [User](#user)
24
+ - [Experiments](#experiments)
25
+ - [Avatar](#avatar)
24
26
  - [Pass](#pass)
25
27
  - [Snapshot](#snapshot)
26
28
  - [Portal](#portal)
29
+ - [Hub](#hub)
27
30
  - [Decks](#decks)
28
31
  - [Clipboard](#clipboard)
29
32
  - [Haptics](#haptics)
@@ -50,6 +53,12 @@ connection.onMessage((message) => receiveServerMessage(message));
50
53
  connection.send({ type: "ready" });
51
54
  ```
52
55
 
56
+ Attach `onMessage` in the same step that receives the connection (right after
57
+ `await Multiplayer.join(...)`). On a hub join (`mode: "hub"`), which resolves a
58
+ little after the socket's hello, messages the place sent in between, such as
59
+ the snapshot it greets a player with, are kept for that first listener. A
60
+ listener attached later gets only what arrives after it.
61
+
53
62
  Named rooms use `{ mode: "named", key: "room-name" }`; party sessions use
54
63
  `{ mode: "party" }`. Only one connection can be live per deck instance. The SDK
55
64
  reconnects temporary socket drops automatically. `onStateChange` reports
@@ -112,6 +121,11 @@ TypeScript server entries can import callback, session, player, and platform
112
121
  handle types from `castle-web-sdk/server`. Server transport has no runtime
113
122
  dependency on a WebSocket package.
114
123
 
124
+ `session.sessionId`, `session.deckId` and `session.mode` say which session this
125
+ is. `session.place` is the hub place the host started the session for (see
126
+ [`session.hub`](#sessionhub)), and `null` otherwise. It is never taken from a
127
+ client.
128
+
115
129
  ### `session.storage`
116
130
 
117
131
  A session ends when its last player leaves, and its memory goes with it.
@@ -133,9 +147,27 @@ export default {
133
147
 
134
148
  `session.storage.deck` is shared by everyone playing.
135
149
  `session.storage.user(userId)` is one player's public data.
136
- `session.storage.board(name)` is a leaderboard. Each scope has the same
150
+ `session.storage.board(name)` is a leaderboard. The root and its buckets also
151
+ support the [atomic save API](#atomic-saves). Each scope has the same
137
152
  `get`, `set`, `remove`, `increment` and `list` as `Store`.
138
153
 
154
+ `session.storage.town(userId)` lets a door deck's server read and write one
155
+ signed-in player's rows in its source town's deck scope, for things such as
156
+ hats bought in town. It has `get`, `set`, `remove` and `increment`; quota counts
157
+ against the town. Every key is one of the town's per-player namespaces
158
+ (`tokens`, `spent`, `own`, `hat`, `chirp`) followed by the user id: for user
159
+ `7`, `own:7:tophat` and `hat:7` work; `own:tophat:7` and `quay:7` do not. The
160
+ rest of the town's rows are its own. The player must be live in this session
161
+ (a player who joined a few seconds ago can briefly get
162
+ `CAULDRON_HUB_NOT_IN_SESSION`, so retry later rather than giving up); in a house, only the owner's rows are
163
+ accessible. Calls reject with `CAULDRON_HUB_NO_TOWN` without a source town
164
+ (including a town calling it), `CAULDRON_HUB_NOT_IN_SESSION` for an absent
165
+ player, or `CAULDRON_HUB_NOT_OWNER` for someone other than the house's owner.
166
+ Invalid key segments reject with `CAULDRON_STORAGE_INVALID_ARGUMENT`.
167
+ During dev serve, keys live under `town/` in the dev deck's own deck scope,
168
+ so testing a door never changes a real town. An unpublished deck still needs
169
+ a published deck id for storage.
170
+
139
171
  Two differences follow from a session server not being a player:
140
172
 
141
173
  - **It owns `deck` scope.** Once a deck has a published server, clients read
@@ -151,6 +183,114 @@ Two differences follow from a session server not being a player:
151
183
  stop. An out-of-memory kill or a host failing sends no shutdown at all, so
152
184
  save as things change and treat `onShutdown` as the last chance, not the plan.
153
185
 
186
+ ### `session.hub`
187
+
188
+ A deck whose `castle.json` declares a `hub` block is a hub place (the hub-game
189
+ kit's contract). `save-deck` publishes the block's titles and result shapes with
190
+ the server bundle, and Castle pins them to every session of the place. Those
191
+ sessions have `session.hub`; every other session has `session.hub === null`.
192
+
193
+ `session.hub.owner` is the owner's user id string on an owner door instance,
194
+ passed by the platform. It is absent on every other hub session, including the
195
+ dev serve.
196
+
197
+ - `session.hub.place`, `.title` and `.result` are the place and the result shape
198
+ as published (`result` is `null` for a place that reports none, such as a town).
199
+ - `session.hub.arrival(playerId)` is how a player came in when Castle placed them
200
+ through a hub door: `{ fromDeckId, doorId }` (`doorId` is `null` when the door
201
+ names none), else `null`. It is Castle's word, never a client's, and it is also
202
+ the third argument of `onPlayerJoin`.
203
+ - `session.hub.reportResult(playerId, { outcome?, score?, stats? })` records a
204
+ signed-in player's result for this visit, once. The host checks it against the
205
+ published shape and Castle keeps it only for a place behind a curated door;
206
+ resolves `{ recorded, record }` (`recorded: false, reason: "duplicate"` for a
207
+ visit that already has one).
208
+ - `session.hub.updatePad({ doorId, padId, memberUserIds, fire? })` names who is
209
+ standing on a pad of one of this place's doors (only players connected to this
210
+ session count); `fire: true` sends them through together. Resolves
211
+ `{ status: "gathering" | "fired", members }`. A fired pad's group is fixed, so
212
+ the next gathering uses a new `padId`.
213
+ - `session.hub.readResults({ userIds?, doorIds? })` reads players' recent results,
214
+ records, platform avatars and wearables (`players[userId]`) and the best scores behind this
215
+ place's doors (`boards[doorId]`). A board also carries the door's title,
216
+ whether it is featured, and its group: `minPlayers`, `maxPlayers` and
217
+ `lateJoin` (null where the door does not say).
218
+ - `session.hub.wear(playerId, { kind, color, rarity } | null)` puts one wearable
219
+ on a signed-in player's platform avatar in every hub place (`kind` a lowercase
220
+ id the kit draws, `color` `'#rrggbb'`, `rarity` 0-9), or clears it with `null`;
221
+ the latest wins. Castle takes it only from a curated hub place. Resolves
222
+ `{ wearable }`; `readResults` returns it as `players[userId].wearable`.
223
+ - `session.hub.unlock(playerId, itemId)` gives a signed-in player one of the
224
+ deck's declared earned items (castle.json `hub.unlocks`: `{ id, class, shape,
225
+ name, deed, color }`, at most 8, `class` one of `badge`, `pin`, `belt`, `sash`,
226
+ `held`). v1.70 also permits up to 6 home items separately: `furniture` or
227
+ `deed`, with `piece` in place of `shape` (the same lowercase id format).
228
+ Each id is unique across both lists. Castle takes it only from a curated hub
229
+ place and only for an id the saved deck declares; a first wearable unlock
230
+ is also shown. Resolves
231
+ `{ unlocked: true, isNew, earned }`, where `earned` is what the player now
232
+ shows (`{ deckId, id, class, shape, name, deed, color }`) or `null`, including
233
+ for home grants. A home grant never changes what the avatar shows; a home
234
+ item appears only in the owned list returned by `readUnlocks`.
235
+ `readResults` returns the shown one as `players[userId].earned`, absent when
236
+ none.
237
+ - `session.hub.wearEarned(playerId, { deckId, id } | null)` chooses which owned
238
+ earned item a player shows, or none. Castle takes it only from the town.
239
+ Home items are refused with `CAULDRON_HUB_EARNED_NOT_WEARABLE`.
240
+ Resolves `{ earned }`.
241
+ - `session.hub.readUnlocks(userIds)` reads the earned items players here own:
242
+ `{ players: { [userId]: { owned: [...] } } }`, including home items with
243
+ their `class` and `piece`. `MultiplayerHubEarnedItem` and
244
+ `MultiplayerHubEarnedClass` describe wearable items only;
245
+ `MultiplayerHubHomeItem` and `MultiplayerHubHomeClass` describe home items.
246
+ Only the owned list uses the union of both item types; unlock/wear receipts
247
+ and arrival results keep the wearable-only type.
248
+ - These three reject with `CAULDRON_HUB_UNAVAILABLE` on a runtime without them.
249
+ - `session.hub.friendsAmong(playerId, userIds)` resolves the subset of candidate
250
+ user ids that are this connected player's friends (`Promise<string[]>`).
251
+ The platform checks mutual follows, age, blocks, the hub gate and the player's
252
+ recent client friends ask. It uses `hubFriendsAmong` or the older runtime's
253
+ storage broker and rejects on platform errors. The hub-game kit wraps it with
254
+ decimal-id filtering, a 100-candidate cap, caching and a silent `[]` fallback.
255
+ - `session.hub.relationsAmong(playerId, userIds)` makes the same single host read
256
+ and resolves `{ friends, follows, followedBy }`, each limited to asked string
257
+ ids. Missing lists are empty on older hosts. Mutual friends keep the chat age
258
+ and block rules; follows in either direction require no block, with no age rule.
259
+ These lists belong to the viewer only and must never go to another client.
260
+ - `session.hub.hiddenPair(playerIdA, playerIdB)` is true when a block hides these
261
+ two players from each other (either one blocked the other). Never send either
262
+ one the other's pose, avatar or name. The block lists themselves never reach
263
+ the deck, and player objects never carry them.
264
+
265
+ Refusals reject with a `CAULDRON_HUB_*` code, such as
266
+ `CAULDRON_HUB_RESULT_ACCOUNT_REQUIRED` for a guest, `CAULDRON_HUB_RESULT_DUPLICATE`
267
+ or `CAULDRON_HUB_PAD_NOT_READY`.
268
+
269
+ ### `session.experiment`
270
+
271
+ A deck with [experiments](#experiments) reads a player's variants on the server
272
+ too. Castle resolves them and delivers them with the join, so they are the same
273
+ arms the player's page reads and nothing a client can claim.
274
+
275
+ - `session.experiment(player, name)` is the variant a connected player (the
276
+ player object or its `playerId`) has in `name`, or `null` when they are in no
277
+ such experiment.
278
+ - `session.experiments(player)` is all of them, `{ name: variant }`.
279
+
280
+ Both answer from `onPlayerJoin` until the player leaves, and `null` / `{}`
281
+ after. The variants are never on the player object, so a roster or state built
282
+ from `session.players` does not show one player's arm to another; send a variant
283
+ to other clients only when the game means to.
284
+
285
+ ```js
286
+ export default {
287
+ onPlayerJoin(session, player) {
288
+ const lives = session.experiment(player, "starting_lives") === "five" ? 5 : 3;
289
+ state.players[player.playerId] = { lives };
290
+ },
291
+ };
292
+ ```
293
+
154
294
  ### Persistent worlds
155
295
 
156
296
  An opted-in persistent-world route has `session.world`; other sessions have
@@ -189,6 +329,49 @@ leave, and shutdown presence automatically, and `participants.read` reflects
189
329
  those host writes. The SDK never writes participant presence, and there is no
190
330
  public touch method.
191
331
 
332
+ A world-notification template is published copy with named slots; push copy is
333
+ always authored by the deck and is never generated at runtime. Ghost owns
334
+ delivery, muting, expiry, and rate caps, including notifications scheduled for
335
+ later. `notifications.notify` and `notifications.cancel` return counts only, so
336
+ deck code cannot learn which participants were muted or unavailable.
337
+
338
+ ```js
339
+ await session.world.notifications.notify({
340
+ key: "lantern-overdue",
341
+ participantIds: [player.participantId],
342
+ templateId: "lantern_overdue",
343
+ slots: { npc: "Mara" },
344
+ at: "2026-10-01T18:00:00Z",
345
+ });
346
+
347
+ await session.world.notifications.cancel({
348
+ key: "lantern-overdue",
349
+ participantIds: [player.participantId],
350
+ });
351
+ ```
352
+
353
+ A destination can opt into real elapsed time with
354
+ `worldTime: { advance: "wallClock" }`. On boot, `catchUp` reads the latest
355
+ checkpoint, clamps the elapsed duration, and commits the full checkpoint
356
+ returned by `apply`; it retries one revision conflict and never advances an
357
+ authored game clock on its own.
358
+
359
+ The checkpoint marker prevents an elapsed interval from being counted twice
360
+ and never moves backwards if the host clock regresses. If a prior commit landed
361
+ without its acknowledgement being observed, the next boot counts only time
362
+ elapsed after that marker, so `apply` may run again for that new interval; it
363
+ returns `{ applied: false, elapsedMs: 0 }` without calling `apply` only when no
364
+ time has passed since the marker.
365
+
366
+ ```js
367
+ const result = await session.world.catchUp({
368
+ maxElapsedMs: 7 * 24 * 60 * 60 * 1000,
369
+ apply({ elapsedMs, checkpoint }) {
370
+ return advanceWorld(checkpoint, elapsedMs);
371
+ },
372
+ });
373
+ ```
374
+
192
375
  ## Store
193
376
 
194
377
  `Store` saves data one key at a time. It is the storage to use for
@@ -351,6 +534,146 @@ A value is JSON, so binary data such as a picture or a video clip costs
351
534
  about a third more once encoded, and a clip of more than a few seconds
352
535
  does not fit in one. Ship media with the deck instead.
353
536
 
537
+ ### Atomic saves
538
+
539
+ `Store` and `session.storage` expose the same typed save methods:
540
+
541
+ - `snapshot({ targets: [{ target, select? }] })` reads every selected target at
542
+ one consistent point. `select` is `{ prefix }` or `{ keys }` within the
543
+ target; omitted selection reads its whole prefix. The result has `access`
544
+ and `targets: [{ target, revision, rows, complete }]`. `complete` describes
545
+ coverage of the entire target. A successful `null` revision means absent;
546
+ an initialized empty target has a revision. A failed read never means absent.
547
+ - `transaction({ operationId, expectedAccess, checks?, changes })` commits all
548
+ changes together. Each change has `target`, `expectedRevision` (string or
549
+ `null`), optional `writer`, and either `put`/`remove` or `replace`. `replace`
550
+ removes omitted rows within the prefix. Each target appears once. A `null`
551
+ expected revision creates only an absent target; an explicit `replace` also
552
+ supports importing unregistered legacy rows. `checks` are read-only target
553
+ checks with an expected revision and/or writer; they leave revisions intact.
554
+ - `claim({ operationId, expectedAccess?, target, initial?, load? })` explicitly
555
+ takes ownership of a target and returns `{ writer, receipt, snapshot }`.
556
+ `initial: [{ target, rows }]` initializes absent targets only. An absent
557
+ claimed target initializes empty if defaults were omitted. Claiming an
558
+ existing target leaves its data revision unchanged. The latest committed
559
+ claim wins; claimed targets require the current writer on every change.
560
+ - `initialize({ operationId, expectedAccess?, initial, load? })` initializes
561
+ only absent targets and returns `{ receipt, snapshot }`. Concurrent defaults
562
+ are applied once. It never claims ownership or replaces existing data.
563
+ - `operation(operationId)` returns `{ state: "committed", receipt }` or
564
+ `{ state: "unknown" }`. Unknown does not cancel a delayed request. Settle it
565
+ by replaying the original input and ID.
566
+
567
+ A target is `{ scope: "deck" | "user" | "private", subject?, prefix }`.
568
+ The prefix must be nonempty, and registered prefixes in a bucket cannot
569
+ intersect. All row keys stay inside their target. `subject` is only for user
570
+ scope; omitted subject means the current player. Session servers must name a
571
+ user and cannot access private targets. Permissions and quotas are unchanged:
572
+ deck-scoped bags remain publicly readable and share the deck allowance.
573
+ `rows`, `put` and `replace` are maps of keys to JSON values, validated through
574
+ the same JSON handling as ordinary Store writes.
575
+
576
+ A receipt contains `operationId`, `access`, `revisions: [{ target, revision }]`
577
+ and, for claims, `writer`. Access and revisions are opaque. Use snapshot access
578
+ as `expectedAccess`; reset and undo invalidate old access, revisions and writer
579
+ tokens. Claim/initialize may omit expected access for a deliberate first open.
580
+ When `load` is omitted, the SDK requests the claimed/initial targets. Supply
581
+ an explicit `load` to choose other targets or smaller projections.
582
+
583
+ Every bucket also has `target(prefix)` and the same methods with a `prefix`
584
+ wherever the root takes a `target`: `snapshot({ prefix, select? })`,
585
+ `transaction({ changes: [{ prefix, ... }], checks?: [{ prefix, ... }] })`,
586
+ `claim({ prefix, initial?: [{ prefix, rows }], load?: [{ prefix, select? }] })`,
587
+ `initialize({ initial: [{ prefix, rows }], load? })` and `operation(id)`.
588
+ Root methods can combine buckets atomically:
589
+
590
+ ```js
591
+ const room = session.storage.deck.target(`room:${roomId}:`);
592
+ const bag = session.storage.deck.target(`bag:${roomId}:${userId}:`);
593
+ const opened = await session.storage.claim({
594
+ operationId: crypto.randomUUID(),
595
+ target: room,
596
+ initial: [{ target: room, rows: roomDefaults }, { target: bag, rows: starterBag }],
597
+ load: [{ target: room }, { target: bag }],
598
+ });
599
+ const receipt = await session.storage.transaction({
600
+ operationId: actionId,
601
+ expectedAccess: opened.snapshot.access,
602
+ changes: [
603
+ { target: room, expectedRevision: opened.snapshot.targets[0].revision,
604
+ writer: opened.writer, put: changedRoomRows, remove: deletedRoomKeys },
605
+ { target: bag, expectedRevision: opened.snapshot.targets[1].revision,
606
+ put: changedBagRows },
607
+ ],
608
+ });
609
+ // Acknowledge the gameplay action only after this receipt.
610
+ ```
611
+
612
+ Atomic saves bypass ordinary coalescing and 32-key splitting. They are never
613
+ split into partial writes or retried as ordinary SETs. A request or snapshot
614
+ that exceeds a transport or single-charge byte allowance fails with
615
+ `CAULDRON_STORAGE_TOO_LARGE`; narrow the selection or reduce the atomic payload.
616
+ Ordinary `set`, `remove` and `increment` reject keys inside registered targets
617
+ with `CAULDRON_STORAGE_MANAGED_TARGET`.
618
+
619
+ Save methods reject with `SaveError`, exported from the client and server SDK.
620
+ It carries `code`, `message`, `operationId`, `retryable`,
621
+ `committed: false | true | "unknown"`, and optional `receipt`, `retryAfterMs`
622
+ and `target`. Extra platform details remain in `extensions`. Conflicts, writer
623
+ loss and stale access require explicit reload/rebuild. A replayed historical
624
+ claim can fail with writer loss while `committed === true` and its original
625
+ receipt; it never takes ownership again. Transport ambiguity keeps the outcome
626
+ unknown until receipt lookup or identical replay settles it.
627
+
628
+ ### Save coordinator
629
+
630
+ `createSaveCoordinator`, from `castle-web-sdk` or `castle-web-sdk/server`,
631
+ sends a deck's transactions in order and keeps each one until it settles:
632
+
633
+ ```js
634
+ const saves = createSaveCoordinator(session.storage, opened.snapshot, [
635
+ { target: room, writer: opened.writer },
636
+ ]);
637
+ const receipt = await saves.submit({
638
+ operationId: actionId,
639
+ checks: [{ target: room, writer: opened.writer }],
640
+ changes: [{ target: bag, put: changedBagRows }],
641
+ });
642
+ await saves.flush();
643
+ // saves.status is "pending", "saved" or "blocked".
644
+ ```
645
+
646
+ `submit` copies the save and resolves with its receipt once it commits. A
647
+ change without `expectedRevision` takes the target's revision from the latest
648
+ receipt, filled in when the save is first sent; a change without `writer`
649
+ takes the one passed in for its target. Saves are sent one at a time, in
650
+ order. A save whose reply was lost is looked up by its id and, when the
651
+ platform has no receipt for it, sent again unchanged.
652
+
653
+ A failure that cannot be settled that way sets `status` to `"blocked"` and
654
+ holds back every save behind it. When the outcome is unknown or the error is
655
+ retryable, the save's promise stays pending and `retry()` sends the same
656
+ request again. When the save definitely did not commit (a conflict, a lost
657
+ writer), its promise rejects and the coordinator stays blocked: reload a
658
+ snapshot and start a new coordinator. It never claims or rebuilds a save on
659
+ its own. `error`, `pendingOperationIds` and `revision(target)` show its state.
660
+
661
+ `flush()` resolves once every save submitted before it has committed, and
662
+ rejects if the queue blocks first. Saves submitted after it do not hold it up,
663
+ and an earlier receipt never marks a later save saved. For start-over, call
664
+ `discardUnsent()` and submit the replacement: saves already sent still settle
665
+ first, and the replacement goes out at the revision they leave.
666
+
667
+ Keep the coordinator in the page when a private worker is replaced. A page or
668
+ process that dies loses the saves it had not sent and the ids of those it had;
669
+ reopening restores the last committed snapshot.
670
+
671
+ During `castle-web serve`, the serve answers the save API itself, from one
672
+ in-memory store per project shared by its pages and named sessions. Its rows,
673
+ receipts and writers survive a runtime restart and are lost when the serve
674
+ exits. Ordinary `get`, `set` and `list` still use the deck's real storage, and
675
+ the two do not see each other's rows.
676
+
354
677
  ## Storage
355
678
 
356
679
  > Older API, still supported and not going away. New decks should use
@@ -564,6 +887,101 @@ if (them?.photoUrl) {
564
887
  }
565
888
  ```
566
889
 
890
+ ## Experiments
891
+
892
+ A/B tests. A deck declares its experiments in `castle.json`; Castle gives each
893
+ player one variant of each, the same one every time they play, and the deck
894
+ reads it. The guide, with the caveats worth knowing before reading results, is
895
+ [docs/ab-tests.md](../docs/ab-tests.md).
896
+
897
+ ```json
898
+ "experiments": {
899
+ "tutorial_length": { "variants": ["short", "long"] },
900
+ "shop_price": { "variants": ["low", "mid", "high"], "weights": [50, 25, 25] }
901
+ }
902
+ ```
903
+
904
+ - An experiment's name and its variants' names are 1 to 32 characters of `a-z`,
905
+ `0-9` and `_`. A deck declares at most 8 experiments, each with 2 to 8
906
+ variants.
907
+ - `weights` are whole percentages, one per variant, summing to 100. Leave them
908
+ out for an equal split.
909
+ - `winner` (one of the variants) ends the test: every player gets that variant.
910
+ - `save-deck` sends the block to Castle, and `castle-web check` and `save-deck`
911
+ both refuse one that breaks a rule. Removing the block ends every experiment
912
+ at the next save.
913
+
914
+ ```js
915
+ import { experiment, experimentsReady } from "castle-web-sdk";
916
+
917
+ await experimentsReady();
918
+ const tutorial = experiment("tutorial_length") === "long" ? longTutorial : shortTutorial;
919
+ ```
920
+
921
+ ### `experiment(name): string | null`
922
+
923
+ The variant this player has in `name`. Synchronous. `null` means the player is
924
+ in no such experiment: the deck does not declare it, it was removed, or the host
925
+ is an older one that knows nothing of experiments. Always give `null` a
926
+ behavior, normally the one the deck had before the test.
927
+
928
+ ### `experiments(): Record<string, string>`
929
+
930
+ Every experiment this player is in on this deck, as `{ name: variant }`.
931
+
932
+ ### `experimentsReady(): Promise<Record<string, string>>`
933
+
934
+ Resolves with that same map once the host has answered, and never rejects.
935
+ Await it once at boot, before the first read: some hosts hand the answer over
936
+ with the page, where it is already resolved, and the rest answer one request. A
937
+ read made before then returns `null` and logs a warning.
938
+
939
+ A multiplayer deck's server reads the same answer with
940
+ [`session.experiment(player, name)`](#sessionexperiment).
941
+
942
+ On `castle-web serve` the experiments come from the folder's `castle.json`,
943
+ assigned to you exactly as they will be once the deck is saved. Add
944
+ `?experiment.<name>=<variant>` to the serve's URL to force an arm (a declared
945
+ experiment and one of its variants); it holds for the whole serve, client and
946
+ local server alike, until `?experiment.<name>=` clears it or the serve stops.
947
+ An open deck page keeps the arm it loaded with until it is reloaded.
948
+ Nothing published reads that parameter. `devExperiments` in `castle.json`, a
949
+ `{ "name": "variant" }` map, answers for experiments the deck does not declare
950
+ itself (ones Castle runs across decks), on the serve only.
951
+
952
+ ## Avatar
953
+
954
+ The player's platform avatar: one per account, worn in every hub place
955
+ (cauldron-game-server `docs/HUB.md`, decision 4). In v1 it is an index into the
956
+ hub-game kit's character templates, with an optional colour choice. The
957
+ hub-game kit is the caller: it dresses the player and offers the choice in its
958
+ picker. Other players' avatars are not read here; a place's server learns them
959
+ from ghost.
960
+
961
+ ### `Avatar.get(): Promise<AvatarResult>`
962
+
963
+ `{ status: "ok", avatar, chosen }` with the player's choice, or the default
964
+ every account has (`chosen: false`). Anonymous accounts have one too.
965
+ `{ status: "unavailable", avatar: null }` for a signed-out visitor; a host from
966
+ before avatars rejects with `UNKNOWN_COMMAND`. Either way the deck picks its own.
967
+
968
+ ### `Avatar.set(avatar, options?: { color?: number }): Promise<AvatarResult>`
969
+
970
+ Makes `avatar` the account's from now on, everywhere. Only a curated hub place
971
+ (a deck an enabled hub door leads into or out of) may: anywhere else, and for a
972
+ signed-out visitor, the answer is `unavailable` and nothing changes. Ghost
973
+ refuses an index outside its template set (`INVALID_ARGUMENT`) and more than 30
974
+ choices a minute (`RATE_LIMIT_EXCEEDED`).
975
+
976
+ A host that supports avatar colours includes `color` in `AvatarResult`; the
977
+ field is absent from older hosts. `Avatar.colors === true` marks an SDK whose
978
+ `Avatar.set` carries `{ color }`; the hub-game kit requires both the host field
979
+ and this marker to offer platform colours. Only after that support is known, pass
980
+ `{ color }` to choose a non-negative integer colour for the current avatar.
981
+ The host accepts 0–255; ghost holds the offered range. Changing character
982
+ without options clears its colour. Omitting colour sends the existing command
983
+ and mutation unchanged. The read query will ask for colour once ghost serves it.
984
+
567
985
  ## Pass
568
986
 
569
987
  A pass is something a creator sells to players for Castle bricks (the
@@ -657,6 +1075,98 @@ Snapshot.onTaken(() => {
657
1075
  });
658
1076
  ```
659
1077
 
1078
+ ## Hub
1079
+
1080
+ `Hub` moves a player between hub places: every deck built on the hub-game kit is
1081
+ a place, and a door is a row in Castle's curated door table. A deck names only a
1082
+ door; the host supplies the current place, the session it joined there and the
1083
+ party, and calls Castle with the player's own account. Hosts without hub support
1084
+ answer `unavailable`; older hosts reject with `CastleError` code `UNKNOWN_COMMAND`.
1085
+
1086
+ - `Hub.doors()` reads the current place's doors and who is inside them:
1087
+ `{ status: "ok", doors }` or `{ status: "unavailable", doors: null }`. A row has
1088
+ `doorId`, `launcherKind` (`door`, `table`, `cabinet`, `gate`), `placementKind`
1089
+ (`layer`, `pad`, `exact`), `placement`, `padSize`, `enabled`, `featured` and
1090
+ `status: { playersInside, friendsInside }`. Where a door leads stays with the host.
1091
+ - `Hub.go(doorId)` acquires the destination behind a door of the current place.
1092
+ A door used within seconds of arriving can take up to 15 seconds to answer
1093
+ while Castle catches up with the arrival.
1094
+ - `Hub.back()` acquires the way back through the door the player came in by: the
1095
+ layer they left if it has room, else their party's, else the usual pick. The
1096
+ deck cannot choose where it leads; `not_found` when there is no way back.
1097
+ - `Hub.friends()` reads the mutual friends in a layer of the current place right
1098
+ now, as far as their now-playing privacy lets this player see them (at most
1099
+ 20): `{ status: "ok", friends: [{ userId, username, here, door }] }` or
1100
+ `{ status: "unavailable", friends: null }`. `here` is true for a friend in the
1101
+ player's own layer; `door` is set instead for a friend inside the place behind
1102
+ one of this place's doors (that door's id), and null otherwise.
1103
+ - `Hub.jump(userId)` acquires the layer of the current place that friend is in:
1104
+ the same place in another session, joined exactly like a door's. The way back
1105
+ does not change. `not_found` when the friend is not in another layer of it.
1106
+ - `Hub.report(userId, reason?)` reports another player in this hub session to
1107
+ Castle's moderators, with an optional short reason (at most 500 characters).
1108
+ The host names the place and session. `{ status: "ok" }`, or `unavailable`
1109
+ outside a hub session or on hosts without hub support. Hub chrome only.
1110
+ - `Hub.chat(after?)` reads this hub session's chat:
1111
+ `{ status: "ok", canChat, peers, messages: [{ id, fromUserId, fromUsername, text, at }], cursor }`.
1112
+ Who may chat is Castle's rule, not the deck's: players who follow each other
1113
+ and are both over 13. Each player is returned only the messages they may
1114
+ read (at most 50), and `peers` are the user ids here who would be sent what
1115
+ they say. Poll it, passing the last `cursor` as `after` for only what is newer.
1116
+ `unavailable` outside a hub session or on hosts without hub support.
1117
+ - `Hub.say(text)` says something in that chat (at most 200 characters). A deck
1118
+ cannot choose recipients: Castle sends it to the players here who may chat
1119
+ with this one. `{ status, id }`, where `status` is `sent`, `none` (nobody to
1120
+ send it to), `refused`, `limited` (said too fast) or `unavailable`.
1121
+ - `Hub.reportChat(messageId)` reports a chat message the player was returned to
1122
+ Castle's moderators. The host names the place and session. `{ status: "ok" }`
1123
+ or `unavailable`. Hub chrome only.
1124
+ - `Hub.cancel()` gives back an acquisition the deck will not join. Optional: one
1125
+ that is never joined expires within two minutes.
1126
+ - `Hub.onBack(listener)` hears the host's Back control, which a game cannot hide.
1127
+ Answer it with `Hub.back()` behind the door animation; a host that hears
1128
+ nothing takes the player home itself. Returns an unsubscribe function.
1129
+
1130
+ A player Castle has barred from the hub is refused at every hub door and at the
1131
+ town itself with `CastleError` code `CAULDRON_HUB_BANNED` (from `Hub.go`,
1132
+ `Hub.back`, `Hub.jump` and the place's `Multiplayer.join`). Do not retry it.
1133
+
1134
+ `go`, `back` and `jump` return a `HubHandoffResult`: `status: "ok"` with `url`, `nonce`
1135
+ (single use, valid for 60 seconds), `sessionId`, `bundleHash` (the
1136
+ destination session's content pack hash) and `contentUrl` (that pack's immutable
1137
+ URL, or null when the destination has none), or `cancelled`, `full`,
1138
+ `unavailable`, `not_found`, `forbidden` or `party_required` with those fields null.
1139
+
1140
+ A door can also lead to a cabinet: a classic Castle deck the host plays itself.
1141
+ `Hub.go` then answers `status: "ok"` with `kind: "classic"` and null `url`, `nonce`
1142
+ and `sessionId`: there is no session to join, and the trip is already committed.
1143
+ Do not pass that handoff to `Multiplayer.join`; cover the place and wait for the
1144
+ host's Back control, which `Hub.back()` answers like any other way back. `kind` is
1145
+ absent on every other handoff.
1146
+
1147
+ Success is an acquisition; nothing has moved yet. Load the destination's content,
1148
+ then join it:
1149
+
1150
+ ```js
1151
+ import { Hub, Multiplayer } from "castle-web-sdk";
1152
+
1153
+ const handoff = await Hub.go("race");
1154
+ if (handoff.status === "ok") {
1155
+ // ...the door opens; load the destination's scene behind it...
1156
+ const connection = await Multiplayer.join({ mode: "hub", handoff });
1157
+ }
1158
+ ```
1159
+
1160
+ When that socket first says hello the SDK tells the host, which commits the trip:
1161
+ the player's presence moves and the way back is recorded, and from then on
1162
+ every SDK call speaks for the destination. `join` resolves once that is done,
1163
+ and rejects when the trip could not be completed (the player is still where they
1164
+ were). A join that fails
1165
+ before then cancels it, and the player is still where they were. A hub
1166
+ connection reconnects with its session's own token like any other, but cannot be
1167
+ acquired again: when one closes for good, go back. The engine stays in the same
1168
+ page throughout; the previous place is left, not held.
1169
+
660
1170
  ## Portal
661
1171
 
662
1172
  A portal sends the player from this deck to another Castle deck,
@@ -808,8 +1318,9 @@ be coalesced, so it's safe to call on frequent events.
808
1318
  through Castle's deck-agent service, so the deck holds no key. Castle pays
809
1319
  for each player's calls out of a daily play allowance, which Castle
810
1320
  characters draw from too; once a player has used it up, calls resolve
811
- `'allowance_exhausted'` until it resets at midnight UTC. Agent.chat is
812
- limited to Castle admins for now; every other player gets `'refused'`.
1321
+ `'allowance_exhausted'` until it resets at midnight UTC. Agent.chat answers
1322
+ Castle admins and the players Castle has given access; every other player
1323
+ gets `'refused'`.
813
1324
 
814
1325
  ### `Agent.chat(params): Promise<AgentChatResult>`
815
1326
 
@@ -900,7 +1411,8 @@ for (let round = 0; round < 8; round++) {
900
1411
  display; it is not part of `message`. `finishReason` is `'stop'`,
901
1412
  `'tool_calls'` or `'length'`. `usage` carries `prompt_tokens`,
902
1413
  `completion_tokens` and `total_tokens`.
903
- - `'refused'` — the player is not a Castle admin. The model was not called.
1414
+ - `'refused'` — the player has no access to the agent. The model was not
1415
+ called.
904
1416
  - `'allowance_exhausted'` — the player's play allowance for the day is used
905
1417
  up. The model was not called. It resets at midnight UTC.
906
1418
  - `'unavailable'` — this host has no route to the deck-agent service, or
@@ -1046,55 +1558,6 @@ Host-specific layout flags such as `CastleEmbed.feed` are deprecated.
1046
1558
  New code should let Castle hosts provide the frame and let the SDK infer
1047
1559
  whether it is running standalone or embedded.
1048
1560
 
1049
- ### Filling the screen: `<meta name="castle-display">`
1050
-
1051
- By default a deck is a 5:7 portrait card. A deck that can lay itself out at
1052
- other shapes opts out of the card in its own `index.html`:
1053
-
1054
- ```html
1055
- <meta name="castle-display" content="layout=fill">
1056
- <!-- or narrow the shapes it accepts (width / height, as 16/9, 16:9 or 1.78) -->
1057
- <meta name="castle-display" content="layout=fill, min-aspect=9/16, max-aspect=16/9">
1058
- ```
1059
-
1060
- A `fill` deck gets the host's whole frame, edge to edge with no rounded
1061
- corners, whenever that frame's aspect is inside its range (default 9/21 to
1062
- 21/9); outside it, the deck gets the largest in-range box, centered. Castle's
1063
- web player gives a fill deck a bigger stage and a fullscreen button. Hosts
1064
- that don't know the tag still give it a 5:7 frame, so a fill deck should look
1065
- right at 5:7 as well.
1066
-
1067
- Lay out against the stage: the element `initCard()` returns, or
1068
- `--castle-card-w`/`--castle-card-h` in CSS, with a resize listener. Not the
1069
- window: the stage is smaller than the window when the host frame is outside
1070
- the deck's range, and inside `castle-web serve`'s own page. `getDisplay()` returns the parsed declaration and `fitStage(w, h,
1071
- display)` the box the SDK would pick.
1072
-
1073
- Size HUD, buttons and text in the card unit, not in `vw` or `cqw`:
1074
-
1075
- ```css
1076
- .score { font-size: calc(5 * var(--castle-unit)); top: calc(3 * var(--castle-unit)); }
1077
- ```
1078
-
1079
- `--castle-unit` is 1% of the width of the largest 5:7 card that fits in the
1080
- stage. On a card, or a stage taller than a card, it is 1% of the stage width
1081
- (the same as `1vw`); on a wide stage it stops growing, so a HUD keeps its card
1082
- size while the playfield gets the extra room. Anything sized in `vw` grows with
1083
- the stage instead, and a 16:9 monitor makes it huge. Canvas code gets the same
1084
- number from `cardUnit(width, height)` (px). The SDK sets the exact value for
1085
- its own stage; in edit mode, and in a kit's scene editor that draws the stage
1086
- smaller than the window, it falls back to the window's.
1087
-
1088
- A 3D camera tuned for the card keeps its framing with `fovForAspect(fov,
1089
- aspect)`: it returns `fov` at 5:7 and wider (a wide stage shows more to the
1090
- sides) and widens it on a stage narrower than 5:7, so a tall phone still sees
1091
- the card's full width.
1092
-
1093
- ```js
1094
- camera.fov = fovForAspect(50, width / height);
1095
- camera.updateProjectionMatrix();
1096
- ```
1097
-
1098
1561
  ### `CARD_RATIO`
1099
1562
 
1100
1563
  The card aspect ratio (`5 / 7`). Use this if you need to size something
@@ -1229,3 +1692,13 @@ Common codes:
1229
1692
  that lacks it.
1230
1693
  - `CAULDRON_STORAGE_STALE_GENERATION` — the handle belongs to an older reset
1231
1694
  generation; reopen explicitly rather than replaying work into the new one.
1695
+
1696
+ ### Your games: hub hearts
1697
+
1698
+ `Hub.hearts()` returns `{ status: 'ok', hearts: string[] }` for the signed-in
1699
+ account. `Hub.setHearts(doorIds)` replaces that list with at most eight distinct
1700
+ door ids, oldest first. The host supplies the current deck id; only curated hub
1701
+ places may save hearts. Guests and unavailable hosts return
1702
+ `{ status: 'unavailable', hearts: [] }`; older hosts throw `UNKNOWN_COMMAND`.
1703
+ Invalid lists throw `INVALID_ARGUMENT`, and other host failures use `CastleError`.
1704
+ The dev serve keeps hearts per account in memory until it stops.