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({ maxLength? }) / t.number({ min?, max?, integer? }) / t.boolean() | Primitive leaves, optionally bounded. |
t.literal(value) | An exact value (t.literal("open")). |
t.union(a, b, …) / t.union([a, b, …]) | One of several nodes (spread or a single array). |
t.optional(inner) | May be absent (deletable). |
t.object({ … }) | Fixed keys, each its own node. |
t.record(t.id("varName"), value, { maxKeys? }) | A dynamic keyed collection, optionally capped. The t.id name binds $varName in rule paths. |
t.array(item, { maxItems? }) | An array — an atomic leaf on the wire (replaced whole). Prefer keyed records. |
t.schema(valibotSchema, { hashTag? }) | Embed any Standard Schema validator at a leaf. |
t.serverContext<TCtx>() | Declares the server-only context type (not part of state). |
Four of these deserve emphasis:
-
Declared bounds are hashed and enforced state-aware.
maxLength(UTF-16 code units), numericmin/max/integer,maxItems, andmaxKeysall contribute to the schema hash (changing a bound is a shape change — both ends must agree) and are enforced by the server on every path a value can enter state: direct writes, intentapplyresults, serverupdate(), and the boot/restore/migration walk.maxKeysis checked against the candidate next state, so a record filled one individually-valid entry at a time still stops exactly at its cap — and a remove+add in one frame nets out. Writers get fast client-side feedback where the projected view allows it, but a sliced view can hide record entries, so the server’s check is the authoritative one and an optimistic prediction may roll back on rejection. -
Prefer
t.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 — unless you tag it. Embedding a Valibot/Zod validator gives you rich value validation at a leaf, but editing the embedded schema does not change the realm’sschemaHash— treat embedded-validator changes as value-rule changes, not shape changes. To opt a leaf’s contract into the attach gate, passt.schema(v, { hashTag: "turn-metadata-v1" })and bump the tag on every contract change — the hash then moves with it, and stale clients are rejected at attach instead of failing later on data they can’t parse. ⚠ Adding (or bumping) a tag moves a persisted realm’s hash: a Cloudflare-hosted realm must ship amigrate(ormigrate: "wipe") in the same deployment. -
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.