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.

attributeswhat the panel does
(neither)today’s panel: code, link, QR, status, optional name box. The default, unchanged.
quick-matcha chooser before the room exists: Start a private lobby and Join a game
quick-match no-privateJoin a game only. No code, no link, no QR anywhere.
no-private aloneinvalid. 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

attributemeaning
room-code, link, statuswhat the panel shows. attach(room) keeps them current for you.
name-entryrender the name box, which emits name before you join.
players, max-playersthe count in the status row.
modal, openrender inside a full-viewport backdrop; open() / close() / toggle().
quick-match, no-privatethe mode table above.
searchingrender “finding a game…” instead of the chooser. attachLobby sets and clears it.
waiting, waiting-ofn of m waiting under the searching state. Both, or neither.
queuethe queue name the Join a game event carries.
hide-readydraw 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.

eventwhendetail
privateStart a private lobby clickednone
quickmatchJoin a game clicked{ queue } when the queue attribute is set
namethe 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

startbehaviour
'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