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)indefineRoomdoes: 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 withroom.ban, and connections the room kicks inonJoinbefore anyawait, 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.entitiesdoesn’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 throughroom.presence, notroom.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);
});