Player storage

Everything else a room keeps is room state. It lives in the room, it hibernates with the room, and it goes away when the room does. room.kv is the exception: a small key/value store, keyed by a player identity you choose, that outlives the room, the match, and the machine the room was running on. Another room in the same project reads back what this one wrote.

It is deliberately small. Values are strings, reads are by exact key, and there is no way to list, scan, or query. Think of it as a shelf you put labelled boxes on, not a database.

room.kv.get(playerId, key)             // Promise<string | undefined>
room.kv.set(playerId, key, value)      // Promise<void>
room.kv.delete(playerId, key)          // Promise<void>

The value arrives after the handler returns

All three methods return promises, and room handlers are synchronous. The continuation runs as its own event between ticks, after the handler that started it has already returned. So a value you ask for is not available in the handler that asked for it. There is no blocking read and no synchronous accessor, because either one would be a lie.

// irtio/room.ts (wrong: this will look like the store is empty)
onJoin(state, ctx) {
  let profile: string | undefined;
  void ctx.room.kv.get(ctx.playerId, 'profile').then((v) => { profile = v; });
  ctx.room.log(profile);   // always undefined
}

The right shape is to write the result into state from inside the continuation. State you mutate there is tracked and flushed exactly like state you mutate in a handler, so it reaches clients on the next delta.

// irtio/room.ts (right)
onJoin(state, ctx) {
  if (ctx.reconnecting) return;
  state.players.add(ctx.clientId, { /* … */ lifetimeWins: 0, profileLoaded: false });

  void ctx.room.kv.get(ctx.playerId, 'profile').then((json) => {
    const me = state.players.get(ctx.clientId);
    if (!me) return;                       // they left while the read was in flight
    me.lifetimeWins = json ? (JSON.parse(json) as { wins: number }).wins : 0;
    me.profileLoaded = true;
  });
}

The profileLoaded flag is not decoration. Between the join and the continuation the field holds its default, so without it the client cannot tell “no wins yet” from “not loaded yet” and will show a zero that turns into a real number a moment later.

A worked example

A room that carries each player’s lifetime win count across sessions. The room reads the profile on join, publishes it in state, and writes it back when a match ends.

The schema

// irtio/schema.ts
import { defineSchema, entity, singleton, bool, enumOf, str, u16, u32 } from '@irtio/schema';
import { rpc } from './rpc.js';

export const schema = defineSchema(
  {
    players: entity(
      {
        // The room has to keep this itself. `room.clients` carries clientId, role, name and
        // connected, and does not carry playerId, so a room that wants to write to a player
        // other than the one whose RPC it is handling needs the id on the record.
        playerId: str(128),
        name: str(24),
        score: u16,
        lifetimeWins: u32,
        profileLoaded: bool,
      },
      { serverOwned: true },
    ),
    match: singleton({ phase: enumOf('lobby', 'play', 'over') }, { serverOwned: true }),
  },
  {
    project: 'p_your_project_id',
    roles: ['player'] as const,
    rpc,
  },
);

playerId is visible to every client here, which is fine today because it is the client id every client can already see. If your game later keys storage by something you would rather not publish, move that field into its own collection with visibility: 'role'.

The RPCs

// irtio/rpc.ts
import { server, u16 } from '@irtio/schema';

export const rpc = {
  start: server({}),
  score: server({ params: { points: u16 } }),
  finish: server({}),
};

The room file

// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';

/** One key per player. irtio never parses the value, so the shape is yours to keep stable. */
const PROFILE_KEY = 'profile';

interface Profile {
  wins: number;
}

/** A row you cannot parse is a row an older version of your game wrote. Do not throw over it. */
function readProfile(json: string | undefined): Profile {
  if (!json) return { wins: 0 };
  try {
    const parsed = JSON.parse(json) as Partial<Profile>;
    return { wins: typeof parsed.wins === 'number' ? parsed.wins : 0 };
  } catch {
    return { wins: 0 };
  }
}

export default defineRoom(schema, {
  mode: 'event',

  onCreate(state) {
    state.match.phase = 'lobby';
  },

  onJoin(state, ctx) {
    if (ctx.reconnecting) return;

    state.players.add(ctx.clientId, {
      playerId: ctx.playerId,
      name: ctx.name || 'anon',
      score: 0,
      lifetimeWins: 0,
      profileLoaded: false,
    });

    void ctx.room.kv
      .get(ctx.playerId, PROFILE_KEY)
      .then((json) => {
        const me = state.players.get(ctx.clientId);
        if (!me) return;
        me.lifetimeWins = readProfile(json).wins;
        me.profileLoaded = true;
      })
      .catch((err: unknown) => {
        // The room keeps running. A profile that failed to load reads as zero wins,
        // and `profileLoaded` stays false so the client can say so.
        ctx.room.log('profile read failed', err);
      });
  },

  onLeave(state, ctx) {
    if (state.players.has(ctx.clientId)) state.players.remove(ctx.clientId);
  },

  rpc: {
    start(state) {
      if (state.match.phase !== 'lobby') return;
      state.match.phase = 'play';
    },

    score(state, { points }, ctx) {
      if (state.match.phase !== 'play') return;
      const me = state.players.get(ctx.clientId);
      if (me) me.score += points;
    },

    finish(state, _params, ctx) {
      if (state.match.phase !== 'play') return;
      state.match.phase = 'over';

      let bestId: string | undefined;
      let bestScore = -1;
      for (const [id, player] of state.players) {
        if (player.score > bestScore) {
          bestId = id;
          bestScore = player.score;
        }
      }
      if (bestId === undefined) return;

      const winner = state.players.get(bestId)!;
      const wins = winner.lifetimeWins + 1;
      winner.lifetimeWins = wins;

      // Read, modify, write. There are no counters and no atomic operations: the last write
      // for a key wins, so hold the current value in state and write the whole value back.
      void ctx.room.kv
        .set(winner.playerId, PROFILE_KEY, JSON.stringify({ wins } satisfies Profile))
        .catch((err: unknown) => ctx.room.log('profile write failed', err));
    },
  },
});

The client

// src/main.ts
import { joinRoom } from '@irtio/client';
import { schema } from '../irtio/schema.js';

const room = await joinRoom(schema, { name: 'you' });
const banner = document.querySelector('#profile') as HTMLElement;

document.querySelector('#start')!.addEventListener('click', () => {
  room.call.start().catch((err: unknown) => console.error('start failed:', err));
});

document.querySelector('#finish')!.addEventListener('click', () => {
  room.call.finish().catch((err: unknown) => console.error('finish failed:', err));
});

function frame() {
  const me = room.state.players[room.me];
  banner.textContent = !me
    ? 'joining'
    : !me.profileLoaded
      ? 'loading your profile'
      : `${me.lifetimeWins} lifetime wins, ${me.score} this match`;

  requestAnimationFrame(frame);
}
frame();

room.state.players[room.me] is undefined for a frame or two after the page loads, because the record is created by onJoin on the server. Guard it, as above.

Limits

Every limit is enforced at the gateway, not by convention, and each one has its own error name so you can tell a limit you can design around from an outage you cannot.

LimitValue
Value size16 KiB of UTF-8
Key size256 bytes of UTF-8
Player id size256 bytes of UTF-8
Keys per player, per project128
Rows per project1,000,000

Values are strings. JSON-encode structured data yourself. irtio never parses the value and has no opinion about what is in it.

ErrorMeans
E_KV_VALUE_TOO_LARGEThe value is over 16 KiB
E_KV_TOO_MANY_KEYSThis player already holds 128 keys in this project
E_KV_BAD_KEYEmpty, oversized, or containing a control character
E_KV_BAD_PLAYERSame, for the player id
E_KV_PROJECT_FULLThe project is at its row limit
E_KV_FORBIDDENA read outside this project’s confinement
E_KV_UNAVAILABLEThe store could not be reached. The only one that is not your bug.

A rejected call rejects its promise and the room keeps running. Attach a .catch() and decide what the game does without the value.

What it is not

  • It is not queryable. No listing, no prefix scans, no secondary indexes, no sorting. The only access path is an exact (playerId, key) lookup. You cannot build a leaderboard by asking the store for the top scores, because there is no route that answers that question.
  • Confinement is per project, not per player. A room may pass any playerId, and reading another player’s row on purpose is allowed. That is what lets the example above write to the winner rather than only to the caller. A read across projects is refused by the gateway.
  • There are no transactions or atomic operations. Read, modify, write, and the last write for a key wins. Two rooms writing the same key at the same time will lose one of the writes.

ctx.playerId, and how long it lasts

ctx.playerId is the identity room.kv is meant to be keyed by, and which one you get depends on how the client joined.

  • Key joins (the ordinary joinRoom({ key }) path, and the default): ctx.playerId is the client id, which a resume token carries across a reconnect. It is stable exactly as long as that token is — reconnectGraceMs, 30 seconds by default — and no longer. A player who comes back after their token has expired arrives as a new playerId, with none of their old rows.
  • JWT joins (token in joinRoom, see reference/client): ctx.playerId is "<iss>:<sub>", the verified token’s subject namespaced by the issuer that minted it. It is durable — the same person gets the same playerId across devices and across time — which is what makes it the identity to build a real player record on.

That is enough for “keep this player’s progress across a disconnect or a page reload” on a key join. It is not enough for “this is the same person next week” until the room adopts JWT. Say what your game actually offers, and do not build a feature that promises durable accounts on top of a key join.

If your game hasn’t adopted JWT yet

Three options, in the order you should want them:

  1. Adopt JWT. Your own server mints the token, ctx.playerId becomes "<iss>:<sub>", and the identity is as durable as your accounts are. It is the only one of the three where irtio actually verifies that the player is who they claim to be.
  2. Pass your own stable id. room.kv’s playerId argument is just a string, and irtio never inspects it — ctx.playerId is only the default value you have in hand, not a requirement. Send whatever identity your game already has (a launcher id, a per-device id you generated once and kept in localStorage) and pass that instead of ctx.playerId to room.kv.get/set/delete. This survives reloads and works today, but it is client-asserted: nothing verifies it, so anyone can claim anyone’s id, and you should key nothing on it you would mind a stranger overwriting.
  3. Key on the room instead of the player. A per-room record under one fixed playerId — the room id, say — is durable and un-spoofable, and covers “this table’s running score” even though it cannot follow a player into the next room.

Adopting JWT later starts a player’s storage over. Rows written under a key join are keyed by the client id; once the project starts verifying tokens, ctx.playerId becomes "<iss>:<sub>", an entirely different key space. There is no automatic migration, and because room.kv has no listing or scan capability, there is no way to write one either — old rows stay keyed by the dead client id forever. Decide your identity before you store anything you would miss.

Reach for this when

  • A player should keep something between sessions: unlocks, a cosmetic choice, a settings blob, a tutorial-completed flag, a running total.
  • You know the exact key you want to read, at the moment you want to read it.
  • The value is small and you are happy to encode it yourself.

Do not reach for this when

  • You need to rank, list, or search across players. There is no query path, and there will not be one you can fake with 128 keys.
  • You need the value inside the handler that asked for it. Restructure around the continuation instead.
  • You need the state of the room itself to survive. That is hibernation, plus saves.
  • You are storing something large. 16 KiB is the ceiling, and a big blob per player is a sign the data belongs in your own backend.

Next steps