# 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](/docs/http-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](/docs/room-state), which is room state.

## Declaring it

```ts
// 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

```ts
// 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](#writes-that-may-or-may-not-have-happened). `put` replaces the whole entry:
`meta`, `score` and `owner` left out become empty.

```ts
// 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:

```ts
// 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 `-`.

```ts
// 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:

```ts
// 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](/docs/limits#player-data-collections-and-leaderboards).

## Locally and in tests

`irt dev` and [self-hosted](/docs/self-hosting) 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:

```ts
// 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 });
});
```
