Schema API

Schema options

Pass these as the second argument to defineSchema(definitions, options).

OptionTypeDefaultPurpose
projectstringUnsetPublic project ID, written by irtio init.
rolesreadonly string[]No custom rolesRoles available to clients and visibility rules.
clientRolesbooleanfalseHonour the role a client asks for in joinRoom. See roles.
rpcRPC definitionsNoneTyped remote calls. See RPCs.
messagesMessage definitionsNoneTyped peer messages. See messages.

Collections and singletons

entity(fields, options?) declares a keyed collection: many instances, each under a string id you choose, each with an owner. singleton(fields, options?) declares exactly one instance and no id, which is what you want for the match, the board, or the round clock. See Singletons for the full treatment.

On the server, an entity collection has this API:

state.players.add(id, values, { owner: ctx.clientId }); // add or replace, returns the instance
state.players.remove(id); // true if it was there
state.players.get(id); // the instance, or undefined
state.players.has(id);
state.players.setOwner(id, ownerId);
state.players.ownerOf(id); // owner id, '' for the server, undefined if no such instance
state.players.size;
state.players.ids(); // iterator of ids
state.players.reconcile(entries); // bulk replace: the collection ends up holding exactly these ids

for (const [id, player] of state.players) {
  player.score += 1;
}

reconcile is the one bulk call in that list: it takes the whole collection, not one id, and ids it is not given are removed. To change one record, read it with get(id) and write the instance you get back (state.players.get(id)!.score += 1). See reconcile(entries).

Iteration yields [id, instance] pairs in insertion order, and that order is part of the contract. The server, every client’s view, and a room restored from a snapshot all iterate the same way.

add() fills in anything you leave out that has a .default() or is .opt, and rejects a field name the collection does not declare. Every other field is required, so a half-built instance is a type error rather than a surprise you find later. f32 values are rounded through Math.fround on the way in, so what you read back is what the wire will carry.

On the client the same collections are read-only except for the instances this client owns. See Ownership.

reconcile(entries)

reconcile takes any iterable of [id, values] pairs and makes the collection hold exactly those ids. It is the primitive for a set you recompute wholesale: fires, pings, scorch marks, hitboxes, anything derived from something else every tick.

// state.fires ends up holding exactly the burning tiles, and nothing else
state.fires.reconcile(burningTiles.map((t) => [t.key, { x: t.x, y: t.y, heat: t.heat }]));
CaseWhat happens
Id in entries, not in the collectionadd(id, values), so .default() and .opt fill the rest and a missing required field throws.
Id in bothThe values are assigned onto the existing instance. Fields you leave out keep their value, the owner is untouched, the instance object stays the same one, and only the fields that really changed go on the wire.
Id in the collection, not in entriesremove(id).

Removals happen after the additions, so an id present in entries is never removed and re-added by one call. A Map, an array of pairs, and a generator are all accepted.

The reason to use it over a loop of add() calls is the middle row. add installs a new instance, which marks every field dirty and, on a physics collection, rebuilds the body at its spawn pose. reconcile leaves a row that is still there alone.

reconcile is a method on entity collections. Calling it on a singleton throws.

clients is reserved as a collection name. irtio merges its own presence collection in under it, and you read it as room.clients. See Presence and lifecycle.

Singletons

singleton(fields, options?) declares one instance of a shape, with no id and no owner. Use it for the state a room has exactly one of: the match phase, the round clock, a run seed, door progress.

// irtio/schema.ts
import { defineSchema, enumOf, entity, f32, singleton, str, u32 } from '@irtio/schema';

export const schema = defineSchema({
  run: singleton({
    seed: u32,
    phase: enumOf('lobby', 'descend', 'over'),
    doorsOpened: u32,
  }),
  players: entity({ x: f32, y: f32, name: str(24) }),
});

A singleton is a plain typed object on both sides. Read and write its fields directly, including nested struct and list fields:

state.run.phase = 'descend';
state.run.doorsOpened += 1;

Assigning the whole object at once is also allowed and replaces every field, so anything without a .default() or .opt has to be present:

state.run = { seed: room.seed, phase: 'lobby', doorsOpened: 0 };

Writing a field the schema does not declare throws, and so does delete. Set an .opt field to undefined instead of deleting it.

What a singleton syncs

A singleton rides the same delta path an entity does: only the fields that actually changed are encoded, and an unchanged write (state.run.doorsOpened = state.run.doorsOpened) is not a change. There is one implicit instance, so there is no add, no remove, and no id on the wire. Clients read it as a plain object under the same name.

Restrictions

The options object is the same one entity takes, but several entries describe per-instance ownership, positions, or bodies, which a singleton does not have. Each is refused by name: physics, serverFields, perPlayer, persist, clientCreate and ownership at defineSchema, and visibility: 'owner' and grid at deploy validation:

OptionOn a singleton
serverOwnedAllowed, and the default in practice: a singleton is never client-owned, so clients always read it as frozen.
visibility, roles, interpolateAllowed, except visibility: 'owner'. Role visibility is per collection, so a singleton is visible to a role or it is not.
physicsRefused. Bodies are per instance.
serverFieldsRefused. A client never writes a singleton, so every field is already server-only.
perPlayerRefused. There is no per-player record.
persistRefused. It names fields of a per-player record.
clientCreateRefused. A singleton is never added.
ownershipRefused. There is no per-record owner to transfer.
visibility: 'owner'Refused. A singleton has no owner.
gridRefused with visibility: 'spatial-grid', which needs per-instance positions.

reconcile() is an entity-collection method and throws on a singleton for the same reason: there is no set of ids to diff.

Clients cannot write a singleton. If a client needs to change one of these values, expose an RPC and let the room decide.

Field types

These are all the field types.

TypeWrittenNotes
BooleansboolA value, not a call.
Unsigned integersu8, u16, u32Range checked on write.
Signed integeri32
Floatsf32, f64f32 is rounded with Math.fround.
Stringstr(max)max is a length in UTF-8 bytes.
EnumenumOf('a', 'b', 'c')1 to 256 unique values, encoded as an index.
Referenceref('players')An id in another entity collection. Must name a real one.
Listlist(str(48), 20)Bounded. Whole-replace on the wire, see below.
Bytesbytes(256)A Uint8Array of exactly n bytes, n from 1 to 65535. Sends only the changed byte ranges, see below.
Nested objectstruct({ x: f32, y: f32 })Fields tracked and encoded individually.

bool and the numeric types are values, so you write x: f32, not x: f32(). Everything that takes an argument is a call.

Two modifiers chain onto any type:

name: str(24).default('anon'), // may be omitted at add(); defaults to 'anon'
heldBy: ref('players').opt,    // may be undefined

.opt is a getter, so it is f32.opt and not f32.opt(). .default(v) is a method and it validates v immediately, so a default that does not fit its own type throws where you wrote it. .default(undefined) throws. Use .opt on its own.

Defaults

A field you do not set, and did not give a .default(), starts at its type’s zero value.

TypeValue with no default
boolfalse
u8, u16, u32, i32, f32, f640
str, ref''
enumOf(...)the first declared value
list[]
bytes(n)n zero bytes
structevery field at its own zero
anything .optundefined

enumOf('lobby', 'play', 'over') starts at 'lobby' rather than undefined, so a fresh singleton is already in a valid phase.

Lists are whole-replace

Writing one element of a list sends the whole list. There is no element-level diff. Keep lists short and fixed in size (a hand of cards, a four-option question, a recent-events ticker), and model anything that grows without a bound as an entity collection instead.

Every way of changing a list costs the same, because they all mark the one list field:

snake.body[0] = head;        // whole list
snake.body.unshift(head);    // whole list
snake.body.pop();            // whole list
snake.body = nextBody;       // whole list

Assigning the field also counts as a change when the contents are identical. Unchanged writes are skipped for primitives, not for lists, so a snake.body = computeBody() that runs every tick sends the whole body every tick whether or not the snake moved.

For long lists with few changed elements, use entities keyed by element id so only changed fields are sent. For a sequence reconstructed from movement, consider sending recent moves and a length. See bandwidth budgeting to measure the tradeoff for your game.

Bytes send changed ranges

bytes(n) is a fixed-length byte buffer for dense data such as a tile chunk. Its value is a Uint8Array of exactly n bytes, and a change inside it sends only the byte ranges that changed. Editing one tile of a 256-byte chunk sends about 5 bytes for the field, not 256:

const schema = defineSchema({
  chunks: entity(
    { cx: u16, cy: u16, tiles: bytes(256) }, // one 16×16 chunk
    { serverOwned: true, interpolate: false },
  ),
});

// In the room:
const chunk = state.chunks.get('0,0')!;
chunk.tiles[17] = 5;                // one byte
chunk.tiles.set([1, 1, 1], 32);     // bytes 32..34
chunk.tiles.fill(0, 64, 80);        // bytes 64..79
chunk.tiles.copyWithin(0, 16, 32);  // bytes 0..15
chunk.tiles = nextTiles;            // only the bytes that differ

Every write validates what it writes. An index outside 0..n-1, a value that is not an integer from 0 to 255, a set that runs past the end, or an assignment of anything other than a Uint8Array of length n throws an Error naming the field. Nothing wraps around modulo 256. A write that leaves the bytes as they were sends nothing. sort() and reverse() send the whole field.

Up to 16 changed ranges are sent per field per tick. More ranges than that, or ranges that together cost about as much as the field, send the whole field instead.

Reading a bytes field in room code gives you a view of the stored buffer. Read it within the tick and call .slice() when you need a copy to keep. A view from subarray() or .buffer shares the same memory but is not tracked, so write through the field itself. ArrayBuffer.isView() returns false for the field; chunk.tiles.slice() is a plain Uint8Array.

Bytes fields are written by the server. On an entity collection that clients can own, declare the collection serverOwned: true or name every bytes field in serverFields; defineSchema throws otherwise. Clients change bytes by calling an RPC, and RPC params, returns and typed messages can carry bytes(n) values:

rpc: {
  paint: server({ params: { chunk: str(16), at: u8, tile: u8 } }),
  loadChunk: server({ params: { chunk: str(16) }, returns: { tiles: bytes(256) } }),
}

When a client owns a record whose bytes field is in serverFields, the owner receives the server’s edits to that field as a correction carrying the whole field (n bytes). Every other client receives only the changed ranges.

bytes(n) has these restrictions:

DeclarationWhy
list(bytes(n), max), or a list whose item contains bytesA list is whole-replace. Model chunks as an entity collection.
A bytes field named in persistThe persistence blob is JSON and does not carry binary data.

A struct may contain a bytes field; its changed ranges are sent the same way.

To add a bytes field to a deployed schema without a migration, give it a default: tiles: bytes(256).default(new Uint8Array(256)). Changing n is a breaking change.

Options on a collection

Both entity and singleton take the same options object. Five of these entries are entity-only; see Restrictions under Singletons for which and why.

OptionValuesMeaning
serverOwnedbooleantrue means no client can ever own an instance. The collection is read-only on the client at compile time. Default false.
visibility'all', 'role', 'owner', 'spatial-grid', 'server'Who receives this collection. 'owner' sends each record to the client that owns it and to nobody else, and is entity-only. Default 'all'.
rolesreadonly string[]With visibility: 'role', the roles that see it. With 'spatial-grid' it is optional: only these roles see the collection, each still filtered to its AOI. Refused with 'server' and 'owner'.
grid{ x, y, cell, radius, wideRoles? }Required with visibility: 'spatial-grid'.
interpolatebooleanfalse makes the client’s room.render path snap this collection on arrival instead of blending. Default true.
physics{ body, intents? }The collection’s instances are rigid bodies.
predictedbooleanClients simulate non-owned bodies of this collection ahead. Needs physics.
proxybooleanDefault true. Give unpredicted bodies kinematic colliders in the client world. Requires physics and a client body factory.
perPlayerbooleanDefault false. Create one owned record per joined client.
persistreadonly string[]Fields of a perPlayer record that follow the player across rooms in a relay project, 4 KB per player. See values that outlive the room.
clientCreatebooleanDefault false. Allow clients to add records and owners to remove them.
ownershipOwnership optionsTransfer and disconnect rules. See relay ownership.
serverFieldsreadonly string[]Fields of a client-owned record that only the server writes. See Server-only fields.

visibility is per collection, never per field. You cannot show one role three fields of a singleton and hide the fourth. To hide a secret, either give it its own collection or do not write it into the state until it should be visible. For per-player hidden state, visibility: 'owner' filters per record rather than per collection. See Visibility for every mode. Physics and Prediction cover the last three.

Server-only fields

Ownership is per record. A client that owns its player row may write every field of it, so the server’s own facts about that player, such as granted upgrades or a validated score, have to live somewhere the client cannot reach. serverFields names those fields on the record itself:

players: entity(
  { x: f32, y: f32, name: str(24), upgrades: u8, score: u32 },
  { serverFields: ['upgrades', 'score'] },
),

The owner writes x, y and name as before. A write that touches upgrades or score has those leaves put back to the value they had, before your validate hook runs, and the client is corrected for exactly those leaves. Everything else in the same write still lands. On a collection with clientCreate, a client that adds a record gets the declared defaults for the server fields rather than the values it sent.

Rooms write them normally: state.players.get(id)!.upgrades = 2.

Because the record belongs to a client, a server write that changes one of these fields reaches that owner as a correction, not as an ordinary delta. Writing the same value again is free: an unchanged write is dropped before it marks anything, so the per-tick p.upgrades = computed() shape costs nothing on the ticks where computed() has not moved.

For a collection with physics, use physics.intents to choose which fields the owner can write. All other fields are server-only, and declaring serverFields on the collection is an error. Include any field the owner needs to write in physics.intents, even if the physics step does not read it, such as a cosmetic yaw or an emote. See Physics.

This is server behavior, so it is part of the schema hash, and adding or removing one is a breaking schema change. Four declarations are refused where you write them:

DeclarationWhy
On a singletonA client never writes a singleton, so every field of one is already server-only.
With serverOwned: trueserverOwned already says no client writes any field.
Naming every fieldThat is serverOwned: true, which says it in one word.
On a physics collectionThe owner writes only the fields in physics.intents, so every other field is already server-only.

There are no per-field owner ids. A field belongs to the client that owns the record or to the server, and nothing in between. If you need a value owned by a different client than the one that owns the row, give it its own collection.

Roles

roles declares the role names a client may hold, and each name has to be unique. Use as const so the literal types survive into ctx.role on the server and into joinRoom(schema, { role }) on the client, where an undeclared role is a compile error.

roles: ['controller', 'host'] as const,

Every client joins with the first declared role unless the server grants another: a JWT role claim, or ctx.room.setRole in room code. A role the client asks for in joinRoom is ignored and the room logs it. With no roles at all, ctx.role is ''. Put the least privileged role first.

clientRoles: true honours the requested role instead, for games where no role grants anything a client should not have:

roles: ['player', 'spectator'] as const,
clientRoles: true,

A requested role the schema does not declare still gets the first role. clientRoles does not change the schema hash. See role views for granting a role from room code.

The project id

project is the public key for your project, written into irtio.json and irtio/schema.ts by npx irtio init. It is safe in client code and safe in a public repository. It identifies the project so the connection can be routed and so the server can check the request origin against the origins you allowed for that project. It is not a secret, not an API key, and not something a player needs to paste anywhere.

It is identity rather than shape, so it is not part of the schema hash. Two builds of the same schema under different project ids are the same schema.

Limits

LimitValueEnforced at
Fields per entity or singleton255entity() / singleton()
str(max) and list(_, max) boundinteger, 0 to 4294967295the builder call
bytes(n) lengthinteger, 1 to 65535the builder call
Changed ranges sent per bytes field per tick16; more, or ranges covering most of the field, send the whole fieldencode
Entity id and owner id length32 UTF-8 bytesadd() / setOwner()
enumOf values1 to 256, uniquethe builder call
Message shapes declared under messages256 per schemadefineSchema
RPCs declared under rpc, server and client together60,000 per schemadefineSchema
A value over its declared boundThrows an Error naming the field and the bound; nothing is truncatedadd(), field assignment, .default(), and encode

Schema changes and version skew

The schema is hashed into a canonical form, and both the client and the room carry that hash. A page running against a room built from a different schema is refused with a version-skew error instead of decoding nonsense. Adding a field with a .default() is the additive case. Renaming, removing, or retyping one changes the shape, and so does changing the n of a bytes(n) field. Redeploy your room and reload the page together. interpolate, predicted, and project are the exceptions: they describe rendering and identity rather than shape, so they never affect the hash.

Declaration order is part of the wire format

RPCs and messages are addressed on the wire by a number, and that number is the order you declared them in. The first RPC in your rpc object is id 0, the second is id 1, and so on; messages are numbered the same way in messages.

The practical rule is one line: add to the bottom, never insert in the middle.

rpc: {
  move: server({ params: { x: f32, y: f32 } }),  // id 0, forever
  shoot: server({ params: { at: u8 } }),         // id 1, forever
  taunt: server({}),                             // id 2 — appended later, additive
}

Appending an RPC or a message is an additive deploy. Nothing already deployed moves, connected players keep their sockets, and no migration is involved. This is true whatever the new name is: names have nothing to do with ids.

Moving a declaration is a breaking change, and the deploy says so by name — the ids really did move, and a client still holding the old schema would call the wrong handler. It is breaking in the wire-only sense: no snapshot is affected, so --allow-breaking ships it with no migration, and connected players reconnect once. Renaming an RPC is a remove plus an add, so it is breaking too.

irtio’s own built-in RPCs (requestOwnership, $ownership.ask) live in a reserved range at the top of the id space, counting down from 65535. You can declare up to 60000 RPCs; their ids remain separate from the built-in range.

  • Ownership for who may write each instance.
  • RPCs for the typed calls declared alongside the schema.