PlayTrova is a free games site I run as a fully static Next.js site, with no backend at all. I still wanted the board games to have the one feature families actually ask for: send a link, play together. No accounts, no lobby, and no server to pay for or keep alive.
Today 12 of the turn-based games, including Checkers, Four in a Row, Reversi and Sea Battle, can be played online that way. Here's how it works, including the parts I'd change if people were playing for prizes.
Transparency note: PlayTrova is built with an AI coding assistant (Claude Code) doing much of the implementation, and this article was written with AI too. The code excerpts are trimmed from the live site.
Two browsers talk directly over a WebRTC data channel. The only outside help they need is signalling: a way to find each other and swap connection details. PeerJS provides that through a free hosted server. After the handshake, game messages go straight from one browser to the other.
The room code is the host's peer ID:
const PREFIX = "playtrova-v1-";
// no 0/O or 1/I/L: codes get read out loud and typed on phones
const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
export const newRoomCode = () =>
Array.from({ length: 6 }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join("");
The host opens new Peer(PREFIX + code) and waits. The invite link is just /games/checkers/?join=K7QX2M: the friend's page sees join, creates an anonymous peer and calls peer.connect(PREFIX + code). The host also shows the link as a QR code, which is handy when two people are sitting together with two phones.
As I described in an earlier post, every turn-based game here is pure logic: init(config, seed) plus apply(state, move), with all randomness coming from a seed stored in the state. That makes the networking almost boring:
"3" for a Four in a Row column, "22-15-8" for a Checkers double jump.
The whole protocol is one union type:
type NetMsg =
| { t: "hello"; gameId: string; v: 1; avatar: string }
| { t: "start"; round: number; seed: number; variant?: string; hostSeat: 0 | 1 }
| { t: "move"; seq: number; m: string }
| { t: "sync"; round: number; seed: number; variant?: string; hostSeat: 0 | 1; moves: string[] }
| { t: "syncreq" }
| { t: "emoji"; e: string }
| { t: "rematch" };
hello checks that both pages opened the same game. start begins a round, and the host swaps seats every round so the first move alternates. rematch is the guest asking the host to start another one.
Anything arriving on the data channel could be anything, so it goes through a strict parser before the game sees it. Wrong shape, too long or unknown type: dropped.
case "move":
return isInt(m.seq) && isStr(m.m, 32) ? { t: "move", seq: m.seq, m: m.m } : null;
case "emoji":
return EMOJIS.includes(m.e) ? { t: "emoji", e: m.e } : null;
That emoji line is also the entire chat system: six reactions (👋 👏 😂 😮 😅 🔥) and no free text. On a family site that's a feature, and checking on receive means a modified client can't push arbitrary text onto the other screen.
Then every move is checked against the rules. Both sides do this, so the guest checks the host's moves too:
const applyRemote = (encoded: string, seq: number) => {
const cur = stRef.current;
if (seq < cur.moves.length) return "dup"; // already have it
if (seq > cur.moves.length) return "desync"; // we missed something
const who = game.current(cur.s);
if (seats[who] !== "remote" || !isLegal(game, cur.s, encoded)) return "illegal";
commit(encoded, who);
return "ok";
};
const isLegal = (game, s, encoded) => game.moves(s).some((m) => game.encode(m) === encoded);
The sequence number is simply the move's index in the game's move list. That one integer handles duplicates, ordering and gaps.
Phones lock, Wi-Fi blips, someone switches apps to answer a message. Because state is just seed plus moves, recovery doesn't need snapshots:
seq is ahead of what it has, it replies syncreq. sync: the round's seed, seats and full move list. initialMoves.
On the host side, a returning friend just connects again with the same room code. The new connection replaces the old one, and its hello triggers a sync:
peer.on("connection", (c) => {
conn?.close(); // a reconnecting friend replaces the old connection
conn = c;
wire(c, handlers);
});
peer.on("disconnected", () => {
if (!peer.destroyed) peer.reconnect(); // lost the signalling server, not the game
});
That last handler matters. PeerJS can lose its connection to the signalling server while the peer-to-peer channel carries on, and until it reconnects, nobody can find the host. Without reconnect(), a friend who drops out can never get back in.
peer-unavailable becomes "That room isn't open any more. Ask your friend for a new link."
This design is cheap and simple because it trusts the people playing. That's fine for friends and family. It wouldn't be fine for ranked play or prizes.
For a free site with no accounts, I'm happy with that list. The networking layer is about 150 lines plus a 300-line React panel, and it costs nothing to run.
Pick any game on the online games with friends page, choose vs Friend, then invite with a link. Or open the link in a second browser window to watch both sides at once.
I'd love to hear how the connection behaves on your network, especially if you're behind something strict.