Rooms

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 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:

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

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:

// 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;
// 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 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, and theme it with the kit’s shared --irt-* properties, which replaced --irt-panel-*.

<irt-lobby router="wss://eu.irt.io" project="space-race" qr></irt-lobby>
// 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 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.
  • 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:

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

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

View this page as Markdown