Skip to content

Serving a Realm

Hosting a realm: the authoritative engine, the Cloudflare Durable Object host, persistence, hibernation, schema migration and ingress limits.

The server side of a realm has two pieces. The engine owns the authoritative state, rule enforcement, the patch log and broadcast slicing. The acceptor plugs engines into a wire connection as a frame protocol. On Cloudflare, createRealmHost assembles both plus persistence in one call, so 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. It needs no @nice-code/action import, no ActionRuntime, and no channel:

MyRealmDurableObject.ts
import { serveWireDurableObject } from "@nice-code/wire/platform/cloudflare";
import { hostRealm, createRealmHost } from "@nice-code/realm/platform/cloudflare";
// In the DO constructor. Keep the host on the class so alarm() can reach it.
this.realms = createRealmHost(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.withPersistentId(this.ctx.id.toString()), // stable DO identity; insId stays per-boot
keyPrefix: "realm-ws:", // DO-storage namespace for the crypto identity + TOFU pins
protocols: [this.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. Actions and realms then share the sockets, since the action host uses the same wire acceptor:

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

What you get automatically:

  • Persistence: every commit is written to the DO’s SQLite patch log, and a snapshot is written every snapshotEveryVersions versions (default 64, and it must be at most half the engine’s logWindow, default 512). 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 instead of a re-download.
  • Identity: with avatarType, each connection’s avatar defaults from the trusted protocol context supplied by the wire acceptor. On a secure socket that is the authenticated handshake identity, including after a hibernation wake. Provide resolveAvatar: (connection, context) => ref only for custom mapping; use context.client and context.level instead of reading private attachment fields.
  • 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 is the whole handler, with no socket scanning or instance bookkeeping: (avatar) => engine.update((d) => { delete d.players[avatar.persistentId]; }).
  • 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 or 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 would otherwise fail at a production cold start throw at construction instead. A realms key that doesn’t match its definition id throws. So does a snapshot cadence that outruns the replayable log, with the fix in the 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 because 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 then full-hydrate instead of replaying a foreign history.

Some state never needs migrating: presence, cursors, anything clients re-assert themselves. Declare that with migrate: "wipe", which 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 instead of a migration.

Cursor positions don’t need a persisted patch log. 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, and 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, and passing one is a construction error. Nor do they take expireAfterIdleMs: there is nothing to GC.

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

this.realms = createRealmHost(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) and disarms on any attach. On firing it drops the realm’s snapshot, patch log, and attached rows. If the name is ever addressed again, it boots genesis.

The app’s own DO deadlines share 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. this.realms.alarms is always there on an alarm-capable DO, whether or not expireAfterIdleMs / a durable grace is set. 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 uses 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 this.realms.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, so 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, losing in-memory state and tearing down sockets, 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 host-agnostic authoritative core. 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 changes state through engine.update((draft) => { … }). It is 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, so anything that can register a frame protocol on a @nice-code/wire connection can serve a realm. You then own persistence: 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, since it must survive hibernation. A plain server has no attachments, so it reads the same authenticated coordinate off the live connection with server.acceptors[i].wireAcceptor.clientForConnection(connection). It is the live-connection counterpart of the DO host’s attachment lookup; both are 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(channel, {
identity: realmServerCoord,
// …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, so 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. The acceptor-level allowPlain escape hatch lets other protocols run at ESecurityLevel.none, but cannot make a realm work. connectRealm enforces the floor client-side, before it sends the first hello, and the server checks it again before resolving the avatar or hydrating state. clientForConnection itself reports the coordinate bound to a live connection; on a host that also accepts plain action traffic, that coordinate alone is not proof of authentication. The realm protocol’s security check is what makes its use in the sample safe. See Security.

Per-realm limits guard every connection (defaults shown). A breach rejects 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.