{"slug": "online-multiplayer-with-no-game-server-webrtc-invite-links-and-a-browser-as", "title": "Online multiplayer with no game server: WebRTC invite links and a browser as referee", "summary": "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.", "body_md": "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.\n\nToday 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.\n\nTransparency 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.\n\nTwo 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.\n\nThe room code *is* the host's peer ID:\n\n``` js\nconst PREFIX = \"playtrova-v1-\";\n// no 0/O or 1/I/L: codes get read out loud and typed on phones\nconst ALPHABET = \"ABCDEFGHJKMNPQRSTUVWXYZ23456789\";\nexport const newRoomCode = () =>\n  Array.from({ length: 6 }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join(\"\");\n```\n\nThe 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.\n\nAs 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:\n\n`\"3\"` for a Four in a Row column, `\"22-15-8\"` for a Checkers double jump.\nThe whole protocol is one union type:\n\n```\ntype NetMsg =\n  | { t: \"hello\"; gameId: string; v: 1; avatar: string }\n  | { t: \"start\"; round: number; seed: number; variant?: string; hostSeat: 0 | 1 }\n  | { t: \"move\"; seq: number; m: string }\n  | { t: \"sync\"; round: number; seed: number; variant?: string; hostSeat: 0 | 1; moves: string[] }\n  | { t: \"syncreq\" }\n  | { t: \"emoji\"; e: string }\n  | { t: \"rematch\" };\n```\n\n`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.\n\nAnything 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.\n\n```\ncase \"move\":\n  return isInt(m.seq) && isStr(m.m, 32) ? { t: \"move\", seq: m.seq, m: m.m } : null;\ncase \"emoji\":\n  return EMOJIS.includes(m.e) ? { t: \"emoji\", e: m.e } : null;\n```\n\nThat 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.\n\nThen every move is checked against the rules. Both sides do this, so the guest checks the host's moves too:\n\n``` js\nconst applyRemote = (encoded: string, seq: number) => {\n  const cur = stRef.current;\n  if (seq < cur.moves.length) return \"dup\";    // already have it\n  if (seq > cur.moves.length) return \"desync\"; // we missed something\n  const who = game.current(cur.s);\n  if (seats[who] !== \"remote\" || !isLegal(game, cur.s, encoded)) return \"illegal\";\n  commit(encoded, who);\n  return \"ok\";\n};\n\nconst isLegal = (game, s, encoded) => game.moves(s).some((m) => game.encode(m) === encoded);\n```\n\nThe sequence number is simply the move's index in the game's move list. That one integer handles duplicates, ordering and gaps.\n\nPhones lock, Wi-Fi blips, someone switches apps to answer a message. Because state is just seed plus moves, recovery doesn't need snapshots:\n\n`seq` is ahead of what it has, it replies `syncreq`.` sync`: the round's seed, seats and full move list.` initialMoves`.\nOn 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`:\n\n``` js\npeer.on(\"connection\", (c) => {\n  conn?.close(); // a reconnecting friend replaces the old connection\n  conn = c;\n  wire(c, handlers);\n});\npeer.on(\"disconnected\", () => {\n  if (!peer.destroyed) peer.reconnect(); // lost the signalling server, not the game\n});\n```\n\nThat 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.\n\n`peer-unavailable` becomes \"That room isn't open any more. Ask your friend for a new link.\"\nThis 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.\n\nFor 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.\n\nPick 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.\n\nI'd love to hear how the connection behaves on your network, especially if you're behind something strict.", "url": "https://wpnews.pro/news/online-multiplayer-with-no-game-server-webrtc-invite-links-and-a-browser-as", "canonical_source": "https://dev.to/playtrova/online-multiplayer-with-no-game-server-webrtc-invite-links-and-a-browser-as-referee-316b", "published_at": "2026-11-05 14:40:41+00:00", "updated_at": "2026-11-05 14:49:34.999190+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["PlayTrova", "PeerJS", "Next.js", "Claude Code"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/online-multiplayer-with-no-game-server-webrtc-invite-links-and-a-browser-as", "markdown": "https://wpnews.pro/news/online-multiplayer-with-no-game-server-webrtc-invite-links-and-a-browser-as.md", "text": "https://wpnews.pro/news/online-multiplayer-with-no-game-server-webrtc-invite-links-and-a-browser-as.txt", "jsonld": "https://wpnews.pro/news/online-multiplayer-with-no-game-server-webrtc-invite-links-and-a-browser-as.jsonld"}}