# @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. ## 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. ## 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()` / `t.number()` / `t.boolean()` | Primitive leaves. | | `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)` | A dynamic keyed collection. The `t.id` name binds `$varName` in rule paths. | | `t.array(item)` | An array — **an atomic leaf on the wire** (replaced whole). Prefer keyed records. | | `t.schema(valibotSchema)` | Embed any Standard Schema validator at a leaf. | | `t.serverContext()` | Declares the server-only context *type* (not part of state). | Three of these deserve emphasis: - **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.** 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. - **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. - **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. 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"), ), ] ``` A `maxStateBytes` engine knob may arrive later; until then the cardinality rule **is** the recipe, and it's strictly more expressive — you cap by the dimension that actually matters (entries per record), not by an opaque byte ceiling. For anything attacker-influenced (a key space a client can grow), a cap like this 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 — 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. --- # 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 | 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. **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 frame, not cumulatively.** Ingress limits cap any single write; they do not cap how large the authoritative tree grows across many legitimate writes. Bounding writable-record **cardinality** — a ceiling on entries per record — is an app rule today (there is no `maxStateBytes` engine knob). If a record's key space is attacker-influenced, cap it in a rule. ## 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`, `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.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"); ``` ## 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. ### 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/) |