Skip to content

Mental Model

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.

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. 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 server owns the state. Clients hold a projection of it — the confirmed authoritative state with their own unconfirmed (optimistic) writes layered on top:

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.

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.

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.

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-code/wire and @nice-code/realm, nothing else.

  • On the server: pass the realm host’s protocol to serveWireDurableObject (realm-only), or to serveDurableObject 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.

This distinction matters enough that it has its own page, 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.