npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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

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 deck scope. 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 read private storage, 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, .title and .result are the place and the result shape as published (result is null for 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 } (doorId is null when the door names none), else null. It is Castle's word, never a client's, and it is also the third argument of onPlayerJoin.
  • 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: true sends them through together. Resolves { status: "gathering" | "fired", members }. A fired pad's group is fixed, so the next gathering uses a new padId.
  • 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, maxPlayers and lateJoin (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 (kind a lowercase id the kit draws, color '#rrggbb', rarity 0-9), or clears it with null; the latest wins. Castle takes it only from a curated hub place. Resolves { wearable }; readResults returns it as players[userId].wearable.
  • session.hub.unlock(playerId, itemId) gives a signed-in player one of the deck's declared earned items (castle.json hub.unlocks: { id, class, shape, name, deed, color }, at most 8, class one of badge, pin, belt, sash, held). v1.70 also permits up to 6 home items separately: furniture or deed, with piece in place of shape (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 }, where earned is what the player now shows ({ deckId, id, class, shape, name, deed, color }) or null, including for home grants. A home grant never changes what the avatar shows; a home item appears only in the owned list returned by readUnlocks. readResults returns the shown one as players[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 with CAULDRON_HUB_EARNED_NOT_WEARABLE. Resolves { earned }.
  • session.hub.readUnlocks(userIds) reads the earned items players here own: { players: { [userId]: { owned: [...] } } }, including home items with their class and piece. MultiplayerHubEarnedItem and MultiplayerHubEarnedClass describe wearable items only; MultiplayerHubHomeItem and MultiplayerHubHomeClass describe 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_UNAVAILABLE on 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 uses hubFriendsAmong or 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 adds validated: true to 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 its playerId) has in name, or null when 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, or null if 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.
}
  • player is the current player's own allowance in this deck: their Store.me and Store.private keys, their board scores, and what the deck's Characters keep for them in their playthrough.
  • shared is Store.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. select is { prefix } or { keys } within the target; omitted selection reads its whole prefix. The result has access and targets: [{ target, revision, rows, complete }]. complete describes coverage of the entire target. A successful null revision 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 has target, expectedRevision (string or null), optional writer, and either put/remove or replace. replace removes omitted rows within the prefix. Each target appears once. A null expected revision creates only an absent target; an explicit replace also supports importing unregistered legacy rows. checks are 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 Store instead: it stores each key on its own, so concurrent writes do not overwrite each other, and it holds far more than this does. Storage keeps 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 at key, or null if it is not set.
  • Storage.set(key, value) — sets key. value must be something that converts to JSON. The next get(key) returns it immediately; the write saves in the background.
  • Storage.remove(key) — removes key.
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 Store instead. SharedStorage has 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. Store fixes 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-9 and _. A deck declares at most 8 experiments, each with 2 to 8 variants.
  • weights are 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-deck sends the block to Castle, and castle-web check and save-deck both 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. 0 or absent is free; otherwise a whole number from 5 to 9995. Anything else throws INVALID_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 second offer while 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 }, or null. 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 change Party.current() or the launch party used by Multiplayer.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. role is member, host or owner.
  • 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 from Hub.chat(after)). Each message has { id, fromUserId, fromUsername, body, text, createdAt }. body is the Castle chat JSON; text is 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 stored PartyMessage.
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 has doorId, launcherKind (door, table, cabinet, gate), placementKind (layer, pad, exact), placement, padSize, enabled, featured and status: { 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_found when 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 }. here is true for a friend in the player's own layer; door is 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_found when 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" }, or unavailable outside 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), and peers are the user ids here who would be sent what they say. Poll it, passing the last cursor as after for only what is newer. unavailable outside 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 }, where status is sent, none (nobody to send it to), refused, limited (said too fast) or unavailable.
  • Hub.reportChat(messageId) reports a chat message the player was returned to Castle's moderators. The host names the place and session. { status: "ok" } or unavailable. 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 with Hub.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. Treat open as 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