Rooms

Player data, collections and leaderboards

Keep a player's progress across rooms, store what players make, and rank players, from room code or the client, with no backend of your own.

A room’s state lasts as long as the room. Some data outlives every room: a player’s progress, the levels players build, a high score table. IRT keeps it for you in the data service, the same from room code, from a client and from the server API:

  • Player data: keys per player per project, each a JSON or bytes value of at most 64 KB with a version that goes up on every write. A player has at most 256 keys and 1 MB.
  • Collections: named sets of entries you declare in irt.config.ts. An entry has an id, a value, flat meta you filter on, an optional score you sort on, an optional owner, a version and its created and updated times.
  • Leaderboards: collections with leaderboard settings, holding one score per player per period, written by submit.

Data for one player in one room needs none of this: keep it in the room’s private state, which is room state.

Declaring it

// irt.config.ts
import { defineConfig } from "@irt/cli/config";

export default defineConfig({
  project: "my-game",
  room: "src/room.ts",
  settings: {
    data: {
      player: {
        clientWrite: ["settings"],
      },
      collections: {
        levels: { clientRead: true, clientWrite: "own", maxOwn: 50 },
        highscores: {
          clientRead: true,
          leaderboard: { order: "desc", keep: "best", periods: ["all", "week"] },
        },
      },
    },
  },
});
Setting Default Meaning
data.player.clientWrite none The player data keys a client may write for its own player. A client always reads its own player’s data.
collections.<name> Names are 1 to 32 of a-z 0-9 _ -, starting with a letter; at most 32 collections
clientRead false Any client may get and list the entries, or read the board
clientWrite none "own": a client may create entries and change and delete the ones it owns, or submit its own score
maxOwn 100 The most entries one player creates from clients, 1 to 10,000
leaderboard.order "desc" "desc" or "asc"; it also decides what the best score is
leaderboard.keep "best" "best", "latest" or "sum"
leaderboard.periods ["all"] Any of "all", "day", "week" and "month", in UTC; weeks are ISO weeks from Monday

Room code may do anything with any declared collection; the client settings only limit clients. A board’s order and keep can’t change once a live build declared it: declare a board under a new name for a new rule. A collection you remove keeps its entries, unreachable, until you declare it again.

From room code

// src/room.ts
import { DataError, defineRoom } from "@irt/room";

interface Save {
  level: number;
  coins: number;
}

export default defineRoom({
  async onJoin(room, conn) {
    // Load once, then keep it in memory for the rest of the visit.
    const save = await room.playerData(conn.player).get<Save>("save");
    conn.data.save = save?.value ?? { level: 1, coins: 0 };
    conn.data.version = save?.version ?? 0;
  },
  async onMessage(room, conn, message) {
    const m = message as { type?: string; score?: unknown };
    if (m.type === "level-done" && typeof m.score === "number") {
      const save: Save = { ...conn.data.save, level: conn.data.save.level + 1 };
      try {
        const { version } = await room.playerData(conn.player).set("save", save, { version: conn.data.version });
        conn.data.save = save;
        conn.data.version = version;
      } catch (e) {
        if (!(e instanceof DataError)) throw e;
        room.send(conn, { error: e.code });
      }
      await room.leaderboard("highscores").submit(conn.player, m.score, { meta: { level: save.level } });
    }
  },
});

room.playerData(player) has get, set, increment, delete, list and refresh:

  • get(key) resolves with { value, version }, or undefined when the key has none. A value is any JSON or a Uint8Array.
  • set(key, value, { version?, once? }) resolves with { version }. Without version the write is unconditional. With it, the write fails with E_VERSION unless the key has that version; version: 0 means the key must have none.
  • increment(key, by, { once? }) adds to a number in one step, creating it at 0, and resolves with the new number. On a value that isn’t a number it fails with E_TYPE.
  • delete(key, { version?, once? }) resolves with whether the key existed.
  • list() gives every key with its value and version.

room.collection(name) has get(id), put({ id?, value, meta?, score?, owner?, version?, once? }), which resolves with { id, version } and makes up an ID when you give none, list({ where?, owner?, order?, limit?, after? }) and delete(id, { version?, once? }). once makes a write safe to repeat; see Writes that may or may not have happened. put replaces the whole entry: meta, score and owner left out become empty.

// levels.ts
import type { Connection, Room } from "@irt/room";

export async function publish(room: Room, conn: Connection, tiles: number[]) {
  const levels = room.collection("levels");
  const { id } = await levels.put({ value: { tiles }, meta: { theme: "ice", size: tiles.length }, owner: conn.player });
  const page = await levels.list({ where: { theme: "ice" }, order: "-created", limit: 20 });
  const mine = await levels.list({ owner: conn.player });
  return { id, newest: page.entries, count: mine.entries.length, more: page.next !== null };
}

A list gives at most 100 entries a page, 20 by default, each without its value, so a page stays small; get fetches one value. Pass next as after for the next page; it’s null on the last. where matches meta keys by equality, all of them; order is score, -score, created, -created (the default), updated or -updated.

room.leaderboard(name) has submit(player, score, { meta?, once? }), which applies the board’s keep rule to the player’s entry of every period at once and resolves with what each keeps, top({ period?, limit?, after? }) and around(player, { period?, count? }), the player’s rank and up to count entries either side, or undefined when they have no entry. A period is "all" (the default), "day", "week" or "month" for the current one, or a full name such as "week:2026-W45" for one of the three before it, which are kept; older ones are deleted. Ranks go by score, then by who reached it first.

The cache in the room

Each room keeps what it read and wrote of player data and entries for 5 seconds: a get within 5 seconds of the room’s last read or write of the same key or entry is answered from memory, with no call. So:

  • A room always sees its own writes. After its own put, the next get of that entry is a call, since the data service sets the entry’s times; the rest is cached as above.
  • Another writer’s change, from another room, a client or your backend, shows within 5 seconds. Call refresh() on the player’s data or the collection to drop what the room holds and read it again.
  • list, top and around are never cached; each is a call.

The cache is what keeps a get in onTick from reaching the limits, but a hit doesn’t extend it: a get every tick still makes a call every 5 seconds. Load what a player needs in onJoin, keep it in conn.data or private state, and write it back when it changes, rather than calling get every tick for data the room is the only writer of. Don’t call list or top per tick; call them on an event, or every few seconds.

From a client

Clients use the same calls for the player they are, over the router’s HTTP API, with or without a room connection:

// client-data.ts
import { connect, dataClient } from "@irt/client";

const router = "wss://eu.irt.io";
const data = dataClient({ router, project: "my-game" });
await data.playerData.set("settings", { volume: 0.5 });
const best = await data.leaderboard("highscores").top({ period: "week", limit: 10 });
const me = await data.leaderboard("highscores").around({ count: 2 });

const room = await connect({ router, project: "my-game", room: "arena" });
const levels = await room.collection("levels").list({ order: "-score" });
console.log(best.entries, me?.rank, levels.entries);

client.playerData, client.collection(name) and client.leaderboard(name) on a connected RoomClient are the same as dataClient’s. A guest with no token gets one from POST /v1/guest on its first call, kept where connect keeps its guest token, so a guest is one player across rooms and data calls. A player token from your backend works too: pass token. A client may:

  • read its own player’s data, and write only the keys in data.player.clientWrite;
  • get and list entries and read a board with clientRead;
  • with clientWrite: "own", create entries, change and delete its own, and submit its own score. owner is always set to the client’s player, whatever it sends, and maxOwn caps how many it creates.

A score a client submits can be faked: any player can send any number. Submit from room code, which decides the score, for any board that matters. Native rooms, which run no room code, need clientWrite: "own".

Writes that may or may not have happened

Every call has a 5-second deadline. When the data service can’t be reached in time, room code and clients get E_UNAVAILABLE; nothing is queued. A write that timed out may or may not have been made. A room can also wake after a host failure without the last 30 seconds of its room state, while the data writes it made in them are kept. Either way, room code may make a write again that already happened.

From room code, give each write a once key built from what happened, and repeating it is safe. A write whose key the project used in the last day doesn’t run again: it resolves as the first one did, without counting as a write. The same key with different arguments fails with E_INVALID. Every write takes it: set, increment and delete on player data, put and delete on a collection, and submit on a leaderboard. Keys are 1 to 64 characters of letters, digits, _, ., : and -.

// reward.ts
import { DataError, type Room } from "@irt/room";

// The match's ID was in room state since the match began, so a room that wakes after a host
// failure still knows it, and rewarding the match again adds nothing.
export async function reward(room: Room, matchId: string, winners: string[]): Promise<void> {
  for (const player of winners) {
    for (let attempt = 0; ; attempt++) {
      try {
        await room.playerData(player).increment("coins", 50, { once: `reward:${matchId}:${player}` });
        break;
      } catch (e) {
        if (e instanceof DataError && e.code === "E_UNAVAILABLE" && attempt < 3) continue;
        throw e;
      }
    }
  }
}

To pair a write with room state, keep what you’re about to do in room state first, such as the matches still to reward, then write with a once key derived from it, then clear it. In onWake, finish whatever is still there. The key has to come from something in room state from before the write, not from a counter the lost seconds could reset, or the repeat gets a new key and runs again. A repeated increment resolves with the number the first one made, which may have changed since; get reads it now.

Clients can’t use once, so a player can’t take a room’s key first. Retry a client’s write with version, which fails with E_VERSION if it was made:

// retry.ts
import { DataError, type PlayerData } from "@irt/room";

export async function saveOnce(data: PlayerData, value: unknown, version: number): Promise<number> {
  for (let attempt = 0; ; attempt++) {
    try {
      return (await data.set("save", value, { version })).version;
    } catch (e) {
      if (!(e instanceof DataError)) throw e;
      if (e.code === "E_UNAVAILABLE" && attempt < 3) continue;
      if (e.code === "E_VERSION") {
        const now = await data.get("save");
        // Written by the try that timed out, or by another writer: compare before writing again.
        if (now && JSON.stringify(now.value) === JSON.stringify(value)) return now.version;
      }
      throw e;
    }
  }
}

Who wrote an entry

Use owner for authorship, not meta. A client’s writes always carry its own player as owner, set by the data service, while meta is whatever the writer sent, so a meta.author a client set proves nothing. list({ owner }) finds a player’s entries, and deleting a player through the server API deletes every entry they own.

Errors

Calls reject with DataError, whose code is one of:

Code When
E_INVALID A malformed call, or one over a size limit
E_FORBIDDEN The caller may not do it, such as a client writing a key not in clientWrite
E_UNKNOWN The collection isn’t declared in the live build, or it’s the wrong kind for the call
E_VERSION version didn’t match
E_TYPE increment on a value that isn’t a number
E_QUOTA Over a player’s 256 keys or 1 MB, a collection’s 1,000,000 entries, or maxOwn
E_LIMIT Over a rate limit
E_SPENDING The project is over its spending limit, or out of credit
E_UNAVAILABLE The data service couldn’t be reached; a write may or may not have happened

Limits

What Limit
A value 64 KB of JSON or bytes
A player 256 keys, 1 MB
meta 1 KB, flat strings, numbers and booleans
A collection 1,000,000 entries
A project 32 collections, 2,000 calls a second
A room 200 calls a second, 64 at once
A client address, and a player 10 calls a second
Guests minted per client address 10 a minute
A request 128 KB

Guests’ player data is deleted 90 days after it was last written; a guest whose token is lost can’t get back to it. Player data of your own player IDs is kept until your backend deletes the player. Calls are metered and free within the limits; see Limits.

Locally and in tests

irt dev and self-hosted room hosts run the same calls on SQLite, in data.sqlite in the data directory, .irt/data for irt dev, and serve POST /v1/data/{project} and POST /v1/guest as the router does. @irt/testing keeps it in memory and lets a test seed and read it:

// data.test.ts
import { createTestHost } from "@irt/testing";
import { expect, test } from "vitest";
import room from "./src/room.js";

test("a finished level is saved and scored", async () => {
  const host = createTestHost(room, {
    settings: { data: { collections: { highscores: { leaderboard: { periods: ["all", "week"] } } } } },
  });
  await host.playerData("ada").set("save", { level: 3, coins: 0 });
  const ada = await host.room("arena").connect({ player: "ada" });
  ada.send({ type: "level-done", score: 1200 });
  await host.tick();
  expect(await host.playerData("ada").get("save")).toMatchObject({ value: { level: 4 } });
  const top = await host.leaderboard("highscores").top({ period: "week" });
  expect(top.entries[0]).toMatchObject({ player: "ada", score: 1200 });
});

View this page as Markdown