Skip to content

Turn-based multiplayer: chess

Last updated October 6, 2026

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

A turn-based game needs one place where the rules are applied, so two players can't make conflicting moves. With GameRelay that place is the room's host: one player's browser, picked by the SDK. Players ask the host to make a move, the host checks it against the rules, and the result goes into the room's state, which every player draws from. There's no server code.

Why the host, and why state

If each player applied their own moves, a slow message or a modified page could leave two boards that disagree. The host is the referee: it alone writes the game, so there's one truth. The truth lives in room.state, not in the host's variables, because the host can change at any time (a closed tab, a lost connection). The next host gets the state automatically and carries on mid-game.

1. Keep the whole game in state

Everything a player needs to draw the game, and everything a new host needs to continue it:

// room.state, written only by the host:
{
  board,                       // the position (whatever your rules code uses)
  turn: 'w',                   // whose move: 'w' or 'b'
  seats: { w: 'p1', b: 'p2' }, // player ids; null while a seat is empty
  moves: ['e4', 'e5'],         // the move list, for everyone's sidebar
  last: ['e2', 'e4'],          // to highlight the last move
  status: 'waiting',           // 'waiting' | 'playing' | 'over'
  winner: null, reason: null,  // 'w' | 'b' | null, and why ('checkmate', 'resignation', …)
}

State is a small JSON document (up to 64 KB) for facts, not motion. A chess position and move list fit easily.

2. Seat the players on the host

The host gives out the two seats as players arrive, and frees a seat when its player leaves. A player who only disconnected keeps their seat: the SDK holds it for 30 seconds while they reconnect.

// Host: fill empty seats from the players in the room, and start once both are filled.
function seatPlayers() {
  if (!room.isHost) return;
  const st = room.state;
  if (!st.board) {
    room.setState(newGame({ w: room.me, b: null })); // the first host sits down as white
    return seatPlayers();
  }
  // A player who left gives up their seat. A disconnected one keeps it while they reconnect.
  const present = new Set(room.players.map((p) => p.id));
  const seats = { ...st.seats };
  for (const c of ['w', 'b']) if (seats[c] && !present.has(seats[c])) seats[c] = null;
  for (const c of ['w', 'b']) {
    if (seats[c]) continue;
    const free = room.players.find((p) => p.id !== seats.w && p.id !== seats.b);
    if (free) seats[c] = free.id;
  }
  const patch = {};
  if (seats.w !== st.seats.w || seats.b !== st.seats.b) patch.seats = seats;
  if (st.status === 'waiting' && seats.w && seats.b) patch.status = 'playing';
  if (Object.keys(patch).length) room.setState(patch);
  render();
}

room.on('player_joined', seatPlayers);
room.on('player_left', seatPlayers);
room.on('host_changed', seatPlayers); // a new host checks the seats too
seatPlayers();

3. Moves are requests to the host

A request asks the host and waits for its answer. The host's handler checks whose turn it is and whether the move is legal, then writes the new position. A rejected move comes back to the player as an error with the reason.

// Every player registers the handler. Whoever is host when a move arrives runs it.
room.onRequest('move', ({ from, to, promo }, playerId) => {
  const st = room.state;
  const colour = st.seats.w === playerId ? 'w' : st.seats.b === playerId ? 'b' : null;
  if (!colour) throw room.reject("You're watching, not playing");
  if (st.status !== 'playing' || st.turn !== colour) throw room.reject('Not your turn');

  const want = promo ?? 'Q'; // a pawn reaching the last rank becomes a queen unless the player picked
  const m = legalMoves(st.board).find((x) => x.from === from && x.to === to && (!x.promo || x.promo === want));
  if (!m) throw room.reject('Illegal move');

  const board = apply(st.board, m);
  room.setState({ board, turn: board.turn, moves: [...st.moves, san(st.board, m)], last: [from, to], drawOffer: null, ...outcome(board) });
  render(); // the host's own setState doesn't fire 'state' on the host
});

// A click on a square: ask the host, show its answer.
async function play(from, to, promo) {
  try {
    await room.request('move', { from, to, promo });
  } catch (err) {
    if (err.code === 'rejected') flash(err.message); // 'Illegal move', 'Not your turn'
    // 'host_changed': the host left mid-move, so try again; 'timeout' or 'disconnected': show it
  }
}

room.on('state', () => render()); // everyone else redraws from the new state

The real chess game sends moves as plain messages to the host (room.send) and has the host ignore illegal ones. Requests, shown here, do the same and also tell the player why. They also run the host's own moves directly, so the host needs no special case.

4. Draw it from state

// Each player works out their side from the state, never from join order.
const myColour = () => (room.state.seats?.w === room.me ? 'w' : room.state.seats?.b === room.me ? 'b' : null);
const flipped = myColour() === 'b'; // black sees the board from its side

Things to get right

  • Re-render on the host after writing. room.setState fires state on everyone else, not on the host that wrote it.
  • Check the seat, not just the turn. A third player in the room (a spectator) can send requests too.
  • Seats from state, colours from seats. Never from the order of room.players, which changes as people leave and join.
  • Clocks. For timed games, use room.timer('turn', ms) on the host. It fires once, even if the host changes, and room.timeLeft('turn') drives everyone's countdown.
  • The host is trusted. The referee is a player's browser, so it suits games among friends and casual play. Ranked play with something at stake needs a referee you run yourself.

Next: getting two players into a game, draw offers and rematches, from the same chess game.

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.