Rooms
State sync and interest
Declare entities, sync them as binary deltas, look them up by ID, and send each connection only what it should see.
All synced state is typed entities of kinds the room declares. Each tick, every changed entity is encoded once as a binary delta, and each connection gets the deltas of the entities it sees. With no interest set, every connection sees every entity.
Declaring kinds
// schema.ts
import { defineSchema, entity } from "@irt/room";
export const Player = entity("player", {
owner: "string",
x: "f32",
y: "f32",
z: "f32",
hp: "u8",
});
export const Chunk = entity("chunk", {
cx: "i32",
cy: "i32",
cz: "i32",
blocks: "bytes",
});
export const Match = entity("match", { round: "u16", phase: "string" });
export const Hand = entity("hand", { cards: "json" });
export const schema = defineSchema({
state: Match,
private: Hand,
entities: [Player, Chunk],
});
entity(name, fields) takes a name matching [A-Za-z_][A-Za-z0-9_]{0,63}
and up to 255 fields. Field names id, kind and patch are reserved,
since entity objects have properties of those names; using one is a type
error at the entity() call, and an error when the module loads. Kind
names starting with irt_ are reserved too: every schema ends with the
built-in irt_presence kind of presence, so the
schema hash covers it the same way for every room.
| Type | Holds | Size |
|---|---|---|
bool |
true or false |
1 byte |
u8, u16, u32 |
unsigned integers | 1, 2, 4 bytes |
i8, i16, i32 |
signed integers | 1, 2, 4 bytes |
f32 |
numbers, rounded with Math.fround |
4 bytes |
f64 |
numbers | 8 bytes |
string |
UTF-8 text | at most 16 KB |
bytes |
Uint8Array |
at most 48 KB |
json |
anything JSON can encode, replaced whole when set | at most 16 KB serialized |
A json field declared as "json" reads as unknown. Declare it with
json<T>() to give it a type, so room and client code reads it without
casts:
// scores.ts
import { defineSchema, entity, json } from "@irt/room";
export const Match = entity("match", {
round: "u16",
scores: json<Record<string, number>>(),
});
export const schema = defineSchema({ state: Match });
export function addPoint(state: { scores: Record<string, number> }, player: string) {
state.scores = { ...state.scores, [player]: (state.scores[player] ?? 0) + 1 };
}
json<T>() is the string "json" at runtime: it changes nothing on the
wire, and a kind declared with it has the same schema hash as one
declared with "json". The type isn’t checked when the field is set,
and like any json field it holds null until it’s first set, so set it
in onCreate or declare json<T | null>().
An entity with every field encoded must fit in 61,376 bytes (60 KB minus
64), so it can enter a view within one state update part. A write that
would make it bigger throws a RangeError naming the entity and field;
split large data across several entities, as chunks do.
defineSchema({ state?, private?, entities? }) returns the room’s
Schema. The order of entities is part of the schema hash: reordering
them is a schema change.
Shared, private and entity state
| In room code | Who sees it | Kept by |
|---|---|---|
room.state |
every connection | the room |
room.private(conn) |
that connection’s player only | player ID, so it survives reconnects |
room.entities |
connections whose interest includes it | entity ID |
room.entities of a hidden kind |
no connection | entity ID |
// match-room.ts
import { defineRoom, type RoomEntity } from "@irt/room";
import { Chunk, Player, schema } from "./schema.js";
export default defineRoom({
schema,
onCreate(room) {
room.state.phase = "lobby";
for (let cx = -2; cx <= 2; cx++) {
room.entities.create(Chunk, { cx, cy: 0, cz: 0, blocks: new Uint8Array(32 * 32 * 32) });
}
},
onJoin(room, conn) {
// After a room host restart the player's entity is still there; see below.
const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player);
conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player, hp: 100 });
room.private(conn).cards = ["ace", "king"];
},
onMessage(room, conn, message) {
const m = message as { dig?: [number, number] };
if (!m.dig) return;
const [cx, index] = m.dig;
const chunk = [...room.entities.all(Chunk)].find((c) => c.cx === cx);
if (!chunk || !Number.isInteger(index) || index < 0 || index >= 32 * 32 * 32) return;
// A block edit sends three bytes and the offset, not the chunk.
chunk.patch("blocks", index, new Uint8Array([0]));
const player = conn.data.player as RoomEntity<typeof Player>;
player.hp = Math.max(0, player.hp - 1);
},
onPlayerLeave(room, player) {
for (const e of room.entities.all(Player)) if (e.owner === player) room.entities.delete(e);
},
});
How writes are tracked:
- Setting a field records it; the delta goes out at the end of the tick. There’s no diff of the whole state.
- Within a tick the last write to a field wins, and a replace after a patch wins.
- Setting a field to its current value records nothing. For
json, a deep-equal value records nothing; mutating ajsonvalue in place records nothing until you set the field again. entity.patch(field, offset, bytes)writes into abytesfield and sends only the range. It grows the field when it writes past the end, filling the gap with zeros, so empty bytes is a fine starting value for data that’s mostly blank, such as an all-air chunk.- Read a
bytesfield from the entity again after a patch. A patch that grows it replaces theUint8Array, so an array you held from before is the old, shorter one; on the client every change is a new array. - Writes to
f32fields go throughMath.froundin the room and on the client, so both hold the same number. - Writing a deleted entity throws.
Where player data lives
A player’s data can live in three places, and they last differently.
What decides it is when onLeave and onPlayerLeave run. onLeave
runs when a connection closes, which includes a dropped network before
the client reconnects. onPlayerLeave runs when the player’s presence
ends, 10 seconds after its last connection closed with no new one. Both
run before the room hibernates, whatever the reason, and neither when a
room host crashes.
| Data in | Leave and rejoin | Dropped network, reconnect | Room host crash | Hibernate and wake |
|---|---|---|---|---|
An entity onPlayerLeave deletes |
lost | kept, within 10 s | kept, and onJoin runs again |
lost |
An entity onLeave deletes |
lost | lost | kept, and onJoin runs again |
lost |
| An entity the room keeps | kept | kept | kept | kept |
| A hidden entity | kept | kept | kept | kept |
room.state, such as a json map by player ID |
kept | kept | kept | kept |
room.private(conn) |
kept for privateRetention, 24 hours by default |
kept | kept | kept for privateRetention |
conn.data, room.data, module variables |
lost | lost | lost | lost |
| A presence status | kept for 10 s | kept for 10 s | lost | lost |
- Entities are the room’s synced objects, seen through interest. A
player’s avatar is usually an entity created in
onJoinand deleted inonPlayerLeave, so it’s on screen while the player is in the room and survives a dropped network. Anything stored on it goes with it: don’t keep a score there that should outlive a visit. room.stateis one entity every connection sees. Keep standings every player sees here, such asscores: json<Record<string, number>>()keyed by player ID.- Private state is kept by player ID and only that player sees it.
It’s created empty at a player’s first join, comes back when they
rejoin, and is dropped once the player has been away for the room’s
privateRetentionof wall-clock time, hibernated time included: 24 hours by default, so it can expire before the room does. SetprivateRetentionindefineRoom, in milliseconds or"forever", when players come back over days; see Room state. Keep a player’s own data here, such as a hand of cards or an inventory. Room code reaches any player’s private state by player ID,room.private("alice"), connected or not; it’sundefinedfor a player with none, androom.private("alice", { create: true })creates it.room.privatePlayers()lists the players that have it. - A rejoining player’s first state is their last session’s private
state. Their client gets it as it was when they left, before any
async work in
onJoinhas finished. A field that describes the session rather than the player, such asloadedset once a backend answers, still holds the old session’s value. Reset such fields inonJoinbefore it awaits anything: whatonJoinsets synchronously is in the player’s first state update. Reset them inonLeavetoo, which runs before every hibernation, so private state is never kept with them set. - Hidden entities are entities no connection ever sees, such as a deck’s order. They’re room state like any entity; see Hidden entities.
conn.datais the room’s own scratch space for one connection, such as a reference to its avatar entity. It isn’t room state.room.datais the same for the whole room, for data derived from its entities, such as a lookup table. Module variables are shared by every room in the room host, so don’t keep one room’s data in them; see Room data and module scope.- Presence is who’s connected now: an entry per player with a name and a small status, which every connection sees. The runtime keeps it, and it isn’t room state.
After a room host crash, the room wakes from its room state with every
entity as it was, clients reconnect and onJoin runs for each of them
again, with no onLeave before it. A room that creates an avatar in
onJoin finds the one from before instead, as the room above does:
const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player);
conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player });
An avatar whose player doesn’t come back after a crash stays in the
room, and in its room state, until room code deletes it. A room that
cares can delete, in onTick, avatars whose owner has had no connection
for a few seconds. onWake is too early: it runs before any client has
reconnected.
Hidden entities
Some room state must reach no client at all: a deck’s order, a puzzle’s
answer, a bot’s plan. room.data isn’t room state, so it’s gone after a
wake, and hiding an entity with interest is easy to get wrong: without
an interest every connection sees every entity, and an interest that
gives the entity EVERYWHERE or a key some connection subscribes to
shows it too. Declare the kind hidden instead:
// cards.ts
import { defineRoom, defineSchema, entity, json } from "@irt/room";
export const Table = entity("table", { drawPile: "u8" });
export const Hand = entity("hand", { cards: json<string[]>() });
export const Deck = entity("deck", { order: json<string[]>() }, { hidden: true });
export const schema = defineSchema({ state: Table, private: Hand, entities: [Deck] });
function shuffled(cards: string[]): string[] {
const out = [...cards];
for (let i = out.length - 1; i > 0; i--) {
const j = crypto.getRandomValues(new Uint32Array(1))[0]! % (i + 1);
[out[i], out[j]] = [out[j]!, out[i]!];
}
return out;
}
export default defineRoom({
schema,
onCreate(room) {
const deck = room.entities.create(Deck, { order: shuffled(["1a", "2a", "3a", "1b", "2b", "3b"]) });
room.state.drawPile = deck.order.length;
},
onJoin(room, conn) {
room.private(conn).cards ??= [];
},
onMessage(room, conn, message) {
if ((message as { type?: unknown }).type !== "draw") return;
const deck = [...room.entities.all(Deck)][0]!;
const [card, ...rest] = deck.order;
if (card === undefined) return;
deck.order = rest;
const hand = room.private(conn);
hand.cards = [...hand.cards, card];
room.state.drawPile = rest.length;
},
});
- A hidden entity is room state like any other: it’s saved, restored
on wake and kept across a room host crash, and hooks and calls read
and write it through
room.entities. - Don’t read hidden entities in a stepper. The client runs the same
stepto predict, on a world that never holds them, so a step that depends on them predicts something other than what the room does. The room’sworldisroom.entities, so it does hold them; nothing stops a stepper reading them, and nothing warns when prediction drifts. - The room never sends it to a connection. It gets no interest keys, so
keysOfisn’t called for it, whateverinterestis; voice’s default rule doesn’t see it; and creating, changing or deleting it sends nothing. The inspector inirt devshows it, marked hidden, since it’s for you and not for players. - A hidden kind can’t have writable fields, so no player can own one of its entities.
- The kind’s declaration is in the schema, so it’s in the client’s bundle and the schema hash; its values never leave the room.
What the room sends is still yours to check. Data reaches clients in other ways than entities:
- What you copy out. A card moved into a hand,
room.state, a message, a call’s reply or error, and presence all reach clients. - Entity IDs. Automatic IDs count up for every entity, hidden ones
included, so a gap between two IDs a client sees says how many
entities were created in between. Give visible entities
{ id }of your own where that matters. - Counts and timing. A
drawPilecount, how long a reply takes, or which tick a change lands in can say what a hidden value is.
In a test with @irt/testing, client.view(Deck) stays empty however
the game goes, and room.stored() shows the deck in room state.
Entity IDs and lookups
An entity’s ID is assigned from 2 by default. Pass { id } to choose
it, so the room and its clients can compute it: a chunk’s ID from its
chunk coordinates, a tile’s from its row and column. Then a stepper, a
hook or a client finds static data with world.get(id) instead of
searching.
// voxel-room.ts
import { defineRoom, defineStepper, type Entity, type RoomEntity, type World } from "@irt/room";
import { Chunk, Player, schema } from "./schema.js";
const SIZE = 16;
const N = 8;
/** A chunk's entity ID, from its chunk coordinates; the world is N × N × N chunks. */
export const chunkId = (cx: number, cy: number, cz: number) => 2 + cx + cz * N + cy * N * N;
/** Where a block is in its chunk's `blocks`, for whole block coordinates from 0. */
const indexOf = (x: number, y: number, z: number) => (x % SIZE) + (z % SIZE) * SIZE + (y % SIZE) * SIZE * SIZE;
export function blockAt(world: World, x: number, y: number, z: number): number {
const bx = Math.floor(x);
const by = Math.floor(y);
const bz = Math.floor(z);
if (Math.min(bx, by, bz) < 0 || Math.max(bx, by, bz) >= N * SIZE) return 0;
const chunk = world.get(chunkId(Math.floor(bx / SIZE), Math.floor(by / SIZE), Math.floor(bz / SIZE)));
// Blocks start empty above the ground; reading past the end gives air.
return (chunk as Entity<typeof Chunk> | undefined)?.blocks[indexOf(bx, by, bz)] ?? 0;
}
export const fall = defineStepper<{ jump: boolean }>({
step(world, inputs, dt) {
for (const p of world.all(Player)) {
if (blockAt(world, p.x, p.y - 0.01, p.z) === 0) p.y -= 10 * dt;
else if (inputs.get(p.owner)?.jump) p.y += 1;
}
},
});
export default defineRoom({
schema,
stepper: fall,
onCreate(room) {
for (let cy = 0; cy < N; cy++) {
for (let cz = 0; cz < N; cz++) {
for (let cx = 0; cx < N; cx++) {
const blocks = cy === 0 ? new Uint8Array(SIZE ** 3).fill(1) : new Uint8Array(0);
room.entities.create(Chunk, { cx, cy, cz, blocks }, { id: chunkId(cx, cy, cz) });
}
}
}
},
onJoin(room, conn) {
const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player);
conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player, x: 8, y: 20, z: 8 });
},
onMessage(room, conn, message) {
const m = message as { place?: [number, number, number] };
if (!m.place || !m.place.every((v) => Number.isInteger(v) && v >= 0 && v < N * SIZE)) return;
const [x, y, z] = m.place;
const chunk = room.entities.get(chunkId(Math.floor(x / SIZE), Math.floor(y / SIZE), Math.floor(z / SIZE)));
// Grows an empty chunk's blocks as far as this block.
(chunk as RoomEntity<typeof Chunk>).patch("blocks", indexOf(x, y, z), new Uint8Array([1]));
},
});
- An explicit ID is an integer from 2 that no live entity has;
createthrows otherwise, naming the entity that has it. A deleted entity’s ID can be used again, even in the same tick. - Automatic IDs are always above the highest ID the room has used, so
they never take an explicit one. An explicit ID can still meet an
automatic one created earlier, so create the entities with computed
IDs first, in
onCreate, as this room does. - IDs are kept in room state, so they’re the same after a wake.
What lookups cost, in a stepper or anywhere else:
world.get(id)is one map lookup, in the room and on the client.world.all(kind)visits every entity of that kind each time it’s called: in the room, every one the room has; on the client, every one the client sees. A stepper runs every tick in the room, and on the client again for each input it replays, so a stepper that searchesall(Chunk)for each player does chunks × players work every time. Loop over the few entities that move withall, and reach static data by ID.
Interest
A connection sees an entity when they share an interest key. Keys are strings or numbers your room assigns.
interface Interest<R = Room> {
keysOf(room: R, entity: RoomEntity): Iterable<InterestKey>;
subscriptionsOf(room: R, connection: Connection): Iterable<InterestKey>;
sees?(room: R, connection: Connection, entity: RoomEntity): boolean;
limit?: { max: number; priority(room: R, connection: Connection, entity: RoomEntity): number };
}
Each function gets the room first, as hooks do, so a rule can read the
room’s own state, such as a player’s block list. At the end of each tick the room calls keysOf for entities that
changed and subscriptionsOf for every connection. An entity that newly
shares a key with a connection enters its view with all its fields; one
that no longer shares any leaves it. sees, when given, narrows that
further: it runs each tick for every connection and entity that share a
key, and the connection sees the entity only when it returns true.
limit applies last.
The grid
function grid(opts: {
cellSize: number;
dims: "xy" | "xz" | "xyz";
position?: (room: Room, entity: RoomEntity) => [x: number, y: number, z?: number] | undefined;
around(room: Room, connection: Connection): RoomEntity | [x: number, y: number, z?: number] | undefined;
prefix?: string;
kinds?: Kind[];
} & ({ range: number } | { radius: number })): InterestPart;
type InterestPart = Pick<Interest, "keysOf" | "subscriptionsOf" | "sees">;
- An entity’s key is the cell at
position(entity); by default itsx,yandzfields.dimspicks the axes:"xz"for a 3D world split on the ground plane. - When
aroundreturns an entity, the grid places it withpositiontoo. Apositionwritten for chunk fields gives a player no position, and its connection then subscribes only toEVERYWHERE; havearoundreturn the player’s point,[p.x, p.y, p.z], or makepositionhandle both kinds. kindslimits the grid to entities of those kinds: others get no key from it, rather thanEVERYWHERE, and itsseeslets them through.prefixstarts every key the grid gives,EVERYWHEREincluded, sogrid({ ..., prefix: "p:" })gives"p:3,-1"and"p:*".- The key format is
cellKey(...cell): the cell coordinates joined by commas, such as"3,-1"or"3,-1,0", where a cell coordinate isMath.floor(position / cellSize). - Entities with no position on those axes get
EVERYWHERE("*"), which every connection subscribes to, so every connection sees them. - While
around(connection)returnsundefined, the connection subscribes only toEVERYWHERE.
Give the grid either a range or a radius:
range, in world units: a connection sees the entities within that straight-line distance ofaround(connection)on the chosen axes, a circle for"xy"and"xz"and a sphere for"xyz". The grid subscribes it to the cells that overlap that circle, and itsseesdrops the entities in those cells that are further away. Visibility is exact: an entity at distancerangeis seen and one a little past it isn’t, wherever either stands in its cell.radius, a whole number of cells: a connection subscribes to its own cell and every cell withinradiuscells of it on each axis, a square of (2 × radius + 1)² cells, or a cube for"xyz". It sees every entity in them, so how far it sees depends on where it stands in its cell: at leastradius × cellSizealong an axis, up to(radius + 1) × cellSize, and further across the corners.seesis undefined. Use it when cells, such as voxel chunks, are what matters.
Combining grids and keys
combine(...parts) joins grids, and parts with keys of your own, into
one interest. A connection sees an entity when, in at least one part,
they share a key and that part’s sees, if it has one, returns true.
So each grid keeps its own semantics: a radius grid shows its whole
cells, and a range grid’s distance check applies only to what it
placed, not to what another part shows.
function combine(...parts: InterestPart[]): Required<InterestPart>;
Give each grid the kinds it places, or every grid also places the
other grids’ entities, as EVERYWHERE when they have no position on its
axes, which shows them to everyone. Give each grid a prefix too, so
cells of different sizes don’t share keys; combine stays exact without
one, but entities in a cell with the same key as one of another grid
are then checked by sees for nothing. Put limit beside it:
// world-room.ts
import { combine, defineRoom, grid, type Connection, type Room, type RoomEntity } from "@irt/room";
import { Chunk, Player, schema } from "./schema.js";
type P = RoomEntity<typeof Player>;
const CHUNK = 32;
const at = (p: P | undefined) => (p ? ([p.x, p.y, p.z] as const) : undefined);
// Chunks within 2 chunks on each axis, placed by chunk coordinates.
const chunks = grid({
cellSize: CHUNK,
radius: 2,
dims: "xyz",
kinds: [Chunk],
prefix: "c:",
position: (_room, e) => {
const c = e as RoomEntity<typeof Chunk>;
return [c.cx * CHUNK, c.cy * CHUNK, c.cz * CHUNK];
},
// A point, since `position` above reads chunk fields.
around: (_room, conn) => at(conn.data.player),
});
// Players within 80 units, by their x, y and z.
const players = grid({ cellSize: 32, range: 80, dims: "xyz", kinds: [Player], prefix: "p:", around: (_room, conn) => conn.data.player });
// Every player sees its own entity, however far it is from where `around` says.
const self = {
keysOf: (_room: Room, e: RoomEntity) => (e.kind === Player ? [`me:${(e as P).owner}`] : []),
subscriptionsOf: (_room: Room, conn: Connection) => [`me:${conn.player}`],
};
export default defineRoom({
schema,
interest: {
...combine(chunks, players, self),
limit: {
max: 500,
// Chunks rank first, players by distance.
priority: (_room, conn, e) => {
const me = conn.data.player as P | undefined;
if (!me || e.kind !== Player) return Infinity;
const p = e as P;
return -Math.hypot(p.x - me.x, p.y - me.y, p.z - me.z);
},
},
},
onJoin(room, conn) {
const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player);
conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player });
},
});
The same goes for a range grid and keys of your own: with combine,
what the keys show isn’t dropped by the grid’s distance check.
// near-room.ts
import { combine, defineRoom, defineSchema, entity, grid, type Connection, type Room, type RoomEntity } from "@irt/room";
const Soldier = entity("soldier", { team: "string", x: "f32", y: "f32", z: "f32" });
const teamOf = (c: Connection) => (c.meta as { team: string }).team;
// Teammates are seen at any distance, everyone else within 600 units.
const near = grid({ cellSize: 100, range: 600, dims: "xz", around: (_room, c) => c.data.soldier });
const team = {
keysOf: (_room: Room, e: RoomEntity) => [`team:${(e as RoomEntity<typeof Soldier>).team}`],
subscriptionsOf: (_room: Room, c: Connection) => [`team:${teamOf(c)}`],
};
export default defineRoom({
schema: defineSchema({ entities: [Soldier] }),
interest: combine(near, team),
onJoin(room, conn) {
conn.data.soldier = room.entities.create(Soldier, { team: teamOf(conn) });
},
});
Writing keysOf, subscriptionsOf and sees by hand works too. Then
give the grids prefixes, and remember that an interest’s sees applies
to every entity, so it must let through what other keys show:
sees: (room, c, e) => isTeammate(c, e) || near.sees!(room, c, e).
Custom keys
Interest isn’t tied to coordinates. Teams, zones and levels are keys too:
// team-room.ts
import { defineRoom, defineSchema, entity } from "@irt/room";
const Unit = entity("unit", { team: "string", level: "u8" });
export default defineRoom({
schema: defineSchema({ entities: [Unit] }),
interest: {
keysOf: (_room, e) => [`team:${e.team as string}`, `level:${e.level as number}`],
subscriptionsOf: (_room, conn) => [`team:${(conn.meta as { team: string }).team}`],
},
});
Limits and hysteresis
limit: { max, priority } keeps the max entities with the highest
priority in each connection’s view. A visible entity stays until it
ranks below max plus 10%, so entities near the cut don’t enter and
leave every tick.
Caps
| Cap | Value | Over it |
|---|---|---|
| Interest keys a connection subscribes to | 4,096 | an error in the room’s log; the extra keys are left out |
| Entities a connection sees | 16,384 | an error in the room’s log; the extra entities are left out |
On the client
// client.ts
import { connect } from "@irt/client";
import { Chunk, Player, schema } from "./schema.js";
const room = await connect({ router: "wss://eu.irt.io", project: "my-game", room: "forest-7", schema });
room.entities.onEnter(Chunk, (chunk) => console.log("mesh chunk", chunk.cx, chunk.blocks.length));
room.entities.onChange(Chunk, (chunk, changed) => {
if (changed.includes("blocks")) console.log("remesh", chunk.id);
});
room.entities.onLeave(Chunk, (chunk) => console.log("drop", chunk.id));
room.onTick((tick) => console.log("applied tick", tick, room.state.phase, room.private.cards));
const players = room.interpolate(Player, ["x", "y", "z"]);
for (const p of room.entities.all(Player)) console.log(p.owner, players.get(p));
- A tick’s state update is applied at once, then the
onEnter,onChangeandonLeavecallbacks run, thenonTick. A client never sees a change before an enter. - A big tick, such as a new player entering a hundred chunks, spans
several messages; the client applies them together. The room
compresses messages of 1 KB or more with DEFLATE, which shrinks voxel
chunks about 17 times, and the client inflates them; nothing in the
game changes for it,
patchincluded. - On a reconnect to the same room generation the client resumes: it
tells the room what it holds, and the room sends only what it doesn’t
hold or what changed since, and a
leavefor what it no longer sees. Entities that didn’t change keep their objects, and the client emitsrestored. On a change of the room’s generation, or a reconnect the room can’t resume, such as after a room host restart, the client gets a full update: every entity leaves and enters again. interpolate(kind, fields)draws numeric fields of remote entities a little behind the room’s clock (two ticks or 100 ms, whichever is longer), between the last two states.
Common mistakes
- Expecting
room.statechanges to be seen at once. Changes go out at the end of the tick. Messages sent withroom.sendin the same handler arrive before the state update. - Mutating a
jsonfield in place. Set the field again after changing the value, or the change isn’t sent. - Keeping a score on an entity
onLeavedeletes. It’s gone when the player leaves, drops off the network or the room hibernates. Keep it inroom.stateor private state; see Where player data lives. - Expecting
radiusto be a distance. It counts cells, in a square. Userangefor “everything within 600 units”. - Putting a whole world in one entity. An entity must fit in 61,376 bytes. Split worlds into chunk entities.
- Using a
stringfor big data. Strings are capped at 16 KB; usebytes(48 KB) and patch it. - Grid entities without a position. They get
EVERYWHEREand every connection sees them; passpositionwhen your coordinates aren’t calledx,yandz, andkindswhen the grid shouldn’t place them at all. - Merging two grids’ keys by hand without prefixes. Their cell keys
and
EVERYWHEREcollide, and arangegrid’sseesdrops what the other grid shows. Usecombine. - A
positionfor chunks with anaroundthat returns the player. The grid places that entity withpositiontoo; return a point instead. - Searching
all(kind)in a stepper for static data. It visits every entity of the kind on every call. Give the data computed IDs and useworld.get; see Entity IDs and lookups. - Assuming
subscriptionsOfruns only on change. It runs for every connection every tick; keep it cheap. - Hiding data with
interest. Without an interest every connection sees every entity, and one key in common shows it. Declare the kind{ hidden: true }; see Hidden entities. - Writing entities on the client. They are read-only and a write throws, except the writable fields of entities the player owns; see Ownership.