# @nice-code/realm — documentation A schema-defined authoritative shared-state engine — many clients observe and optimistically write one server-owned state tree over the action channel's socket. This file concatenates only the @nice-code/realm pages of https://nicecode.io — point a local AI coding assistant at it when you're working with just this package. For the full set (every nice-code package and how they fit together) use /llms.txt. Generated from src/content/docs — do not edit by hand. --- # Connecting & React Source: /nice-realm/connecting Description: The client handle — connectRealm over your app's connection, status and liveness, the staleness probe, the patch feed, replicas, and the React hooks. A realm attaches to your app's **connection** — one secure socket, one handshake, one authenticated identity. The connection is a `@nice-code/wire` concern; a realm rides it, actions ride it, both can share one. Open it with **`createWireClient`** when the realm is the only traffic (no `@nice-code/action` at all — see [Realm-only apps](#realm-only-apps--createwireclient) below), or with **`connectChannel`** when the app also has actions and both planes share the one socket: ```ts title="client.ts" import { connectChannel } from "@nice-code/action"; import { connectRealm, realmConnection } from "@nice-code/realm"; // The app's connection — the realm rides it (alongside actions, or alone). const connector = connectChannel(runtime, gameChannel, { peer, storage, transports }); const realm = connectRealm(gameRealm, { avatar: { type: "player" }, // persistentId defaults from the authenticated coordinate connection: realmConnection(connector), onMutationRejected: ({ error, rolledBackPaths }) => toastRollback(error, rolledBackPaths), }); realm.store // @nice-code/state Store realm.update((draft) => { draft.players[me].x = 412; }); // optimistic; returns a settle handle realm.intents.purchase({ purchaseId: newId(), itemId }); // settle-only server mutation realm.pending // { count, has(prefix), subscribe } ``` `realm.store` is a regular [`@nice-code/state` Store](/nice-state/stores/) holding the projected view — read it, subscribe to it, and bind it to React exactly like any other store. The projection starts empty and fills on hydrate. (`connectChannel` itself — the peer coordinate, storage, and transports — is covered in [Serving & Connecting](/nice-action/serving-connecting/); the realm-only sample below shows a complete call.) The `avatar.type` is always explicit (it's realm-specific); `persistentId`/`instanceId` default from the connection's **authenticated coordinate** when it has one — pass them explicitly only for tests and replicas. If the server resolves your identity differently than the client believes (a resolver mismatch), the client warns loudly once — the symptom would otherwise be "prediction passes locally, server rejects every time". ### Realm-only apps — `createWireClient` A realm-only app has no actions, so it needs **no `@nice-code/action` at all** — no `ActionRuntime`, no channel. Open the connection directly with `createWireClient` from `@nice-code/wire` and bind the realm onto it. It takes the same facts every secure connection needs — who you are (`identity`), who you're dialing (`peer`), how you prove it (`storage`), and how you reach it (`transports`): ```ts title="client.ts" import { connectRealm, realmConnection } from "@nice-code/realm"; import { createWireClient, wsCarrier } from "@nice-code/wire"; const client = createWireClient({ identity: myCoordinate, // this client's authenticated coordinate peer: serverCoordinate, // the acceptor it dials storage, // persists the crypto identity (TOFU) transports: [{ carrier: wsCarrier(() => ({ url })) }], }); const realm = connectRealm(gameRealm, { avatar: { type: "player" }, // persistentId defaults from `identity` connection: realmConnection(client), }); await client.connect(); // dial + secure handshake; the realm hello follows on the same pipe ``` `createWireClient` returns the same connection surface `connectChannel` does — `realmConnection` binds to either structurally, so `connectRealm` doesn't know or care which opened the socket. Everything on this page below the connection — keepalive, link events, the redial ladder, the staleness probe — is identical for both, because it all lives on the wire connection, not the plane riding it. (`connectChannel` is the action-side twin: same `peer`/`storage`/`transports`, plus a channel and `onPush`. See [Serving & Connecting](/nice-action/serving-connecting/).) With actions in play the first action call dials implicitly; a realm-only app calls `client.connect()` itself. ## Status & liveness ```ts await realm.whenLive(); // resolves on first hydrate; rejects on attach failure/timeout realm.status; // "connecting" | "live" | "detached" realm.version; // the confirmed authoritative version realm.subscribeStatus((status) => …); // fires on EVERY transition, incl. quiet link drops realm.detach(); // leave: sends detach, expires pending writes ``` An attach the server refuses (schema-hash mismatch, unknown realm, unresolvable identity) surfaces on `realm.attachError` and rejects `whenLive()`. **Write after `whenLive()`**, not before: the projection is `{}` until the first hydrate, and a `realm.update()` in that window settles as a guided `write_before_hydrate` rejection (a draft that throws re-throws with the same hint). State that must exist *whenever you're attached* doesn't need to race that at all — declare it once: ### `assertOnAttach` — state you must own while attached ```ts const realm = connectRealm(lobbyRealm, { avatar: { type: "player" }, connection: realmConnection(connector), // Runs after EVERY full hydrate: the first attach, an out-of-log-window resume, // and an epoch change (a wiped or ephemeral server that cold-started). assertOnAttach: (draft) => { draft.players[me] = currentEntry(); }, }); ``` It is an ordinary optimistic write — rules apply, rejections surface once via `onMutationRejected` — that the library re-runs exactly when your entry could have been lost, and never on an incremental patch-replay resume (your entry is still confirmed state there). It resolves before `whenLive()` continuations run, so "await whenLive, read my entry" always sees it. This one option replaces the hand-rolled join/rejoin/re-assert stacks presence apps build; the [Presence Realms](/nice-realm/presence/) guide is built around it. ## Reconnects & durability What actually happens to your writes when the link drops — nothing here needs app code: 1. **Pending writes resend automatically.** Every unconfirmed optimistic frame survives a disconnect and is rebuilt against the fresh basis and resent on reattach — whether the server answers the re-hello with a patch replay or a full hydrate. A flaky socket does not lose writes; you do not need to re-issue anything on reconnect. 2. **`pendingExpiryMs` (default 10 s) is the only loss window** — and it's an *optimistic-settle* dial, not a delivery deadline: past it, the write's UI effect rolls back and `onMutationRejected` fires with the rolled-back paths. It is per-handle: a producer whose screen renders from local truth (a game host drawing from its own simulation) can raise it freely — the usual cost of a long window, stale UI that snaps back, doesn't apply there. **A `frame_too_stale` rejection is an _unknown_ outcome, not a definite "no":** the write may have committed on the server while the ack was lost. Don't blindly re-issue a **non-idempotent intent** on it — re-read state (it converges on the next replay/hydrate) and decide. A *rules* rejection, by contrast, carries the rule's own error and is a definite reject. 3. **`durability: "reassert"` removes the window entirely** for regions the client authoritatively produces (append-only logs): those frames resend across reconnects — and across epoch changes — until the server confirms or rejects them. See [Durable writes](/nice-realm/writes-intents/#durable-writes-durability-reassert). 4. **`assertOnAttach` covers the *confirmed-then-lost* case** — state the server once had but lost to a wipe/ephemeral cold start. Durability covers *never-confirmed* writes; the assert hook covers confirmed ones. A presence realm typically wants only the hook; a producer wants only the tier. 5. **The staleness probe is a *read* self-heal, not a redelivery mechanism** — it re-syncs what you *see* (below), and never rescues an expired write. Write durability is points 2–4. And the boundary that stays: a durable exchange that needs a **data response** (submitting a score, anything with an ack payload the app must act on) is the action plane — [reliable delivery](/nice-action/reliable-delivery/), with its own 10-minute-class redelivery deadline. ### Who re-dials the link — nothing here needs app code either A realm is push-driven, so a permanently dead socket is never the right steady state. The connection **owns its own reconnection**: when a realm rides a wire link (from `createWireClient` or `connectChannel`), an unexpected drop auto-redials with exponential backoff + jitter (≈1 s → 30 s), and on success the mux re-attaches and every realm re-hellos and resumes. You don't dispatch anything or call `connect()` again. Opt out with `keepLinkAlive: false` when your app manages its own connection lifecycle; an explicit `.clearTransportCache()` or `.dispose()` on the client/connector always stops it. (A pure-action connection is unchanged — it keeps its dispatch-driven reconnect.) The realm's own **staleness probe** (below) is the other half: it escalates a *half-open* socket — one that looks open but silently swallows sends — by asking the connection to re-dial. Together they mean a game client recovers from both a clean drop and a network blip without app-level plumbing. ### The staleness probe On by default (`staleProbeAfterMs: 25_000`; `0` disables): a `live` handle that hears **no realm frame** for the window quietly re-hellos with its last-known version and log **epoch**. The probe is idempotent by design: - a **current** client gets an ~10 B empty replay (which itself re-arms the timer); - a **stale** client gets exactly the missed patches; - a **wiped or re-created** server forces a clean full hydrate — the epoch names the history, so version numbers from a different past never replay as a diff. Lost wake broadcasts, missed frames, suspended tabs — every "silently stale forever" failure mode converges on *quiet too long → probe → resync*. The probe never flips `status`, so an idle-but-healthy connection shows no UI flap. **Escalation (`probeReplyTimeoutMs`, default 10 s; `0` disables).** A half-open socket answers the probe's re-hello with silence. If no frame arrives within this window after a probe, the link is presumed dead: `status` flips to `"connecting"`, a `probe_escalated` diagnostic fires, and the connection is asked to re-dial (the reconnect-ownership above). So the worst-case time to notice a dead-but-open socket is roughly `staleProbeAfterMs + probeReplyTimeoutMs` (≈35 s by default) — tune both down for a twitchier game. ### Observing sync health — `onDiagnostic` Wire it in production; a healthy realm emits nothing. It reports automatic recoveries so you can alarm on a *rate* of them rather than discover a bad link from user reports: ```ts connectRealm(gameRealm, { connection: realmConnection(connector), onDiagnostic: (event) => metrics.count(`realm.${event.type}`), // resync | gap_dropped | frame_error | probe_escalated }); ``` - `resync` — a lost/misapplied frame was detected (a version gap or a decode/apply failure) and healed by an automatic re-hello. - `gap_dropped` — a non-continuous frame that crossed an in-flight recovery was dropped (expected during a resync; benign). - `frame_error` — an inbound frame failed to decode/apply and triggered a resync (should be ~never — investigate if it recurs). - `probe_escalated` — a probe went unanswered and the link was re-dialed (above). **Unwired, the library still leaves a floor:** the first occurrence of each diagnostic type logs one `console.warn`, and a rolled-back optimistic write with **no `onMutationRejected` handler** warns once too — so a "my move silently vanished" bug is never fully invisible. Wire both handlers to replace the warnings with real telemetry. ### Limits are advertised and livable The hydrate advertises the server's ingress limits. An over-limit coalesced flush **splits** into consecutive ≤`maxPatchesPerFrame` frames (each part server-atomic; all handles settle with the last part, so read-your-writes holds), and `limits_exceeded` rejects **retry** — re-split against a halved ceiling, or a ~1 s backoff for rate breaches — instead of settling fatally. A well-behaved client stays attached under load without you tuning anything. ## The patch feed (imperative renderers) React re-renders from the store — but a canvas, a typed array, a WebGL scene wants **deltas**, not snapshots: ```ts realm.listenToPatches((patches) => board.applyPatches(patches)); ``` The feed emits the projected-store delta per flush, in name-space [immer patch](/nice-state/patches/) shape: - a confirmed-only flush with no optimistic overlay emits the authoritative patches **verbatim**; - a rebase flush emits a **structural diff**, identity-pruned via immer's structural sharing (unchanged subtrees skip in O(1)); - a hydrate — or any full rebuild — emits exactly `[{ op: "replace", path: [], value }]`, the "rebuild everything" signal. Listeners fire after the store flush, so the store already reflects the values. Apply the patches to your render target instead of re-diffing the whole state per flush. ### Every flush says whether it carried server truth The listener's second argument tells a consumer that must distinguish "new server truth" from "my own echo" which one this flush was — **`meta.authoritative`**: ```ts realm.listenToPatches((patches, meta) => { if (!meta.authoritative) return; // your own optimistic echo / a local rollback reconcile(patches, meta.confirmedVersion); // fresh server state — with or without your writes in flight }); ``` That is the whole rule for a predictor or reconciler. Reacting to a **non**-authoritative flush replays your own un-acked input echo as if the server had confirmed it — a bug that type-checks, renders plausibly, and re-adds the full round trip as input lag. `meta.cause` is the companion field, and it describes **how** the flush was produced rather than whether it carried authority: | `cause` | What produced it | `authoritative` | |---|---|---| | `"confirm"` | Fresh server patches with **no** write of yours in flight — emitted as the server's own ops, verbatim | `true` | | `"rebase"` | A rebuilt projection, emitted as a structural diff: fresh server patches landing **while you have writes in flight**, *or* a purely local rebuild (an expiry/reject rollback) | `true` / `false` — check the field | | `"optimistic"` | Your own write/intent applying locally | always `false` | | `"hydrate"` | A full rebuild — the root replace above | `true` | :::caution[Don't gate a reconciler on `cause === "confirm"`] `"confirm"` means "authoritative **and** your outbox happened to be empty". A client that writes continuously — any game client — has a write in flight for roughly a round-trip after every input, so most of its authoritative traffic arrives as `"rebase"`, and a confirm-gate silently discards it. The effect **scales with latency and is invisible on localhost**: a consumer measured 100 % of fresh ticks as `"confirm"` locally but only 40 % at +200 ms, with the other 60 % correctly labelled `"rebase"` and wrongly dropped. Gate on `meta.authoritative` (or, equivalently, skip `"optimistic"`), and use a domain freshness check — a tick or version on the authoritative region — to decide what is actually new. ::: One behavioural note if you are moving a render path from `store.subscribe` to this feed: `listenToPatches` emits only when the projection actually changed (an empty diff fires nothing), whereas `store.subscribe` fires on every flush. ### Measuring the link — `realm.stats` Every handle keeps a live link-quality estimate, fed by traffic the realm already earns — write-confirm round trips (measured from the frame's *send*, so a coalescing window is never counted as network) and answered hellos (the staleness probe's ~10 B empty replay): ```ts const { rttMs, jitterMs, samples } = realm.stats; // EWMA-smoothed; null until the first sample hud.setPing(rttMs == null ? "—" : `${Math.round(rttMs)} ms`); ``` Poll it from a HUD or render tick — the getter is O(1) and allocation-free apart from the result object. Two deliberate design points: there is **no dedicated ping frame** (a realm-level ping would wake a hibernated server just to be measured — an idle spectator would keep every match's Durable Object hot and billed), and samples ride existing traffic — so a **write-active client samples constantly**, while a **read-mostly client samples at its probe cadence**. If a spectator view wants denser samples, lower `staleProbeAfterMs` deliberately: that is a documented trade (more probe traffic, denser RTT), not a hidden default. ## React hooks `@nice-code/realm/react` is a thin layer over the handle: | Hook | What it gives you | |---|---| | `useRealm(realm)` | The whole projected view (re-renders on any change — prefer `useRealmValue`). | | `useRealmValue(realm, (v) => slice)` | A derived slice; re-renders only when the slice changes. | | `useRealmStatus(realm)` | `{ status, version, pendingCount }` — connection UI. | | `useRealmPending(realm, "players.p1")` | `true` while writes under the prefix are unconfirmed — the "saving…" affordance. | | `useRealmIntent(realm, "purchase")` | `{ run, status, error }` — classic submit → spinner → done, zero rollback risk when the intent has no predictor. | ### Reading before hydrate: selectors must return a stable reference "Write after `whenLive()`" has a **reads** counterpart, and it's easy to miss because the types hide it. Before the first hydrate, `realm.store.state` is genuinely `{}` — no keys at all — *regardless of what `TView` declares*. A mounted component reads the store on every render, so a direct page load renders your selectors against that empty `{}` at least once, before any network round-trip. `TView` says `presence` is always there; the runtime value in that window says otherwise. That collides with a `useSyncExternalStore` rule `useRealmValue` inherits: **a selector must return the same reference when nothing changed.** A selector that fills a default for a possibly-absent field with an inline literal — ```ts // ✗ allocates a fresh {} every call while state is still empty → infinite render loop const presence = useRealmValue(realm, (v) => v.presence ?? {}); ``` — returns a *new* object on every `getSnapshot`, so the hook sees "changed" every time and React trips its own guard: **"The result of getSnapshot should be cached to avoid an infinite loop"**, then "Maximum update depth exceeded". The fix is a stable fallback reference (a module constant, `useRef`, or `useMemo`) — ```ts const EMPTY_PRESENCE: TView["presence"] = {}; // ✓ same reference every call → the hook's strict-equality fast path holds const presence = useRealmValue(realm, (v) => v.presence ?? EMPTY_PRESENCE); ``` — or a non-strict comparator as the third argument, `useRealmValue(realm, sel, deepEqual)`, when the slice is genuinely recomputed each call. The same footgun applies to any narrow selector that *builds* a value (`.filter`, `.map`, an object literal) rather than returning a state branch as-is. ## Performance: pointer/cursor-frequency direct writes Every `realm.update()` call — even one that's about to coalesce with others — synchronously commits to `realm.store` and notifies every subscriber before returning (`coalesceMs` only delays the *wire* send, never the local commit). Wire that straight to a native event that can fire dozens of times a second (`pointermove`, `drag`, a paint stroke) and you drive your renderer at native input frequency — often well above what the display can show, and well above what a *remote* peer's copy of the same path does (theirs arrives already coalesced by their sender's window plus network latency, in sparser jumps). The result is a real, counter-intuitive symptom: **your own cursor can look laggier than a peer's**, even though it's always working from the freshest possible value. Remote motion looks smooth because it's sparse — a CSS transition (or any interpolation) fills the gaps nicely. Local motion re-renders, and restarts any transition, every single frame, which reads as trailing rather than live. Two changes fix it, and a hot direct-write path generally wants both: 1. **Render off a narrow subscription, not the whole view.** `useRealmValue(realm, (v) => v.presence)` (or `realm.store.subscribe` / `listenToPatches` for a canvas/WebGL target) re-renders only what actually watches that slice — put it in its own component (or render target) so a cursor move doesn't also re-render a notes list, a HUD, or anything else sharing the page. 2. **Batch the writes to the render cadence** — typically one `requestAnimationFrame`: buffer the latest value in a ref and flush it as a single `realm.update()` call at most once per frame, instead of once per native event. There's no benefit to writing, or re-rendering, faster than the screen refreshes. Apply any interpolation (CSS transitions, a lerp) only to *remote* entities. Your own value already updates every frame; smoothing it just adds a fixed visual lag with nothing to hide. ### Worried about network volume, not render cost? That's `coalesceMs` The two fixes above are **render-side** — they don't change how much goes over the wire. Wire volume is a separate, already-solved axis: `connectRealm(realm, { coalesceMs })`. Every `update()` inside the window merges into one pending frame, collapsed **last-write-wins per path** (twenty `presence.p1.x` writes become one patch), and that frame ships once when the window closes. Sends cap at `1000 / coalesceMs` per second no matter how often you call `update()` — so `coalesceMs: 40` is ~25 frames/sec/client, whether the writes arrive at raw `pointermove` rate or already rAF-batched. It's a **fixed-window throttle, not a resetting debounce**: the window doesn't restart on each new write, so a continuously-moving cursor keeps flushing every window — it never goes silent until motion stops (which is what a debounce would do, and exactly wrong for live presence). Reach for it on any pointer-frequency direct-write path; the render fixes above and this one are independent and a hot path usually wants all of them. One edge to know before you set it: the `coalesceMs` window is **trailing-edge** — the first write arms it and the frame ships when it *closes*, so an isolated write (a keyboard transition after seconds of silence) eats the whole window as added latency before it reaches the network. `coalesceMs` is for paths that write continuously. For a connection that carries both duty cycles, `coalesce: { ms, leading: true }` sends the first write of a quiet period immediately and coalesces the burst behind it; a single fast lane can use `realm.update(fn, { flush: "immediate" })` instead. Full duty-cycle guidance: [Coalescing](/nice-realm/writes-intents/#coalescing--dont-hand-roll-a-throttle). ## Replicas A Worker (or any process) that wants a live **read copy** — a matchmaker reading lobby state, an analytics tap — connects as a replica: a plain object plus a patches callback, no Store/React stack, no special authority. Its powers are whatever its avatar type's [rules](/nice-realm/rules/#replicas-are-an-avatar-rule) grant: ```ts import { connectRealmReplica } from "@nice-code/realm"; const replica = connectRealmReplica(lobbyRealm, { avatar: { type: "replica", persistentId: "matchmaker" }, connection: realmConnection(connector), onPatches: (patches, { version }) => index.apply(patches), }); replica.state; // the confirmed slice (plain object, replaced per batch) replica.update(…); // non-optimistic write — settles on the server verdict replica.intents.kick({ playerId }); // typed intents — the same mapped object as the client handle ``` Intents are compile-typed on the replica handle exactly as on the client handle — a misspelled intent is a compile error, not a runtime `intent_unknown`. For dynamic names there is an untyped `replica.intent(name, input)` fallback; prefer the mapped object. Replicas get the same `subscribeStatus`, staleness probe (with `probeReplyTimeoutMs` escalation), `onDiagnostic`, and reconnect ownership as full clients — they live longer, so silent staleness matters more, not less. A replica's `update`/`intent` is non-optimistic, but its handle is still bounded: an unacked write is resent on every recovery and rejects `frame_too_stale` after `pendingExpiryMs` (default 10 s) rather than hanging forever — so `await replica.update(...)` always settles. --- # Defining a Realm Source: /nice-realm/defining-realms Description: The defineRealm block — state schema via the t builder, avatar types, server context, and the schema hash. A realm is declared once, in shared code, with `defineRealm`. Both the client and the server import this exact definition — it is the type boundary, the wire vocabulary, and the rulebook, all derived from one block: ```ts title="shared/marketRealm.ts" import { defineRealm, t } from "@nice-code/realm"; export const marketRealm = defineRealm({ id: "market_realm", serverContext: t.serverContext<{ db: MarketDb }>(), avatars: { vendor: { persistentId: t.string() }, consumer: { persistentId: t.string() }, }, state: { config: t.object({ open: t.boolean(), taxRate: t.number() }), inventory: t.record( t.id("vendorId"), t.record(t.id("produceId"), t.object({ pricePerKg: t.number(), stockKg: t.number() })), ), presence: t.record(t.id("avatarId"), t.object({ cursorX: t.number(), cursorY: t.number() })), }, rules: (r) => [ /* next page */ ], intents: (i) => ({ /* the writes-and-intents page */ }), }); ``` > **This module ships to clients.** Never close over secrets in a realm definition. Server-only data enters at serve time via `createRealmServerEngine(realm, { ctx })` — declare its *type* here with `serverContext: t.serverContext()`, and it becomes reachable only inside `r.serverChecked(...)` rules and intent `apply` bodies. Nothing in the definition module itself ever holds a secret. ## The `t` schema builder The `state` shape is declared with `t.*` nodes. From this single declaration the library derives the TypeScript state type, the compact wire token map (keys travel as small integers, not strings), the schema hash, and the server-side value validator. | Builder | What it declares | |---|---| | `t.string({ maxLength? })` / `t.number({ min?, max?, integer? })` / `t.boolean()` | Primitive leaves, optionally bounded. | | `t.literal(value)` | An exact value (`t.literal("open")`). | | `t.union(a, b, …)` / `t.union([a, b, …])` | One of several nodes (spread or a single array). | | `t.optional(inner)` | May be absent (deletable). | | `t.object({ … })` | Fixed keys, each its own node. | | `t.record(t.id("varName"), value, { maxKeys? })` | A dynamic keyed collection, optionally capped. The `t.id` name binds `$varName` in rule paths. | | `t.array(item, { maxItems? })` | An array — **an atomic leaf on the wire** (replaced whole). Prefer keyed records. | | `t.schema(valibotSchema, { hashTag? })` | Embed any Standard Schema validator at a leaf. | | `t.serverContext()` | Declares the server-only context *type* (not part of state). | Four of these deserve emphasis: - **Declared bounds are hashed and enforced state-aware.** `maxLength` (UTF-16 code units), numeric `min`/`max`/`integer`, `maxItems`, and `maxKeys` all contribute to the schema hash (changing a bound is a shape change — both ends must agree) and are enforced by the server on every path a value can enter state: direct writes, intent `apply` results, server `update()`, and the boot/restore/migration walk. `maxKeys` is checked against the **candidate next state**, so a record filled one individually-valid entry at a time still stops exactly at its cap — and a remove+add in one frame nets out. Writers get fast client-side feedback where the projected view allows it, but a *sliced* view can hide record entries, so the server's check is the authoritative one and an optimistic prediction may roll back on rejection. - **Prefer `t.record` over `t.array`.** Records are keyed collections — each entry is its own wire path, so two clients editing different entries never conflict, and rules can address entries by key (`inventory.$vendorId.…`). Arrays have leaf-replace semantics: the whole array travels on every change. Two consequences of being a wire-leaf: - **Rule an array with its exact path.** Patches and visibility for `terrain: t.array(t.number())` are decided at exactly `terrain`, so the rule is `r.path("terrain")` — `r.path("terrain.**")` could never match anything (wildcards need at least one further segment), and `defineRealm` throws on such dead patterns. - **Replace, don't mutate, in intents.** In-draft element writes (`state.terrain[i] = v`) commit as a whole-array replace either way — write `state.terrain = next` so the code says what the wire does. - **`t.schema(...)` is invisible to the schema hash — unless you tag it.** Embedding a Valibot/Zod validator gives you rich value validation at a leaf, but editing the embedded schema does **not** change the realm's `schemaHash` — treat embedded-validator changes as value-rule changes, not shape changes. To opt a leaf's *contract* into the attach gate, pass `t.schema(v, { hashTag: "turn-metadata-v1" })` and bump the tag on every contract change — the hash then moves with it, and stale clients are rejected at attach instead of failing later on data they can't parse. ⚠ Adding (or bumping) a tag moves a **persisted** realm's hash: a Cloudflare-hosted realm must ship a `migrate` (or `migrate: "wipe"`) in the same deployment. - **Record keys are strings on the wire.** Writing `d.log[ev.seq] = ev` with a numeric `seq` works (JS coerces), but hydrated keys come back as strings. When entry *order* matters — an event log keyed by sequence number — sort numerically on read rather than trusting key type or iteration order. ## Avatar types An **avatar** is the identity a connection presents to a realm — the thing rules judge and views are sliced for. It is deliberately *not* called a "client": the network client (the app that dials in) and the avatar are different ideas — one connecting process can present different avatars to different realms, and a server-side Worker can hold an avatar too (that's what a [replica](/nice-realm/connecting/#replicas) is). `avatars` names the kinds your realm admits — `player`, `spectator`, `vendor`, an internal `replica` — and rules are written against those types. Every connection resolves to an **avatar ref**: ```ts { type: "vendor", persistentId: "v_42", instanceId: "tab_1" } ``` - `type` — which avatar kind, from your `avatars` block. - `persistentId` — the stable identity ("who"). On a secure connection this defaults from the **authenticated** handshake coordinate. - `instanceId` — one of possibly several live connections for that identity (tabs, devices). Rules receive this ref (`{ avatar, params }`), which is how "owner-only" patterns work: compare `avatar.persistentId` against the `$param` bound by the path. ## The schema hash `defineRealm` computes a canonical hash of the state shape. It's used in two places: - **Attach**: a client whose definition hash differs from the server's is refused at hello — the two sides are running different shapes, and letting them talk would corrupt the projection silently. - **Persistence**: the Durable Object host stores the hash with each snapshot. A server booting on a changed schema **refuses to start** unless you provide a `migrate` hook ([Serving a Realm](/nice-realm/serving/#schema-evolution)) — persisted state is never silently reinterpreted. The practical consequence: ship client and server together when the state shape changes, exactly as you already do for action schemas. ## Requiring a security level A realm inherits its connection's security, and can declare the **minimum** it will accept — `security: "authenticated"` (the default) or `"encrypted"`: ```ts export const marketRealm = defineRealm({ id: "market_realm", security: "encrypted", // refuse anything below encrypted, on both ends // … }); ``` There is deliberately no `"none"` — a realm always requires at least an identity-bound connection. The floor is a policy declaration (enforced fail-closed: the server refuses to hydrate, the client refuses to send) and is **not** part of the schema hash, so changing it never forces a migration. See [Security](/nice-realm/security/) for the whole model — effective minimums, per-connection/per-host overrides, and the at-rest and wire-visibility caveats. --- # Devtools & Traffic Source: /nice-realm/devtools Description: Observing a realm: the browser realm panel, what its decoded traffic means versus the true wire cost, and the egress-message count that maps to your Durable Object bill. A realm exposes two things worth watching in dev — its **projected state + sync health**, and its **traffic** (the number that becomes your Durable Object bill). This page is the realm-specific wiring for both. The window that renders them, the server host, the topology band, and the relay are the same across every plane and live in the [Devtools section](/devtools/window/) — this page links there rather than repeating it. ## The realm panel `createNiceDevtools()` builds the shared devtools cores and streams them; instrument each `connectRealm` with the tap it hands back. The panel that renders it lives in the [devtools window](/devtools/window/), never in your page. ```ts title="src/devtools.ts" import { createNiceDevtools } from "@nice-code/devtools"; export const devtools = createNiceDevtools({ topologyId: "my-app", runtime }); ``` ```ts title="gameClient.ts" const realm = connectRealm(gameRealm, { avatar: { type: "player" }, connection: realmConnection(connector), devtools: devtools.realm("game"), // ← the observation seam (one null-check when omitted) }); ``` The panel shows one tab per instrumented realm (status dot, confirmed version, pending-write badge) with three views: - **View** — the live projected store, plus status / version / pending / avatar identity, and two instruments over it (below): the **RTT sparkline** and the **flush strip**. - **Timeline** — every mutation with its outcome (`created → confirmed / rejected`, with reject ids and rolled-back paths), every sync-health diagnostic (`resync`, `probe_escalated`, `link_redialed`, `reown_missing`, …), and status flips. - **Traffic** — decoded realm frames per kind (`hydrate`, `patches`, `write`, `ack`, `batch`, …), counts + bytes over rolling windows. ### RTT and the flush strip — reading a client's shape Beside the sync-health row, the View tab charts the two things that tell you how a client is *behaving*, as opposed to whether it is connected: - **The RTT sparkline** plots [`realm.stats`](/nice-realm/realtime-games/) over time — the smoothed confirmed round trip, its jitter (mean absolute deviation), and the sample count. It appears once there is something to plot: RTT is measured on a confirmed round trip, so a realm that only ever reads has none, and the panel says nothing rather than showing a `0 ms` that isn't true. - **The flush strip** puts one tick per recent projection flush, oldest on the left, coloured by [`meta.cause`](/nice-realm/realtime-games/) — the same provenance `listenToPatches` reports. Ticks for non-authoritative flushes are dimmed. The mix is the point, which is why it is a strip rather than rows in the Timeline — a game client flushes at its tick rate and would bury every discrete event in the log. Read it like this: | What you see | What it means | | --- | --- | | Mostly **optimistic** (warm, dimmed) | The client is predicting well ahead of what the server has confirmed — an overlay-heavy client, visible at a glance. | | Mostly **rebase** | Server patches are landing while writes are in flight. Normal and expected for any client that writes continuously. | | Mostly **confirm** | Server truth arriving with an empty outbox — a client that mostly watches. | | **Hydrate** ticks mid-session | Full rebuilds. One at attach is the hydrate; more than that means re-syncs — check the Timeline for `resync`. | | No authoritative ticks at all | Nothing is arriving from the server, whatever the status dot says. | Both series are bounded rings (`maxSamples`, default 240) and carry **no leaf values** — only timings, a cause, and a flag — so they stream unchanged from a [shape-gated remote producer](/devtools/remote-sessions/#5-data-minimization--metrics-by-default). Construct the underlying `RealmDevtoolsCore` (`@nice-code/realm/devtools/browser`) yourself only if you aren't using `createNiceDevtools`. `subscribeDiagnostics(listener)` on the handle is the post-hoc, multi-listener complement of the `onDiagnostic` connect option — handy for tests and ad-hoc telemetry (and one subscriber satisfies the unwired-diagnostics console floor). ## Decoded payload vs. true wire cost The realm panel's Traffic tab shows *decoded realm payloads*. The **true wire cost** — encrypted frames, envelopes, handshakes, keepalives — is measured one layer down, at the carrier, with `wireTap`: ```ts title="client.ts" const connector = connectChannel(runtime, channel, { // …transports, storage… wireTap: devtools.wireTap, // ← every carrier frame, true post-encryption byte sizes }); ``` `devtools.wireTap` is one traffic core for the whole app, already streamed to the window (its per-lane, per-client breakdown, sparklines, and largest-frame list are documented on [Devtools & Traffic in the window](/devtools/window/)). The realm-specific reading: - **`egressMessages` is your billing unit.** Each outgoing WebSocket *message* is a Cloudflare request-count unit; the byte totals are your bandwidth. - **The `realm` lane vs. the panel's decoded totals** is the transport overhead (encryption + framing + batch envelope) per payload byte. - **`egressCoalesceRatio` (`coalesce ×N`)** is "how many realm frames rode each billable message" — the number to watch when tuning `broadcastCoalesceMs` and confirming the batch envelope is working. ## Reading the engine directly With no devtools wired at all, the realm engine exposes its own counters — scrape them from a dev-gated route: ```ts title="MatchDO.ts" if (url.pathname === "/debug/traffic" && env.STAGE !== "production") { return Response.json(this.engine.getTrafficSnapshot()); // { ingressFrames, ingressBytes, egressMessages ← billing unit, egressFrames ← pre-coalescing, // egressBytes, egressCoalesceRatio ← the batch-envelope win, connections: [...] } } ``` To contribute that same data to a **server devtools host** (console summaries or a live window feed), the realm scopes are `realmEngineScope(engine, "demo_board")` (traffic totals) and `serverWireTrafficScope(engine)` (per-frame samples). The host itself — its options, sinks, and how it streams to a window alongside your action domains — is documented in [Observing Backends](/devtools/backends/): ```ts title="server.ts" import { createServerDevtoolsHost } from "@nice-code/devtools-core"; import { realmEngineScope, serverWireTrafficScope } from "@nice-code/realm/devtools/server"; createServerDevtoolsHost({ name: "match-api" }) .contribute(realmEngineScope(engine, "demo_board")) .contributeSample(serverWireTrafficScope(engine)) .start(); ``` ## Inside a Durable Object A DO can't dial *out* to a devtools window (an open outbound socket pins it awake) and can't run the console sink's timer across hibernation — so the **window dials in** instead, riding the DO's ordinary WebSocket as a token-gated protocol via `createNiceDurableObjectDevtools`. The full setup, safety model, and the honest hibernation caveat are in [Observing Backends → a Cloudflare Durable Object](/devtools/backends/#a-cloudflare-durable-object). --- # Limits & Production Source: /nice-realm/limits Description: What the realm engine bounds for you per connection, the one limit your app must bound itself (state cardinality), and how to keep a realm cheap and safe under load. A realm host is a shared resource — one Durable Object, many clients, some of them buggy or hostile. So the engine **bounds every connection for you**, and hands you the one lever it can't guess: the total size of your state tree. This page is both of those, in one place. ## What the engine bounds for you Every realm host enforces per-connection ingress limits. A breach rejects the offending write (surfaced as `limits_exceeded`) and counts against the connection; **`maxBreachesBeforeDetach` breaches force a detach** — a hostile or buggy client cannot hold the host hostage. The client-side coalescer reads `maxPatchesPerFrame` / `maxFrameBytes` / `writeRatePerSecond` off the hydrate and **splits over-limit flushes automatically**, so a well-behaved app rarely sees a breach at all. | Limit | Default | Bounds | | --- | --- | --- | | `maxPatchesPerFrame` | `64` | Patch ops in one write frame. | | `maxFrameBytes` | `64 KB` | Encoded bytes of one inbound realm frame. | | `maxRecordKeyLength` | `128` | Dynamic record-key length (also protects the intern dictionary). | | `maxExpandedLeaves` | `256` | Leaves one subtree write may expand to. | | `writeRatePerSecond` | `100` | Sustained write/intent rate per connection (token bucket). | | `writeRateBurst` | `200` | Bucket depth — the burst a connection may spend at once. | | `maxBreachesBeforeDetach` | `10` | Limit breaches on one connection before it is forcibly detached. | Override any of them in the [serve engine options](/nice-realm/serving/); the effective values ride the hydrate, so the client splits against the same numbers you set. A bare `limits_exceeded` that reaches your `onMutationRejected` (rather than being split away) means a genuinely oversized write — back off, don't hammer. ## What your app must bound: state cardinality The engine bounds each *frame* and each *write*, but not the **total size of a realm's state** — that is application-shaped, so it stays your call. A realm whose state is a record that grows one entry per client (a presence roster, a chat log, a leaderboard) grows without limit unless you cap it. Cap it **with a rule** — the same place you already authorize writes: ```ts // doc-check: skip — illustrative rule shape; see nice-realm/rules rules: (r) => [ r.on("roster.*").alter((ctx) => Object.keys(ctx.state.roster).length < 500 || err_game.fromId("roster_full"), ), ] ``` Since bounded schema nodes landed, the *declarative* form of this cap is `t.record(t.id("rosterId"), …, { maxKeys: 500 })` — hashed into the schema, enforced against the candidate next state on every write/intent/server mutation, and checked once over the whole tree at boot. Prefer it for plain entry-count ceilings; keep the rule form for caps that need context a schema can't see (per-avatar quotas, conditional limits). The same goes for value-size bounds: `t.string({ maxLength })` and `t.array(item, { maxItems })` bound leaves declaratively. A total-state `maxStateBytes` engine knob may arrive later (it remains deferred in the registry); declared per-node bounds cap each dimension, not the product of all of them — for anything attacker-influenced (a key space a client can grow), *some* cap, declared or ruled, is not optional. ## Keep it cheap: transient and ephemeral Two hosting choices keep a realm small and inexpensive to run — both covered in full on [Serving a Realm](/nice-realm/serving/), flagged here because they're the load-and-cost levers: - **Transient regions** (`transient: ["presence", ...]`) — a branch that never persists and reloads empty on a hibernation wake. Presence, cursors, "who's typing" — anything you'd re-assert on attach rather than store. See [Presence Realms](/nice-realm/presence/). - **Ephemeral hosting** (`persistence: "ephemeral"`) — the whole realm lives only in memory and cold-starts from genesis; nothing is written to disk. Pair it with [`assertOnAttach`](/nice-realm/connecting/#assertonattach--state-you-must-own-while-attached) so each client re-owns its entries on a wake. ## Where this sits in production Bounding state is one line of your [pre-flight checklist](/production/posture/). The rest — the trust model, payload exposure, devtools in production — is stack-level and lives there and on [Identity & Trust](/nice-wire/identity/). Next in the realm arc: [Presence Realms](/nice-realm/presence/). --- # Mental Model Source: /nice-realm/mental-model Description: Realms are the state plane — one server-owned state tree that many clients watch and optimistically write, over your app's secure connection. `@nice-code/realm` is a schema-defined, **authoritative shared-state engine**: many clients continuously observe (and optimistically write) one server-owned state tree, over a binary patch protocol riding your app's secure connection. If the app also uses `@nice-code/action`, both share that one connection; a realm works just as well [with no actions at all](/nice-realm/connecting/#realm-only-apps--createwireclient). ## In one sentence > **Realm is the state plane; actions are the action plane** — realm for state that many parties watch, actions for things that happen. One-shot commands with results, reliability tiers, offline outboxes, progress streaming — those stay [actions](/nice-action/mental-model/). Live positions, presence, inventory, lobby state, a shared cursor — that's a realm. | You want… | Use | |---|---| | "What is the state of the world right now, kept live?" | **realm** | | "Do this thing and tell me the result." | **action** | ## The loop The server owns the state. Clients hold a **projection** of it — the confirmed authoritative state with their own unconfirmed (optimistic) writes layered on top: ```text CLIENT SERVER ┌───────────────────────────┐ ┌──────────────────────────┐ │ projected view │ write frame │ authoritative state │ │ = confirmed + optimistic │ ───────────────────► │ rules checked, applied │ │ overlay │ │ in arrival order │ │ │ patches + ack │ │ │ rebase: drop the │ ◄─────────────────── │ broadcast, sliced per │ │ confirmed part of the │ │ client's view rules │ │ overlay, keep the rest │ │ │ └───────────────────────────┘ └──────────────────────────┘ ``` - A `realm.update()` applies **instantly** to the local projection and is sent to the server. - The server validates it against the realm's **rules**, applies it, and broadcasts the resulting patches — **sliced per avatar**, so state an avatar isn't allowed to view never leaves the server. - When the client's own write comes back confirmed (or rejected), the optimistic overlay is rebased: confirmed parts drop out, rejected parts roll back, everything else stays. Every write returns a **settle handle** — a lazy thenable that's safe to ignore (fire-and-forget cursor moves produce zero unhandled rejections), with a read-your-writes guarantee: after `await handle`, the store already reflects the authoritative outcome. ## One definition, both sides Everything derives from a single `defineRealm` block that both the client and the server import — the state schema, the avatar types, the rules, the intents. An **avatar** is the identity a connection presents to the realm — "who you are in this world" — and it's what the rules judge: `avatars` names the kinds a realm admits (`player`, `spectator`, …), and every rule sees the acting avatar's ref. From this one declaration the library derives the TypeScript types, the compact wire token map, a **schema hash** (so a server never silently reinterprets persisted state written under a different shape), and the client-side prediction bundle. ```ts title="shared/gameRealm.ts" import { defineRealm, t } from "@nice-code/realm"; export const gameRealm = defineRealm({ id: "game_realm", avatars: { player: { persistentId: t.string() }, spectator: { persistentId: t.string() }, }, state: { config: t.object({ roundActive: t.boolean() }), players: t.record(t.id("playerId"), t.object({ x: t.number(), y: t.number() })), }, rules: (r) => [ r.path("config.*").view(r.everyone).alter(r.serverOnly), r.path("players.$playerId.{x,y}") .view(r.everyone) .alter(({ avatar, params }) => avatar.persistentId === params.playerId || myErr.fromId("not_yours")), ], }); ``` Because the same rules run on the client (prediction) and the server (authority), a write you aren't allowed to make **rejects locally, instantly** — it never even reaches the wire. Only checks that need server-side facts (`r.serverChecked`) can differ, and that's exactly the slice of optimism that can roll back. ## One connection, two planes A realm attaches to your app's secure **connection** — it registers as a **frame protocol** on the wire connection, and if you also use actions they share it: one socket, one handshake, one authenticated identity, two planes. Actions are optional; a realm-only app opens the same connection with [`createWireClient`](/nice-realm/connecting/#realm-only-apps--createwireclient) — `@nice-code/wire` and `@nice-code/realm`, nothing else. - On the server: pass the realm host's `protocol` to [`serveWireDurableObject`](/nice-realm/serving/) (realm-only), or to [`serveDurableObject`](/nice-action/cloudflare/) when the DO also serves actions. - On the client: `connectRealm(realm, { connection: realmConnection(client) })` where `client` is the connection returned by `createWireClient` (or `connectChannel`, with actions). The avatar identity defaults from the **authenticated coordinate** of that connection — the identity the secure handshake established — so realm rules like "only you may move your own player" are enforced against a cryptographically-bound identity, not a self-declared one. ## The two write shapes This distinction matters enough that [it has its own page](/nice-realm/writes-intents/), but in short: - **Direct writes** (`realm.update`) are last-writer-wins with **absolute-set semantics**: "cursor is at 412". High-frequency, coalesced, optimistic. - **Intents** are named, atomic, server-side mutations: "purchase item X". Read-modify-write, counters, multi-path invariants — anything where composing the new value locally would race. ## The pages that follow - [Defining a Realm](/nice-realm/defining-realms/) — the schema builder, avatar types, and server context. - [Rules & Visibility](/nice-realm/rules/) — who can see what, who can write what. - [Writes & Intents](/nice-realm/writes-intents/) — the write contract (read this one). - [Serving a Realm](/nice-realm/serving/) — the engine, the Cloudflare Durable Object host, persistence. - [Connecting & React](/nice-realm/connecting/) — the client handle, liveness, the patch feed, hooks. - [Testing Realms](/nice-realm/testing/) — the deterministic in-memory world. --- # Presence Realms Source: /nice-realm/presence Description: The textbook first realm — a lobby/roster of \"who is here, and what are they doing\", built from assertOnAttach, last-detach pruning, and ephemeral hosting. Presence — a lobby roster, cursors on a canvas, "who is watching" — is the textbook first realm: pure shared state, everyone sees everyone, each avatar owns exactly its own entry. It is also where hand-rolled sync stacks grow the most belts (reconcile polls, re-assert-on-reconnect, last-socket scans, stale-entry sweeps). A presence realm needs **none of them** — this page is the whole pattern, end to end. ## The definition One record, keyed by the avatar's persistent id; the owner writes their entry, the server owns membership: ```ts title="shared/lobbyRealm.ts" import { defineRealm, t } from "@nice-code/realm"; export const lobbyRealm = defineRealm({ id: "lobby_realm", avatars: { player: { persistentId: t.string() } }, state: { players: t.record( t.id("playerId"), t.object({ name: t.string(), position: t.object({ x: t.number(), y: t.number() }), facing: t.union(t.literal(1), t.literal(-1)), activityId: t.optional(t.string()), // "watch me" pointers, status, … }), ), }, rules: (r) => [ // Everyone sees the roster; only the owner writes their own entry. r.path("players.$playerId.**") .view(r.everyone) .alter(({ avatar, params }) => avatar.persistentId === params.playerId || err_lobby.fromId("not_your_entry"), ), ], }); ``` ## The client — `assertOnAttach` is the join There is no "join" action. Your entry is state you must own *whenever you are attached*, and that is exactly what [`assertOnAttach`](/nice-realm/connecting/#assertonattach--state-you-must-own-while-attached) declares: ```ts title="client.ts" const realm = connectRealm(lobbyRealm, { avatar: { type: "player" }, // persistentId from the authenticated coordinate connection: realmConnection(connector), assertOnAttach: (draft) => { draft.players[me] = initialEntry(); }, }); // Everything else is plain optimistic writes — coalesced, deduped, rate-shaped: realm.update((d) => { Object.assign(d.players[me].position, target); }); realm.update((d) => { d.players[me].activityId = runId; }); ``` The hook re-runs on every **full hydrate** — first attach, an out-of-log-window resume, an epoch change after a server wipe — which is precisely the set of moments your entry could have been lost. It does *not* re-run on an ordinary patch-replay reconnect (your entry is still confirmed there), and within `pendingExpiryMs` any in-flight writes [resend on their own](/nice-realm/connecting/#reconnects--durability). The re-assert belts a hand-rolled lobby carries — "re-join after reconnect", "re-announce my status after the socket blips", "recover after a server restart" — are this one option. For pointer-frequency fields (cursor, walk target), set `coalesceMs` on the connect options instead of hand-throttling — and if the same connection also carries event-shaped writes (a click-to-act, a keyed transition), use `coalesce: { ms, leading: true }` so those isolated writes send immediately while the stream still merges. See [coalescing](/nice-realm/writes-intents/#coalescing--dont-hand-roll-a-throttle). ### Write cadence is a network cadence, not a render cadence The instinct for a cursor is to write inside `requestAnimationFrame`. That bounds writes to the **display refresh rate** — which is a rendering budget (60 Hz, or 120–144 Hz on a high-refresh monitor), not a networking one. Every write you emit is one authoritative commit fanned out to *every* other attached avatar, and on a Durable Object every one of those is a billed inbound message. A room of `N` cursors all writing at 144 Hz is `N × 144` messages/second arriving at each participant — the cost scales with the crowd, so pick the cadence deliberately: ```ts const realm = connectRealm(lobbyRealm, { // … coalesceMs: 40, // ~25 cursor frames/sec is smooth; the newest value per path wins the window }); ``` Set `coalesceMs` to your real network cadence (30–50 ms is imperceptible for cursors) and let the coalescer collapse the rAF stream into it — deduped per path, so only the latest position in each window travels. Smooth the *remote* dots with a short CSS transition rather than by writing faster: sparse network updates plus interpolation look better than a flood, and cost a fraction of the messages. (The server side of this fan-out — batching and tick-rate broadcast — is the library's job, tracked in the realm message-overload plan; `coalesceMs` is the half you own.) ## The server — pruning is one line `onAvatarDetach` fires **once per avatar identity when its last connection drops** — multi-tab and multi-device are counted by the engine, so the prune handler is genuinely this: ```ts title="LobbyDO.ts" const realms = serveRealmDurableObject(this.ctx, { realms: { lobby_realm: hostRealm(lobbyRealm, { avatarType: "player", persistence: "ephemeral", // presence has no history worth persisting engine: { ctx: {}, initialState: { players: {} }, onAvatarDetach: (avatar) => { realms.engines.lobby_realm.update((d) => { delete d.players[avatar.persistentId]; }); }, }, }), }, }); ``` No socket scanning, no "is this their last tab" bookkeeping, no departure races. Hibernation is covered too: avatars whose sockets vanished while the DO slept get a **synthesized detach** on wake, so the roster never keeps ghosts. ### `avatarDetachGraceMs` — don't flap on a blip With the connection's [auto-redial](/nice-realm/connecting/#who-re-dials-the-link--nothing-here-needs-app-code-either), a transient drop heals in ~1–2 s — but a bare `onAvatarDetach` still fires the instant the socket closes, and `onAvatarAttach` a second later. Every presence consumer ends up hand-rolling a departure debounce (and inheriting a real footgun: a raw `setTimeout` in a Durable Object is **not hibernation-safe**). Declare the debounce instead: ```ts engine: { // Fire onAvatarDetach only if the avatar hasn't re-attached within the window. // A re-attach inside it swallows BOTH hooks — the avatar never left. avatarDetachGraceMs: 8_000, onAvatarDetach: (avatar, reason, ctx, detail) => { // detail.via: "grace-expired" (window elapsed, detail.deferredMs tells how late) // or "immediate" (no grace / a revocation via detachAvatar) realms.engines.lobby_realm.update((d) => { delete d.players[avatar.persistentId]; }); }, }, ``` On the DO host the deferred detach is backed by the **alarm mux + persisted pending rows**, so it fires even if the DO hibernates mid-window — the guarantee a consumer `setTimeout` cannot give. Wake losses run the same funnel: a player whose socket died while the DO slept gets the grace too, so their prompt redial swallows the detach instead of flapping the roster. `detachAvatar()` (revocation) always bypasses the grace — kicking someone is not a blip. ### Why `persistence: "ephemeral"` is right here Presence is attach/detach churn — persisting its patch log buys nothing. [Ephemeral hosting](/nice-realm/serving/#ephemeral-realms-presence) skips SQLite entirely: every cold start boots the empty roster on a fresh epoch, surviving clients full-rehydrate, and `assertOnAttach` re-fills the roster within a round-trip. The two features are a pair: **ephemeral hosting is only safe because the assert hook exists** (and the docs will keep saying so). Schema changes stop mattering as well — presence never needs a `migrate`; if you keep the realm durable for other reasons, `migrate: "wipe"` declares the same disposability. ## Mixed realms: mark presence `transient` instead `persistence: "ephemeral"` is all-or-nothing — it makes the *whole realm* disposable. The common case is **mixed**: a board that keeps notes and a click counter (durable) but also carries live cursors (throwaway). Hosting that whole realm as ephemeral would lose the notes; hosting it as durable makes every 60 Hz cursor write pay the full persistence cost — a SQLite log row per commit and a slice of every snapshot — for state worthless a second later. Declare the hot region **`transient`** on the definition instead: ```ts title="shared/boardRealm.ts" export const boardRealm = defineRealm({ id: "board", state: { notes: t.record(/* … durable … */), board: t.object({ totalClicks: t.number() }), presence: t.record(t.id("visitorId"), t.object({ x: t.number(), y: t.number() })), }, transient: ["presence"], // cursor writes: broadcast, but never logged / snapshotted / CAS-tracked rules: (r) => [ /* … */ ], }); ``` A transient write still broadcasts (peers see the cursor) and still obeys its `view`/`alter` rules — it just isn't persisted: no log row, no snapshot bytes, no `touchedSince` history. On a cold start (or an out-of-window resume) the region comes back **empty** and each client re-owns its entry via `assertOnAttach`; a resuming client's [staleness probe](/nice-realm/connecting/#the-staleness-probe) refreshes it for free. v1 `transient` paths are concrete, wildcard-free subtree roots (`["presence"]`), validated at `defineRealm` time. ### Which durability tier does my state belong in? | Tier | Declare it with | Persisted? | Rolled back on link loss? | Use for | |---|---|---|---|---| | **Durable** (default) | nothing | yes (log + snapshots) | no | Notes, scores, anything with history | | **Transient region** | `transient: ["presence"]` | **no** | no | Cursors, "who's looking", live position — hot, throwaway, on a realm that *also* has durable state | | **Ephemeral realm** | `persistence: "ephemeral"` | no (whole realm) | no | A pure-presence realm with *nothing* worth persisting | | **Durable client write** | `realm.update(fn, { durability: "reassert" })` | (client-side) | **no — resent until confirmed** | A client that is the sole *producer* of a region (an input log) — see [durable writes](/nice-realm/writes-intents/#durable-writes-durability-reassert) | ### Transient regions survive a wake Transient and `assertOnAttach` are a pair, exactly like ephemeral hosting: the region is disposable *because* every client re-asserts its own entry on resume. This matters most on a **durable** realm: transient regions are stripped from every snapshot, so a same-epoch DO hibernation wake reloads them **empty** while your socket stays attached — and a resume answers by patch replay, not a full hydrate. The library therefore re-runs `assertOnAttach` after *every* resume for a transient-bearing realm (reconnect and live probe/rehello alike, including the idle staleness probe), not only on a full hydrate — so your entry is re-created across wakes, and your cursor's leaf writes never land on a vanished parent (which would reject `write_target_missing`). **Ordering guarantee:** on a resume, the re-own reaches the server **before** any resent write or intent. So an intent whose server logic reads your transient entry — a `fire` that reads your `aim` — runs against the re-owned entry, never the momentarily-empty region. You don't sequence this; the library rides the recovery hello with the re-own on an internal lane. (If it didn't, a resent `fire` that outran the re-own would reject "you haven't joined" for a perfectly legitimate action.) Do **not** use CAS (`touchedSince`) under a transient region — it keeps no touch history, so the check would silently always pass (the engine throws if you try). `durability: "reassert"` is the opposite pairing and perfectly fine: a producer re-asserting its own transient region across reconnects is the intended shape. ## What presence deliberately does not need | Belt from the hand-rolled version | Replaced by | |---|---| | Roster reconcile poll ("a dropped push can't leave the list wrong forever") | Hydrate + versioned patches + the [staleness probe](/nice-realm/connecting/#the-staleness-probe) — convergence is the library's contract | | Re-assert status after every reconnect | `assertOnAttach` + automatic pending-write resend | | Authoritative lookup at read time ("don't trust the cached broadcast") | Reading confirmed state *is* the authoritative lookup, staleness-bounded | | Last-socket scan before announcing a departure | `onAvatarDetach` last-drop semantics | | Departure debounce timers ("don't flap the roster on a blip") | [`avatarDetachGraceMs`](#avatardetachgracems--dont-flap-on-a-blip) — hibernation-safe, both hooks swallowed on a prompt return | | Local store patch-ups when a related feature misses an event | The roster converges on its own; delete the belt | | `migrate` hooks / durable writes / intents | Presence is default-tier owner-writes over disposable state — none of them apply | When an entry needs *more* than owner-writes — a matchmaking claim, a seat count — that specific mutation becomes an [intent](/nice-realm/writes-intents/#intents-named-atomic-server-side); the rest of the roster stays exactly as above. --- # Realtime Games Source: /nice-realm/realtime-games Description: Recipes for a responsive online game on realms — the latency budget, input fast lanes, why blending toward a stale sample re-adds your ping, rewind-replay prediction, tick-indexed interpolation, and how to measure any of it honestly. Realms give a game its state plane: optimistic LWW writes for inputs, intents for atomic acts, authoritative simulation on the server, patches fanning out to every peer. What they do *not* do is make latency decisions for you — and in a game, latency decisions are gameplay decisions. This page is the recipe collection: where the milliseconds hide, the client patterns that work, and the one seductive pattern that mathematically cannot. Much of this page is distilled from a real consumer diagnosis (the Dirt Derby team's racing game), including the fixed-point analysis below — reproduced with their blessing because every integrator should be inoculated against that bug before writing a reconciler. ## The latency budget Between a keypress and every screen reflecting it, a realm game crosses these stations: | Station | Cost | Whose knob | |---|---|---| | Client coalesce window | `0` (microtask) … full `coalesceMs` for a lone write | yours — see below | | Uplink | ½ RTT | geography | | Server tick sampling | 0 … one tick (the sim consumes "latest input" at tick boundaries) | your tick rate | | Server broadcast | `broadcastCoalesceMs`, **default 0** | leave it at 0 for games | | Downlink | ½ RTT | geography | | Remote-entity interpolation delay | your buffer depth | yours — see below | Two of those rows are knobs this library owns, and both default correctly for games — but both have a failure mode worth naming: **The client window is trailing-edge by default.** `coalesceMs` arms a timer on the *first* write of a window and ships when it closes — so a lone keyboard transition pays the whole window as pure added latency before it even reaches the wire. For a game input lane, either leave `coalesceMs` unset (the microtask default), or use the leading edge so streams merge but transitions fly: ```ts connectRealm(gameRealm, { // … coalesce: { ms: 25, leading: true }, // isolated writes send NOW; bursts still merge }); ``` A single hot path on an otherwise-windowed connection can instead use the per-write escape hatch, `realm.update(fn, { flush: "immediate" })`. Full edge semantics: [Coalescing](/nice-realm/writes-intents/#coalescing--dont-hand-roll-a-throttle). **The server knob stays at 0.** `broadcastCoalesceMs` exists for chatty non-game realms where per-message cost outweighs latency. A game realm should never trade the downlink for message count — if fan-out volume is the problem, fix the *write* cadence, not the broadcast. ## The input fast lane The realm shape that works for continuous player input is a **transient LWW region keyed by player**, carrying *transitions only* (write `accel: true` on keydown, not the held state at 60 Hz). `defineInputLane` in `@nice-code/realm/predict` generates it — schema, rule pack, and the `seq` leaf — from one declaration: ```ts import { defineInputLane, type IInputLaneOwnerContext } from "@nice-code/realm/predict"; const inputLane = defineInputLane({ root: "inputs", // the state key you spread it under, below key: "playerId", // ⇒ rule patterns read `inputs.$playerId…` shape: { accel: t.boolean(), brake: t.boolean(), steer: t.number() }, // `key` is the entry being written, already resolved. Annotating this context is what types // `state` — and it infers the lane's type arguments, so you never write them out. owner: ({ avatar, key, state }: IInputLaneOwnerContext<{ seats: Record }, "player">) => { if (avatar.persistentId !== key) return err.fromId("not_your_lane"); return state.seats[key] != null ? true : err.fromId("not_seated"); }, validate: { steer: (next) => (Math.abs(next) <= 1 ? true : err.fromId("bad_steer")) }, }); defineRealm({ state: { inputs: inputLane.schema, /* … */ }, rules: (r) => [...inputLane.rules(r), /* your own */], transient: [inputLane.transientRoot], }); ``` A realm's full state type is circular with the lane living inside it, so the `owner` gate names the **slice** it reads (`{ seats: … }` above) rather than the whole state — that annotation is what types `state`, and inference does the rest. Three things it does that are easy to get wrong by hand: - **It emits the rules that can actually match — one per leaf.** Realm rules are consulted **per leaf**: every write expands to the leaf paths it covers, so a whole-entry add, a whole-entry replace and a single-leaf transition all land on the *leaf* rules. A rule written at the entry itself (`inputs.$playerId`) matches nothing, and a lane built that way default-denies every write — including the first. The tempting fix, a permissive `**` catch-all, is what actually unguards the lane. - **It writes no type checks, because they already happen.** Every value is validated against its schema node *before any rule runs*, on both sides — a `t.boolean()` leaf rejects a string with `value_invalid` whether the client is buggy or hostile. `validate` is only for what the schema can't express: ranges, enums, cross-field checks. - **It generates *and guards* the `seq` leaf** — the protocol half of prediction. `createLaneWriter` (below) stamps it; your authoritative tick copies the consumed input's seq onto the simulated body (`body.inputSeq = input.seq` — one line, at the one point that knows which input a body consumed). That echo is **proof of consumption**: it is what `lane.lastAppliedSeq` and `lane.unackedInputs` read, so you can see how far ahead of the server you are. Note what it is *not*: a replay input. Replay is indexed by **tick**, not by seq — ordering a replay on seq is a tempting wrong turn. The library rejects a `NaN`, `Infinity` or negative seq for you — all three pass a `t.number()` check and would quietly corrupt the counter that everything above reads. **Transient** means input storms cost zero storage writes and a hibernation wake starts clean — which is exactly why the entry must be re-owned on every recovery ([presence](/nice-realm/presence/)). `createLaneWriter` owns that too: ```ts import { createLaneWriter } from "@nice-code/realm/predict"; const lane = createLaneWriter(inputLane, { // takes the lane: root + entry type come from it key: myId, initial: { accel: false, brake: false, steer: 0 }, // Annotating `state` types it and infers the writer's state type — the realm's full state type // is nameable here, unlike inside the (circular) lane definition. canOwn: (state: TMatchState) => state.seats[myId] != null, // not seated ⇒ don't re-own appliedSeq: (state) => state.bodies[myId]?.inputSeq, // the tick's echo }); const realm = connectRealm(matchRealm, { // … coalesce: { ms: 25, leading: true }, // transitions are event-shaped — see the budget above assertOnAttach: lane.assertOnAttach, // stable reference, safe to pass before attach }); lane.attach(realm); lane.write({ accel: true }); // stamps the next seq, skips no-ops, sends immediately lane.current; // the freshest local input — simulate from this lane.unackedInputs; // how far ahead of the server you are: the replay window ``` The re-own seeds from the writer's **own live value**, not from schema defaults — that distinction is the difference between a wake being invisible and a wake snapping the player's throttle shut mid-corner. And `write` suppresses no-ops, so a key-repeat costs nothing and never burns a `seq`: the counter advances only on a real transition, which is what makes `unackedInputs` meaningful. Note the writer takes **the lane itself**, not a root string: the record it writes to and the entry type it stamps are read from the definition, so a writer can't drift from the lane it writes to — rename the lane's root, or add a leaf to its shape, and the writer follows or fails to compile. ## The trap: blending toward a stale sample Here is the pattern nearly every first reconciler uses. It type-checks, renders plausibly, and re-adds your entire ping as input lag: ```ts // ❌ On every flush: softly pull the predicted body toward the latest authoritative sample. body.x += 0.2 * (auth.x - body.x); body.y += 0.2 * (auth.y - body.y); ``` The authoritative sample is inherently `L` seconds old, where `L ≈ RTT + coalesce + tick sampling` — it reflects your inputs from `L` ago. While moving at speed `v`, the authoritative position trails your true (locally simulated) position by `d = v·L`. Per flush, with `e` = how far the rendered body lags the true local body: ``` e ← 0.8·e + 0.2·(v·L) → fixed point e* = v·L ``` **The blend converges to the full latency offset.** Prediction wins only the first instants after an input transition; then the correction drags the body back onto the server's stale timeline — at 250 px/s and `L = 250 ms` that's a steady-state error of ~62 px, and heading obeys the same recurrence during a turn. The trap is invisible on localhost (`L ≈ 5 ms ⇒ e* ≈ 1 px`), which is exactly why it ships: *fine locally, laggy deployed* is this bug's signature. No blend factor fixes it — a smaller factor only slows convergence to the same fixed point. Two amplifiers to check for even before the real fix: reconciling on **every** store flush (including your own input-write echoes) instead of only on fresh authoritative data — this is what `listenToPatches`' [`meta.authoritative`](/nice-realm/connecting/#every-flush-says-whether-it-carried-server-truth) is for — and a hard-snap threshold that `e*` alone can push the total error past, which reads as rubber-banding. ### Gate the reconciler on authority, not on `"confirm"` Before the fix itself, the predicate it runs on — because the natural-looking one is wrong in a way that, like the trap above, only shows up under latency. ```ts realm.listenToPatches((patches, meta) => { if (!meta.authoritative) return; // ✓ your own echo and local rollbacks — nothing new from the server if (state.race.tick === lastTick) return; // ✓ nothing actually advanced reconcile(patches); }); ``` **Not `meta.cause === "confirm"`.** That cause means "authoritative *and* your outbox happened to be empty" — and a driving client's outbox is never empty for long, since every input write is in flight for about a round trip. A consumer census over identical 20-second races found the split moves entirely with latency: | Fresh authoritative ticks (n = 400) | 0 ms | +200 ms | |---|---|---| | arrive as `"confirm"` | 400 (100 %) | 160 (40 %) | | arrive as `"rebase"` — real server truth a confirm-gate **drops** | 0 | **240 (60 %)** | | arrive as `"optimistic"` | 0 | 0 | At +200 ms a confirm-gated reconciler throws away three of every five things the server says — and at 0 ms it throws away nothing and looks perfect. *Fine locally, laggy deployed*: the same signature as the blend trap, one layer up. The bottom row is the other half of the argument: an optimistic flush **never** carries authoritative state, at any latency, so skipping `"optimistic"` (or equivalently gating on `meta.authoritative`) drops 100 % of the echo class and 0 % of the truth. ## The fix: rewind-replay prediction The standard, game-agnostic shape. `@nice-code/realm/predict` ships this as [`createPredictedEntity`](#createpredictedentity--the-above-without-writing-it) — read this section anyway, because it is what the helper is doing and you cannot debug a predictor you don't picture: 1. Simulate your own entity locally with a **fixed-timestep** integrator (the same step function the server runs, if you share the sim). 2. Keep a bounded history of your input transitions **indexed by tick** (a second or two is plenty). 3. On each **fresh authoritative** sample (`meta.authoritative`, and only when the authoritative tick actually advanced — see the [gate](#gate-the-reconciler-on-authority-not-on-confirm)): take the auth body and re-run the fixed-step integrator over the ticks between that sample and now, feeding each tick the input that was in force at it. The replayed result is the render body. 4. The residual between replayed and previously-rendered state is now **genuine misprediction only** (a server-side collision, a shove) — smooth *that* with a small blend or snap policy. Residual smoothing finally does the job the stale-sample blend was pretending to do. 5. Steady-state error is `e* ≈ 0` at any ping: latency no longer appears while your inputs are the only force acting on you. **Peg your tick clock to the server's — never let it free-run.** The clock you replay against is `localTick = serverTick + leadTicks`, re-derived from every authoritative sample. The tempting alternative — run `localTick` off the wall clock at your nominal rate and let a controller nudge it — drifts by construction, because the authoritative cadence is *whatever the server actually delivers*, not what your constant says. A consumer whose server was quietly running at ~16 Hz (see [`setInterval`](#dont-run-the-authoritative-loop-on-setinterval)) watched a free-running 20 Hz client clock gain ~2.8 ticks/second until the predicted car was three seconds ahead of reality and snapped back at every corner. Pegging is drift-proof and costs one assignment. Two more details that only show up in a real integrator: anchor the residual correction to the last **drawn** position and **set** it (`=`, not `+=`) so it can't accumulate across samples, and **cap** any between-sample presentation extrapolation (~3 tick-steps) so a link hiccup glides instead of launching the entity. The replay is cheap: at a 20 Hz tick and 250 ms of latency it's ~5 fixed steps per authoritative sample, for one entity. Bound the replay window (~1 s) with a snap fallback. Use the *fixed* tick `dt` in the replay even though your render loop is variable — clamped integrators don't commute across step sizes, and the drift between a variable-step replay and the fixed-step server accumulates. Deterministic-by-construction tests for this shape — script the delivery of authoritative frames N ticks late and assert the replayed body lands where the unlagged sim would have — are what [`createTestRealm`'s frame-hold controls](/nice-realm/testing/) exist for. ## `createPredictedEntity` — the above, without writing it Everything in that section is a real thing to get right, and `@nice-code/realm/predict` ships it. It is an **extraction**, not a design: it was taken from a shipped, measured racing game's predictor, and every default in it exists because that game measured the alternative. ```ts import { createPredictedEntity } from "@nice-code/realm/predict"; const predictor = createPredictedEntity({ tickRate: 20, // Your physics — the same function the server's tick runs. Movement only. step: (body, input, dt) => stepVehiclePhysics(body, input, params, dt, locked), input: () => lane.current, // the input-lane writer's live value predict: (body) => !body.destroyed && body.place === 0, // a wreck mirrors authority residual: { distance: (a, b) => Math.hypot(a.x - b.x, a.y - b.y), displace: (body, from, to, amount) => ({ ...body, x: body.x + (from.x - to.x) * amount, y: body.y + (from.y - to.y) * amount, heading: body.heading + wrapAngle(from.heading - to.heading) * amount, }), snapDistance: 110, // a mine's shove should arrive, not ooze }, }); predictor.attach(realm, { tick: (state: TMatchState) => state.race.tick, body: (state) => state.race.vehicles[myId], }); // in your render loop: const body = predictor.present(); // pegged fixed step + capped sub-tick + residual glide ``` `attach` is the whole realm-facing surface: it reconciles on flushes that carried fresh server state **and** moved your tick, so there is no listener to write and no predicate to get wrong. The lead calibrates itself from `realm.stats.rttMs` with no wiring. **What stays yours** is your physics (`step`) and the shape of your body (the residual's two callbacks). The library never introspects your fields — the same boundary `createInterpolationBuffer` draws around `lerp`. **What it owns** is the list above that is wrong-by-default when hand-rolled: the pegged clock, the authority gate, the lead controller, the capped extrapolation, the residual anchor — and one thing the hand-rolled reference did *not* do, below. ### The tick clock cannot free-run, structurally `present()` takes **no delta time and cannot advance a tick**. It moves sub-tick position only; ticks arrive from the server or not at all. That is not a documented rule you could ignore — there is no API that would let you. ### It replays the inputs you actually issued The obvious shortcut is to stamp the *current* input across the whole replay window. It is wrong whenever the input changed inside the window, and the error scales with the window — which is to say, with latency: | one-way latency | flat-stamped replay error | `createInputTimeline` | | --- | --- | --- | | 50 ms | 0.00 | 0 | | 100 ms | 0.17 | 0 | | 200 ms | 1.62 | 0 | | 400 ms | 3.49 | 0 | (Toy fixed-step integrator, units arbitrary, held-then-released input. The point is the shape.) Note the first row: at 50 ms the shortcut is *exactly right*, which is why it survives review and ships. `createInputTimeline` stores **transitions, not samples** — a held key costs one entry, not one per tick — and the replay reads it back as the step function an input actually is. One subtlety it handles that is easy to get wrong by hand: an input is filed at the tick the **server will apply it** (about a round trip ahead of the sample you can see), not at your local tick. Filing at the local tick is wrong by `lead − RTT`, which is ~0 on a bad link and 2+ ticks on a good one — it doubled the measured misprediction at 0 ms when we tried it. ### What it measured Swapping the reference implementation onto the toolkit, in that game, on its own harness through a TCP lag proxy — same session, three runs a side: | | hand-rolled | `createPredictedEntity` | | --- | --- | --- | | misprediction @ 0 ms | 3.01 px | **2.60 px** | | misprediction @ +200 ms | 8.05 px | **3.79 px** | | worst-case peak @ +200 ms | ~30 px (past a car length) | **~15 px** | The mean roughly halves at latency; the peak matters more, because a peak past a car length is the one the player actually feels. Read `predictor.stats` for those numbers live — `correction` (and `correctionPeak`) is **the** number that says whether prediction is working, and the property to hold it to is that it should not scale with the round trip. ## `createServerClock` — what tick is the server on *now*? A spectator, a HUD, or a replay scrubber needs the server's clock without predicting anything: ```ts import { createServerClock } from "@nice-code/realm/predict"; const clock = createServerClock({ tickRate: 20 }); realm.listenToPatches((_patches, meta) => { if (!meta.authoritative) return; // never sample your own echo clock.sample(realm.store.state.race.tick); }); clock.tick; // the server's tick, now — anchored, clamped, never free-running clock.measuredHz; // what it ACTUALLY delivers, against the tickRate you claimed ``` The part that is trivially wrong hand-rolled: **you cannot smooth "server tick" directly**, because the thing you are averaging moves between samples — an EWMA over raw tick numbers averages the server's motion along with its jitter and lags by design. Subtract the tick-rate term first and smooth the *offset*, which holds still: ``` offset = serverTick − now / tickMs // stationary — smoothing this means something tick(now) = floor(now / tickMs + offset) ``` That is the whole helper, plus a clamp (`maxLeadTicks`, and `isStale`) so a dead link cannot invent ticks the server never sent, and a re-anchor so a fresh round's tick reset does not spend seconds easing down through tick numbers that never existed. ## Remote entities: interpolate on tick indices Your own entity predicts; **everyone else renders slightly in the past**, interpolated between authoritative samples. Two rules make it robust: - **Index by server tick, not arrival time.** Arrival timestamps carry all the network's jitter; tick indices are the server's own even clock. Buffer samples keyed by tick and render at `latestTick - delay`. - **Size the delay from measured jitter, not a constant.** A fixed buffer (say 120 ms) is either wastefully deep on a good link or stuttery on a bad one. The robust sizing is p95 of the samples' clock jitter plus one tick — measured live, grown fast when the link degrades and shrunk slowly when it recovers. Both rules are packaged as **`createInterpolationBuffer`** in `@nice-code/realm/predict` — the jitter estimation is exactly the part a hand-rolled version gets subtly wrong: ```ts import { createInterpolationBuffer } from "@nice-code/realm/predict"; const remoteCar = createInterpolationBuffer<{ x: number; y: number }>({ tickRate: 20, // the server sim's tick rate lerp: (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }), }); realm.listenToPatches((_patches, meta) => { if (!meta.authoritative) return; // fresh server state only — never your own echo const state = realm.store.state; remoteCar.push(state.race.tick, state.cars.p2); // indexed by the SERVER's clock }); // In the render loop: const rendered = remoteCar.sample(); // interpolated, delayTicks behind the newest sample ``` The index just has to be a monotone source clock — state that streams without a server tick (a peer's cursor or aim lane) can carry a sender-side `Date.now()` stamp and use `tickRate: 1000`; our tank-shooter demo renders enemy barrels exactly this way. `buffer.latest` carries the newest un-delayed value for discrete fields (a selected weapon should switch, not morph), and `buffer.delayTicks` is chartable if you want to watch the buffer breathe. Note the `push` gate is `meta.authoritative`, for a sharper reason than correctness-in-general: feed the buffer on `cause === "confirm"` and it silently receives its samples **in bursts with latency-sized gaps** (see [the gate](#gate-the-reconciler-on-authority-not-on-confirm)) — and since the delay is sized from measured jitter, the buffer reads those gaps as a bad link and inflates `delayTicks`, pushing remote entities *further* behind truth exactly when the local player is most active. Feeding every authoritative flush gives it the clean, monotone stream it wants. Smooth remote motion with the interpolation itself (or a short CSS transition for DOM cursors) — never by writing faster. And apply interpolation **only** to remote entities: your own predicted body already updates every frame, so smoothing it just adds fixed visual lag with nothing to hide ([the render-side rules](/nice-realm/connecting/#performance-pointercursor-frequency-direct-writes)). ## Measure before and after — honestly Netcode instruments fail *quietly* and *flatteringly*: every trap below reports excellent numbers from a broken measurement. Each one has cost a real team a false conclusion. - **Measure at 0 ms *and* at latency, and compare — the comparison is the measurement.** Either column alone is decoration. Every metric here (and the `cause` census above) reports a clean bill of health on localhost whether the netcode is right or wrong; that is precisely the property that ships both the blend trap and the confirm-gate. A number that doesn't move with latency is either a fix or a broken instrument, and you cannot tell which from one column. - **Prove the entity was moving and alive before you believe any prediction metric.** A parked car predicts perfectly. So does a dead one. A consumer's harness measured 18 seconds of flawless prediction on a wreck, and a stuck-against-a-wall run produced flat, beautiful errors at both latencies — indistinguishable from the bug they had just fixed. Drive with your own game AI, and refuse to report unless the entity moved. - **Shape the network for real.** Chrome DevTools throttling does **not** shape WebSocket traffic — it will show you a lie. Use a real remote deployment, a local shaper ([clumsy](https://jagt.github.io/clumsy/) on Windows), or ~40 lines of TCP relay that delays every byte both ways — scriptable, and the version you can put in CI. - **`realm.stats`** is the built-in RTT/jitter estimate (EWMA, fed by write confirms and probe answers, coalesce window excluded) — poll it from the HUD tick and show the ping badge; players diagnose "is it me or the game" with it too. [Details](/nice-realm/connecting/#measuring-the-link--realmstats). - **Draw the truth.** A dev-only overlay rendering the raw authoritative body and the rendered body as two outline rects (plus `authTick / error px` as text) makes the stale-sample drag — and the fix — visible in seconds. Keep it behind a query flag permanently; it's your regression detector. - **Expect the right metric to get "worse".** After a correct rewind-replay fix, the gap between the stale authoritative sample and your drawn entity **grows with latency** (it is `v·L` — the latency, now visible instead of hidden). A *flat* gap across latencies is the blend trap wearing a lab coat. The number that should stay small is the prediction error at each rebase. - **Know your budget's fixed floor.** With a 20 Hz server tick, 0–50 ms of input-to-sim sampling and a 50 ms reconciliation quantum are inherent; measure your improvements against that floor, not against zero. ## Don't run the authoritative loop on `setInterval` `setInterval(fn, 50)` schedules a **gap between runs, not a period**: each tick's own cost — the sim step, plus a realm commit that broadcasts to every client — lands *on top* of the 50 ms. A consumer measured their nominal 20 Hz race loop actually delivering **15.8 Hz**: a race running at 79 % speed, a "3 second" countdown taking 3.8 s, and it scales with player count and host load. It is nearly invisible in-game — every speed, cooldown and timer scales together, so it reads as "the cars feel sluggish" rather than as a clock bug — and it silently poisons any client that assumes the nominal rate (see the pegged-clock rule above; a free-running client clock against a slow server loop diverges without bound). Schedule against an absolute wall-clock grid so the delay subtracts the work: ```ts let nextTickAt = Date.now(); const loop = () => { runTick(); nextTickAt += TICK_MS; // the grid, not "now + TICK_MS" setTimeout(loop, Math.max(0, nextTickAt - Date.now())); }; ``` ## Checklist - [ ] Input lane: transient, transitions-only, owner-gated **on every leaf** (an entry-level rule on a record of objects never matches — see [the input fast lane](#the-input-fast-lane)), monotone `seq`, `body.inputSeq` echo in the tick - [ ] `coalesce: { ms, leading: true }` (or no window) on the game connection; `broadcastCoalesceMs` left at 0 - [ ] Reconciler reacts to `meta.authoritative` + tick-advance only — **never** `cause === "confirm"` (drops truth under load), never its own echo - [ ] Own entity: fixed-step rewind-replay; residual-only smoothing with a bounded snap; the client tick clock **pegged to the server's** (`localTick = serverTick + lead`), never free-running — or [`createPredictedEntity`](#createpredictedentity--the-above-without-writing-it), which is all of that with the free-running version made unrepresentable - [ ] Remote entities: tick-indexed interpolation, jitter-sized delay, fed on `meta.authoritative` - [ ] Authoritative loop on a wall-clock grid, not `setInterval`; measured Hz ≈ nominal Hz - [ ] Ping badge from `realm.stats`; overlay for auth-vs-rendered; tested under a real shaper - [ ] **Every number above checked at 0 ms *and* at ~200 ms, and compared** — and the probe entity proven to be moving and alive when they were taken --- # A Resilient Browser Client Source: /nice-realm/resilient-client Description: The canonical wiring for a client that survives flaky links — keep-alive, half-open detection, truthful reconnect UX, unknown-outcome intents, teardown order, and chaos-testing your own app. Every piece of link resilience is individually documented; this page is the **assembly** — the wiring a production browser client (a game, a live board, anything realtime) should ship, and what each part is for. It exists because consumers otherwise re-derive it from library source, one incident at a time. ## The canonical wiring ```ts title="client.ts" import { connectChannel, wsCarrier } from "@nice-code/action"; import { connectRealm, realmConnection, isUnknownOutcomeRejection } from "@nice-code/realm"; let session: { matchId: string } | undefined; // your mutable dial context const connector = connectChannel(runtime, matchChannel, { peer, storage, transports: [ { // Return null when there's no valid endpoint (torn-down session): the redial // loop PARKS instead of dialing garbage — teardown order stops mattering. carrier: wsCarrier(() => session == null ? null : { url: `${WS_BASE}?matchId=${session.matchId}` }, ), }, ], // Auto-redial on unexpected drops (backoff + jitter). Default: already on for a // realm-carrying link — shown here for the guide. keepLinkAlive: true, // Half-open detection (browser offline, NAT death: the socket LOOKS open, frames // vanish). Idle ⇒ wire ping; no reply ⇒ local close ⇒ the ordinary redial. Also on // by default with keepLinkAlive; the knobs are the point (≈ idleMs + pongTimeoutMs // worst-case detection, default 15s + 5s). linkKeepalive: { idleMs: 15_000, pongTimeoutMs: 5_000 }, // The observation surface: truthful reconnect UX + flaky-link telemetry. onLinkEvent: (e) => { if (e.type === "link_down") hud.linkDown(); if (e.type === "redial_scheduled") hud.retrying(e.attempt, e.delayMs); if (e.type === "link_up") hud.linkUp(e.downForMs); // undefined on the first connect }, }); const realm = connectRealm(matchRealm, { avatar: { type: "player" }, connection: realmConnection(connector), onMutationRejected: (rejection) => toast.rejected(rejection), onDiagnostic: (e) => telemetry.count(`realm_${e.type}`), // see "who sees what" below }); ``` ## The first connect — when the server isn't up yet The classic dev-loop race: you restart both processes, the frontend reloads in 200 ms, the backend takes two seconds, and the app's very first dial hits a closed port. This is **not** a special case. A failed dial arms the same keep-alive backoff ladder that a mid-session drop does — *including the first one, before any link has ever come up* — so the app heals on its own with no reload: ``` redial_scheduled (attempt 1, in 758 ms) redial_scheduled (attempt 2, in 1613 ms) link_up ← the backend finished booting ``` What the library does for you, and what it leaves to you: - **It retries indefinitely.** Each rung doubles — 1 s, 2 s, 4 s … capped at 30 s — and the actual wait is jittered into the top half of its rung, so a whole fleet doesn't reconnect in lockstep. A server can always come back, so nothing gives up on its own: see [deciding to stop](#deciding-to-stop-trying). - **`connect()` still rejects** on that first failure. The rejection is the truthful answer to *"am I connected right now?"*; the retries continue behind it. If you `await connector.connect()` at startup, catch it and render "connecting…" rather than treating it as fatal. - **A pre-open dial failure is silent on the console.** It is a failed *dial*, not an outage, and logging it would spam a line per backoff rung while a peer boots. Observe it through `onLinkEvent`, never `console.error`. (A failure *after* the socket opened still warns.) - **`createRequest` returning `null` parks instead.** No endpoint means there is nothing to dial, so the ladder waits for a real one rather than churning — the teardown-order guarantee from the wiring above. - **A permanently-rejected identity parks too.** If the server rejects the handshake because this client's verify key doesn't match the one pinned for its identity (trust-on-first-use), retrying can never succeed — so the ladder parks instead of re-running a doomed handshake forever. The rejection is **typed**: `connect()` rejects with `identity_pin_mismatch` (branch on it with `err_wire_connect.isExact(e) && e.hasId("identity_pin_mismatch")`; see [recovering from a pin mismatch](/nice-wire/identity/#recovering-from-a-pin-mismatch) for what causes it and how to recover). - **`keepLinkAlive: false`** opts out of all of this: the link is dialed on dispatch only. ## Joining vs reconnecting — one latched boolean `status === "connecting"` covers two very different UX moments. `hasBeenLive` (latched on the first `live`, never reset for the handle's lifetime) tells them apart: ```ts realm.subscribeStatus((status, { hasBeenLive }) => { if (status === "live") hud.live(); else if (status === "connecting" && hasBeenLive) hud.reconnecting(); // warning banner else if (status === "connecting") hud.joining(); // first-join spinner else hud.left(); }); ``` A "retry now" button belongs on the reconnect banner — bind it to the connector, which resolves when the fresh link's handshake lands: ```ts retryButton.onclick = () => void connector.reconnectLink(); ``` ### Status flips only on evidence of an outage A serverless host (a Cloudflare Durable Object) **hibernates on every gameplay lull** and wakes on the next frame. The wake heals in one round-trip on a socket that never dropped — so it must be **invisible**. It is: the wake re-hello is *probe-flavored* and keeps `status === "live"` throughout, exactly like the idle staleness probe. `status` moves to `"connecting"` only on **evidence** — a link the transport reported down, or a probe/re-hello that goes **unanswered** past `probeReplyTimeoutMs` (then `probe_escalated` fires and the connection re-dials). It never flips for a protocol chore. So folding `status` into a "reconnecting…" banner does **not** flap it on every wake — a real outage is the only thing that lights it. What a wake looks like on the wire now: `hello → (re-own) → answer → your rebased writes`. Your in-flight writes **park** for that one round-trip instead of streaming at a momentarily-empty server (no reject burst), and any transient region you own is re-created *before* your resent intents run — see [presence](/nice-realm/presence/#transient-regions-survive-a-wake). ## Deciding to stop trying The library never gives up on an outage, because it cannot know whether your server is rebooting or decommissioned. (The two *non*-outages above — no endpoint to dial, a pin-rejected identity — park by themselves; retrying can't fix those.) A **bounded** policy — "after N attempts, tell the user we're offline and stop burning battery" — is the app's to own, and `redial_scheduled` carries the counter to build it from: ```ts const connector = connectChannel(runtime, matchChannel, { // …transports, storage… onLinkEvent: (e) => { if (e.type === "link_up") hud.online(); if (e.type === "redial_scheduled") { hud.retrying(e.attempt, e.delayMs); // "reconnecting… (attempt 3, in 4.1 s)" if (e.attempt >= 8) { connector.releaseLink(); // stop for good — no phantom `link_down` hud.offline(); // the button below revives it } } }, }); reconnectButton.onclick = () => void connector.connect(); ``` | Lever | Effect | |---|---| | `connector.reconnectLink()` | **Retry now** — tears down and re-dials immediately, skipping the remaining backoff wait. Awaitable; rejects if that dial fails (the ladder carries on). | | `connector.releaseLink()` | **Stop trying.** Suppresses the redial and releases the socket. An app-initiated release is not an outage, so it emits no `link_down`. Not terminal — a later `connect()` revives the connector. | | `keepLinkAlive: false` | Never auto-redial at all; dial on dispatch only. | | `connector.dispose()` | Permanent teardown of the connector. | Bound the *attempts*, not the elapsed time: with the 30 s cap, eight attempts is roughly a minute and a half of patient retrying — long enough to ride out a deploy, short enough that a laptop that closed its lid doesn't retry until the battery dies. ## Who sees what: `onLinkEvent` vs `onDiagnostic` A drop that heals by redial is **silent at the sync layer** — no sync was lost, so `onDiagnostic` correctly reports nothing. The division of labor: | Event | Where | Meaning | |---|---|---| | `link_down` / `redial_scheduled` / `link_up` | connector `onLinkEvent` | Transport churn — reconnect UX, "how flaky are my users" telemetry | | `link_redialed` (with `downForMs`) | realm `onDiagnostic` | The ONE transport event forwarded to realm telemetry — count churn without a second wiring point | | `resync`, `gap_dropped`, `frame_error`, `probe_escalated`, `late_reject`, `reown_missing` | realm `onDiagnostic` | Sync-layer health — a healthy realm emits **none**, whatever the transport does | | RTT / jitter | [`realm.stats`](/nice-realm/connecting/#measuring-the-link--realmstats) getter | Link **quality** (not health) — deliberately *not* an `onDiagnostic` event, so "a healthy realm emits none" stays alarmable; poll it from a HUD tick | A `resync` with `reason: "write_target_missing"` is the self-heal for a *structural divergence*: the server rejected a write whose target it no longer had (a delete/reset broadcast that never reached this client). The client re-reads and re-owns in one round-trip instead of looping on doomed writes — so a single such `resync` is recovery working, not a problem; a *stream* of them means a broadcast is being lost upstream. A `late_reject` (a reject for a frame already settled) is harmless but is the fingerprint of a divergence window worth logging. A `reown_missing { path }` is the tripwire behind that self-heal: it fires only when a recovery ran but did **not** re-create a transient entry you own — your `assertOnAttach` produced no write for `path` (there is none, or the parent is genuinely gone). The library escalates once automatically (a forced full-hydrate, the in-place equivalent of a page reload); a *second* `reown_missing` for the same `path` means even that did not bring it back, so treat the entry as permanently gone (a pruned record, a removed peer) and reflect that in your UI rather than retrying. If you silence `write_target_missing` in `onMutationRejected` (recommended — it is benign wake noise once recovery is wired), `reown_missing` is your one trustworthy "this one did not heal" signal, so wire it. ## Owning transient state across a wake: `assertOnAttach` A **transient region** (`transient: ["aim"]`) is never logged or snapshotted, so a same-epoch Durable Object hibernation wake reloads it **empty**. Anything you own there — a cursor row, an aim reticle, "my side of the record" — must be re-declared on recovery. `assertOnAttach` is that hook: the library runs it after every hydrate **and** every replay resume on a transient-bearing realm (reconnect, wake rehello, and the periodic staleness probe all heal), and rides the re-own to the server *ahead of* any resent write or intent — so a `fire` that reads your `aim` runs against the re-owned entry, never the wiped region. Write it as a **guard** — re-own only if the entry is actually gone: ```ts connectRealm(matchRealm, { avatar: { type: "player" }, connection: realmConnection(handler), // Re-own my aim entry on recovery, preserving my real values from my own store. assertOnAttach: (draft) => { const me = identity.id; if (draft.players[me] == null || draft.aim[me] != null) return; // already present — nothing to do draft.aim[me] = { angle: myStore.angle, power: myStore.power, item: myStore.item }; }, onDiagnostic: (e) => { if (e.type === "reown_missing") forceHardReload(); }, // belt-and-suspenders }); ``` The draft the library hands you on a resume reflects the **authoritative post-recovery state** (the region refresh has already landed), so the guard observes a wake-wiped entry as genuinely missing and re-creates it, and a healthy resume observes it present and enqueues nothing — cheaper than an unconditional rewrite and equally correct. Read the values you re-own from your **own** source-of-truth store (above), not from `draft`: after a wipe the draft's authoritative value is absent, so `draft.aim[me]?.power ?? 0` would re-own at defaults. ### When each event fires | Event | Fires | Not | |---|---|---| | `link_down` | At the **moment of close**, for any real outage — network, server close, `connector.debug.dropLink()`. Independent of how long the socket's own close callback takes (a real WebSocket's `onclose` can lag by seconds). | Never for an app-initiated teardown (`releaseLink()` / `clearTransportCache()` / `dispose()`) — that is not an outage. Never coupled to the redial. | | `redial_scheduled` | When keep-alive actually arms attempt N. A `debug.dropLink({ suppressRedialMs })` window suppresses **only this** — never the `link_down` above. | When keep-alive is off, parked (`dial_unavailable`), or suppressed. | | `link_up { downForMs }` | On re-establishment. `downForMs` measures the **full outage** (from `link_down` at close), not the redial duration. | `downForMs` is `undefined` only on the first-ever connect. | ## Non-idempotent intents: the unknown-outcome catch A `frame_too_stale` rejection means the outcome is **unknown** — the intent may have committed while the ack was lost. Never blindly re-issue; re-read state (it converges on the next replay/hydrate) and decide: ```ts try { await realm.intents.fireShot({ angle, power }); } catch (e) { if (isUnknownOutcomeRejection(e)) { // The shot may have landed — re-read state, don't re-fire. await refreshFromRealmState(); } else { showRejection(e); // a definite "no": a rules rejection (or transport failure) } } ``` ## Teardown: leaving a session without a 3 a.m. bug Two levers, and with the `null`-returning `createRequest` above the ordering between them stops mattering: ```ts function leaveMatch() { realm.detach(); // leave the realm (slot released, pending writes expired) session = undefined; // the dial context is gone — createRequest now returns null connector.releaseLink(); // stop the auto-redial and release the socket } ``` `releaseLink()` is not terminal — the next `connect()`/dispatch revives the connector for a new session. (`clearTransportCache()` does the same suppression under a cache-flavored name; `dispose()` is the permanent teardown.) ## Detection windows, and how the knobs compose | Detector | Knob(s) | Worst-case notice | Covers | |---|---|---|---| | Clean close → auto-redial | `keepLinkAlive` | ~1 s (first backoff rung) | Any real socket close | | Wire keepalive (half-open) | `linkKeepalive: { idleMs, pongTimeoutMs }` | ≈ `idleMs + pongTimeoutMs` (default ~20 s) | Every protocol on the link | | Realm staleness probe + escalation | `staleProbeAfterMs` + `probeReplyTimeoutMs` | ≈ 35 s at defaults | Realm-layer belt-and-braces (also catches an unhealthy-but-open session) | | Server presence grace | [`avatarDetachGraceMs`](/nice-realm/presence/#avatardetachgracems--dont-flap-on-a-blip) | — | The *server's* side of a blip: no roster flap while the client heals | The wire keepalive is the primary half-open detector; keep the probe knobs at their defaults as the sync-layer backstop rather than tightening them to do transport work. (One legitimate reason to lower `staleProbeAfterMs` anyway: every answered probe is also an RTT sample for [`realm.stats`](/nice-realm/connecting/#measuring-the-link--realmstats), so a read-mostly client that wants a denser ping readout can trade probe traffic for sampling rate — a deliberate trade, not transport work.) **Close semantics — the heal is not coupled to the platform's close event.** A *deliberate local* close — a teardown (`clearCache`), the half-open keepalive detector, or `debug.dropLink()` — notifies the disconnect fan-out (the auto-redial trigger) immediately, so the heal runs on the fast ladder (~1 s). It does **not** wait for the carrier's own platform close event, which a real WebSocket can deliver seconds late against a dead peer. The eventual platform event is deduped confirmation, so `link_down` fires once and `link_up.downForMs` measures the full outage. Practical upshot: a bare `debug.dropLink({})` is a faithful *fast-blip* simulator (heals in ~1 s, like a real transient blip) — reach for `{ suppressRedialMs }` only when you specifically want to hold a *sustained* outage open. ## Chaos-testing your own app Outage simulation from *outside* the library doesn't work — a browser's offline mode never closes the socket, and page-level `WebSocket` patching can't gate the keep-alive redial (it heals straight through, as it should). Use the built-in seams: ```ts // 1. Manual QA / e2e: the airplane-mode button — a REAL close (the server sees it), // with the redial suppressed for the window. await connector.debug.dropLink({ suppressRedialMs: 10_000 }); // 2. Integration tests: the library's own chaos transport. import { chaosLink } from "@nice-code/action/testing-transport"; const link = chaosLink(); // wire link.onConnection(...) to your acceptor; pass link.carrier to connectChannel link.chaos.hold(); // half-open: isOpen() true, frames vanish (browser-offline shape) link.chaos.releaseHeld(); // the network comes back, in order link.chaos.dropHeld(); // ...or lossy: the parked frames never arrive link.chaos.closeLink(); // a real drop; chaos.dialCount counts the redials link.chaos.refuseDials(true); // and keep the "server" unreachable // 3. Unit-level WS control: inject the socket itself. wsCarrier(() => ({ url }), { createWebSocket: (url) => new MyControllableSocket(url) }); ``` `@nice-code/realm/testing` covers the *rules/intents* side; this transport surface is its link-layer counterpart. --- # Rules & Visibility Source: /nice-realm/rules Description: View and alter rules — path patterns, owner gating, serverChecked conditions, and how visibility slicing keeps hidden state on the server. Every path in a realm's state answers two questions, decided by rules: **who may see it** (`view`) and **who may change it directly** (`alter`). Rules are declared in the definition, so they run identically as client-side prediction and as server-side authority. ```ts title="shared/marketRealm.ts" rules: (r) => [ // Everyone sees config; only server code writes it. r.path("config.*").view(r.everyone).alter(r.serverOnly), // Presence: everyone sees all cursors; you may only move your own. r.path("presence.$avatarId.{cursorX,cursorY}") .view(r.everyone) .alter(({ avatar, params }) => avatar.persistentId === params.avatarId || err_market.fromId("not_yours")), // Vendor fast lane: a vendor sets their own prices directly, // with a server-checked floor. r.path("inventory.$vendorId.$produceId.pricePerKg") .view(r.everyone) .alter(r.all( ({ avatar, params }) => avatar.persistentId === params.vendorId || err_market.fromId("not_your_stall"), r.serverChecked(({ next, ctx }) => next >= ctx.priceFloor || err_market.fromId("below_floor")), )), ], ``` ## Path patterns A pattern addresses a set of leaves in the state shape. Patterns are resolved against the schema **at the type level**, so the rule functions get precise `params`, `prev` and `next` types. | Pattern piece | Matches | |---|---| | `config.open` | One exact leaf. | | `config.*` | Every child under `config`. | | `presence.$avatarId` | Each entry of a `t.record` — binds the key as `params.avatarId`. | | `players.$playerId.{x,y}` | A brace set: just `x` and `y` under each player. | When several rules could match a leaf, the **most specific pattern wins** — and anything no rule matches is **default-deny**, for both viewing and writing. You opt paths *in*, never out. Wildcards (`*`, `**`) need at least one further segment, so they never match the node they hang off — a **wire-leaf** (a primitive, a `t.array`, a `t.schema`) is addressed by its *exact* path: `r.path("terrain")`, not `r.path("terrain.**")`. `defineRealm` throws on patterns that could never match a schema path (a typo'd key, or a wildcard descending into a wire-leaf), so a dead rule fails at definition time instead of silently default-denying its branch. ## View conditions `view` decides which avatars receive a path's values (and its changes). View conditions are **coordinate-only** — they see `{ avatar, params }`, never state — because they're evaluated per leaf when slicing broadcasts: - `r.everyone` — all avatar types (including replica-class avatars). - `r.avatarTypes("vendor", "auditor")` — listed types only. - A plain function `({ avatar, params }) => boolean` — e.g. owner-only: `avatar.persistentId === params.avatarId`. **A view rule's `avatar` is `type` + `persistentId` only — never `instanceId`** (reading it is a compile error). Broadcasts are sliced and cached per *view signature* `(type, persistentId)`, so one avatar's tabs and devices share one encoded payload; a view branching on `instanceId` would be silently mis-sliced across them. Per-instance visibility isn't representable by design. (Alter rules run per write and *do* see the full identity including `instanceId`.) **A hidden branch's bytes never leave the server.** Broadcasts are sliced per view signature and encoded once per audience group — clients don't "receive and ignore" hidden state; they never receive it. ### Owner visibility is a state-shaping pattern Want each consumer to see only *their own* orders while vendors see the orders *for their stall*? Don't reach for clever conditions — shape the state so the coordinate is in the path, and denormalise per audience inside the intent that writes both: ```ts state: { // keyed by the viewing coordinate — the view rule is then just an owner check ordersByConsumer: t.record(t.id("consumerId"), /* … */), ordersByVendor: t.record(t.id("vendorId"), /* … */), } ``` ## Alter conditions `alter` decides who may **directly write** a path with `realm.update()` (intents bypass alter rules — they're trusted server code gated by their own `allow` list): - `r.serverOnly` — only `engine.update()` (server code) writes here. - A plain function `({ avatar, params, prev, next, state, basisVersion }) => true | NiceError` — runs on the client as prediction *and* on the server as authority. Return `true` to allow, or a [NiceError](/nice-error/domains/) to reject with a typed reason. - `r.serverChecked(fn)` — a check that needs server-only facts: the injected `ctx`, or the log-backed CAS helper `touchedSince(basisVersion)`. **Client prediction auto-passes it** — this is exactly the slice of optimism that can roll back. - `r.all(...conds)` — compose: all must pass, the first NiceError rejects. Because plain alter functions run in prediction, a disallowed write **rejects locally and instantly** — the rejection surfaces on the settle handle and in `onMutationRejected`, and nothing hits the wire. ### Compare-and-set with `touchedSince` For the rare direct write that must not clobber a concurrent edit, a `serverChecked` condition can consult the patch log: ```ts r.serverChecked(({ basisVersion, touchedSince }) => !touchedSince(basisVersion) || err_market.fromId("concurrent_edit")) ``` `basisVersion` is the confirmed version the writer composed against; `touchedSince` asks "was this path modified after that?" If routinely you need this, the mutation probably wants to be an [intent](/nice-realm/writes-intents/) instead. ## Replicas are an avatar rule A Worker (or any server-side process) holding a live read copy of a realm is **just another avatar type** — one whose view rules say "everything" (or a partial slice), connected with `connectRealmReplica`. No special authority, no second code path; the same rules decide what it sees. See [Connecting & React](/nice-realm/connecting/#replicas). --- # Security Source: /nice-realm/security Description: A realm inherits its connection's security level and can require a minimum — declared once, enforced on both ends. Plus the caveats that matter- at-rest storage, wire-visible rejections, replay, and TOFU. A realm carries **avatar data** — identities, positions, presence, whatever your state tree holds — and it moves that data over your app's connection. So a realm's confidentiality and authenticity are exactly the connection's: the realm rides the same secured socket the action plane uses, negotiated **once** at the handshake. There is no separate realm crypto to configure. That means everything in [Security Levels](/nice-wire/security/) applies verbatim — the three levels (`none` / `authenticated` / `encrypted`) — as does [Identity & Trust](/nice-wire/identity/): how `authenticated` proves identity with a signed challenge, and the trust-on-first-use key pinning. Read those first; this one is only what the realm layer adds on top. ## Declare a minimum: `security` A realm can **require** a floor. The connection is where the level is chosen, but a realm shouldn't have to trust that every caller chose well — so it declares the least it will accept, and both ends enforce it: ```ts title="shared/gameRealm.ts" export const gameRealm = defineRealm({ id: "game_realm", security: "encrypted", // "authenticated" (default) | "encrypted" avatars: { /* … */ }, state: { /* … */ }, rules: (r) => [ /* … */ ], }); ``` Two members, no `"none"`: a realm always requires at least an **identity-bound** (`authenticated`) connection — an unauthenticated realm has no idea who is writing, which no rule could survive. `authenticated` is the default, so a realm that says nothing already refuses a plain connection. The declaration is **not** part of the schema hash — it's a policy floor, not a wire-compat fact, so raising or lowering it never forces a client migration. ### Enforced on both ends, fail-closed The floor is checked twice, independently: - **The server rejects an under-secured attach.** A `hello` arriving on a connection below the floor is answered with an `insufficient_security` rejection and **no hydrate** — no avatar data leaves the server for a connection that can't carry it at the required level. This holds across a hibernation wake too: the reconstructed host re-reads the connection's negotiated level from the persisted socket attachment and enforces the floor identically. - **The client refuses to send.** Because the server "rejecting" still means the client already *sent* its `hello` (identity, and then its writes), a misconfigured client is stopped locally *before the first frame*: `connectRealm` / `connectRealmReplica` fail the attach — `whenLive()` rejects, `attachError` carries `insufficient_security`, and **not one realm frame is emitted onto the wire**. This is what keeps avatar data from leaving the device as app-layer plaintext when someone points an `encrypted` realm at an `authenticated` connection by mistake. ### Overrides only raise, never lower You can tighten the floor for a particular deployment or connection without editing the definition: ```ts // Server: raise the floor this host enforces (e.g. a PII deployment of a shared realm). hostRealm(gameRealm, { security: "encrypted", resolveAvatar, engine }); // Client: raise the bar this connection asserts before its first hello. connectRealm(gameRealm, { avatar, connection, security: "encrypted" }); ``` Both are clamped to **≥** the definition's declared level — an override can only *raise* the requirement, never weaken it. The two sides are enforced independently: a `hostRealm` floor above what a client checked still rejects at the server (a client validating only the realm's declared minimum would have sent), so the server's floor is a real backstop, not advice. ### One connection, many realms — take the max Several realms can share one connector (one handshake, one identity). A connection's security level is fixed at the handshake and **cannot be upgraded while live**, so establish the connection at the **maximum** of every realm's minimum it will carry. A realm whose floor exceeds the connection's negotiated level is a hard `connectRealm` / `connectRealmReplica` failure — never a silent downgrade or a renegotiation. ### Prefer `encrypted` for replicas A [replica](/nice-realm/connecting/#replicas) is the highest-value connection in the system: its avatar type's rules typically grant it a broad — often "everything" — view, so its socket carries the most state. Treat it accordingly and require `encrypted` for replica connections handling anything sensitive, even when your client realm runs `authenticated`. ## Caveats worth internalizing ### Transport security is not at-rest encryption `encrypted` seals every frame **in flight**. It says nothing about storage. A realm served on a Cloudflare Durable Object keeps its authoritative state in the DO's SQLite, which is **cleartext at rest**. For state that must not persist in the clear: - prefer [`persistence: "ephemeral"`](/nice-realm/serving/) — nothing is written to disk; the realm lives only in memory and cold-starts from genesis (pair it with [`assertOnAttach`](/nice-realm/connecting/#assertonattach--state-you-must-own-while-attached) so clients re-assert their entries on a wake); - or `migrate: "wipe"` plus a short `expireAfterIdleMs`, so an idle realm's storage is reclaimed quickly rather than lingering. The principle: minimize what sensitive data is persisted and for how long. Field-level encryption of specific values (before they enter the state tree) is an app concern — the library encrypts the pipe, not your columns. ### Rejections are wire-visible — never put secrets in an error A rule or intent rejection is sent to the client as the error's public JSON (`toJsonObject()` — its id, **message, and context**). The engine never serializes your `serverContext`, but it faithfully sends whatever *you* put in the error. So a rule error's message and context — and, like any `@nice-code/error`, its stack — are readable by the client. Treat them like the client bundle: don't embed server secrets, internal ids, or `ctx` values you wouldn't hand to the user in a rejection. ### `authenticated` is trust-on-first-use, not a CA Identity pinning happens the **first** time two peers meet and is enforced on every subsequent connection — strong against an outsider who never held the key, but it is not certificate-authority-grade PKI. If first contact happens over a hostile network, the pin is of whoever answered. This is the same TOFU model [Identity & Trust](/nice-wire/identity/#trust-on-first-use-tofu) describes; it's called out here because a realm's whole authority model rests on the avatar identity that pin establishes. ### Replay is neutralized — but keep intents idempotent The engine guards against replayed and duplicated frames: a resent `write` (same client sequence) is **re-acknowledged, not re-applied**, and that guard survives reconnects and hibernation wakes. You don't implement replay protection. You *should* still make intents naturally idempotent, because at-least-once delivery means a **retry after a lost ack** can legitimately re-present an intent the server already applied. The pattern is a client-generated id the apply keys on: ```ts // Client mints the id; a retry re-sends the SAME id. realm.intents.purchase({ purchaseId: newId(), itemId }); ``` ```ts // Server apply: already applied ⇒ commit as a no-op (no double-spend). apply: ({ state, avatar, input }) => { if (state.orders[avatar.persistentId]?.[input.purchaseId] != null) return true; // …charge, decrement stock, write the order under input.purchaseId… }, ``` An intent that isn't idempotent turns a dropped ack into a double effect — a correctness *and* security problem (double-charge, double-grant). See [Writes & Intents](/nice-realm/writes-intents/). ### Record keys and state size Two more the engine handles or delegates: - **Prototype-pollution keys** (`__proto__`, `constructor`, `prototype`) can never be dynamic record keys — the engine rejects a frame (or an intent) that tries, on both apply and client projection. Nothing to do. - **State size is bounded per node, not in total.** Ingress limits cap any single frame, and declared schema bounds — `t.record(…, { maxKeys })`, `t.string({ maxLength })`, `t.array(…, { maxItems })` — cap each dimension of growth, enforced against the candidate next state (so incremental adds stop exactly at the cap). There is still no total `maxStateBytes` knob: bounds cap dimensions, not their product. If a record's key space is attacker-influenced, cap it — declaratively, or in a rule when the ceiling needs context. ## The short version - A realm inherits its connection's level; declare a **minimum** with `security` (default `authenticated`; no `none`). - The floor is enforced **both ends, fail-closed** — the server refuses to hydrate, the client refuses to send. Overrides only raise it. - One connection carries realms at the **max** of their minimums; a shortfall is a hard failure, not a downgrade. - `encrypted` is in-flight only — mind **at-rest** storage (ephemeral/wipe for sensitive state), keep secrets **out of rejections**, and make intents **idempotent**. --- # Serving a Realm Source: /nice-realm/serving Description: 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. ## Cloudflare Durable Objects 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`](/nice-wire/connection/) — the server counterpart of [`createWireClient`](/nice-realm/connecting/#realm-only-apps--createwireclient) — with **no `@nice-code/action` import, no `ActionRuntime`, and no channel**: ```ts title="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): ```ts title="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](/nice-realm/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"`). ### Boot-time validation 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). ### Schema evolution 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: ```ts 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`](/nice-realm/connecting/#assertonattach--state-you-must-own-while-attached) on the client and a schema change costs a blink, not a migration. ### Ephemeral realms (presence) Why persist a patch log for cursor positions? A presence-shaped realm can skip storage entirely: ```ts 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`](/nice-realm/connecting/#assertonattach--state-you-must-own-while-attached) — 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). ### Idle-TTL storage GC (per-entity realms) 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: ```ts 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: ```ts 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.) #### Upgrading from ≤ 0.49 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. #### When a deadline's handler fails 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](https://developers.cloudflare.com/durable-objects/api/alarms/). 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`. ## The engine `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: | Option | What it does | |---|---| | `ctx` | The server-only context (secrets live here — never in the definition). Reaches `serverChecked` rules and intent `apply` bodies. | | `initialState` | Genesis — a value or `(info) => state`, run exactly once per instance, before the first attach. | | `onAvatarAttach` / `onAvatarDetach` | An 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. | | `onMutation` | Fires **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. | | `limits` | Ingress limits (below). | | `logWindow` | Versions 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. ## Serving outside Cloudflare `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 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: ```ts title="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 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](/nice-realm/security/). ## Ingress limits Every connection is guarded by per-realm limits (defaults shown); breaches reject with `limits_exceeded`, and a connection exceeding `maxBreachesBeforeDetach` is forcibly detached: | Limit | Default | Guards | |---|---|---| | `maxPatchesPerFrame` | 64 | Ops in one write frame. | | `maxFrameBytes` | 64 KiB | Encoded size of one inbound frame. | | `maxRecordKeyLength` | 128 | Dynamic record-key length (also protects the intern dictionary). | | `maxExpandedLeaves` | 256 | Leaves one subtree write may expand to. | | `writeRatePerSecond` / `writeRateBurst` | 100 / 200 | Token-bucket write rate per connection. | | `maxBreachesBeforeDetach` | 10 | Strikes 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](/nice-realm/connecting/#limits-are-advertised-and-livable). --- # Testing Realms Source: /nice-realm/testing Description: createTestRealm — a deterministic in-memory world for testing rules, intents, predictors, and rollback UX with no network and no Cloudflare. `@nice-code/realm/testing` runs the whole loop — engine, acceptor, any number of clients and replicas — over a zero-network loopback, deterministically. Your rules, intents, predictors, and rollback UX get real tests with no sockets and no Cloudflare: ```ts title="marketRealm.test.ts" import { createTestRealm } from "@nice-code/realm/testing"; const world = createTestRealm(marketRealm, { ctx, initialState }); const alice = world.asAvatar({ type: "consumer", persistentId: "alice" }); alice.realm.update((draft) => { draft.presence.alice = { cursorX: 1, cursorY: 1 }; }); await world.settleAll(); expect(world.engine.state.presence.alice).toBeDefined(); expect(alice.realm.store.state).toEqual(world.serverSlice(alice.avatar)); ``` ## The world `createTestRealm(realm, options)` takes `ctx`, `initialState`, and optionally `limits`, `logWindow`, and `pendingExpiryMs` (handy for making expiry tests fast). It returns: | Member | What it does | |---|---| | `world.engine` | The real `RealmServerEngine` — assert on `engine.state` directly. | | `world.asAvatar({ type, persistentId }, connectOptions?)` | A connected avatar handle: `{ realm, avatar, rejections, … }`. The second argument passes per-avatar connect options through (`assertOnAttach`, `coalesce`/`coalesceMs`, `pendingExpiryMs`, `staleProbeAfterMs`) — so presence asserts and durability scenarios test exactly as they ship. | | `world.asReplica({ type, persistentId })` | A connected replica handle. | | `world.update(fn)` | Server-authored mutation (`r.serverOnly` paths) — sugar for `engine.update`. | | `world.settleAll()` | Drain the coalescing microtask + settle the round-trips. | | `world.tick()` | Advance the scripted-latency clock (below) one tick — queued server→client frames whose delay elapsed deliver now, in order. | | `world.serverSlice(avatar)` | **The oracle**: the server's current sliced view for an avatar. | That last one is the assertion that matters most — after any sequence of writes settles, every client's projection should equal its `serverSlice`: ```ts expect(alice.realm.store.state).toEqual(world.serverSlice(alice.avatar)); ``` If those two ever diverge after `settleAll()`, something is wrong — in your rules, your predictor, or (tell us!) the library. ## Observing the optimistic window Delivery is normally synchronous-ish (writes flush on a microtask). To *see* the pending state, park the server's replies: ```ts alice.holdServerFrames(); // server → client frames queue up const h = alice.realm.update((d) => { d.presence.alice.cursorX = 5; }); expect(h.status).toBe("pending"); // optimistic: applied locally, unconfirmed expect(alice.realm.pending.count).toBe(1); alice.pump(); // deliver everything parked await h; // read-your-writes: store is authoritative now expect(h.status).toBe("confirmed"); ``` ## Scripted latency — hold a client in the past For netplay work — prediction, reconciliation, interpolation — you need a client that observes authoritative state **N ticks late**, deterministically. `setServerFrameLatency(n)` queues every server→client frame for that avatar; each `world.tick()` advances the clock one tick and delivers whatever came due, in wire order: ```ts bob.setServerFrameLatency(2); // bob now lives 2 ticks in the past alice.realm.update((d) => { d.presence.alice = { cursorX: 1, cursorY: 1 }; }); await world.settleAll(); expect(world.engine.state.presence.alice).toBeDefined(); // the server is current… expect(bob.realm.store.state.presence.alice).toBeUndefined(); // …bob hasn't seen it world.tick(); world.tick(); // two ticks later, the broadcast arrives expect(bob.realm.store.state.presence.alice).toBeDefined(); ``` Ordering is that of a real (TCP-like) link: lowering the latency mid-test never lets a new frame overtake a queued one, and `holdServerFrames()` composes on top (a due frame parks; `pump()` releases it). Client→server delivery stays immediate — the scripted lag is the *downlink*, which is where reconciliation bugs live (see [Realtime Games](/nice-realm/realtime-games/) for what to do with it). ## Rejections and rollback Each test client collects its rejections, so predictor-vs-authority tests are one array assertion: ```ts const mallory = world.asAvatar({ type: "consumer", persistentId: "mallory" }); mallory.realm.update((d) => { d.presence.alice.cursorX = 99; }); // not hers await world.settleAll(); expect(mallory.rejections).toHaveLength(1); expect(mallory.rejections[0].rolledBackPaths).toContain("presence.alice.cursorX"); // and the rollback held: expect(mallory.realm.store.state).toEqual(world.serverSlice(mallory.avatar)); ``` (With a plain — non-`serverChecked` — alter rule this rejects **locally** before any frame is sent; the shape of the test is the same either way.) ## Scripting the reconnect matrix `disconnect()` / `reconnect()` drop and restore a client's link — frames in flight are lost, exactly like the real thing: ```ts alice.disconnect(); world.update((d) => { d.config.open = false; }); // happens while she's away alice.reconnect(); await world.settleAll(); // she resumed via patch replay and converged: expect(alice.realm.store.state).toEqual(world.serverSlice(alice.avatar)); ``` This is the harness the library's own suite is built on — reconnect/replay, rule enforcement, intent idempotency, pending-expiry, and rollback behavior are all expressible in a few lines each. --- # Writes & Intents Source: /nice-realm/writes-intents Description: The realm write contract — LWW direct writes vs atomic server-side intents, settle handles, and choosing between them. This is the one page to internalise before shipping a realm. There are exactly two ways state changes from a client, and picking the wrong one is the classic way to lose a decrement. ## Direct writes: absolute-set, last-writer-wins `realm.update()` is an optimistic direct write with **absolute-set semantics**: the server applies your literal values in arrival order. ```ts realm.update((draft) => { draft.presence[me].cursorX = 412; }); ``` "Cursor is at 412" is a perfect direct write — the newest value is simply the truth. But **read-modify-write composed locally races**: ```ts // ❌ WRONG — two clients doing this concurrently lose one decrement, silently. realm.update((draft) => { draft.stock[itemId] -= 1; }); ``` Both clients read `5`, both send "stock is 4", the server applies both — stock is `4`, not `3`. No error, no warning: each write was individually legal. Anything read-modify-write, multi-path atomic, or dependent on hidden state is an **intent**. ### Coalescing — don't hand-roll a throttle Direct writes **coalesce**: successive updates inside the same microtask merge into one wire frame, deduped per path — pointer-frequency writing is cheap by default. For hover/cursor-grade streams, widen the window with the `coalesceMs` connect option instead of writing your own throttle timer: ```ts connectRealm(gameRealm, { // … coalesceMs: 120, // pointer-frequency writes batch into one frame per 120 ms }); ``` `realm.update((d) => { d.hover = pos; })` on every pointermove is then already rate-shaped — the library dedupes per path inside the window, so only the newest value travels. The `coalesceMs` window is **trailing-edge**: the first write of a window arms a timer, and the merged frame ships when the window *closes* — nothing is sent on the opening write. For a continuous stream that's the right merge point (a cursor emitting all window long loses nothing by waiting), but it means **a lone write pays the full window as pure added latency**: a keyboard *transition* (`accel = true` on keydown, seconds after the last write) waits out the entire `coalesceMs` before it travels. Match the edge to the duty cycle: ```ts connectRealm(gameRealm, { // … coalesce: { ms: 50, leading: true }, }); ``` With `leading: true` the window becomes a classic leading+trailing throttle: a write arriving with **no open window sends immediately**, then a merge window opens behind it as a cooldown — a burst still coalesces (the merged remainder flushes once at the window's end), and sends stay capped at `1000 / ms` per second, but an isolated transition no longer pays the window as latency. That's the shape for a connection carrying both duty cycles at once, a held-key stream *and* transition events. (`coalesceMs: 120` stays as the trailing-edge shorthand for `coalesce: { ms: 120 }` — right when *everything* on the connection is a continuous stream.) For a single latency-critical lane on an otherwise-windowed connection there's also the per-write escape hatch: ```ts realm.update((d) => { d.inputs[me].brake = true; }, { flush: "immediate" }); ``` It flushes any buffered coalescing writes first (compose order is preserved, exactly like an intent) and then ships its own frame at once. And if a path writes event-shaped transitions only, the simplest correct setting is still no window at all — the microtask-only default merges same-tick writes at zero added latency. Building a game? The full latency playbook — input lanes, prediction, interpolation, measurement — lives at [Realtime Games](/nice-realm/realtime-games/). The server has a mirror-image knob for the *downlink*, `broadcastCoalesceMs`. It defaults to `0`, and a game realm should leave it there — it exists for chatty non-game realms where per-message cost outweighs latency, not as a pair for the client window. ### Durable writes: `durability: "reassert"` The default optimistic contract expires an unanswered write after `pendingExpiryMs` and rolls it back — right for presence and cursors, wrong for a client that is the **authoritative producer** of a region (a game host streaming an append-only input log: the data exists nowhere else, so "roll it back" means "lose it"). That role gets the durable tier: ```ts realm.update((d) => { for (const ev of batch) d.log[ev.seq] = ev; }, { durability: "reassert" }); ``` A reassert write never expires: it is rebuilt and resent on every reattach — including across a full rehydrate or a server epoch change — until the server confirms it. **Authority still wins**: a rules rejection settles it immediately, durable or not; durability is about *link* failures, never about overriding the server. Two usage rules: - **One tier per path region.** Reassert frames only coalesce with other reassert frames — keep the producer's paths (the log) durable and everything else default, and never write one path with both tiers. - **The backlog is bounded.** Past ~256 unconfirmed reassert frames (an outage measured in minutes), further reassert writes reject with `durable_backlog_overflow` — the cue to fall back to re-syncing from your own source of truth after reconnecting, instead of queueing more. Intents have no durable tier by design: a durable, data-answering exchange is the **action plane** — that's [reliable delivery](/nice-action/reliable-delivery/). See [Reconnects & durability](/nice-realm/connecting/#reconnects--durability) for the full reconnect lifecycle. ## Intents: named, atomic, server-side An intent is a named mutation whose `apply` body runs **on the server, atomically, against the full authoritative state**: ```ts title="shared/marketRealm.ts" intents: (i) => ({ purchase: i.define({ allow: ["consumer"], input: t.object({ purchaseId: t.string(), itemId: t.string() }), // Optional client-side prediction on the caller's own view. optimistic: ({ view, avatar, input }) => { /* … */ }, apply: ({ state, avatar, input, ctx }) => { // Idempotent retry: already applied ⇒ commit as a no-op. if (state.purchases[avatar.persistentId]?.[input.purchaseId] != null) return true; if (state.stock[input.itemId] < 1) return err_market.fromId("sold_out"); state.stock[input.itemId] -= 1; // authoritative read-modify-write state.purchases[avatar.persistentId][input.purchaseId] = { itemId: input.itemId }; return true; // or a NiceError — draft discarded entirely }, }), }), ``` ```ts title="client.ts" const handle = realm.intents.purchase({ purchaseId: newId(), itemId }); await handle; // then read the store at your client-supplied key ``` The pieces: - **`allow`** — the avatar types that may call it. Together with the `input` schema, this is the security boundary; the `apply` body itself is trusted code and bypasses view/alter rules. - **`input`** — a `t.*` schema, validated on the way in. Give entities **client-supplied ids** (`purchaseId: newId()`) so retries are idempotent and the client knows where to read the outcome. - **`optimistic`** *(optional)* — a prediction run on the caller's own view; its patches join the pending overlay like a write. A predictor that throws is treated as absent — prediction must never break the write. - **`apply`** — an [Immer](https://immerjs.github.io/immer/) draft of the full authoritative state. Return `true` to commit, or a NiceError to reject — the draft is then **discarded entirely, never partially applied**. Intents are **settle-only**: no data result comes back. Outcomes live in state — `await` the handle, then read the store at the keys you supplied. If you need a data *response*, that's a [nice-action action](/nice-action/defining-actions/). ### Presentation lags authority Because an intent commits atomically, everything it touched lands in **one client flush**. That is what you want for consistency — and exactly what will spoil an animation: a shot that ends the match delivers the replay payload (`lastShot`) *and* `meta.status = "finished"` together, so a component that renders status directly announces the outcome before the scene has animated the cause. Gate outcome UI (winner overlays, end-of-round screens) on your presentation layer's own progress — an `animating` flag, a scene event — not straight on the authoritative field. The state plane says what is *true*; your scene decides when to *show* it. ## Settle handles Every write API returns an `IRealmWriteHandle` — a **lazy thenable**: - Safe to ignore: a fire-and-forget cursor write produces zero unhandled-rejection noise. - `handle.status` (`"pending" | "confirmed" | "rejected"`) and `handle.error` for imperative checks. - **Read-your-writes**: after `await handle`, the store already reflects the authoritative outcome — confirmed values are in, rejected values are rolled back. Rejections also fan into the handle-level `onMutationRejected` callback with the rolled-back paths — the "toast + highlight what snapped back" hook: ```ts connectRealm(gameRealm, { // … onMutationRejected: ({ error, kind, rolledBackPaths }) => toastRollback(error, rolledBackPaths), }); ``` A pending write that the server never answers (link trouble) expires after `pendingExpiryMs` (default 10 s) and rolls back — the optimistic overlay can't get stuck. The dial is per-handle: raise it freely on a handle whose UI doesn't render from realm state (a game host drawing from its local sim), or skip expiry entirely for producer regions with [`durability: "reassert"`](#durable-writes-durability-reassert). (This is a different dial from nice-action's `reliableActionTimeout`, which is a redelivery deadline for at-least-once actions.) Within the window, a disconnect does **not** expire anything — pending writes resend automatically on reattach; see [Reconnects & durability](/nice-realm/connecting/#reconnects--durability). ### Split flushes If a coalesced flush exceeds the server-advertised `maxPatchesPerFrame`, the client **splits** it into consecutive frames. Each part stays atomic on the server and all handles settle with the last part — read-your-writes holds — but a split flush is no longer atomic *as a whole*. If a group of paths must change together atomically, put them in one intent. ## Choosing, at a glance | Situation | Use | | --- | --- | | High-frequency absolute-set values (cursor, position, a toggle you own) | `realm.update` | | Read-modify-write, counters, balances, multi-path invariants | intent | | An append-only region you alone produce (event/input logs) | `realm.update(fn, { durability: "reassert" })` | | "My entry must exist while I'm attached" (presence) | [`assertOnAttach`](/nice-realm/presence/) | | Needs a data response back | a [nice-action](/nice-action/defining-actions/) action | | Rare single-path write that must not clobber concurrent edits | `realm.update` + `touchedSince` CAS in a [`serverChecked` rule](/nice-realm/rules/#compare-and-set-with-touchedsince) | | High-stakes / low-confidence mutation | intent **without** `optimistic` — await the handle (spinner UX, zero rollback risk) | | Server-driven mutation (ticks, fulfilment) | `engine.update()` on the [server](/nice-realm/serving/) |