Skip to content

Quick match, room codes, draw offers and rematches

Last updated October 6, 2026

From Chess, a real game built on GameRelay · play it

The parts of a two-player game around the game itself: getting two people into the same room, and what happens after a move. From a real chess game: quick match with strangers, a room code or link for a friend, draw offers, resignations and rematches that swap colours.

1. Connect once

Connect when the player first presses a button, and keep the connection for the whole visit. The SDK reconnects by itself after a dropped connection, and tells you so you can show it:

const relay = await GameRelay.connect({ publicKey: 'gr_pub_…', playerName, playerAvatar: '♞' });
relay.on('disconnected', () => setBanner('Reconnecting…'));        // the SDK reconnects by itself
relay.on('reconnected', () => setBanner(null));
relay.on('replaced', () => setBanner('This game is open in another tab.')); // signed-in players only

2. Three ways into a game

  • Quick match puts you with anyone else waiting. The tag is the pool: only players with the same tag are matched, so one key can serve several games or modes.
  • Create a room and share its short code.
  • An invite link carries the code, so a friend lands in your game with one tap.
// Three buttons on the title screen, plus a friend's invite link.
async function enter(how) {
  try {
    const room =
      how === 'quick' ? await relay.quickMatch({ maxPlayers: 2, tag: 'chess' }) // anyone else waiting for chess
      : how === 'create' ? await relay.createRoom({ maxPlayers: 2 })           // share room.code with a friend
      : await relay.joinRoom(codeInput.value.trim().toUpperCase());
    attach(room);
  } catch (err) {
    const why = { room_not_found: 'No game with that code.', room_full: 'That game is full.', banned: "You can't join that game." };
    setLobbyStatus(why[err.code] ?? 'Could not connect.');
  }
}

function attach(room) {
  history.replaceState(null, '', room.inviteUrl()); // the address bar is now the invite link
  for (const m of room.chatHistory) addChat(m);     // the last lines of chat, for late joiners
  room.on('chat', addChat);
  room.on('closed', (reason, message) => {
    history.replaceState(null, '', location.pathname);
    showLobby(message || { kicked: 'You were removed from the game.', closed: 'The game was closed.', lost: 'Lost the connection.' }[reason]);
  });
  // …then the seats and moves from the turn-based example
}

// Opened from an invite link (?join=… or ?room=…, which inviteUrl() writes)? Go straight in.
// joinInvite() resolves null when the page has no link, and rejects if that room is gone or full.
relay.joinInvite().then((room) => room && attach(room)).catch(() => setLobbyStatus('That game has ended.'));

Give each error its own message. A code typed wrong (room_not_found) and a game already full (room_full) need different help.

3. Draw offers, resignations and rematches

These are agreements between two players, so they go through the host like moves do. Each is a request, and the offer itself is a field in state, so both players see it and a new host remembers it:

// More fields in room.state: drawOffer ('w' | 'b' | null), rematch (colours who asked), round.
// commit(): write state and redraw. The host's own setState doesn't fire 'state' on the host.
function commit(patch) {
  room.setState(patch);
  render();
}

room.onRequest('draw', (_, playerId) => {
  const st = room.state;
  const colour = colourOf(st, playerId);
  if (!colour || st.status !== 'playing') throw room.reject('No game to draw');
  // The other side offered already? Then this accepts. Otherwise it's an offer.
  if (st.drawOffer === other(colour)) commit({ status: 'over', winner: null, reason: 'agreement', drawOffer: null });
  else commit({ drawOffer: colour });
});

room.onRequest('resign', (_, playerId) => {
  const colour = colourOf(room.state, playerId);
  if (!colour || room.state.status !== 'playing') throw room.reject('No game to resign');
  commit({ status: 'over', winner: other(colour), reason: 'resignation', drawOffer: null });
});

room.onRequest('rematch', (_, playerId) => {
  const st = room.state;
  const colour = colourOf(st, playerId);
  if (!colour || st.status !== 'over' || st.rematch.includes(colour)) return;
  const rematch = [...st.rematch, colour];
  if (rematch.length < 2) return commit({ rematch }); // waiting for the other player
  // Both asked: a new game with the colours swapped.
  commit({ ...newGame({ w: st.seats.b, b: st.seats.w }), status: 'playing', round: st.round + 1 });
});

// And a move clears a standing offer: add drawOffer: null to the move's state.

A rematch needs both players to ask. The first ask is stored in state, and the second starts a new game with colours swapped, in the same room. Nobody has to share a code again.

Things to get right

  • Clear the link when the game ends. Otherwise a reload tries to rejoin a room that has closed.
  • Chat is text. Render chat lines with textContent, never as HTML: another player wrote them.
  • Tabs. An anonymous player is per tab, so two tabs are two players: handy for testing alone. A signed-in player is one player across tabs. A second tab takes over, and the first gets replaced and should say so instead of looking frozen.
  • Two seats, two players. maxPlayers: 2 keeps a third person out. For spectators, make the room bigger and seat only two (the seat check in the moves handler already handles it).

Before this: the moves themselves, checked by the host.

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.