Online multiplayer with no game server: WebRTC invite links and a browser as referee A developer built PlayTrova, a fully static Next.js games site with no backend, adding peer-to-peer online multiplayer to 12 turn-based board games using WebRTC data channels via PeerJS for signalling only. Room codes double as the host's peer ID and are shared through invite links or QR codes, while a strict message parser, rule validation on both sides, and a six-emoji reaction system replace a server-side referee. The developer notes the setup would need changes if players were competing for prizes. 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 https://playtrova.com/games/checkers/ , Four in a Row, Reversi and Sea Battle https://playtrova.com/games/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 https://peerjs.com/ 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: js 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 https://dev.to/playtrova/how-to-build-a-daily-challenge-thats-the-same-for-every-player-with-no-server-176l , 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: js 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 : js 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 https://playtrova.com/category/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.