Skip to content

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:

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<MyCtx>(), and it becomes reachable only inside r.serverChecked(...) rules and intent apply bodies. Nothing in the definition module itself ever holds a secret.

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.

BuilderWhat 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.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.

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 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.

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) — 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.

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.