Phones as controllers

One screen everybody looks at, and a phone in each player’s hand. The screen and the phones are the same web page in the same room, joined with different roles, and each role renders a different half of the game. examples/party-quiz is this pattern worked end to end: a shared quiz screen, any number of controllers, five questions.

The whole join story is a room code and a QR code, both of which <irt-lobby> draws for you.

What a player actually does

  1. Someone opens the host page. joinRoom creates a room and puts ?room=CODE in the address bar.
  2. The host page shows a room code, a share link and a QR code of that link.
  3. A player points a phone camera at the QR code and taps the notification. No app, no store, no account.
  4. The phone’s page asks for a name, then joins the same room as a controller.
  5. The phone shows buttons. The screen shows the game.

Nothing in that flow needs your server. The room code lives in the URL, and the project id in your schema tells the client which project it belongs to.

Two roles

Roles are declared in the schema and requested at join time. A role the schema does not declare is a type error at the call site.

// irtio/schema.ts
import { bool, defineSchema, entity, enumOf, singleton, str, u8, u16 } from '@irtio/schema';

import { rpc } from './rpc.js';

export const schema = defineSchema(
  {
    match: singleton(
      { phase: enumOf('lobby', 'asking', 'reveal', 'over'), question: str(120), round: u8 },
      { serverOwned: true },
    ),
    // One instance per controller. The shared screen does not get one: there is nothing
    // for a screen to answer with.
    players: entity({ name: str(24), score: u16, answered: bool }, { serverOwned: true }),
  },
  {
    project: 'p_deadbeefcafe1234',
    roles: ['host', 'controller'] as const,
    rpc,
  },
);

The room decides what a requested role means. ctx.role is the role the client asked for, presence already includes this join by the time onJoin runs, and room.setRole and room.kick are how you disagree with the request:

// irtio/room.ts
onJoin(state, ctx) {
  if (ctx.reconnecting) return;

  // "The first client to join as host is the host." A second one is demoted rather than
  // trusted. This is the one piece of access control that cannot live in an RPC handler,
  // because by the time a host-only RPC runs it is too late to ask whether the role
  // should exist.
  const alreadyHosted = ctx.room.clients.some(
    (c) => c.clientId !== ctx.clientId && c.role === 'host',
  );
  const role = ctx.role === 'host' && alreadyHosted ? 'controller' : ctx.role;
  if (role !== ctx.role) ctx.room.setRole(ctx.clientId, role);

  if (role === 'controller') {
    state.players.add(ctx.clientId, { name: ctx.name || 'player', score: 0, answered: false });
  }
},

Then gate each RPC on the role that is allowed to call it:

rpc: {
  start(state, _params, ctx) {
    if (ctx.role !== 'host') throw new Error('start: host only');
    openRound(state, ctx.room, 0);
  },

  answer(state, { choice }, ctx) {
    if (ctx.role !== 'controller') throw new Error('answer: controller only');
    // Mashing the button after the round moved on is normal input, not an error worth
    // interrupting a player over.
    if (state.match.phase !== 'asking') return;
    const player = state.players.get(ctx.clientId);
    if (!player || player.answered) return;
    player.answered = true;
    score(state, player, choice);
  },
},

Everything a controller could gain by lying about lives on a serverOwned collection and changes only inside a handler. See Server authority and RPCs.

One page, two halves

Pick the role from the URL and run one of two functions. Both sides join the same room.

// main.ts
import { joinRoom } from '@irtio/client';
import '@irtio/lobby';
import type { IrtLobbyElement } from '@irtio/lobby';

import { schema } from './irtio/schema.js';

const role = new URLSearchParams(location.search).get('role') === 'host' ? 'host' : 'controller';

if (role === 'host') void runHost();
else void runController();

Passing a literal role narrows room.state to that role’s view, so a page only sees the collections its role is allowed to see. That matters when you scope a collection with visibility: 'role'; see Visibility.

The lobby element

@irtio/lobby is one custom element with no dependencies and no build step. Importing the package registers it.

<irt-lobby id="host-lobby"></irt-lobby>
<irt-lobby id="controller-lobby" name-entry></irt-lobby>
import '@irtio/lobby';

document.querySelector<IrtLobbyElement>('#host-lobby')?.attach(room);

attach(room) copies the room’s code, link and status into the element and follows the status from then on. It returns a detach function if your framework wants one, and the element unsubscribes itself when it leaves the document, so most apps never need it.

What it renders, top to bottom: a status dot with a label, the room code as a large button that copies on click, the share link as a button that copies on click, a QR code of that link, the optional name box, and a small multiplayer by irt.io badge.

Attributes

attributepropertywhat it does
room-coderoomCodethe code shown large
linklinkthe shareable URL, shown under the code and encoded into the QR
statusstatusone of connecting, starting, connected, reconnecting, closed
name-entrynameEntryboolean; shows the name box

All four are observed, so setting a property re-renders. attach sets three of them for you. starting renders as “waking the server” and reconnecting as amber, which are the two states a share screen most needs to explain.

Styling

The element uses shadow DOM, so your page’s CSS does not leak in. Five internal nodes carry a part you can target from outside: status, code, link, qr and badge.

irt-lobby::part(code) { letter-spacing: 0.2em; }
irt-lobby::part(qr) { border-radius: 12px; }

If you want a different tag name, or your framework owns element registration, import { defineLobby, IrtLobbyElement } and call defineLobby('my-lobby') yourself.

The QR code

The QR is drawn from the link attribute, on a canvas, by an encoder built into the package. There is no CDN request, no wasm and no image to host. The encoder is available on its own if you want to draw the code somewhere else:

import { encodeQr } from '@irtio/lobby';

const qr = encodeQr(room.link); // { size, modules: boolean[][] }

It encodes the link and nothing else, so whatever you put in link is exactly what a phone opens.

Names before the join

A controller should join with a real name rather than being renamed afterwards. The name-entry attribute renders a small form that fires a name event, and you wait for it before calling joinRoom:

const lobby = document.querySelector<IrtLobbyElement>('#controller-lobby')!;

const name = await new Promise<string>((resolve) => {
  lobby.addEventListener(
    'name',
    (event) => resolve((event as CustomEvent<{ name: string }>).detail.name),
    { once: true },
  );
});

const room = await joinRoom(schema, { role: 'controller', name });
lobby.attach(room);

The event carries detail.name, trimmed and capped at 24 characters by the input.

Where the QR sends phones

room.link is the current page’s URL with ?room=CODE set on it, minus role. The address bar keeps role, so a host screen that reloads comes back as the host, and the share link drops it, so a phone that scans the QR joins as a controller. You get the right behaviour without doing anything.

Keep the host demotion in onJoin anyway, exactly as the snippet above does. It costs two lines and it covers the case the link cannot: somebody opening the host page a second time, or pasting a host URL they already had.

If you want the QR to point somewhere else entirely, a different page or a deep link, set the element’s link property yourself rather than calling attach:

lobby.roomCode = room.id;
lobby.link = `https://myapp.example.com/play?room=${room.id}`;
lobby.status = room.status;
room.on('status', (status) => {
  lobby.roomCode = room.id;
  lobby.status = status;
});

Do not mix the two on the same element: attach re-reads room.link whenever the status changes, so it would overwrite a link you set by hand.

When you do not need shared state

If the phones are pure input and the host screen owns the entire game, you may not want a schema at all. joinRelay gives you a room with presence and a raw message channel, and nothing else:

import { joinRelay } from '@irtio/client';

// On a phone. Off localhost, pass `key: 'p_…'`: there is no schema to read it from.
const relay = await joinRelay({ name: 'player one', role: 'controller' });
relay.message({ role: 'host' }, encodeInput(stick));

// On the host screen.
relay.onMessage((from, bytes) => applyInput(from, decodeInput(bytes)));

A message target is 'all' (everyone but the sender), a client id, or { role: 'host' }. relay.clients is the presence list, ordered by join, with each client’s id, role and name.

The tradeoff is that you bring your own protocol and get none of the guarantees. No typed state, no validation, no ownership, no interpolation, nothing kept when a phone reconnects. Use it when the host screen is genuinely the whole game and the phones are a gamepad. Use a schema and RPCs for anything else.

Practical notes

Room size. maxClients defaults to 64 and is set in the room config. A party game with a shared screen counts the screen as a client too.

Phones sleep. A controller whose screen locks drops its socket and reconnects when it comes back. Keep per-player state in the room, keyed by the client, rather than in the phone’s page, so coming back is a resync rather than a restart.

Event mode fits this shape. A quiz spends most of its life waiting for a human, so mode: 'event' lets the room hibernate between rounds and wake on the next call with its scores intact. Reach for a durable alarm rather than a timer if something has to happen while nobody is connected, and design for it firing at or after the time you asked for, at second resolution.

Test it with two tabs first. Open the host URL, then paste the controller link into a second tab. A phone is a slower way to discover that a role gate is wrong.

Next steps