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.
The shape of it
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.
Why it's mostly easy: the games are deterministic
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:
- The host picks the seed and who moves first, and sends them once.
- After that, each side only sends its own moves, as short strings:
"3"for a Four in a Row column,"22-15-8"for a Checkers double jump. - Dice don't travel either. In Lucky Lanterns, "roll" is the move, and the face comes from the seed in the state, so both browsers roll the same die.
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.
Never trust the other browser
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.
Reconnecting is "send me the move list again"
Phones lock, Wi-Fi blips, someone switches apps to answer a message. Because state is just seed plus moves, recovery doesn't need snapshots:
- If the guest receives a move whose
seqis ahead of what it has, it repliessyncreq. - The host answers with
sync: the round's seed, seats and full move list. - The guest rebuilds the board by replaying the moves from scratch. In React, the board component is keyed on the round and a sync counter, so a sync simply remounts it with
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.
The small UX things that made it usable
- Friendly errors. PeerJS errors come with a type, and each one maps to a sentence a parent understands.
peer-unavailablebecomes "That room isn't open any more. Ask your friend for a new link." - A join timeout. If nothing connects within 20 seconds, the guest gets a clear message instead of an endless spinner.
- A way out. If connecting fails, the screen offers "Try again" and "Play on this device instead", because some networks simply won't connect peer to peer (more on that below). If the line drops mid-game, the guest gets a Reconnect button and the host waits for them.
- "Keep this page open." The host sees this while waiting, because the host's tab is the server.
The trade-offs, honestly
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.
- It's not cheat-proof. Both browsers hold the full game state. In Sea Battle, both fleets are placed from the shared seed, and the board only hides your friend's ships on screen, so anyone with devtools could find them. The host also picks the seed, so a determined host could hunt for a lucky one. A real fix needs a server that deals each player only their own view.
- Some networks block it. Strict school or office networks can stop a direct WebRTC connection. I don't run my own relay (TURN) server, so those players get the "play on this device" fallback.
- The host is the referee. If the host closes the tab, the game ends. There's no host migration.
- Signalling is borrowed. The free PeerJS server makes no uptime promise. Self-hosting PeerServer would be a small swap if it ever becomes a problem.
- Two players online. Some games take up to six players on one device, but online play is one-to-one for now.
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.
Try it
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.
