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 sideThings to get right
- Re-render on the host after writing.
room.setStatefiresstateon 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, androom.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.