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 leastminPlayerspeople and bots. - countdown: ends after
countdownseconds, or goes back to waiting when the lobby drops belowminPlayers, or, underallReady, 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:
waitseconds after its first person joins, 20 by default, the lobby adds bots until people and bots reachupTo:"min", the default, isminPlayers, and"max"ismaxPlayers. 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 thebehaviournamed in the settings, before itsonCreateruns. 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 whosemetamatches the party’s ownmeta, with space for everyone, the fullest first, as quick join picks; else a new public room with thatmeta. The router holds a seat for each person, so players quick joining at the same moment don’t split the party.quickJoinneedsstay, and takes people only, so it can’t havebots.- 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);
});