castle-web-sdk
v0.4.48
Published
`castle-web-sdk` lets a deck use services provided by the Castle platform — for example, a deck can save per-player data, post and read scores on a leaderboard, or get a server-synced time for daily content.
Keywords
Readme
Castle Web SDK Reference
castle-web-sdk lets a deck use services provided by the Castle
platform — for example, a deck can save per-player data, post and read
scores on a leaderboard, or get a server-synced time for daily content.
Comes with every deck: the SDK is a file of the base kit each deck
imports (imports/castle.base/sdk/), and the bare specifier resolves
there — nothing to install, and no deck package.json names it. Import
what you need from castle-web-sdk:
import { setup, initCard, Store, Leaderboard } from "castle-web-sdk";Contents
- Store
- Storage
- SharedStorage
- Leaderboard
- Time
- User
- Experiments
- Avatar
- Pass
- Snapshot
- Portal
- Social
- Party
- Hub
- Decks
- Clipboard
- Haptics
- Agent
- Lifecycle
- Display
- Multiplayer
- Setup
- Saving files
- CastleError
Multiplayer
Multiplayer.join connects the deck to its Cauldron server. Pick a public
session, the current Castle party, or a stable named room:
import { Multiplayer } from "castle-web-sdk";
const connection = await Multiplayer.join({ mode: "public" });
connection.onRoster(({ players, you }) => {
console.log(`${you?.username} is one of ${players.length} players`);
});
connection.onMessage((message) => receiveServerMessage(message));
connection.send({ type: "ready" });Attach onMessage in the same step that receives the connection (right after
await Multiplayer.join(...)). On a hub join (mode: "hub"), which resolves a
little after the socket's hello, messages the place sent in between, such as
the snapshot it greets a player with, are kept for that first listener. A
listener attached later gets only what arrives after it.
Named rooms use { mode: "named", key: "room-name" }; party sessions use
{ mode: "party" }. Only one connection can be live per deck instance. The SDK
reconnects temporary socket drops automatically. onStateChange reports
acquiring, connecting, connected, reconnecting, and closed; its optional
metadata distinguishes a server bye, a replaced server version, an explicit
leave, and connection failures.
connection.serverBundleId and connection.serverBundleSha256 identify the server publication running the connected session. These read-only diagnostics are null before a live connection, while disconnected, in solo mode, or when the host does not provide them. The ID identifies a publication; the SHA-256 identifies its code bytes, so two publications can share a hash. Read the values again after a reconnect or a destination join. They describe the running server, not the client bundle or the deck’s newest save.
Experimental: Multiplayer.join({ mode: "public", pongTimeoutMs: 10_000 })
reconnects if a keepalive ping receives no pong within the given milliseconds.
Omitting the option (or passing 0) preserves the existing keepalive behavior.
The deadline starts at a ping, normally sent every 25 seconds; it is not a
deadline for game messages. A deadline callback delayed by more than one second (for example during page
suspension) sends one fresh ping and waits a full deadline before reconnecting. Only one
such extension is allowed without a pong, so repeated timer throttling cannot
postpone recovery indefinitely.
During local development, reloading a Play panel restores that panel's session when its dev server is still running. A separate Play panel joins separately.
When Castle selects solo fallback, the returned connection has isSolo === true,
a one-player roster, and no server process. Calls to send are accepted but do
nothing. soloReason says why: private_deck or no_server_bundle are the
creator's to fix (change the deck's visibility, or publish a deck that has a
server entry); anything else reports unavailable. Client→server frames are limited to 16 KB on the wire.
Limits
A client may send about 30 messages per second; going past that closes the socket, so throttle pose or input sends rather than sending one per animation frame.
A session may send about 40 + 40 × connected players frames per second, with
roughly two seconds of burst and about 1 MiB/s of bytes. session.send costs
one frame per call, session.broadcast(data) costs one frame however many
players are connected, and session.broadcast(data, { except }) is a loop of
sends, so it costs one frame per recipient. Frames over the budget are dropped
silently and never retried — the session keeps running — so broadcast one
snapshot per tick instead of sending a tailored message per player. Each client
reads every entry in that snapshot, its own included: the server's copy of its
hp, score and match state is authoritative, and only its own position stays
locally predicted.
Binary messages
Pass any typed array, DataView, or ArrayBuffer to client send, server send,
or server broadcast to send its raw bytes. Receivers always get those bytes as a
Uint8Array; reconstruct another view type such as Float32Array on receipt.
Multi-byte values use the sender platform's byte order. Every other payload uses
JSON. Client→server messages are capped at 16 KB and server→client messages at
64 KB, for both JSON and binary frames. Both caps are on the frame, and a binary
frame includes a one-byte protocol tag, so the largest payload is one byte under the
cap. A published server bundle on an older runtime throws
TypeError: This Castle runtime does not support binary multiplayer messages yet.
Server callbacks are the default export from the deck's server entry. They are session-first and receive only platform-trusted player identities:
export default {
onPlayerJoin(session, player) {
session.broadcast({ type: "joined", playerId: player.playerId });
},
onMessage(session, player, message) {
session.broadcast({ from: player.playerId, message });
},
};TypeScript server entries can import callback, session, player, and platform
handle types from castle-web-sdk/server. Server transport has no runtime
dependency on a WebSocket package.
session.sessionId, session.deckId and session.mode say which session this
is. session.place is the hub place the host started the session for (see
session.hub), and null otherwise. It is never taken from a
client.
session.storage
A session ends when its last player leaves, and its memory goes with it.
session.storage is what outlives one, and it is the same store the deck's
clients see through Store:
export default {
async onPlayerJoin(session, player) {
const { wins = 0 } = await session.storage.deck.get(["wins"]);
session.send(player.playerId, { wins });
},
async onShutdown(session) {
await session.storage.deck.set({ endedAt: Date.now() });
},
};session.storage.deck is shared by everyone playing.
session.storage.user(userId) is one player's public data.
await session.storage.playthrough(userId) opens that player's generation-bound
game state. Its resetGeneration is present only after an administrator has
reset that player's progress for this deck. It is a monotonic reset marker,
separate from the handle's generation, which fences playthrough operations.
Deck-scope storage writes are not fenced by either generation; a server using
session.storage.deck must discard its own stale queued writes and cached
per-player values when it observes a newer reset marker.
session.storage.board(name) is a leaderboard. The root and its buckets also
support the atomic save API. Each scope has the same
get, set, remove, increment and list as Store.
session.storage.town(userId) lets a door deck's server read and write one
signed-in player's rows in its source town's deck scope, for things such as
hats bought in town. It has get, set, remove and increment; quota counts
against the town. Every key is one of the town's per-player namespaces
(tokens, spent, own, hat, chirp) followed by the user id: for user
7, own:7:tophat and hat:7 work; own:tophat:7 and quay:7 do not. The
rest of the town's rows are its own. The player must be live in this session
(a player who joined a few seconds ago can briefly get
CAULDRON_HUB_NOT_IN_SESSION, so retry later rather than giving up); in a house, only the owner's rows are
accessible. Calls reject with CAULDRON_HUB_NO_TOWN without a source town
(including a town calling it), CAULDRON_HUB_NOT_IN_SESSION for an absent
player, or CAULDRON_HUB_NOT_OWNER for someone other than the house's owner.
Invalid key segments reject with CAULDRON_STORAGE_INVALID_ARGUMENT.
During dev serve, keys live under town/ in the dev deck's own deck scope,
so testing a door never changes a real town. An unpublished deck still needs
a published deck id for storage.
Two differences follow from a session server not being a player:
- It owns
deckscope. Once a deck has a published server, clients read that data but no longer write it, which is what lets you keep authoritative state players cannot forge. - It has no self. There is no
storage.me, and a user scope or a board submission must name the player:session.storage.user(player.userId),session.storage.board("high").submit(player.userId, score). A server can submit anyone's score, which is what makes a server-run leaderboard worth trusting. It can never readprivatestorage, not even for its own players.
onShutdown is awaited, so a save made there does land — but only on a clean
stop. An out-of-memory kill or a host failing sends no shutdown at all, so
save as things change and treat onShutdown as the last chance, not the plan.
session.hub
A deck whose castle.json declares a hub block is a hub place (the hub-game
kit's contract). save-deck publishes the block's titles and result shapes with
the server bundle, and Castle pins them to every session of the place. Those
sessions have session.hub; every other session has session.hub === null.
session.hub.owner is the owner's user id string on an owner door instance,
passed by the platform. It is absent on every other hub session, including the
dev serve.
session.hub.place,.titleand.resultare the place and the result shape as published (resultisnullfor a place that reports none, such as a town).session.hub.reconnectWindowMs, when present, is Castle's pinned hub-place reconnect grace in milliseconds, inclusive at the deadline. It is absent on sessions without a pinned grace and older hosts. Kits use it to match visit retention to the transport window; deck code does not choose it.session.hub.arrival(playerId)is how a player came in when Castle placed them through a hub door:{ fromDeckId, doorId }(doorIdisnullwhen the door names none), elsenull. It is Castle's word, never a client's, and it is also the third argument ofonPlayerJoin.session.hub.reportResult(playerId, { outcome?, score?, stats? })records a signed-in player's result for this visit, once. The host checks it against the published shape and Castle keeps it only for a place behind a curated door; resolves{ recorded, record }(recorded: false, reason: "duplicate"for a visit that already has one).session.hub.updatePad({ doorId, padId, memberUserIds, fire? })names who is standing on a pad of one of this place's doors (only players connected to this session count);fire: truesends them through together. Resolves{ status: "gathering" | "fired", members }. A fired pad's group is fixed, so the next gathering uses a newpadId.session.hub.readResults({ userIds?, doorIds? })reads players' recent results, records, platform avatars and wearables (players[userId]) and the best scores behind this place's doors (boards[doorId]). A board also carries the door's title, whether it is featured, and its group:minPlayers,maxPlayersandlateJoin(null where the door does not say).session.hub.wear(playerId, { kind, color, rarity } | null)puts one wearable on a signed-in player's platform avatar in every hub place (kinda lowercase id the kit draws,color'#rrggbb',rarity0-9), or clears it withnull; the latest wins. Castle takes it only from a curated hub place. Resolves{ wearable };readResultsreturns it asplayers[userId].wearable.session.hub.unlock(playerId, itemId)gives a signed-in player one of the deck's declared earned items (castle.jsonhub.unlocks:{ id, class, shape, name, deed, color }, at most 8,classone ofbadge,pin,belt,sash,held). v1.70 also permits up to 6 home items separately:furnitureordeed, withpiecein place ofshape(the same lowercase id format). Each id is unique across both lists. Castle takes it only from a curated hub place and only for an id the saved deck declares; a first wearable unlock is also shown. Resolves{ unlocked: true, isNew, earned }, whereearnedis what the player now shows ({ deckId, id, class, shape, name, deed, color }) ornull, including for home grants. A home grant never changes what the avatar shows; a home item appears only in the owned list returned byreadUnlocks.readResultsreturns the shown one asplayers[userId].earned, absent when none.session.hub.wearEarned(playerId, { deckId, id } | null)chooses which owned earned item a player shows, or none. Castle takes it only from the town. Home items are refused withCAULDRON_HUB_EARNED_NOT_WEARABLE. Resolves{ earned }.session.hub.readUnlocks(userIds)reads the earned items players here own:{ players: { [userId]: { owned: [...] } } }, including home items with theirclassandpiece.MultiplayerHubEarnedItemandMultiplayerHubEarnedClassdescribe wearable items only;MultiplayerHubHomeItemandMultiplayerHubHomeClassdescribe home items. Only the owned list uses the union of both item types; unlock/wear receipts and arrival results keep the wearable-only type.- These three reject with
CAULDRON_HUB_UNAVAILABLEon a runtime without them. session.hub.friendsAmong(playerId, userIds)resolves the subset of candidate user ids that are this connected player's friends (Promise<string[]>). The platform checks mutual follows, age, blocks, the hub gate and the player's recent client friends ask. It useshubFriendsAmongor the older runtime's storage broker and rejects on platform errors. The hub-game kit wraps it with decimal-id filtering, a 100-candidate cap, caching and a silent[]fallback.session.hub.relationsAmong(playerId, userIds)makes the same single host read and resolves{ friends, follows, followedBy }, each limited to asked string ids. Missing lists are empty on older hosts. Mutual friends keep the chat age and block rules; follows in either direction require no block, with no age rule. These lists belong to the viewer only and must never go to another client. Optional third argument{ strict: true }rejects malformed raw lists before normalization and addsvalidated: trueto a successful result. Without it, the existing response shape and older-host behavior remain unchanged.session.hub.hiddenPair(playerIdA, playerIdB)is true when a block hides these two players from each other (either one blocked the other). Never send either one the other's pose, avatar or name. The block lists themselves never reach the deck, and player objects never carry them.
Refusals reject with a CAULDRON_HUB_* code, such as
CAULDRON_HUB_RESULT_ACCOUNT_REQUIRED for a guest, CAULDRON_HUB_RESULT_DUPLICATE
or CAULDRON_HUB_PAD_NOT_READY.
session.experiment
A deck with experiments reads a player's variants on the server too. Castle resolves them and delivers them with the join, so they are the same arms the player's page reads and nothing a client can claim.
session.experiment(player, name)is the variant a connected player (the player object or itsplayerId) has inname, ornullwhen they are in no such experiment.session.experiments(player)is all of them,{ name: variant }.
Both answer from onPlayerJoin until the player leaves, and null / {}
after. The variants are never on the player object, so a roster or state built
from session.players does not show one player's arm to another; send a variant
to other clients only when the game means to.
export default {
onPlayerJoin(session, player) {
const lives = session.experiment(player, "starting_lives") === "five" ? 5 : 3;
state.players[player.playerId] = { lives };
},
};Persistent worlds
An opted-in persistent-world route has session.world; other sessions have
session.world === null. Alongside its checkpoint, the world keeps a journal
of its latest 512 entries and a last-seen row for each participant:
await session.world.journal.append({
operationId: "market-opened-day-12",
entries: [{
kind: "market.opened",
disclosure: "world",
data: { day: 12 },
}],
});
const page = await session.world.journal.read({
afterSeq: savedCursor,
participantId: player.participantId,
});
const { participants } = await session.world.participants.read({
participantIds: [player.participantId],
});journal.append accepts up to 16 entries per operation; each optional data
value must be JSON no larger than 2 KiB. Save nextSeq from journal.read as
the next cursor, and compare it with oldestRetainedSeq to detect a cursor that
fell behind retention. Entries disclosed to participants name participant
ids for the trusted deck server to project to those players; never broadcast
those entries to the whole session.
Every MultiplayerServerPlayer has a stable participantId for these calls
(scopedUserId when available, otherwise userId). The host records join,
leave, and shutdown presence automatically, and participants.read reflects
those host writes. The SDK never writes participant presence, and there is no
public touch method.
A world-notification template is published copy with named slots; push copy is
always authored by the deck and is never generated at runtime. Ghost owns
delivery, muting, expiry, and rate caps, including notifications scheduled for
later. notifications.notify and notifications.cancel return counts only, so
deck code cannot learn which participants were muted or unavailable.
await session.world.notifications.notify({
key: "lantern-overdue",
participantIds: [player.participantId],
templateId: "lantern_overdue",
slots: { npc: "Mara" },
at: "2026-10-01T18:00:00Z",
});
await session.world.notifications.cancel({
key: "lantern-overdue",
participantIds: [player.participantId],
});A destination can opt into real elapsed time with
worldTime: { advance: "wallClock" }. On boot, catchUp reads the latest
checkpoint, clamps the elapsed duration, and commits the full checkpoint
returned by apply; it retries one revision conflict and never advances an
authored game clock on its own.
The checkpoint marker prevents an elapsed interval from being counted twice
and never moves backwards if the host clock regresses. If a prior commit landed
without its acknowledgement being observed, the next boot counts only time
elapsed after that marker, so apply may run again for that new interval; it
returns { applied: false, elapsedMs: 0 } without calling apply only when no
time has passed since the marker.
const result = await session.world.catchUp({
maxElapsedMs: 7 * 24 * 60 * 60 * 1000,
apply({ elapsedMs, checkpoint }) {
return advanceWorld(checkpoint, elapsedMs);
},
});Store
Store saves data one key at a time. It is the storage to use for
multiplayer decks: two players writing different keys no longer
overwrite each other, a counter can be added to safely from several
places at once, and a deck can hold much more than Storage allows and
read back a slice of it rather than the whole thing.
Values can be anything that converts to JSON, the same as Storage.
Scopes decide who owns a key:
Store.deck— shared by everyone playing. If the deck publishes a session server, the server owns these keys and players read them.Store.me— the current player's data, which other players can read.Store.user(userId)— another player's public data. Anyone can read it; only that player, or the deck's session server, can write it.Store.private— the current player's own data. Nobody else can read it, and a session server never sees it.
Store.<scope>.get<T>(keys): Promise<Record<string, T>>
Reads several keys at once. Keys with nothing stored are missing from
the result rather than present as null.
const { level = 1, name } = await Store.deck.get(["level", "name"]);Store.<scope>.set(values): Promise<void>
Writes several keys. Nearby writes are grouped, so saving on every change costs one request per burst. The promise resolves once the platform has the write, so a deck can tell whether saving worked.
await Store.deck.set({ level: 7, name: "Ada" });Store.<scope>.remove(keys): Promise<void>
Removes keys.
Store.<scope>.increment(key, delta?): Promise<number>
Adds delta (default 1) to a number and returns the new total,
starting the key at delta if it was not set. Two players incrementing
at the same moment both count, which is the thing a read, add and write
back cannot promise.
const plays = await Store.deck.increment("plays");Store.<scope>.list(options?): Promise<{ entries, cursor }>
Reads a page of keys in order, optionally only those starting with
prefix. Pass the cursor from one page to get the next.
let cursor = null;
const world = [];
do {
const page = await Store.deck.list({ prefix: "block:", cursor });
world.push(...page.entries);
cursor = page.cursor;
} while (cursor);Keep reading until cursor is null. A page can come back shorter than
you asked for because it reached its size limit, not because the keys
ran out, so the cursor is the only reliable end signal.
A paged read is not a snapshot: something written while you are paging may land in no page or in two. Treat the result as a starting point and apply live updates on top, which a multiplayer deck is doing anyway.
Store.board(name)
A leaderboard, separate from the keys above so that reading the top scores stays fast no matter how many players there are.
await Store.board("highscores").submit(score);
const top = await Store.board("highscores").top(10);
const mine = await Store.board("highscores").get();submit(score)records the current player's score. A later submission replaces an earlier one, so compare first if you only want to keep a personal best.top(limit?)returns the highest scores, best first.get(userId?)returns one player's score, defaulting to the current player, ornullif they have none.
A deck's session server can submit any player's score, which is how a leaderboard becomes something a player cannot fake. See the multiplayer guide.
Store.usage(): Promise<StoreUsage>
How much the current player and the deck have stored, against the limits below, so a deck can warn a player or clear out old saves before a write is refused instead of after.
const { player, shared } = await Store.usage();
if (player.byteCount > player.byteLimit * 0.9) {
// Nearly full: offer to delete old saves.
}playeris the current player's own allowance in this deck: theirStore.meandStore.privatekeys, their board scores, and what the deck'sCharacterskeep for them in their playthrough.sharedisStore.deck, one allowance for every player together.
Each has byteCount, byteLimit, keyCount and keyLimit. A board
score counts as a key. The answer includes the set and remove calls
the page made before calling it, even ones still being batched; await an
increment or a board submit first if the answer should include it. A player can only ever see their own usage, not
another player's.
An app too old to know usage rejects with a CastleError whose code
is UNKNOWN_COMMAND.
Limits
The platform caps key length, value size, how many keys one read may
name, and how much each player may store, and it limits how fast a
player may read and write. Going over one of these rejects with a
CastleError whose code says which, so a deck can tell a player it is
saving too fast apart from a player who is out of space:
| limit | value |
|---|---|
| key | 128 bytes |
| value | 256 KB |
| keys in one get | 32 |
| one player's data in a deck | 50 MB, 50,000 keys |
| the deck's shared data (Store.deck) | 500 MB, 1,000,000 keys |
| one player's calls | 20 a second, 512 KB a second |
| every player of a deck together | 500 calls a second, 8 MB a second |
These do not reject. set and remove with more than 32 keys are split
into several calls by the SDK (each still counts against the rate above).
A list page holds at most 1,000 entries (a larger limit is lowered to
it) and ends early once it reaches 1 MB; take the next page with its
cursor.
A player's data is everything stored for them: their Store.private
keys, their Store.user(id) keys, their scores on every board, and
their Characters playthrough.
Each player has their own allowance, so a deck with more
players can hold more, and one player filling theirs does not stop
anyone else. A write that shrinks what a player stores always succeeds,
so a player who is full can always free space.
A value is JSON, so binary data such as a picture or a video clip costs about a third more once encoded, and a clip of more than a few seconds does not fit in one. Ship media with the deck instead.
Atomic saves
Store and session.storage expose the same typed save methods:
snapshot({ targets: [{ target, select? }] })reads every selected target at one consistent point.selectis{ prefix }or{ keys }within the target; omitted selection reads its whole prefix. The result hasaccessandtargets: [{ target, revision, rows, complete }].completedescribes coverage of the entire target. A successfulnullrevision means absent; an initialized empty target has a revision. A failed read never means absent.transaction({ operationId, expectedAccess, checks?, changes })commits all changes together. Each change hastarget,expectedRevision(string ornull), optionalwriter, and eitherput/removeorreplace.replaceremoves omitted rows within the prefix. Each target appears once. Anullexpected revision creates only an absent target; an explicitreplacealso supports importing unregistered legacy rows.checksare read-only target checks with an expected revision and/or writer; they leave revisions intact.claim({ operationId, expectedAccess?, target, initial?, load? })explicitly takes ownership of a target and returns{ writer, receipt, snapshot }.initial: [{ target, rows }]initializes absent targets only. An absent claimed target initializes empty if defaults were omitted. Claiming an existing target leaves its data revision unchanged. The latest committed claim wins; claimed targets require the current writer on every change.initialize({ operationId, expectedAccess?, initial, load? })initializes only absent targets and returns{ receipt, snapshot }. Concurrent defaults are applied once. It never claims ownership or replaces existing data.operation(operationId)returns{ state: "committed", receipt }or{ state: "unknown" }. Unknown does not cancel a delayed request. Settle it by replaying the original input and ID.
A target is { scope: "deck" | "user" | "private", subject?, prefix }.
The prefix must be nonempty, and registered prefixes in a bucket cannot
intersect. All row keys stay inside their target. subject is only for user
scope; omitted subject means the current player. Session servers must name a
user and cannot access private targets. Permissions and quotas are unchanged:
deck-scoped bags remain publicly readable and share the deck allowance.
rows, put and replace are maps of keys to JSON values, validated through
the same JSON handling as ordinary Store writes.
A receipt contains operationId, access, revisions: [{ target, revision }]
and, for claims, writer. Access and revisions are opaque. Use snapshot access
as expectedAccess; reset and undo invalidate old access, revisions and writer
tokens. Claim/initialize may omit expected access for a deliberate first open.
When load is omitted, the SDK requests the claimed/initial targets. Supply
an explicit load to choose other targets or smaller projections.
Every bucket also has target(prefix) and the same methods with a prefix
wherever the root takes a target: snapshot({ prefix, select? }),
transaction({ changes: [{ prefix, ... }], checks?: [{ prefix, ... }] }),
claim({ prefix, initial?: [{ prefix, rows }], load?: [{ prefix, select? }] }),
initialize({ initial: [{ prefix, rows }], load? }) and operation(id).
Root methods can combine buckets atomically:
const room = session.storage.deck.target(`room:${roomId}:`);
const bag = session.storage.deck.target(`bag:${roomId}:${userId}:`);
const opened = await session.storage.claim({
operationId: crypto.randomUUID(),
target: room,
initial: [{ target: room, rows: roomDefaults }, { target: bag, rows: starterBag }],
load: [{ target: room }, { target: bag }],
});
const receipt = await session.storage.transaction({
operationId: actionId,
expectedAccess: opened.snapshot.access,
changes: [
{ target: room, expectedRevision: opened.snapshot.targets[0].revision,
writer: opened.writer, put: changedRoomRows, remove: deletedRoomKeys },
{ target: bag, expectedRevision: opened.snapshot.targets[1].revision,
put: changedBagRows },
],
});
// Acknowledge the gameplay action only after this receipt.Atomic saves bypass ordinary coalescing and 32-key splitting. They are never
split into partial writes or retried as ordinary SETs. A request or snapshot
that exceeds a transport or single-charge byte allowance fails with
CAULDRON_STORAGE_TOO_LARGE; narrow the selection or reduce the atomic payload.
Ordinary set, remove and increment reject keys inside registered targets
with CAULDRON_STORAGE_MANAGED_TARGET.
Save methods reject with SaveError, exported from the client and server SDK.
It carries code, message, operationId, retryable,
committed: false | true | "unknown", and optional receipt, retryAfterMs
and target. Extra platform details remain in extensions. Conflicts, writer
loss and stale access require explicit reload/rebuild. A replayed historical
claim can fail with writer loss while committed === true and its original
receipt; it never takes ownership again. Transport ambiguity keeps the outcome
unknown until receipt lookup or identical replay settles it.
Conditional writes and recovery (gated)
Store.conditionalWrite(input) and session.storage.conditionalWrite(input)
use the same request and receipt as transaction. Read with snapshot, then
pass its access and each target's revision as expectedAccess and
expectedRevision. Use null to create an absent target. A revision belongs
to a prefix, so changing any key in it changes the revision. For an
independent value, give it a separate, non-overlapping prefix.
This additive API is off by default. The platform must enable conditional
storage for the deck after its database index and host support are ready.
Older hosts and the dev memory store do not support it. Keep a dependent
feature off when the API is unavailable; never fall back to ordinary writes.
Existing get, set and save methods keep their behavior.
One call changes up to eight targets, with up to eight explicit key changes
per target, across the same deck and at most two players. Every target needs
its expected revision. A read-only check can use expectedRevision: null
to require absence without creating that target. All changes commit together. Clients may address their
own user/private targets and writable deck targets; only the deck server can
condition on another player's user target. Servers cannot access private data.
Existing quota, byte and rate allowances still apply. A prefix replacement
also removes its previous rows, subject to those allowances.
On a revision conflict, SaveError.code is
CAULDRON_STORAGE_REVISION_CONFLICT, committed is false, and current
contains the current values and revisions read at the failed comparison.
If that full snapshot exceeds the byte allowance, the call fails with
CAULDRON_STORAGE_TOO_LARGE; no changes commit. Keep the player's proposed
change and reload before offering another choice.
Use a unique operationId for each action. Retrying the identical request
after a lost reply returns its original committed receipt, even if values
have since changed. Reusing the ID with different input is refused. Failed
comparisons do not create receipts. Receipts have no automatic expiry and
are retained beyond 24 hours; an explicit data/account reset may remove them.
Never reuse old operation IDs or access tokens after a reset.
recentOperations({ limit?, cursor? }), on both root APIs, discovers the
caller's committed operation IDs after a restart. It returns
{ operations: [{ operationId, committedAt, receipt }], cursor }, newest
first. limit defaults to 16 and is at most 32; pass the returned cursor for
the next page. This lists existing save receipts too. Client and server
histories are separate; all layers of the same deck share the server history.
Use a smaller limit if the reply exceeds the byte allowance. A history page
is discovery, not a snapshot of current values: reload with snapshot before
building the next change. operation(id) still looks up a known ID.
Pairing can atomically create a project and reserve both players' recovery entries. The game server must still validate consent, membership and editing leases. A market sale can atomically update stock, the daily cap and the purse, once every writer of those values uses the managed targets. Durable game recovery entries belong in those targets; recent operation receipts alone do not define permanent project ownership or sale policy.
Save coordinator
createSaveCoordinator, from castle-web-sdk or castle-web-sdk/server,
sends a deck's transactions in order and keeps each one until it settles:
const saves = createSaveCoordinator(session.storage, opened.snapshot, [
{ target: room, writer: opened.writer },
]);
const receipt = await saves.submit({
operationId: actionId,
checks: [{ target: room, writer: opened.writer }],
changes: [{ target: bag, put: changedBagRows }],
});
await saves.flush();
// saves.status is "pending", "saved" or "blocked".submit copies the save and resolves with its receipt once it commits. A
change without expectedRevision takes the target's revision from the latest
receipt, filled in when the save is first sent; a change without writer
takes the one passed in for its target. Saves are sent one at a time, in
order. A save whose reply was lost is looked up by its id and, when the
platform has no receipt for it, sent again unchanged.
A failure that cannot be settled that way sets status to "blocked" and
holds back every save behind it. When the outcome is unknown or the error is
retryable, the save's promise stays pending and retry() sends the same
request again. When the save definitely did not commit (a conflict, a lost
writer), its promise rejects and the coordinator stays blocked: reload a
snapshot and start a new coordinator. It never claims or rebuilds a save on
its own. error, pendingOperationIds and revision(target) show its state.
flush() resolves once every save submitted before it has committed, and
rejects if the queue blocks first. Saves submitted after it do not hold it up,
and an earlier receipt never marks a later save saved. For start-over, call
discardUnsent() and submit the replacement: saves already sent still settle
first, and the replacement goes out at the revision they leave.
Keep the coordinator in the page when a private worker is replaced. A page or process that dies loses the saves it had not sent and the ids of those it had; reopening restores the last committed snapshot.
During castle-web serve, the serve answers the save API itself, from one
in-memory store per project shared by its pages and named sessions. Its rows,
receipts and writers survive a runtime restart and are lost when the serve
exits. Ordinary get, set and list still use the deck's real storage, and
the two do not see each other's rows.
Storage
Older API, still supported and not going away. New decks should use
Storeinstead: it stores each key on its own, so concurrent writes do not overwrite each other, and it holds far more than this does.Storagekeeps one document per player and rewrites all of it on every change.
Storage saves data for the current player. Nobody else can read it.
A player's whole Storage document is at most 2 MB. A write that takes
it past that never saves, and nothing tells the deck: set has already
returned.
Storage.get<T>(key): Promise<T | null>— the value atkey, ornullif it is not set.Storage.set(key, value)— setskey.valuemust be something that converts to JSON. The nextget(key)returns it immediately; the write saves in the background.Storage.remove(key)— removeskey.
const level = (await Storage.get("level")) ?? 1;
Storage.set("level", 7);The same thing with Store, which is what a new deck should write:
const { level = 1 } = await Store.private.get(["level"]);
await Store.private.set({ level: 7 });SharedStorage
Older API, still supported and not going away. New decks should use
Storeinstead.SharedStoragehas no ownership rule — any player can overwrite any key in the'deck'bucket — and two players writing at the same moment lose one of the writes.Storefixes both, and adds counters, listing, and leaderboards.
SharedStorage saves data that other players can read. Values must be
something that converts to JSON, same as Storage. Each bucket holds at
most 2 MB, and a write past that is dropped without an error, as with
Storage.
Scopes: 'deck' is one bucket for the whole deck, readable and writable
by any player. 'user' is a per-player bucket that anyone can read and
only the owning player can write.
SharedStorage.get(scope, key)— reads a shared value. For'user', pass a user id before the key to read someone else's bucket.SharedStorage.set(scope, key, value)— writes.'user'writes always go to the current player.SharedStorage.remove(scope, key)— removes a shared value.
const worldHighScore = await SharedStorage.get("deck", "highScore");
const theirColor = await SharedStorage.get("user", otherUserId, "color");
SharedStorage.set("user", "color", "red");The same thing with Store:
const { highScore } = await Store.deck.get(["highScore"]);
const { color } = await Store.user(otherUserId).get(["color"]);
await Store.me.set({ color: "red" });Leaderboard
Leaderboard ranks players by a numeric score, per deck and per
variable name. Pick a variable name for each leaderboard the deck has
(e.g. 'score', 'time'). show opens Castle's own leaderboard screen;
reach for fetch only if the deck draws the scores itself.
Leaderboard.write(variable, score, options?)
Submits a score. Only the player's best score for that variable (and
scope) is kept. In the editor it does nothing — safe to call from
gameplay code unconditionally.
options is { scope?: string }. By default the score goes to the
deck's global leaderboard for variable; pass scope to write to a
separate leaderboard (for example a daily one, or a custom id):
Leaderboard.write("score", 1200);
// A daily leaderboard: scope by the current Castle day so each day gets
// its own board (see Time.getServerDate).
const { daysSinceCastleEpoch } = await Time.getServerDate();
Leaderboard.write("score", 1200, { scope: `daily-${daysSinceCastleEpoch}` });Leaderboard.show(variable, type, options?): Promise<LeaderboardShowResult>
Opens Castle's own leaderboard screen for variable — the same board
players see on other Castle decks, with FOR YOU and GLOBAL tabs, sharing,
and profile links. This is the normal way to show a leaderboard: Castle
draws it and owns the data, so the deck gets no entries back.
type is 'high' (highest first) or 'low' (lowest first). options is
{ scope?, label? } — scope works as it does in write, and label
titles the column of scores (default 'Score'; e.g. 'Time', 'Coins').
Opening the board also submits the score the player has written this
session, so their own result is on it right away. The board appears
immediately, on a loading spinner; the promise resolves once the scores
are in. The player closes it themselves — there's no hide.
LeaderboardShowResult has a status:
'shown'— Castle opened the leaderboard.'unavailable'— this host has no leaderboard screen, or can't show one right now. Nothing was submitted; the score still saves normally.
Leaderboard.write("score", finalScore);
gameOverButton.onclick = () => Leaderboard.show("score", "high");Leaderboard.fetch(variable, type, options?): Promise<LeaderboardData>
Fetches the leaderboard's entries so the deck can draw them itself — use
this only when Castle's own screen isn't what you want. type and
options.scope work as they do in show.
Like show, a fetch reflects the player's own score written this
session right away. Other players' recent scores still appear on their own
normal timing.
The returned LeaderboardData has:
list— array of entries, each{ place, value, username, userId? }.playerRank— the current player's place on the board (if they have a score).playerValue— the current player's score (if they have one).
const data = await Leaderboard.fetch("score", "high");
for (const entry of data.list) {
console.log(`${entry.place}. ${entry.username} — ${entry.value}`);
}
if (data.playerRank) {
console.log(`you are #${data.playerRank} with ${data.playerValue}`);
}Time
Time.getServerTime(): Promise<number>
Returns the current server time as a Unix timestamp in seconds.
const now = await Time.getServerTime();Time.getServerDate(timezone?): Promise<CastleDateParts>
Returns the current server time broken into date parts. timezone is
'Castle' (default; Castle's server timezone, same for every player)
or 'player' (the player's local timezone).
The returned CastleDateParts has:
sec,min,hour— time of day.day(1-31),month(1-12),year— date.wday— day of the week (1-7, Sunday = 1).yday— day of the year (1-366).daysSinceCastleEpoch— a day number that increments every day. Use it for daily content.
const date = await Time.getServerDate("player");
const dailyPuzzle = (date.daysSinceCastleEpoch % 30) + 1;User
User.getCurrent(): Promise<CastleUser>
Returns the signed-in player. Throws CastleError
(LOGIN_REQUIRED) when nobody is signed in.
A CastleUser has userId, username, isAnonymous, isActive, photoUrl
(their 256px avatar), frameUrl (the 96px frame drawn around it) and color
(the hex their username is shown in); the last three are null when unset. The
urls are data: urls, so they go straight into an <img> or a texture.
Anonymous accounts are real logins with generated anonymous-user-... names;
check isAnonymous before accepting writes into anything other players see,
such as a shared gallery.
const me = await User.getCurrent();
greet(me.username);User.get(userId): Promise<CastleUser | null>
Another player as anyone may see them, or null when no such user exists.
Results are cached per id for the life of the page. The ids come from the
session's player list or from Store.user(id) rows.
const them = await User.get(player.userId);
if (them?.photoUrl) {
avatar.src = them.photoUrl;
}Experiments
A/B tests. A deck declares its experiments in castle.json; Castle gives each
player one variant of each, the same one every time they play, and the deck
reads it. The guide, with the caveats worth knowing before reading results, is
docs/ab-tests.md.
"experiments": {
"tutorial_length": { "variants": ["short", "long"] },
"shop_price": { "variants": ["low", "mid", "high"], "weights": [50, 25, 25] }
}- An experiment's name and its variants' names are 1 to 32 characters of
a-z,0-9and_. A deck declares at most 8 experiments, each with 2 to 8 variants. weightsare whole percentages, one per variant, summing to 100. Leave them out for an equal split.winner(one of the variants) ends the test: every player gets that variant.save-decksends the block to Castle, andcastle-web checkandsave-deckboth refuse one that breaks a rule. Removing the block ends every experiment at the next save.
import { experiment, experimentsReady } from "castle-web-sdk";
await experimentsReady();
const tutorial = experiment("tutorial_length") === "long" ? longTutorial : shortTutorial;experiment(name): string | null
The variant this player has in name. Synchronous. null means the player is
in no such experiment: the deck does not declare it, it was removed, or the host
is an older one that knows nothing of experiments. Always give null a
behavior, normally the one the deck had before the test.
experiments(): Record<string, string>
Every experiment this player is in on this deck, as { name: variant }.
experimentsReady(): Promise<Record<string, string>>
Resolves with that same map once the host has answered, and never rejects.
Await it once at boot, before the first read: some hosts hand the answer over
with the page, where it is already resolved, and the rest answer one request. A
read made before then returns null and logs a warning.
A multiplayer deck's server reads the same answer with
session.experiment(player, name).
On castle-web serve the experiments come from the folder's castle.json,
assigned to you exactly as they will be once the deck is saved. Add
?experiment.<name>=<variant> to the serve's URL to force an arm (a declared
experiment and one of its variants); it holds for the whole serve, client and
local server alike, until ?experiment.<name>= clears it or the serve stops.
An open deck page keeps the arm it loaded with until it is reloaded.
Nothing published reads that parameter. devExperiments in castle.json, a
{ "name": "variant" } map, answers for experiments the deck does not declare
itself (ones Castle runs across decks), on the serve only.
Avatar
The player's platform avatar: one per account, worn in every hub place
(cauldron-game-server docs/HUB.md, decision 4). In v1 it is an index into the
hub-game kit's character templates, with an optional colour choice. The
hub-game kit is the caller: it dresses the player and offers the choice in its
picker. Other players' avatars are not read here; a place's server learns them
from ghost.
Avatar.get(): Promise<AvatarResult>
{ status: "ok", avatar, chosen } with the player's choice, or the default
every account has (chosen: false). Anonymous accounts have one too.
{ status: "unavailable", avatar: null } for a signed-out visitor; a host from
before avatars rejects with UNKNOWN_COMMAND. Either way the deck picks its own.
Avatar.set(avatar, options?: { color?: number }): Promise<AvatarResult>
Makes avatar the account's from now on, everywhere. Only a curated hub place
(a deck an enabled hub door leads into or out of) may: anywhere else, and for a
signed-out visitor, the answer is unavailable and nothing changes. Ghost
refuses an index outside its template set (INVALID_ARGUMENT) and more than 30
choices a minute (RATE_LIMIT_EXCEEDED).
A host that supports avatar colours includes color in AvatarResult; the
field is absent from older hosts. Avatar.colors === true marks an SDK whose
Avatar.set carries { color }; the hub-game kit requires both the host field
and this marker to offer platform colours. Only after that support is known, pass
{ color } to choose a non-negative integer colour for the current avatar.
The host accepts 0–255; ghost holds the offered range. Changing character
without options clears its colour. Omitting colour sends the existing command
and mutation unchanged. The read query will ask for colour once ghost serves it.
Pass
A pass is something a creator sells to players for Castle bricks (the in-app currency): buy it once, own it for good. Use one to gate part of a deck behind a purchase — bonus levels, a cosmetic, supporting the creator. Set up the pass (name, art, price) on Castle; a deck refers to it by id.
Pass.has(passId): Promise<boolean>
Returns true if the current player owns the pass. No UI, nothing
charged — use it to gate content.
if (await Pass.has(bonusLevelsPassId)) {
showBonusLevels();
}Pass.offer(passId): Promise<PassOfferResult>
Presents the pass for the player to buy; resolves when they're done.
Bricks cost real money, so this only works in the Castle mobile app —
elsewhere (the website, the dev server) it resolves unavailable.
PassOfferResult has a status:
'purchased'— just bought it; grant access.'alreadyOwned'— already had it (not charged); grant access.'cancelled'— dismissed without buying.'unavailable'— can't buy here (e.g. the website).
const { status } = await Pass.offer(bonusLevelsPassId);
if (status === "purchased" || status === "alreadyOwned") {
showBonusLevels();
}Snapshot
A snapshot is a picture of the deck's current frame that a player collects into their Castle inventory, free or for bricks. It is the same item a classic deck makes with its "offer snapshot" rule. The player can also take one any time with the camera button in the feed; the deck is not involved in that one.
Snapshot.offer(options?): Promise<SnapshotOfferResult>
Captures the current frame and shows Castle's collect card over it; resolves
when the player collects or closes it. options:
title,description— shown on the card. With no title the card reads "A snapshot of @creator's deck".price— bricks.0or absent is free; otherwise a whole number from 5 to 9995. Anything else throwsINVALID_ARGUMENT.
Collecting works only in the Castle mobile app; the website shows "collect in
the app" and the dev server a short notice, and both resolve unavailable.
offer was added to the app on 2026-09-22; an app build from before then
rejects it with UNKNOWN_COMMAND (see CastleError), so catch
it where a deck has to keep going without the card.
SnapshotOfferResult has a status:
'collected'— the player collected it.'cancelled'— the player closed the card.'unavailable'— can't collect here, or another snapshot card is already open (a secondofferwhile one is open gets this and the first carries on).
const { status } = await Snapshot.offer({
title: "Level 3 cleared",
description: `${score} points`,
});A snapshot is not the deck's cover, and no SDK call sets the cover. A cover
the creator chose by hand is kept; otherwise save-deck photographs the deck
as it opens, falling back to a preview.png the deck folder holds when that
capture does not produce one.
Snapshot.onTaken(handler): () => void
Registers a function called just before an offer's capture, the twin of the classic "snapshot taken" trigger. Change what's on screen in it — hide the HUD, pose the character — and the capture shows the next painted frame. Returns a function that unregisters it. The camera button never calls it.
Snapshot.onTaken(() => {
hud.hidden = true;
});Social
Social gives a Cauldron deck the signed-in player's mutual Castle follows.
The host supplies the player and current deck from trusted play context; a deck
cannot ask about another account or deck. A host from before Social rejects with
CastleError code UNKNOWN_COMMAND.
Social.friends()returns at most 20 mutual follows as{ userId, username, photoUrl? }. A one-way follow is not a friend here.Social.presence()returns the mutual friends playing this deck now as{ userId, username, photoUrl?, sessionId, routeId?, slot?, updatedAt }. Castle's now-playing privacy and multiplayer gates apply. It fails closed to[]when presence is gated or unavailable.
Presence has no event in v1. Poll Social.presence() when the game needs to
refresh its list; do not treat updatedAt as a durable offline record.
import { Social } from "castle-web-sdk";
const friends = await Social.friends();
const playingHere = await Social.presence();Party
Party creates and drives Castle parties with the signed-in player's account.
Castle still enforces membership, roles, age and party permissions; refusals are
CastleErrors carrying the server's code. Party and user-id arrays are bounded
to 32 users.
Party.current()returns the party this play was launched with as{ partyId, name, status, hasUnreadMessages }, ornull. The party id comes only from trusted host context, and no request is made when the play has none.Party.create(userIds)creates a party containing those users and the signed-in player. It never sets an initial deck. Creating one does not changeParty.current()or the launch party used byMultiplayer.join({ mode: "party" }).Party.invite(partyId, userIds)invites more users.Party.respond(partyId, accept)accepts or rejects an invitation.Party.members(partyId)returns{ userId, username, photoUrl?, status, role }rows.roleismember,hostorowner.Party.leave(partyId)leaves the party.Party.messages(partyId, cursor?)returns{ messages, cursor }. Messages are newest first; pass the cursor to load older history (the opposite direction fromHub.chat(after)). Each message has{ id, fromUserId, fromUsername, body, text, createdAt }.bodyis the Castle chat JSON;textis non-null only when that body is plain text with no tags, links or embeds.Party.say(partyId, text)sends plain text to the party. The host trims it and caps it at 1,000 characters, then returns the storedPartyMessage.
import { Party } from "castle-web-sdk";
const party = await Party.current();
if (party) {
const page = await Party.messages(party.partyId);
await Party.say(party.partyId, "Meet by the gate!");
}Hub
Hub contract v1.95 adds optional invites. A place declaring hub.invite: true
can call hub.invite(playerId) on its server to offer Invite a friend.
The hub kit asks Hub.invite() for a link; Hub.invite({ crew: true }) makes
a crew link for up to five invitees. Links expire within 24 hours and default
to one invitee. Hosts without invite support return unavailable.
The trusted host resolves the link after sign-in, retains its token, and requests
the inviter's layer when it has room. An invite can also route through the
existing Hub.inviteArrival() result { status: 'route', doorId } to the
inviter's house or active run behind a curated door that allows late joins.
Contract v1.113 active-run routing requires root hub.bringFriend: true
in addition to invite: true on both the town and destination. The destination
also declares hub.bringFriend: true so its saved bundle and fresh pin retain
the invite capability;
it admits only to the matching live run while the inviter is present. A full,
ended or unavailable run is refused rather than recreated. The kit calls
Hub.inviteArrival() after joining that destination. Ghost verifies arrival
before creating mutual follows among the eligible
over-13 members of that link. Under-13 arrivals create no follows. The arrival
surface shows an authored now-friends line and Hub.undoInvite(userId) removes
both follow directions for that pair. No player-entered message travels with a link.
These operations retain the existing hub access gates. Link creation and arrival
completion wait up to 16 seconds when the server presence roster is still catching
up with the game connection.
Hub moves a player between hub places: every deck built on the hub-game kit is
a place, and a door is a row in Castle's curated door table. A deck names only a
door; the host supplies the current place, the session it joined there and the
party, and calls Castle with the player's own account. Hosts without hub support
answer unavailable; older hosts reject with CastleError code UNKNOWN_COMMAND.
Hub.signIn({ reason }) asks the host to open its native sign-in flow for an
anonymous player. reason is the short explanation shown to the player (at most
200 characters and 512 UTF-8 bytes). It resolves to
{ status: 'signedIn', userId? }, { status: 'dismissed' }, or
{ status: 'unavailable' }. userId is present only when the host can return
the signed-in account id. A new host without native sign-in support returns
unavailable; an older host that does not know this command rejects with
UNKNOWN_COMMAND.
Hub.doors()reads the current place's doors and who is inside them:{ status: "ok", doors }or{ status: "unavailable", doors: null }. A row hasdoorId,launcherKind(door,table,cabinet,gate),placementKind(layer,pad,exact),placement,padSize,enabled,featuredandstatus: { playersInside, friendsInside }. Where a door leads stays with the host.Hub.go(doorId)acquires the destination behind a door of the current place. A door used within seconds of arriving can take up to 15 seconds to answer while Castle catches up with the arrival.Hub.back()acquires the way back through the door the player came in by: the layer they left if it has room, else their party's, else the usual pick. The deck cannot choose where it leads;not_foundwhen there is no way back.Hub.friends()reads the mutual friends in a layer of the current place right now, as far as their now-playing privacy lets this player see them (at most 20):{ status: "ok", friends: [{ userId, username, here, door }] }or{ status: "unavailable", friends: null }.hereis true for a friend in the player's own layer;dooris set instead for a friend inside the place behind one of this place's doors (that door's id), and null otherwise.Hub.jump(userId)acquires the layer of the current place that friend is in: the same place in another session, joined exactly like a door's. The way back does not change.not_foundwhen the friend is not in another layer of it.Hub.report(userId, reason?)reports another player in this hub session to Castle's moderators, with an optional short reason (at most 500 characters). The host names the place and session.{ status: "ok" }, orunavailableoutside a hub session or on hosts without hub support. Hub chrome only.Hub.chat(after?)reads this hub session's chat:{ status: "ok", canChat, peers, messages: [{ id, fromUserId, fromUsername, text, at }], cursor }. Who may chat is Castle's rule, not the deck's: players who follow each other and are both over 13. Each player is returned only the messages they may read (at most 50), andpeersare the user ids here who would be sent what they say. Poll it, passing the lastcursorasafterfor only what is newer.unavailableoutside a hub session or on hosts without hub support.Hub.say(text)says something in that chat (at most 200 characters). A deck cannot choose recipients: Castle sends it to the players here who may chat with this one.{ status, id }, wherestatusissent,none(nobody to send it to),refused,limited(said too fast) orunavailable.Hub.reportChat(messageId)reports a chat message the player was returned to Castle's moderators. The host names the place and session.{ status: "ok" }orunavailable. Hub chrome only.Hub.cancel()gives back an acquisition the deck will not join. Optional: one that is never joined expires within two minutes.Hub.onBack(listener)hears the host's Back control, which a game cannot hide. Answer it withHub.back()behind the door animation; a host that hears nothing takes the player home itself. Returns an unsubscribe function.
A player Castle has barred from the hub is refused at every hub door and at the
town itself with CastleError code CAULDRON_HUB_BANNED (from Hub.go,
Hub.back, Hub.jump and the place's Multiplayer.join). Do not retry it.
go, back and jump return a HubHandoffResult: status: "ok" with url, nonce
(single use, valid for 60 seconds), sessionId, bundleHash (the
destination session's content pack hash) and contentUrl (that pack's immutable
URL, or null when the destination has none), or cancelled, full,
unavailable, not_found, forbidden or party_required with those fields null.
A door can also lead to a cabinet: a classic Castle deck the host plays itself.
Hub.go then answers status: "ok" with kind: "classic" and null url, nonce
and sessionId: there is no session to join, and the trip is already committed.
Do not pass that handoff to Multiplayer.join; cover the place and wait for the
host's Back control, which Hub.back() answers like any other way back. kind is
absent on every other handoff.
Success is an acquisition; nothing has moved yet. Load the destination's content, then join it:
import { Hub, Multiplayer } from "castle-web-sdk";
const handoff = await Hub.go("race");
if (handoff.status === "ok") {
// ...the door opens; load the destination's scene behind it...
const connection = await Multiplayer.join({ mode: "hub", handoff });
}When that socket first says hello the SDK tells the host, which commits the trip:
the player's presence moves and the way back is recorded, and from then on
every SDK call speaks for the destination. join resolves once that is done,
and rejects when the trip could not be completed (the player is still where they
were). A join that fails
before then cancels it, and the player is still where they were. A hub
connection reconnects with its session's own token like any other, but cannot be
acquired again: when one closes for good, go back. The engine stays in the same
page throughout; the previous place is left, not held.
Portal
A portal sends the player from this deck to another Castle deck, referred to by its deck id.
Portal.open(deckId): Promise<PortalOpenResult>
Sends the player to deckId: the Castle app swipes the feed to it, the
website opens its page. There's nowhere to go in the editor or dev server,
so there it resolves unavailable. PortalOpenResult has a status:
'navigating'— the host is moving to the target deck. Treatopenas final: this deck is torn down as it navigates away, so don't rely on code running afterward.'unavailable'— nowhere to navigate here (the editor or dev server).
portalButton.onclick = async () => {
const { status } = await Portal.open(nextDeckId);
if (status === "unavailable") {
// No feed to navigate here — fall back to your own affordance.
}
};Portal.prefetch(deckId): Promise<PortalPrefetchResult>
A best-effort hint that the player may soon open(deckId). In the Castle app
the host warms that deck (fetching it now so a later open transitions without
a cold load); everywhere else it's a no-op. PortalPrefetchResult
has a status:
'prefetching'— the host accepted the hint (or the deck was already warm).'rejected'— the host declined, e.g. this deck has already prefetched its limit of upcoming decks. Prefetch a few likely destinations, not everything.'unavailable'— the host doesn't prefetch here (the website, editor, or dev server).
link.addEventListener("pointerenter", () => {
Portal.prefetch(link.dataset.deckId);
});Portal.info(deckId): Promise<PortalInfoResult>
Where a portal leads: the destination deck's title, creator and cover, for a sign or label on the portal. A deck can't reach Castle's API or load remote images itself, so this is how it shows another deck's details. It needs a Castle login, which the app and a signed-in website visitor have; the c
