# Lobbies and parties

> Gather players in a lobby declared in irt.config.ts, with ready and start rules, a countdown and bots, and hand them off to a room of another type; keep a party together across rooms; and hand players off from your own room code.

Most multiplayer games start with players gathering: friends with an
invite code, or strangers quick joining until there are enough. A
**lobby** does this without any room code. It's a
[native room](/docs/concepts#rooms) with lobby settings: players join
it, mark themselves ready, and when its start rule holds it counts down
and hands everyone off to a new room of another type. The router makes
that room, holds a seat in it for each player, and tells their clients,
which connect by themselves. A failure, such as the project being at its
room limit, leaves everyone in the lobby with the reason.

## Declare a lobby

A lobby is a native room type with `lobby` settings, in
`irt.config.ts`:

```ts
// irt.config.ts
import { defineConfig } from "@irt/cli/config";

export default defineConfig({
  project: "space-race",
  rooms: {
    lobby: {
      native: true,
      size: "small",
      lobby: {
        minPlayers: 2,
        maxPlayers: 4,
        start: "allReady",
        countdown: 5,
        to: { type: "match" },
        bots: { wait: 20, upTo: "min", behaviour: "racer" },
      },
    },
    match: { room: "src/match.ts", maxPlayers: 4 },
  },
});
```

| Setting | Meaning |
|---|---|
| `maxPlayers` | The lobby's seats, at most its size's connection limit and the `to` type's `maxPlayers` or connection limit. A lobby type doesn't also set the type's own `maxPlayers` |
| `minPlayers` | The fewest people and bots the countdown starts with, 1 to `maxPlayers`. Default 1 |
| `start` | `"allReady"` (the default): every person is ready. `"leader"`: only the leader's `start`. `"full"`: a join, of a person or a bot, brings the lobby to `maxPlayers`. Under every rule the leader can also call `start` |
| `countdown` | Seconds from the start rule holding to the handoff, 0 to 60. Default 5 |
| `to` | `{ type }`, a room type of the build, standard or native: each handoff makes a new private room of it. With `quickJoin: true` the people go to a public room of it instead, with no bots; see [Parties](#parties) |
| `stay` | Keep the players connected to the lobby while they play. Default false |
| `bots` | Optional: fill seats with bots, for a standard `to` type; see [Bots](#bots) |

`irt build` and `irt deploy` refuse a lobby whose `to.type` isn't a type
of the build, whose `maxPlayers` is more than the target seats, with
`bots` and a native target or `quickJoin`, with `upTo: "min"` and
`start: "full"`, with `quickJoin` and no `stay`, or with bots running a behaviour the target
room doesn't declare. The manifest carries the settings on the type as
`lobby`, every default filled in.

## What players see

```
waiting -> countdown -> handing-off -> waiting
```

- **waiting**: people join and set ready. The countdown starts when the
  start rule holds, or the leader calls `start`, with at least
  `minPlayers` people and bots.
- **countdown**: ends after `countdown` seconds, or goes back to waiting
  when the lobby drops below `minPlayers`, or, under `allReady`, someone
  un-readies. The leader can cancel it, which clears every ready flag,
  so the countdown doesn't start again at once.
- **handing-off**: the lobby has asked the router for the room and takes
  no one new; a connection arriving now is refused with 4003. It ends
  with the router's answer, or after 15 seconds without one.

Then it's back at waiting with every ready flag cleared. Without `stay`
its people have moved on, so a public lobby is a standing place to
gather, not one room per game. A lobby that is still full after a
handoff, such as a `"full"` party, doesn't count down again until
someone joins it at `maxPlayers` or the leader calls `start`. A person
whose client couldn't reach the new room stays in the lobby as before,
and the next handoff takes them too.

The client SDK keeps the lobby's state in `client.lobby`, and marks
ready, starts and cancels with calls:

```ts
// web/lobby.ts
import { connect, type RoomClient } from "@irt/client";
import { matchSchema } from "./schema.js";

const lobby = await connect({
  router: "wss://eu.irt.io",
  project: "space-race",
  room: "public-lobby",
  type: "lobby",
  targets: { match: matchSchema },
});

lobby.on("lobby", (state) => {
  if (state.phase === "countdown") console.log(`starting at ${new Date(state.startsAt!)}`);
  if (state.failed) console.log("the last start failed:", state.failed);
});

lobby.onHandoff((handoff) => {
  if (handoff.error) return console.log("couldn't join the match:", handoff.error.message);
  play(handoff.client);
});

await lobby.setReady(true);

declare function play(match: RoomClient<any, any, any>): void;
```

```ts
// web/schema.ts
import { defineSchema, entity } from "@irt/client";

export const matchSchema = defineSchema({ state: entity("race", { lap: "u8" }) });
```

`client.lobby` is `{ phase, ready, startsAt, min, max, failed }`:
`ready` lists the ready people, in the order they joined, then every
bot, which is always ready; `startsAt` is when the countdown ends, in
the room's wall-clock milliseconds, so compare it with
`client.serverTime()`; `failed` is why the last handoff failed, until the
next countdown: `players` (too few were still connected), `limit` (the
project is at `limits.maxRooms`, or the lobby handed off 6 times in a
minute), `spending` or `unavailable`.

**Following a handoff.** When the router hands the player off, the
client connects to the new room itself, with the schema `targets` names
for its type, and once it holds the room's state it leaves the lobby,
unless the lobby has `stay`, and calls `onHandoff` with the new
`RoomClient`. If it can't connect, `onHandoff` gets the error and the
client stays in the lobby. The router holds each player's seat for 10
seconds from when their client reaches it, through a wake. A token bound
to the lobby by its `room` claim is accepted at a room the lobby's
handoff created, for each person it brought there, so a reconnect later
works too.

A client older than protocol 4 can ready and start through calls, but
the router never moves it: it's left out of the handoff, as someone who
just disconnected would be.

## The lobby element

`<irt-lobby>`, from the [UI kit](/docs/ui-kit) at `@irt/client/ui/lobby`,
shows an `<irt-invite>` for the code, share link and QR code, the
lobby's players with who is ready and who leads, a ready button, start
and cancel buttons for the leader, a "Make leader" button beside each
other connected player for the leader, the countdown, and why a handoff
failed. Give it the lobby's client, and take the new room's client from
its `handoff` event. The invite, `el.invite`, replaced the lobby
element's name field and create and join buttons; find the lobby with
it or the [room browser](/docs/ui-kit#irt-room-browser), and theme it
with the kit's shared `--irt-*` properties, which replaced
`--irt-panel-*`.

```html
<irt-lobby router="wss://eu.irt.io" project="space-race" qr></irt-lobby>
```

```ts
// web/element.ts
import { RoomClient } from "@irt/client";
import "@irt/client/ui/lobby";
import { matchSchema } from "./schema.js";

const el = document.querySelector("irt-lobby")!;
el.invite.addEventListener("room-join", (e) => {
  el.client?.leave();
  el.client = new RoomClient({ router: el.router, project: el.project, room: e.detail.room, type: "lobby", targets: { match: matchSchema } });
});
el.addEventListener("handoff", (e) => {
  if (e.detail.client) el.hidden = true;
});
```

The invite joins the room a share link names and fires `room-join`. For
a lobby found another way, such as by quick join, set `el.invite.room`
and `el.invite.code` so players can share it. The start button shows to
the leader while the lobby waits, and the cancel button during the
countdown. Its text is in `labels`, as `LobbyLabels`, with
`countdown`'s `{n}` for the seconds left. It takes the kit's theme,
opens as a dialog with the `dialog` attribute, and its parts are listed
on the [UI kit](/docs/ui-kit#irt-lobby) page.

## Bots

With `bots`, the lobby fills empty seats so a game can start without
waiting for strangers:

- `wait` seconds after its first person joins, 20 by default, the lobby
  adds bots until people and bots reach `upTo`: `"min"`, the default,
  is `minPlayers`, and `"max"` is `maxPlayers`. It keeps them at that
  number, so each person who joins takes a bot's seat.
- A bot is in presence with a `bot:` ID and a name, never in voice, and
  always ready. In the lobby it only holds a seat.
- The handoff brings the bots along: the new room has them in
  `room.bots`, with the same IDs and names, running the `behaviour`
  named in the settings, before its `onCreate` runs. See
  [Bots](/docs/guides/bots).
- After a handoff the lobby has no bots, since they went with the
  people, and the wait for new ones starts again. An empty lobby has no
  bots, and one that hibernates comes back with none.

## Parties

A **party** is a lobby with `stay: true`, usually private with a join
code. Its people keep their party connection while they play, so the
party keeps its leader, invite code and presence, and when they're done
with the match they're already back in the party's waiting phase, ready
for the next one. Nothing has to send them back: they never left.

- `to: { type }` takes the party to a new private room, as for any lobby.
- `to: { type, quickJoin: true }` takes it into a public room of that
  type whose `meta` matches the party's own `meta`, with space for
  everyone, the fullest first, as quick join picks; else a new public
  room with that `meta`. The router holds a seat for each person, so
  players quick joining at the same moment don't split the party.
  `quickJoin` needs `stay`, and takes people only, so it can't have
  `bots`.
- A party is billed as the native room it is, for every hour anyone is
  connected to it, the time they spend in the match included.

## From your own room code

A standard room with its own idea of a lobby, with team picks or a map
vote, asks the router for the same handoff with `room.handoff`:

```ts
// src/hall.ts
import { defineRoom, HandoffFailed, isBot } from "@irt/room";

export default defineRoom({
  async onMessage(room, _conn, message) {
    if ((message as { type?: string }).type !== "go") return;
    const people = [...room.presence].filter((p) => p.connected && !isBot(p.player));
    try {
      const { room: id } = await room.handoff({ players: people, to: { type: "match", meta: { map: "dunes" } }, stay: true });
      room.broadcast({ type: "started", room: id });
    } catch (e) {
      if (e instanceof HandoffFailed) room.broadcast({ type: "failed", reason: e.reason });
      else throw e;
    }
  },
});
```

It moves people connected to the calling room only, never bots, into a
room of a type of the live build: a new private one with `to.meta`, or
with `quickJoin` a public one, and resolves once their clients have been
told. It rejects with `HandoffFailed`, whose `reason` is `players`,
`limit`, `spending`, `unavailable` or `invalid`, and nobody moves. A
room hands off at most 6 times a minute.

The room a handoff created has `room.from`, `{ lobby, players, bots }`,
before `onCreate`: the room they came from and who it brought, with the
names and statuses they had there. It's `null` in a room no handoff
created, and in one a quick join picked or made.

## Testing

`@irt/testing` runs lobbies in process, with the target type in
`types`:

```ts
// test/lobby.test.ts
import { defineRoom, nativeRoom } from "@irt/room";
import { createTestHost } from "@irt/testing";
import { expect, it } from "vitest";

it("moves two ready players into a match", async () => {
  const host = createTestHost(nativeRoom(undefined, { lobby: { minPlayers: 2, maxPlayers: 2, countdown: 0, to: { type: "match" } } }), {
    types: { match: defineRoom({}) },
  });
  const lobby = host.room("lobby");
  const [ada, bob] = [await lobby.connect({ player: "ada" }), await lobby.connect({ player: "bob" })];
  await ada.client.setReady(true);
  await bob.client.setReady(true);
  await host.tick(4);
  expect(ada.handoffs[0]?.room).toBe(bob.handoffs[0]?.room);
});
```
