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 a json value in place records nothing until you set the field again.
  • entity.patch(field, offset, bytes) writes into a bytes field 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 bytes field from the entity again after a patch. A patch that grows it replaces the Uint8Array, so an array you held from before is the old, shorter one; on the client every change is a new array.
  • Writes to f32 fields go through Math.fround in 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 onJoin and deleted in onPlayerLeave, 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.state is one entity every connection sees. Keep standings every player sees here, such as scores: 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 privateRetention of wall-clock time, hibernated time included: 24 hours by default, so it can expire before the room does. Set privateRetention in defineRoom, 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’s undefined for a player with none, and room.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 onJoin has finished. A field that describes the session rather than the player, such as loaded set once a backend answers, still holds the old session’s value. Reset such fields in onJoin before it awaits anything: what onJoin sets synchronously is in the player’s first state update. Reset them in onLeave too, 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.data is the room’s own scratch space for one connection, such as a reference to its avatar entity. It isn’t room state.
  • room.data is 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 step to 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’s world is room.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 keysOf isn’t called for it, whatever interest is; voice’s default rule doesn’t see it; and creating, changing or deleting it sends nothing. The inspector in irt dev shows 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 drawPile count, 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; create throws 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 searches all(Chunk) for each player does chunks × players work every time. Loop over the few entities that move with all, 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 its x, y and z fields. dims picks the axes: "xz" for a 3D world split on the ground plane.
  • When around returns an entity, the grid places it with position too. A position written for chunk fields gives a player no position, and its connection then subscribes only to EVERYWHERE; have around return the player’s point, [p.x, p.y, p.z], or make position handle both kinds.
  • kinds limits the grid to entities of those kinds: others get no key from it, rather than EVERYWHERE, and its sees lets them through.
  • prefix starts every key the grid gives, EVERYWHERE included, so grid({ ..., 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 is Math.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) returns undefined, the connection subscribes only to EVERYWHERE.

Give the grid either a range or a radius:

  • range, in world units: a connection sees the entities within that straight-line distance of around(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 its sees drops the entities in those cells that are further away. Visibility is exact: an entity at distance range is 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 within radius cells 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 least radius × cellSize along an axis, up to (radius + 1) × cellSize, and further across the corners. sees is 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, onChange and onLeave callbacks run, then onTick. 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, patch included.
  • 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 leave for what it no longer sees. Entities that didn’t change keep their objects, and the client emits restored. 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.state changes to be seen at once. Changes go out at the end of the tick. Messages sent with room.send in the same handler arrive before the state update.
  • Mutating a json field in place. Set the field again after changing the value, or the change isn’t sent.
  • Keeping a score on an entity onLeave deletes. It’s gone when the player leaves, drops off the network or the room hibernates. Keep it in room.state or private state; see Where player data lives.
  • Expecting radius to be a distance. It counts cells, in a square. Use range for “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 string for big data. Strings are capped at 16 KB; use bytes (48 KB) and patch it.
  • Grid entities without a position. They get EVERYWHERE and every connection sees them; pass position when your coordinates aren’t called x, y and z, and kinds when the grid shouldn’t place them at all.
  • Merging two grids’ keys by hand without prefixes. Their cell keys and EVERYWHERE collide, and a range grid’s sees drops what the other grid shows. Use combine.
  • A position for chunks with an around that returns the player. The grid places that entity with position too; 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 use world.get; see Entity IDs and lookups.
  • Assuming subscriptionsOf runs 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.

View this page as Markdown