# 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.

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

export default defineRoom({});
```

```ts
// 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:

```ts
// 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](/docs/guides/ownership), 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`:

```ts
// 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

```ts
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](/docs/guides/state-sync#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](/docs/reference/room-sdk#room) or an entity.
- **It isn't voice.** [Proximity voice](/docs/guides/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:

```ts
// 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);
});
```
