Presence and lifecycle

Read room.clients for the players in a room. Lifecycle callbacks handle joins and departures; connection status lets your UI show connecting and reconnecting states. For online status across rooms, see social presence.

The server lifecycle

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

import { schema } from './schema.js';

export default defineRoom(schema, {
  mode: 'tick',
  tickRate: 20,
  maxClients: 8,
  reconnectGraceMs: 30_000,

  // Once, when the room is first created and before anyone has joined.
  onCreate(state, room) {
    state.match.phase = 'lobby';
    room.log('room created');
  },

  onJoin(state, ctx) {
    // A resumed session runs onJoin again with reconnecting: true. Their record is still here.
    if (ctx.reconnecting) return;
    state.players.add(
      ctx.clientId,
      { x: 0, y: 0, name: ctx.name || 'anon', score: 0 },
      { owner: ctx.clientId },
    );
  },

  onLeave(state, ctx, reason) {
    // reason: 'left' | 'timeout' | 'kicked' | 'closed'
    state.players.remove(ctx.clientId);
  },

  tick(_state, _dt, _room) {},
});

onJoin is the handler people skip, and skipping it is the most common first-run failure. irtio creates no per-player record for you, so without it room.state.players[room.me] is undefined forever in the browser. See Ownership for the three details that have to line up in that add() call.

onLeave fires once per client, with a reason:

ReasonWhat happened
leftThe client called room.leave(), or closed the page cleanly.
timeoutThe client dropped and did not come back within reconnectGraceMs.
kickedThe room called room.kick(clientId).
closedThe room called room.close().

onSleep and onWake bracket hibernation, which is normal operation for an event-mode room rather than an error path. See Hibernation.

What ctx carries

Every lifecycle handler and every RPC handler receives a ctx describing the client that caused the call.

FieldMeaning
ctx.clientIdThis client’s id. The same value the client reads as room.me.
ctx.roleThe resolved role. An unknown or missing role becomes the schema’s first declared role, or '' if the schema declares none.
ctx.nameThe display name passed to joinRoom. '' if none.
ctx.tickThe server tick this join or call is applied at.
ctx.reconnectingTrue on a resumed session. Meaningful on joins, false everywhere else.
ctx.playerIdThe identity room.kv is keyed by. See Player storage.
ctx.roomThe room object, the same one the lifecycle handlers get.

The roster on the server

// irtio/room.ts
for (const c of room.clients) {
  // { clientId, role, name, connected }
}

room.setRole(clientId, 'spectator');
room.kick(clientId, 'idle too long');
room.close('match over');

room.clients is ordered by join, and by the time onJoin runs it already includes the client currently joining. In a schema with clientRoles: true, where clients pick their own role, that is what lets a room spot a second client asking to be host and demote it:

// irtio/room.ts
onJoin(state, ctx) {
  const alreadyHosted = ctx.room.clients.some(
    (c) => c.clientId !== ctx.clientId && c.role === 'host',
  );
  if (ctx.role === 'host' && alreadyHosted) ctx.room.setRole(ctx.clientId, 'controller');
  // ... add the player record
}

maxClients defaults to 64, in irtio/room.ts. A join past it is refused with a room-full error before onJoin runs.

Disconnects and the grace window

When a socket drops, the client is not removed. Its presence record flips to connected: false and its state stays exactly where it was, so a player who walks through a tunnel comes back to their own avatar rather than a fresh one.

reconnectGraceMs (30 000 by default, in irtio/room.ts) is how long that seat is held. Come back inside the window and the session resumes: the same client id, the same records, and onJoin running again with ctx.reconnecting true. onLeave has not run, which is why the record is still there for the guard to protect.

Miss the window and onLeave does fire, with reason timeout, and it takes the record with it. A client that comes back after that runs onJoin with ctx.reconnecting false and is spawned as a new player, so there is no state where a reconnect skips the spawn and leaves nobody behind.

The reconnecting guard in onJoin matters because of this. Without it, every reconnect adds a second copy of the player.

The roster on the client

// main.ts
for (const c of room.clients) {
  // { clientId, role, name, connected }
  renderRosterRow(c.clientId, c.name, c.role, c.connected);
}

room.clients is the same roster, ordered by join, and it updates as people arrive and leave. connected: false is a player who has dropped and is still inside the grace window. Showing them greyed out rather than removing them matches what the server is doing.

room.me is this client’s id, so room.state.players[room.me] is your own record.

Connection status

// main.ts
const room = await joinRoom(schema, {
  name: 'you',
  // Catches the statuses that happen during the join itself.
  onStatus(status) {
    banner.textContent = label(status);
  },
});

room.on('status', (status) => {
  banner.textContent = label(status);
});

room.status reads the current value, onStatus in the join options catches the ones that happen during the join itself, and room.on('status', cb) subscribes afterwards. There are five values.

StatusWhenWhat a UI should do
connectingAt the start of the join, before the socket opens.Show a connecting state. joinRoom has not resolved yet.
startingThe server said the room is still starting. The connection stays open.Say the room is starting. Do not treat it as an error and do not retry.
connectedThe room accepted the join and the first state has arrived.Normal play.
reconnectingThe socket dropped after a successful join. The SDK retries with full jitter from 250 ms, doubling to a 30 second cap; the ladder resets once a connection has held for 10 seconds.Keep the last known state on screen, show a reconnecting banner, and disable anything irreversible.
closedroom.leave(), or a fatal error.Terminal. Getting back in means a fresh joinRoom.

onStatus sees connecting. A listener registered after joinRoom resolves cannot.

Two limits:

  • A resumed session keeps the same room.me. If the resume token expired first, the client joins fresh and room.me is a new id. If you cache the id anywhere, re-read it after a reconnect.
  • status reports this client’s connection, not the room’s liveness. A room that hibernates while your socket stays open resyncs silently and status never leaves connected. starting fires only when your own connect attempt raced a room that was not running yet.

Errors are separate from status. room.on('error', ({ code, message, fatal }) => ...) reports what the server said, and a fatal one closes the room.

Presence is the roster, not a scratchpad

There is no separate per-client metadata channel. For “who is typing” or “who is ready”, put a field on the player record the client already owns and write it like any other state.

// irtio/schema.ts
players: entity({ x: f32, y: f32, name: str(24), typing: bool, ready: bool }),
// main.ts
const me = room.state.players[room.me];
if (me) me.typing = true;

That gets you the same thing with one mechanism instead of two, and the server can validate it.