Skip to content

A lobby that lives in a room

Last updated October 6, 2026

From racecar, a real game built on GameRelay · the source

racecar's online lobby is a GameRelay room. The lobby's seats, options and phase live in the room's state. Every change is a request to the SDK's host, which runs the same pure apply function a local lobby uses. The room's listing feeds the server browser on the title screen. There's no lobby server.

Two hosts, on purpose

This is the idea that makes the rest simple. The lobby's host is a player in the game: the one who made the lobby, picks the map and starts the race. The SDK's host is whichever browser holds the room's write role. It moves on its own when a tab reloads or a connection drops.

The SDK's host only applies actions. The rules inside apply check the lobby's host. So it doesn't matter which browser holds the SDK's role: the player who made the lobby still runs it, and a reload never hands the lobby to someone else.

1. The rules as one pure function

// lobby.ts: the rules, as one pure function. No SDK in here, so it's easy to test.
// Returns the new lobby, or null if the action isn't allowed.
export function apply(lobby, actor, action) {
  const isHost = actor === lobby.host;   // the lobby's host: the player who made it
  switch (action.type) {
    case 'seat':    if (!isHost) return null; /* an AI in a seat, or an open seat (never a player's) */ break;
    case 'options': if (!isHost) return null; /* map, laps, traffic… */ break;
    case 'car':     /* your own seat's car and paint */ break;
    case 'ready':   /* your own ready flag */ break;
    case 'kick':    if (!isHost) return null; /* free that seat */ break;
    case 'start':   if (!isHost) return null; /* phase: racing, at: the start time */ break;
    // …
  }
  return next;
}

Keeping the rules free of networking means the same function runs a local lobby (against AIs, offline) and an online one. The tests cover it without a server.

2. The lobby in the room

// relay.ts: a lobby is a room. The lobby lives in room.state.lobby; only the SDK's host writes it.
const room = await relay.createRoom({ maxPlayers: 8, tag: 'racecar', public: true });
commit(createLobby(room.code, me));

// Host: write the lobby, redraw (the host's own setState doesn't fire 'state' on the host),
// and keep the server browser up to date.
function commit(lobby) {
  room.setState({ lobby });
  drawLobby(lobby);
  relist();
}

// Everyone registers the handler; whoever is the SDK's host when an action arrives applies it.
room.onRequest('lobby', (data, from) => {
  const action = readAction(data, from);           // check it first: another player sent it
  const next = action && apply(room.state.lobby, from, action);
  if (!next) return null;                         // refused: the sender's screen stays as it was
  commit(next);
  return next;
});

// Any player, any action. The host's own requests run its handler directly.
async function send(action) {
  return room.request('lobby', action);
}

room.on('state', () => drawLobby(room.state.lobby));

readAction checks the shape of what arrived before apply sees it. An action comes from another player's page, which could be modified, so it's checked like any input from outside.

3. A server browser from room listings

// What the server browser shows: a summary of the lobby, at most once a second.
// (Listings and access share a budget: 10 changes in a row, then one a second.)
let listTimer = null;
function relist() {
  if (listTimer) return;                        // one is already on its way: it sends the latest
  listTimer = setTimeout(() => {
    listTimer = null;
    if (!room.isHost) return;                   // host only
    const { name, ...meta } = summarize(room.state.lobby); // map, laps, seats, phase, who can join
    room.setListing({ name, meta });
  }, 1000);
}

// The title screen's server browser: full and locked rooms too, shown greyed out.
const rooms = await relay.listRooms('racecar', { includeFull: true });
for (const r of rooms) addRow(r.name ?? 'New lobby', r.meta?.map ?? '?', `${r.players}/8`, r.locked); // meta is null until listed

A listing is a room's name and a little JSON, shown by listRooms without joining the room. The host keeps it up to date as the lobby changes, at most once a second.

4. Leaving, and tidying up

// The SDK host's chores: when someone leaves for good, free their seat. When the role moves to
// you, check for anyone who left while nobody held it.
room.on('player_left', () => room.isHost && tidy());
room.on('host_changed', () => room.isHost && tidy());

// The last player out unlists the room, so quick match and the browser don't send anyone into an empty one.
if (room.isHost && room.isPublic && room.players.every((p) => p.id === room.me)) {
  await room.setAccess({ public: false });
}
await room.leave();

Also in racecar's lobby

  • Invite-only lobbies use createRoom({ linkOnly: true }): nobody gets in by guessing the code, only by the lobby's link.
  • Each lobby is also a party, so its players connect to each other directly where their networks allow (src/lobby/party.ts).
  • Pings per player. Each player measures their own ping and tells the room, so the lobby can show everyone's, like a console's party screen (src/lobby/presence.ts).
  • The race keeps your seat. The race page joins the same room again, and a reload resumes the same player, so one room lasts through many races.

The whole lobby is in src/lobby, and ONLINE.md maps each file to its job.

Try it

GameRelay has a free plan, no card needed. Get a public key, then follow the quickstart, or point your AI coding agent at llms.txt.