Lobby panel
<irt-lobby> started as a share panel: the room code, a link, a QR, a connection dot. It is also the
front door. With one attribute it renders a chooser before a room exists, joins a public game when
the player asks for one, and holds everybody at a ready screen until the game starts.
The element has no dependencies. It never imports the SDK and never joins anything itself: it
renders, and it emits. attachLobby from @irtio/client is the one-liner that answers those events
for you, and everything it does you can do by hand.
<irt-lobby quick-match></irt-lobby>
<script type="module">
import { attachLobby } from '@irtio/client';
import '@irtio/lobby';
import { schema } from './irtio/schema.js';
attachLobby(document.querySelector('irt-lobby'), schema, {
onRoom: (room) => start(room),
});
</script> Modes
Two boolean attributes, and they mean nothing on their own.
| attributes | what the panel does |
|---|---|
| (neither) | today’s panel: code, link, QR, status, optional name box. The default, unchanged. |
quick-match | a chooser before the room exists: Start a private lobby and Join a game |
quick-match no-private | Join a game only. No code, no link, no QR anywhere. |
no-private alone | invalid. The panel renders as it always did and says so once on the console. |
no-private on its own is refused rather than guessed at, because a panel with no private games and
no quick match has no way into a game at all.
Once a room is attached the chooser is gone and the panel is the panel again, with the lobby section below added if the room has a lobby.
Attributes
| attribute | meaning |
|---|---|
room-code, link, status | what the panel shows. attach(room) keeps them current for you. |
name-entry | render the name box, which emits name before you join. |
players, max-players | the count in the status row. |
modal, open | render inside a full-viewport backdrop; open() / close() / toggle(). |
quick-match, no-private | the mode table above. |
searching | render “finding a game…” instead of the chooser. attachLobby sets and clears it. |
waiting, waiting-of | n of m waiting under the searching state. Both, or neither. |
queue | the queue name the Join a game event carries. |
hide-ready | draw the roster but not the ready button, for a game that draws its own. |
Every one mirrors a property (el.quickMatch = true), so a framework can drive the element without
touching attributes.
Events
The chooser emits and your code answers. Both events bubble and cross the shadow boundary.
| event | when | detail |
|---|---|---|
private | Start a private lobby clicked | none |
quickmatch | Join a game clicked | { queue } when the queue attribute is set |
name | the name box submitted | { name } |
Answering them by hand is three lines:
// main.ts
const panel = document.querySelector('irt-lobby');
panel.addEventListener('private', async () => panel.attach(await joinRoom(schema)));
panel.addEventListener('quickmatch', async (e) => {
panel.searching = true;
panel.attach(await joinPublic(schema, { queue: e.detail.queue }));
panel.searching = false;
}); Ready, and starting the game
A room opts into the lobby convention in two places: its schema carries the fragment, and its config says when to start.
// irtio/schema.ts
import { defineSchema, entity, f32 } from '@irtio/schema';
import { lobbyCollections } from '@irtio/server';
export const schema = defineSchema({
...lobbyCollections,
players: entity({ x: f32, y: f32 }),
}); // irtio/room.ts
export default defineRoom(schema, {
mode: 'tick',
maxClients: 4,
lobby: { start: 'when-ready', min: 2 },
tick() {},
}); Both are needed. A config without the fragment, or a fragment without a config, is refused at deploy time by name, because a lobby that silently never starts is a bug nobody can see.
The fragment is a spread rather than a flag because both sides of the wire have to agree about the shape of the state, and the schema is where they agree about everything else. The lobby travels the ordinary state sync: no new frames, no new protocol version, nothing to upgrade.
When it starts
start | behaviour |
|---|---|
'when-full' (default) | the moment the room reaches maxClients. No ready button: everyone who landed here asked to play. |
'when-ready' | when every present player is ready and min is met. This is the one with a ready button. |
'manual' | never by itself. Your code calls room.lobby.start(). |
'when-full' is the default because it is right for the quick-match path: a player who pressed Join a game has already said yes, and asking them to say it twice is a worse game.
room.lobby
room.lobby.phase // 'lobby' | 'started'
room.lobby.ready // [{ clientId, ready }, …] in join order
room.lobby.public // is this room findable by strangers right now
room.lobby.start() // start now; idempotent, legal under every policy
room.lobby.onStart(cb) // a handler for this run of the room
room.lobby.setPublic(v) // enter or leave the public registry lobby.onStart in the config is the handler that survives a hibernation, because the config is code. room.lobby.onStart(cb) is for a callback this run of the room wires up itself.
The game starts once. A second start() is not a second game, and the handlers run once.
The client’s side
import { lobbyOf } from '@irtio/client';
const lobby = lobbyOf(room);
lobby.phase // hold your game at its lobby screen until this is 'started'
lobby.players // [{ clientId, ready, connected, me }, …]
lobby.readyUi // true only under 'when-ready'
lobby.ready(true) // this player is ready
lobby.on('change', render) attachLobby builds this for you and hands it to the panel. Pass it yourself when you are drawing
your own lobby screen.
The ready flag is an ordinary owner write: each player owns their own record, and pressing the button writes one boolean. That is why the feature needs no RPC and no protocol change, and it is also why your validators and your rate limits already apply to it.
The public toggle
A private lobby can be opened to strangers. The room owns that truth, always:
// irtio/room.ts: your own RPC. The platform does not take this from a client.
rpc: {
makePublic(state, { value }, ctx) {
if (ctx.clientId !== state.match.host) return; // your rule, not ours
ctx.room.lobby.setPublic(value);
},
} // main.ts
const lobby = lobbyOf(room, { setPublic: (v) => void room.call.makePublic({ value: v }) }); The panel renders the room’s flag rather than the click, so a refusal shows up as the toggle
springing back rather than as a lie on screen. A room with no setPublic wired gets no toggle.
The room’s own lobby.onSetPublic(state, value) is the veto:
lobby: {
start: 'manual',
onSetPublic: (state) => state.match.status === 'lobby', // only before the game
} Returning nothing accepts. Returning false refuses. A handler that throws refuses too, because a
hook that said nothing at all has not agreed to expose the game.
An operator can also flip the flag from outside with the rooms API: PATCH /v1/projects/:id/rooms/:roomId with { "public": true }. It moves the same registry row.
The two doors are not a loop, and it is worth knowing which way each one points. A PATCH changes
whether strangers can find the room; the room is not told, so room.lobby.public and the panel’s
toggle go on showing what the room itself last decided. The room’s own setPublic is always
obeyed at the registry, including when it agrees with the flag the room already had, so calling setPublic(false) is how a game takes back a room an operator published.
When the game starts
The room leaves the public registry, and it leaves for good. There is no backfill into a game in progress in this release, and no report, retry or toggle can put a started room back in front of a stranger.
Styling
Every part of the panel is addressable with ::part(): status, players, code, link, qr, badge, choose, join, private, searching, waiting, roster, lobby-note, ready, public-toggle, and in modal mode backdrop, panel and close.
Next steps
- Quick match for the queue behind Join a game.
- Room reference for
room.lobbybeside the rest of the room API. - Rooms API for the
publicflag from outside the game.