Defining a Realm
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:
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 withserverContext: t.serverContext<MyCtx>(), and it becomes reachable only insider.serverChecked(...)rules and intentapplybodies. Nothing in the definition module itself ever holds a secret.
The t schema builder
Section titled “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<TCtx>() | Declares the server-only context type (not part of state). |
Three of these deserve emphasis:
- Prefer
t.recordovert.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 exactlyterrain, so the rule isr.path("terrain")—r.path("terrain.**")could never match anything (wildcards need at least one further segment), anddefineRealmthrows 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 — writestate.terrain = nextso the code says what the wire does.
- Rule an array with its exact path. Patches and visibility for
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’sschemaHash— treat embedded-validator changes as value-rule changes, not shape changes.- Record keys are strings on the wire. Writing
d.log[ev.seq] = evwith a numericseqworks (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
Section titled “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 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:
{ type: "vendor", persistentId: "v_42", instanceId: "tab_1" }type— which avatar kind, from youravatarsblock.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
Section titled “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
migratehook (Serving a Realm) — 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
Section titled “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":
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 for the whole model — effective minimums, per-connection/per-host overrides, and the at-rest and wire-visibility caveats.