Defining a Realm
How to declare a realm with defineRealm: the state schema, avatar types, server context and the schema hash.
You declare a realm once, in shared code, with defineRealm. The client and the server import the same definition, so the types, the wire format and the rules all come from one block and cannot drift apart:
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 is passed in at serve time, through
createRealmServerEngine(realm, { ctx }). Here you declare only its type:serverContext: t.serverContext<MyCtx>(). The context is then reachable only insider.serverChecked(...)rules and intentapplybodies, so the definition module itself never holds a secret.
The t schema builder
Section titled “The t schema builder”You describe the state shape with t.* nodes. From that one declaration the library derives the TypeScript state type, the schema hash, the server-side value validator and a compact wire encoding (keys travel as small integers, not strings).
| 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 need a closer look:
-
Declared bounds are part of the hash, and the server enforces them everywhere.
maxLength(UTF-16 code units), numericmin/max/integer,maxItemsandmaxKeysall feed the schema hash. Changing a bound is a shape change, so both ends must agree. The server enforces them on every route a value can take into state: direct writes, intentapplyresults, serverupdate(), and the boot/restore/migration walk.maxKeysis checked against the candidate next state, so a record filled one valid entry at a time still stops exactly at its cap, and a remove+add in one frame nets out. Clients reject early where their projected view allows it. A sliced view can hide record entries, though, so the server’s check is the authoritative one and an optimistic prediction may roll back on rejection. -
Prefer
t.recordovert.array. A record is a keyed collection where each entry is its own path. Two clients editing different entries never conflict, and rules can address entries by key (inventory.$vendorId.…). An array is a single wire-leaf: the whole array is replaced and resent on every change. That has two consequences:- 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"). Wildcards need at least one further segment, sor.path("terrain.**")could never match anything, anddefineRealmthrows on such dead patterns. - Replace, don’t mutate, in intents. An element write in the draft (
state.terrain[i] = v) commits as a whole-array replace anyway. Writestate.terrain = nextso the code says what happens.
- 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 that validator does not change the realm’sschemaHash. Treat such edits as changes to value rules, not to the shape. If you want a leaf’s contract to count at attach, 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 because JS coerces it, but hydrated keys come back as strings. When entry order matters, as in 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. It is what the rules judge, and what each client’s view is sliced for. It is deliberately not called a “client”, because the network client (the app that dials in) is a different idea. One connecting process can present different avatars to different realms, and a server-side Worker can hold an avatar too, which is what a replica is.
avatars names the kinds your realm admits, such as player, spectator, vendor or an internal replica, and you write rules 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 it defaults from the authenticated handshake coordinate.instanceId— one of possibly several live connections for that identity (tabs, devices).
Rules receive this ref as { avatar, params }. That 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 silently corrupt the client’s projection.
- 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), so persisted state is never silently reinterpreted.
So ship client and server together when the state shape changes, 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 and the client refuses to send. It is not part of the schema hash, so changing it never forces a migration. See Security for the whole model, including effective minimums, per-connection/per-host overrides, and the at-rest and wire-visibility caveats.