World Stores
The core CrowdyJS client is a deliberately thin transport: it sends packets, delivers notifications, and stores almost nothing. Every game on top of it therefore rebuilds the same client-side bookkeeping — an actor registry, a pose codec and send loop, a chunk cache, a chat ring, a host poller. The reference MMO (Blocks with Friends) carries roughly 1,600–2,000 lines of exactly that.
World Stores (CrowdyJS 8.4+) moves those data structures into the SDK as
an opt-in layer: typed, queryable, source-of-truth stores fed by ONE shared
udpNotifications subscription, with the developer supplying only their
custom types and encoders/decoders.
import {
createWorldSession, structCodec, f32, u8, jsonCodec, workerTicker,
} from '@crowdedkingdoms/crowdyjs/stores';
// 1. Describe your replication state ONCE (binary layout, declaratively).
const poseCodec = structCodec({
x: f32(), y: f32(), z: f32(), yaw: f32(),
flags: u8(), held: u8(),
});
// 2. Configure the stores you want. Only these are constructed — and only
// these exist on the session's TYPE.
const session = createWorldSession(game, appId, {
ticker: workerTicker(), // keep 5 Hz sends alive in backgrounded tabs
self: { codec: poseCodec, initialState: { x: 0, y: 0, z: 0, yaw: 0, flags: 0, held: 0 } },
actors: { codec: poseCodec },
errors: true,
chunks: { voxelStateCodec: jsonCodec<CropMeta>() },
});
// 3. Play. The SDK owns the bookkeeping.
await session.self.join({ x: '0', y: '0', z: '0' });
session.self.patchState({ x: 12.5 }); // replicated next tick
for (const other of session.actors.list()) { /* render typed poses */ }
session.dispose();
Design contract
- Stores are the source of truth; events are notifications. Every store
is synchronously queryable (
get/list/snapshots, cheaprevisioncounters) and emits change callbacks. Render from the store — no shadow maps. - Render-loop and background-tab friendly. Nothing runs on
requestAnimationFrame. Writes happen in WebSocket message handlers, which browsers do not throttle in hidden tabs, so inbound state keeps flowing while your tab is hidden; the paused render loop simply catches up on resume. Timer-driven work (send loop, reaping, write-back, heartbeats) rides an injectable Ticker:intervalTicker()(default) orworkerTicker()— a dedicated Web Worker, exempt from background-tab timer throttling. Staleness is also computed at read time, so a throttled timer can never serve stale actors. - Compile-time toggles. The layer lives behind the
@crowdedkingdoms/crowdyjs/storessubpath and the package declares"sideEffects": false: the core client never bundles it, and unimported stores tree-shake away. On top of that the session's TypeScript type is conditional on your config —session.hostwithouthost: ...is a compile error, not an undefined-at-midnight surprise. - One subscription, one scheduler, one dispose.
createWorldSessionopens at most oneudpNotificationssubscription (lazily, on the first store that needs it), shares one ticker, anddispose()tears everything down.
Codecs: your types on the wire
Every opaque base64 blob on the platform is app-defined. A StateCodec<T>
names that definition once:
interface StateCodec<T> {
encode(value: T): string; // typed value → base64
decode(data: string): T; // base64 → typed value
}
Built-ins:
| Codec | Use for |
|---|---|
structCodec({...}) | High-rate replication state: a declarative fixed-layout binary DSL (f32, f64, u8, u16, u32, i8, i16, i32, bool8, bytes(n), reserved(n)), little-endian, exposes byteLength |
jsonCodec<T>() | Low-rate structured state (saves, profiles, channel payloads) |
textCodec | Plain UTF-8 strings (chat) |
rawCodec | You already have your own pipeline; the value IS the base64 string |
Replication and durable state take independent codecs — a 48-byte pose and a JSON save blob are different types, and each store binds its own.
Spatial packets have a ~1.1 KB budget: use structCodec for anything sent
per tick. The Blocks-with-Friends 48-byte pose, declaratively:
const poseCodec = structCodec({
x: f32(), y: f32(), z: f32(),
yaw: f32(), pitch: f32(),
vx: f32(), vy: f32(), vz: f32(),
flags: u8(), heldBlockId: u8(), _r0: reserved(2),
updatedAt: f64(), _r1: reserved(4),
}); // byteLength === 48
The stores
self — your actor (LocalActorStore)
Identity, typed state, and the send loop — the boilerplate every multiplayer client starts with:
- Identity: a 32-char uuid is minted and persisted through a pluggable
UuidStore(localStorageUuidStore()keeps it stable across reloads; explicituuidwins). - Send loop: defaults to 5 Hz (
sendIntervalMs: 200); setfalseto drivesendNow()yourself. WithsendOnChange(default) unchanged encodes are deduped, and a keyframe still goes out everykeyframeEveryMs(default 3000) so presence never starves. Since replication v0.37.0 two idle players on different servers see each other at the first keyframe after joining; no faster cadence is needed. That keyframe is when an idle actor first appears. Updates after that arrive at the send rate, including for a player on another server. - Queryable records:
lastSent(typed state + encoded form + sequence number + timestamp),lastAck(your server-applied self-echo, decoded),lastError, andstatus: 'idle' | 'pending' | 'acked' | 'error'. - Presence maintenance:
join(chunk)/moveTo(chunk)send immediately;refresh()re-registers (dropped first-join, reconnects); a built-invisibilitychangehook re-registers when the tab returns.
actors — everyone else (RemoteActorStore)
The remote registry, decode-once:
Map-backed records with stable object identity:{ uuid, state, chunk, distance, epochMillis, receivedAt, samples }.sampleskeeps the lasthistorySize(default 2) timestamped states — what an interpolating renderer needs without keeping its own buffers (the lerp math itself stays in your renderer).- Self-echo filtered automatically (wired from
self). - Leaves are server-announced (CrowdyJS 15.5, Buddy v0.25): when the
server stops considering an actor present it sends an
ActorLeftNotification(opcode 145) to the neighbourhood, and the store removes the actor and firesonLeaveat once — about five seconds after its last update, or immediately when its session ended.remove(uuid)is public for a game that wants to force the same path. - Staleness stays as the fallback, both ways: reads filter by
receivedAtage (staleAfterMs, default 12 s) so results are always live, and a reap timer physically deletes quiet records and firesonLeavefor a leave whose datagram was lost. Tolerate leave → join for the same uuid (a reconnect after a stale looks exactly like that):onLeavethenonJoinis the correct sequence, not a bug. - Lanes route one decoded stream to several consumers — the players-vs-mobs split every MMO hits:
actors: {
codec: poseCodec,
lanes: {
mobs: (s) => (s.flags & FLAG_MOB) !== 0,
players: () => true, // first match wins
},
}
// session.actors.lane('mobs').onJoin(spawnMobVisual);
errors — attributed send errors (ErrorStore)
The server reports UDP failures asynchronously as GenericErrorResponse { sequenceNumber, errorCode }. The session tracks every store-made send in
a 256-slot sequence table, so each error is attributed to the send that
caused it (kind, actor uuid, detail) and kept in a ring buffer:
errors.recent(n), errors.lastFor(uuid), errors.onError(cb). Attribution
is best-effort by design — sequence numbers are uint8 correlation ids.
chunks — terrain (ChunkStore)
The client-side source of truth for chunks and voxels:
ensureAround(center, radius)bulk-loads viagetChunksByDistance(in-flight deduped), then hydrates each newly loaded chunk withgetChunk. ItsvoxelStatescarry every voxel edit recorded for the chunk, each with its type: a hub's or mod'sworld.set_voxels,updateVoxel, other players' realtime voxel updates (ck-api v2.33.0). The store puts them over the dense grid; the bulk load'svoxelshold none of them. It hydrates when you configure avoxelStateCodecor sethydrateVoxelStates: true; without either, a reload shows none of those edits. A chunk the store has already loaded keeps its cache when a later bulk load returns it again (CrowdyJS 18.2.0; earlier releases put the stored grid back over the hydrated edits as the player moved). A chunk the server has never stored comes back from neither read, even when edits were recorded for it.- Realtime
voxelUpdatenotifications merge into the cache automatically: dense grid write, typed state decode, revision bump,onChunkChanged. setVoxel(...)applies locally first (optimistic) and replicates over the UDP path with your typed voxel state. Withoptimistic: falsenothing changes locally until the server's echo of the edit arrives, which the store then applies, so the cache holds only what the server accepted.- Deterministic worldgen write-back: chunks the server has never stored
are handed to your
onMissing(coord)hook; returned grids are seeded locally and persisted through a throttled write-back queue (one chunk perwriteBackIntervalMs, default 700 ms) — the proven shared-worldgen pattern. The write-back isupdateChunkas the player, so it persists only chunks the player may build in (see Writing whole chunks); a refusal the server will not change (FORBIDDEN,BAD_REQUEST,retryable: false, HTTP 400/403/404/413/422) is sent once and dropped, and any other failure is retried after 0.7, 1.4, 2.8 and 5.6 s and then dropped. A dropped chunk keeps its local voxels, is no longer dirty, holds up no other chunk, and is reported toonWriteBackFailedwith itsreason(refusedorexhausted),attemptsand theerror;flush()returns the failures (CrowdyJS 18.0.4). - Typed reads everywhere:
voxelTypeAt,voxelStateAt,get(coord)withvoxels(4096-byte dense grid),voxelStates: Map<index, T>, typedchunkState,loadState,revision. - A 16×16×16 helper, and wide edits are kept, not truncated (CrowdyJS
18.7.0). Voxel positions and types are your app's signed 16-bit values; the
dense grid holds one byte per voxel of a 16×16×16 chunk. An edit it cannot
hold (a type outside 0-255, a position outside 0-15) goes to the chunk's
overlay(Map<voxelKey(x, y, z), { x, y, z, voxelType, state }>), andvoxelTypeAt/voxelStateAtreturn the overlay's value for that voxel. A game with other addressing reads the rawvoxelUpdateevents instead. - Your own accepted edits come back to you as
voxelUpdates (replication v0.37.0 echoes every accepted edit to its sender), and the store applies that echo idempotently over its optimistic write.
channelInbox, actorInbox, events — messaging
- ChannelInbox: every
channelMessagedecoded into a per-channel chronological history (capped, default 100) withonMessage(cb, channelId?)and typedsend— the inbound channel fan-out most games never write. - ActorInbox: typed
singleActorMessagehistory +send(targetUuid, payload, targetChunk). The payload is opaque to the server; embed sender identity in your payload type (the platform convention). - EventRouter: register
{ eventType → codec + handler }for app-defined client and server events (uint16 ids); the latest decoded event per type stays queryable vialastEvent(eventType);sendemits typed events.
host, save, avatar — election and durable state
- HostTracker: heartbeats on the session ticker (default 3 s — also
keeps you host-eligible), caches
hostUserId/isHost, firesonHostChanged. Election is informational — decide host-only rules in a ck-exec hub. - SaveStateStore: a typed cache over the per-user app save blob
(
client.state):load(),set()/patch(),save(), and debounced autosave (autosaveMs). - AvatarStateStore: typed, cached public / private / per-app avatar state, each with its own codec.
Adopting it in an existing game
The layer replaces, one for one, the bookkeeping the reference MMO hand-writes:
| Hand-written today (BWF) | LOC | World Store |
|---|---|---|
| Pose codec (48-byte buffer code) | 61 | structCodec (~10 declarative lines) |
| Actor send loop + uuid persistence | ~95 | self |
| Remote registry + stale reap + session wiring | ~175 | actors |
UDP fan-out singleton (NetworkManager handler sets) | 327 | the session itself |
| Chunk cache + streamer + voxel codecs | ~860 | chunks (+ your onMissing worldgen) |
| Host heartbeat poller | 50 | host |
| Chat ring buffer | 64 | channelInbox |
| Save-state encode/decode | 121 | save |
| Error handling | console.warn | errors (attributed) |
Custom store? Build on the same wiring: session.context exposes the
notification bus, the shared ticker, and send tracking, and every store's
attach* factory is exported individually.
Relationship to other layers
- Your app's ck-exec hubs are the server-side rules layer;
World Stores is the client-side state layer. A hub's state reaches
clients on its topics, through
client.execsubscriptions, rather than through a store. - The Unreal SDK's Crowdy State solves an adjacent problem — diff-based property replication between clients on the view plane. World Stores manages what arrives over the platform's standard replication surfaces in a browser/TypeScript client; the concepts align (typed state, host authority, heartbeat keyframes) but the wire mechanics differ.
- For the underlying transport semantics see the GraphQL UDP proxy guide.