Player storage

room.kv is a small key/value store, keyed by a player identity you choose, that outlives the room and the match. Another room in the same project reads back what this one wrote.

room.kv is available to rooms that deploy room code. A relay project with a schema and no room file keeps per-player values with persist instead: see values that outlive the room.

Use it when a player should keep something between sessions. Everything else a room holds is room state: it lives in the room, it hibernates with the room, and it goes away when the room does.

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

It is deliberately small. Values are strings, reads are by exact key, and there is no way to list, scan, or query.

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.

// 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
}

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 in this example, where 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'. See Visibility.

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 by irtio, not by convention, and each one has its own error name.

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
Reads per project12,000 a minute (200 a second), with a burst of 3,000
Writes and deletes per project6,000 a minute (100 a second), with a burst of 2,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, containing a control character, or exactly . or ..
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, or a write for a player who has deleted their account
E_RATE_LIMITEDThe project as a whole went over its read or its write rate. Reads and writes are counted separately
E_KV_UNAVAILABLEThe store could not be reached. 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.

The rate limits count every room of the project together, not each room on its own. Saving on every change is fine for a small game, but hundreds of concurrent players each saving every few seconds add up. Keep the state in the room and write it back on a timer, at match end, or in onLeave, rather than on every change. After an E_RATE_LIMITED, wait before retrying: the bucket refills over a minute.

Usage notes

  • 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. See Leaderboards.
  • 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.
  • 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.

Choose a player identity

Use ctx.playerId as the storage key. Choose an identity before saving persistent progress:

Join optionPlayer IDLifetime
Project key onlyClient IDCurrent session and its resume window
identity: truePer-project account IDReturning browser sessions and linked devices
token<iss>:<sub>Your verified account’s lifetime

See player identity for built-in accounts, or authentication for your own login system. Changing identity methods changes the storage key; existing rows remain under their original keys.

Use leaderboards for rankings and saves for room state.