Connecting & React
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 below), or with connectChannel when the app also has actions and both planes share the one socket:
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 Storerealm.update((draft) => { draft.players[me].x = 412; }); // optimistic; returns a settle handlerealm.intents.purchase({ purchaseId: newId(), itemId }); // settle-only server mutationrealm.pending // { count, has(prefix), subscribe }realm.store is a regular @nice-code/state Store 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; 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
Section titled “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):
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 pipecreateWireClient 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.)
With actions in play the first action call dials implicitly; a realm-only app calls client.connect() itself.
Status & liveness
Section titled “Status & liveness”await realm.whenLive(); // resolves on first hydrate; rejects on attach failure/timeoutrealm.status; // "connecting" | "live" | "detached"realm.version; // the confirmed authoritative versionrealm.subscribeStatus((status) => …); // fires on EVERY transition, incl. quiet link dropsrealm.detach(); // leave: sends detach, expires pending writesAn 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
Section titled “assertOnAttach — state you must own while attached”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 guide is built around it.
Reconnects & durability
Section titled “Reconnects & durability”What actually happens to your writes when the link drops — nothing here needs app code:
- 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.
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 andonMutationRejectedfires 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. Aframe_too_stalerejection 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.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.assertOnAttachcovers 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.- 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, with its own 10-minute-class redelivery deadline.
Who re-dials the link — nothing here needs app code either
Section titled “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
Section titled “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
Section titled “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:
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
Section titled “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)
Section titled “The patch feed (imperative renderers)”React re-renders from the store — but a canvas, a typed array, a WebGL scene wants deltas, not snapshots:
realm.listenToPatches((patches) => board.applyPatches(patches));The feed emits the projected-store delta per flush, in name-space immer patch 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
Section titled “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
Section titled “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 —
// ✗ allocates a fresh {} every call while state is still empty → infinite render loopconst 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) —
const EMPTY_PRESENCE: TView["presence"] = {};// ✓ same reference every call → the hook's strict-equality fast path holdsconst 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
Section titled “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:
- Render off a narrow subscription, not the whole view.
useRealmValue(realm, (v) => v.presence)(orrealm.store.subscribe/listenToPatchesfor 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. - Batch the writes to the render cadence — typically one
requestAnimationFrame: buffer the latest value in a ref and flush it as a singlerealm.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
Section titled “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
Section titled “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 grant:
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 verdictreplica.intents.kick({ playerId }); // typed intents — the same mapped object as the client handleIntents 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.