Skip to main content

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, cheap revision counters) 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) or workerTicker() — 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/stores subpath 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.host without host: ... is a compile error, not an undefined-at-midnight surprise.
  • One subscription, one scheduler, one dispose. createWorldSession opens at most one udpNotifications subscription (lazily, on the first store that needs it), shares one ticker, and dispose() 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:

CodecUse 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)
textCodecPlain UTF-8 strings (chat)
rawCodecYou 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; explicit uuid wins).
  • Send loop: defaults to 5 Hz (sendIntervalMs: 200); set false to drive sendNow() yourself. With sendOnChange (default) unchanged encodes are deduped, and a keyframe still goes out every keyframeEveryMs (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, and status: 'idle' | 'pending' | 'acked' | 'error'.
  • Presence maintenance: join(chunk) / moveTo(chunk) send immediately; refresh() re-registers (dropped first-join, reconnects); a built-in visibilitychange hook 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 }. samples keeps the last historySize (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 fires onLeave at 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 receivedAt age (staleAfterMs, default 12 s) so results are always live, and a reap timer physically deletes quiet records and fires onLeave for a leave whose datagram was lost. Tolerate leave → join for the same uuid (a reconnect after a stale looks exactly like that): onLeave then onJoin is 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 via getChunksByDistance (in-flight deduped), then hydrates each newly loaded chunk with getChunk. Its voxelStates carry every voxel edit recorded for the chunk, each with its type: a hub's or mod's world.set_voxels, updateVoxel, other players' realtime voxel updates (ck-api v2.33.0). The store puts them over the dense grid; the bulk load's voxels hold none of them. It hydrates when you configure a voxelStateCodec or set hydrateVoxelStates: 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 voxelUpdate notifications 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. With optimistic: false nothing 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 per writeBackIntervalMs, default 700 ms) — the proven shared-worldgen pattern. The write-back is updateChunk as 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 to onWriteBackFailed with its reason (refused or exhausted), attempts and the error; flush() returns the failures (CrowdyJS 18.0.4).
  • Typed reads everywhere: voxelTypeAt, voxelStateAt, get(coord) with voxels (4096-byte dense grid), voxelStates: Map<index, T>, typed chunkState, 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 }>), and voxelTypeAt / voxelStateAt return the overlay's value for that voxel. A game with other addressing reads the raw voxelUpdate events 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 channelMessage decoded into a per-channel chronological history (capped, default 100) with onMessage(cb, channelId?) and typed send — the inbound channel fan-out most games never write.
  • ActorInbox: typed singleActorMessage history + 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 via lastEvent(eventType); send emits 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, fires onHostChanged. 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)LOCWorld Store
Pose codec (48-byte buffer code)61structCodec (~10 declarative lines)
Actor send loop + uuid persistence~95self
Remote registry + stale reap + session wiring~175actors
UDP fan-out singleton (NetworkManager handler sets)327the session itself
Chunk cache + streamer + voxel codecs~860chunks (+ your onMissing worldgen)
Host heartbeat poller50host
Chat ring buffer64channelInbox
Save-state encode/decode121save
Error handlingconsole.warnerrors (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.exec subscriptions, 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.