Skip to content

Serving a Realm

The authoritative engine, the Cloudflare Durable Object host, persistence, hibernation, schema migration, and ingress limits.

The server side of a realm is two pieces: an engine (the authoritative state, rules enforcement, patch log, broadcast slicing) and an acceptor (the frame-protocol adapter that plugs engines into a wire connection). On Cloudflare, serveRealmDurableObject assembles both plus persistence in one call — start there.

The Durable Object serves connections — hibernatable secure sockets — and the realm registers on them as a protocol. A realm-only DO hosts them with serveWireDurableObject from @nice-code/wire — the server counterpart of createWireClient — with no @nice-code/action import, no ActionRuntime, and no channel:

MyRealmDurableObject.ts
import { serveWireDurableObject } from "@nice-code/wire/platform/cloudflare";
import { hostRealm, serveRealmDurableObject } from "@nice-code/realm/platform/cloudflare";
const realms = serveRealmDurableObject(this.ctx, {
realms: {
game_realm: hostRealm(gameRealm, {
avatarType: "player", // identity defaults from the authenticated socket
engine: {
ctx: serverCtx,
initialState: (info) => makeGenesis(info.instanceId),
onAvatarDetach: (avatar) => pruneEphemeral(avatar), // e.g. delete presence rows
},
}),
},
});
const server = serveWireDurableObject(this.ctx, {
identity: backendCoord.specify({ insId: this.ctx.id.toString() }), // the handshake identity
keyPrefix: "realm-ws:", // DO-storage namespace for the crypto identity + TOFU pins
protocols: [realms.protocol], // ← the whole integration
});
// fetch(req) => server.fetch(req)
// webSocketMessage(ws, m) => server.receive(ws, m)
// webSocketClose/Error(ws) => server.drop(ws)
// alarm() => this.realms.alarms?.alarm()

A DO that also serves actions registers the same protocol on @nice-code/action’s serveDurableObject instead — both planes then share the sockets (the action host rides the same wire acceptor underneath):

MyGameDurableObject.ts
import { serveDurableObject } from "@nice-code/action/platform/cloudflare";
const server = serveDurableObject(this.ctx, gameChannel, {
runtime, clientEnv,
protocols: [realms.protocol],
channelCases: { /* the channel's actions */ },
});

What you get automatically:

  • Persistence: every commit lands in the DO’s SQLite patch log; snapshots are written on a cadence (snapshotEveryVersions, default 64); a cold start is snapshot + tail replay. The Durable Object class needs SQLite storage enabled in wrangler.
  • Hibernation: the socket is hibernatable. On wake, presence is reconciled — clients whose sockets vanished during hibernation get a synthesized detach — and surviving clients resume via patch replay, not a re-download.
  • Identity: with avatarType, each connection’s avatar defaults from the coordinate the server persisted on the socket attachment — on a secure socket, the authenticated handshake identity. Provide resolveAvatar: (ws) => ref only when you need custom mapping (it must also work at DO wake, from the attachment alone).
  • Security floor: the host enforces the realm’s declared security minimum fail-closed — a connection below it is refused at attach (insufficient_security, no hydrate), on the first hello and on every post-wake re-hello. Pass hostRealm(realm, { security: "encrypted", … }) to raise the floor for this deployment (it can only raise, never lower). See Security.
  • Multi-tab / multi-device is already counted. onAvatarDetach fires once per avatar identity (type + persistentId) when its last connection drops — a second tab or device never reaches the hook, and the same identity reconnecting elsewhere never fires a false departure. The presence prune above really is the whole handler: (avatar) => engine.update((d) => { delete d.players[avatar.persistentId]; }) — no socket scanning, no instance bookkeeping.
  • A reconnect is not a departure. A client’s own staleness-probe re-hello, a hibernation-wake resume, or any same-avatar re-attach on a live socket swaps the connection in place without firing onAvatarDetach/onAvatarAttach — so a prune-on-detach handler never deletes an idle-but-present player’s state. The hooks fire only on a genuine first-arrival / last-departure; a hello that resolves a different identity on the same socket runs the old avatar’s detach first (reason "identity-changed").

Misconfigurations that used to surface as production cold-start failures are construction-time errors instead: a realms key that doesn’t match its definition id throws, and a snapshot cadence that outruns the replayable log throws with the corrective message (snapshotEveryVersions must be ≤ half the engine’s logWindow — otherwise a crash between snapshots can leave the log compacted past the last snapshot).

A changed schema hash refuses to boot rather than silently reinterpreting persisted state. To evolve the shape, pass a migrate hook on the realm entry:

hostRealm(gameRealm, {
avatarType: "player",
engine: { /* … */ },
migrate: (oldStateBlob, oldHash) => upgradeState(decode(oldStateBlob), oldHash),
});

On migrate, the old patch log is discarded (its ops are in the old shape), a fresh snapshot is written immediately, and the realm starts a new log epoch — connected clients holding old version numbers cleanly full-hydrate instead of replaying a foreign history.

State that never needs migrating — presence, cursors, anything clients re-assert themselves — declares that instead: migrate: "wipe" discards the old snapshot + log on a hash mismatch and boots initialState on a fresh epoch. Pair it with assertOnAttach on the client and a schema change costs a blink, not a migration.

Why persist a patch log for cursor positions? A presence-shaped realm can skip storage entirely:

hostRealm(lobbyRealm, {
avatarType: "player",
persistence: "ephemeral", // no snapshots, no persisted patch log
engine: { ctx, initialState: emptyLobby, onAvatarDetach: prune },
});

Every cold start — including a hibernation eviction — boots initialState on a fresh epoch; surviving sockets full-rehydrate (near-)empty state. This mode assumes clients assert their own entries via assertOnAttach — without it, an eviction silently empties the roster with everyone still connected. Ephemeral realms need no migrate (there is never a snapshot to migrate; passing one is a construction error) and don’t take expireAfterIdleMs (nothing to GC).

One realm per match / run / document (idFromName(matchId)) orphans SQLite snapshots and patch logs forever once the entity is over. The host GCs them for you:

const realms = serveRealmDurableObject(this.ctx, {
realms: { match_realm: hostRealm(matchRealm, { /* … */ }) },
expireAfterIdleMs: 24 * 60 * 60 * 1000,
onExpire: (realmId, engine) => {
if (isStillInteresting(engine.state)) return false; // veto — one more period
// also the place to drop app-owned storage that dies with the realm
},
});
// A Durable Object has exactly ONE alarm — forward it (the host exposes its mux):
// async alarm() { await this.realms.alarms?.alarm(); }

The deadline arms on the last detach (and at boot when nothing is attached), disarms on any attach, and on firing drops the realm’s snapshot, patch log, and attached rows — the name simply boots genesis if it is ever addressed again.

The app’s own DO deadlines ride the same mux. A Durable Object has exactly one alarm and ctx.storage.setAlarm is last-write-wins, so a second scheduler on the same DO would silently clobber the host’s deadlines. host.alarms is always there on an alarm-capable DO — whether or not expireAfterIdleMs / a durable grace is set — so registering an app deadline is one call, no second mux to build:

this.realms.alarms?.register("my-cleanup", (now) => { /* fires via the DO's single alarm */ });

(If the app already built its own createDurableObjectAlarmMux, pass it as alarms and the host rides that instead.)

Before 0.50 you only got a mux if you asked for one (or set expireAfterIdleMs / a durable avatarDetachGraceMs). From 0.50 the host builds one on every alarm-capable DO. If your DO class calls ctx.storage.setAlarm() directly alongside the realm host, move those deadlines onto host.alarms.register(...) — once the mux holds a deadline of its own, the two schedulers are back to last-write-wins on the DO’s single alarm slot.

The mux never touches the alarm slot until it has a deadline to arm, so an un-migrated app alarm is left alone rather than clobbered. That is a grace period, not a supported configuration: migrate.

A registrant’s deadline is deleted only after its handler resolves. A handler that throws is logged and its deadline pushed out on a bounded exponential backoff (2 s, 4 s, 8 s … capped at 5 min), so it retries until it succeeds or you cancel() it. A handler evicted mid-await keeps its row and re-dispatches when the DO next wakes and reconstructs the mux.

Two consequences worth designing for:

  • Dispatch is at-least-once. An eviction between your handler resolving and its row being deleted re-runs it. Handlers should be idempotent.
  • alarms.alarm() does not throw on a registrant’s behalf. An uncaught exception out of a real DO’s alarm() aborts the object — in-memory state gone, sockets torn — and Cloudflare abandons the alarm after 6 retries. One registrant’s bad handler must not do that to a DO shared with everyone else’s, so the mux catches, logs, and owns the retry. Watch your logs for [alarmMux] deadline "…" threw.

createRealmServerEngine(realm, options) is the authoritative core, host-agnostic. The Durable Object host builds it for you from the engine block above; outside Cloudflare you construct it directly. The options:

OptionWhat it does
ctxThe server-only context (secrets live here — never in the definition). Reaches serverChecked rules and intent apply bodies.
initialStateGenesis — a value or (info) => state, run exactly once per instance, before the first attach.
onAvatarAttach / onAvatarDetachAn avatar’s first live connection / last connection dropped. Dispatch is deferred a microtask, so the hooks may freely call engine.update() (presence rows, prune-on-leave). Attach can re-fire for hibernation survivors without a paired detach — keep it idempotent.
onMutationFires synchronously after every commit with the canonical patches — persist/report/throttle here. Observe-only: calling engine.update() from it is unbounded recursion, not re-entrancy.
limitsIngress limits (below).
logWindowVersions kept replayable behind the head (default 512); older reconnects fall back to full hydrate.

Server code mutates through engine.update((draft) => { … }) — the r.serverOnly write path for ticks, fulfilment, and anything the server drives itself.

createRealmAcceptor({ realms }) produces a wire frame protocol from any set of engines — anything that can register a frame protocol on a @nice-code/wire connection can serve a realm. You own persistence in that case: feed restore: { state, version, epoch } to the engine at boot and persist from onMutation.

Resolving avatar identity off a live connection

Section titled “Resolving avatar identity off a live connection”

The Cloudflare host’s avatarType shortcut reads identity from the coordinate persisted on a socket’s attachment (it has to survive hibernation). A plain server has no attachment concept, so it reads the same authenticated coordinate off the live connection instead — server.acceptors[i].wireAcceptor.clientForConnection(connection), the live-connection counterpart of the DO host’s attachment lookup, both public API:

realm_ws_server.ts
// Set right after `serveChannel` returns — the resolver only runs on an inbound hello,
// so this just breaks the type-checker's "server referenced in its own initializer".
let clientForConnection: ((ws) => RuntimeCoordinate | undefined) | undefined;
const server = serveChannel(runtime, channel, {
// …carriers, storage…
protocols: [
createRealmAcceptor({
realms: {
game_realm: {
engine,
resolveAvatar: (ws) => {
const client = clientForConnection?.(ws); // the authenticated handshake identity
if (client?.perId == null) return undefined; // no identity yet → refuse the attach
return { type: "player", persistentId: client.perId, instanceId: client.insId ?? "default" };
},
},
},
}),
],
});
clientForConnection = (ws) => server.acceptors[0]?.wireAcceptor.clientForConnection(ws);

Returning undefined from resolveAvatar refuses the attach — the realm never serves a connection whose identity it can’t resolve.

The security floor is never "none"allowPlain can’t open a realm

Section titled “The security floor is never "none" — allowPlain can’t open a realm”

A realm’s declared security minimum is "authenticated" or "encrypted" — there is deliberately no "none" tier (TRealmSecurityLevel has no such member). So a realm can’t be served over an unauthenticated connection, and the acceptor-level allowPlain escape hatch — which does let other protocols run at ESecurityLevel.none — cannot make one work: connectRealm enforces the floor client-side, before it sends the first hello, so a client connected at none sets attachError and stays detached no matter what the server allows. Serve realms over the real authenticated handshake, exactly like the Cloudflare host does (the sample above already does — clientForConnection only returns a coordinate for an authenticated connection). See Security.

Every connection is guarded by per-realm limits (defaults shown); breaches reject with limits_exceeded, and a connection exceeding maxBreachesBeforeDetach is forcibly detached:

LimitDefaultGuards
maxPatchesPerFrame64Ops in one write frame.
maxFrameBytes64 KiBEncoded size of one inbound frame.
maxRecordKeyLength128Dynamic record-key length (also protects the intern dictionary).
maxExpandedLeaves256Leaves one subtree write may expand to.
writeRatePerSecond / writeRateBurst100 / 200Token-bucket write rate per connection.
maxBreachesBeforeDetach10Strikes before forcible detach.

The client-relevant limits are advertised to clients in the hydrate, and well-behaved clients never breach them: over-limit flushes split automatically, and rate breaches back off and retry — see Connecting & React.