Rooms

Presence

Who's in the room, with a name and a small status per player, synced to every client with no kinds to declare and no join or leave code.

Every room has presence: an entry for each player in it, with a name and a small status, synced to every client. The room runtime adds a player when it connects and removes it when it leaves, so a player list, “who’s here” avatars, a lobby screen or a typing indicator need no kind in your schema and no onJoin or onLeave code.

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

export default defineRoom({});
// web/players.ts
import { connect } from "@irt/client";

const room = await connect({ router: "wss://eu.irt.io", project: "my-game", room: "main" });

for (const p of room.presence) console.log(p.player, p.name, p.status);
room.presence.onJoin((p) => console.log(`${p.name || p.player} joined`));
room.presence.onLeave((p) => console.log(`${p.name || p.player} left`));
room.presence.onChange((p, changed) => console.log(p.player, "changed", changed));

room.presence.setStatus({ typing: true });

An entry

Field Type Who sets it
player string The player ID, from the token’s sub or a guest ID.
name string The room, with room.presence.set. It starts as the name string in the token’s meta, if there is one of at most 64 characters, else "".
status JSON The player, with setStatus, or the room. At most 1 KB serialized; null until set.
connected boolean The runtime: false for 10 seconds after the player’s last connection closes, then the entry leaves.

One entry per player, however often it reconnects. A dropped connection that reconnects within 10 seconds (PRESENCE_GRACE) keeps its entry and its status: other clients see connected go false and back to true, not a leave and a join. A player connecting again from a new tab, which closes its old connection with 4012, doesn’t change its entry at all.

Status

The status is the player’s own: a typing flag, “away”, a ready check, an emoji, which seat it’s looking at. Set it from the client:

// web/status.ts
import type { RoomClient } from "@irt/client";

export function markReady(room: RoomClient, ready: boolean): void {
  room.presence.setStatus({ ready });
}

setStatus changes the local copy at once and sends it with the client’s other owner writes, at most once per room tick. It throws a RangeError for a status over 1 KB as JSON. Like any owner write, a status not yet sent when the connection drops is lost, and the room has the last word: when it changes a player’s status, that player gets a correction and onChange runs for status.

A status is a claim from the player, so keep anything that decides the game out of it. Your room’s onWrite isn’t called for status writes; the room checks a status after the fact and replaces it with room.presence.set:

// src/room-status.ts
import { defineRoom } from "@irt/room";

export default defineRoom({
  onJoin(room, connection) {
    const meta = connection.meta as { displayName?: string } | null;
    room.presence.set(connection.player, { name: meta?.displayName?.slice(0, 64) ?? "guest" });
  },
  onTick(room) {
    for (const p of room.presence) {
      if (typeof p.status !== "object" || p.status === null) continue;
      if ("ready" in p.status && typeof p.status.ready !== "boolean") room.presence.set(p.player, { status: null });
    }
  },
});

In the room

interface Presence {
  get(player: string): PresenceEntry | undefined;
  readonly size: number;
  [Symbol.iterator](): IterableIterator<PresenceEntry>;
  set(player: string, values: { name?: string; status?: unknown }): void;
}

room.presence holds the same entries clients see. set throws for a player with no entry, a name over 64 characters or a status over 1 KB. A player’s entry exists before onJoin runs and is still there, with connected false from the next tick, when onLeave runs.

What it costs, and what it isn’t

  • Every connection sees every entry, whatever your interest says. A player list is useless with gaps, and a room has at most 1,024 connections, so the whole list is at most 1,024 small entries. An entry counts toward a connection’s cap of 16,384 visible entities, and a resync sends the whole list. Rooms hold at most 1,024 entries (MAX_PRESENCE); when a room is full of them, the entry of the player gone longest makes room.
  • Unless the room hides entries. Interest doesn’t apply to presence, but seesPresence(room, viewer, entry) in defineRoom does: a viewer sees another player’s entry only while it returns true, so a player who blocked someone stops seeing them in the list too. The client sees a hidden entry leave, and join again when it’s shown. A player always sees its own entry. Players banned with room.ban, and connections the room kicks in onJoin before any await, never get an entry anyone sees.
  • It isn’t room state. Presence is who’s connected now, so it isn’t saved and doesn’t survive hibernation: after a wake, the list fills again as players reconnect, with empty statuses. Keep per-player data that should last, such as settings or a seat, in private state or an entity.
  • It isn’t voice. Proximity voice ignores presence, so seeing every entry doesn’t make every player audible.
  • It isn’t one of your entities. room.entities doesn’t hold presence, it doesn’t change the IDs your entities get, and an entity you create with an explicit ID that a presence entry holds moves the entry, which clients don’t notice. On the client, read presence through room.presence, not room.entities.

Testing

Test clients have client.presence like any other:

// src/presence.test.ts
import { testRoom } from "@irt/testing";
import { expect, it } from "vitest";
import room from "./room-status.js";

it("shows who is ready", async () => {
  const r = testRoom(room);
  const alice = await r.connect({ player: "alice", meta: { displayName: "Alice" } });
  const bob = await r.connect({ player: "bob" });
  alice.client.presence.setStatus({ ready: true });
  await r.host.tick(2);
  expect(bob.client.presence.get("alice")).toMatchObject({ name: "Alice", status: { ready: true }, connected: true });
  expect(r.live.presence.size).toBe(2);
});

View this page as Markdown