Two Transports for One Chess Game: a WebSocket Relay and a WebRTC Data Channel
Why the online board on dankchess.com sends moves over a WebSocket relay and chat over a WebRTC data channel between the two browsers — the seven-message relay vocabulary, the per-match byte arithmetic, and what the split costs.
Real-time transport
The online board sends two kinds of message over two different pipes, on purpose. Moves ride a plain WebSocket relay. Chat rides a WebRTC data channel opened directly between the two browsers, and only that. The reasoning, as written down at decision time.
- Moves: WebSocket to a relay process — blind passthrough, no rules engine
- Chat: an
RTCDataChannelbrowser-to-browser, never relayed, stored or logged - Durability: zero, deliberately — 0 bytes on disk
- Live: play a friend on dankchess.com
Why not skip the backend entirely
The tempting framing: WebRTC lets two browsers talk to each other, so no server is needed. That option does not exist. WebRTC cannot introduce two browsers in the first place — the offer, the answer and a handful of ICE candidates have to reach the far side over something — so a signaling server exists either way. Once it exists, carrying the move over that same socket is nearly free — and it removes the worst failure, where a pair whose data channel never comes up loses the game rather than the chat.
A move frame is one JSON object holding a version tag, the move in UCI, the ply number and the full FEN. Three frames built to the exact shape the client sends measure 108, 110 and 116 bytes — against the relay’s 1,024-byte ceiling on the single envelope it ever parses. The design note had sized a move at ~60 bytes; the full FEN made the real frame about twice that. Both are structural numbers, not traffic measurements.
The whole vocabulary, named
That argument is easy to assert and easy to check, so here is the check. I grepped the two files that define the wire — the relay, relay/server.js, and the browser client, html/play/online.js — for every quoted message type. Seven come back from the relay. Five more live only in the client, quoted with single quotes, so a double-quote grep misses them; a second pass picked those up. A sixth is quoted nowhere at all — it goes out as t: RESUME_FRAME, a named constant — so no grep for string literals can reach it; it is listed on its own below the five, read off its send site.
The relay’s own seven (all from server.js):
host— client → relay. The creating player’s first frame, and the only frame that socket will ever have parsed.{"t":"host"}, 12 bytes.hosted— relay → host. Carries the 12-character game id that is also the capability to take the second seat.join— client → relay. The joiner’s first frame, carrying that id.joined— relay → joiner. Seat confirmed.peer-joined— relay → host. The one unsolicited “something happened” frame in the pairing phase; it is how the host learns anybody arrived.peer-left— relay → whichever socket survived, sent immediately before the relay closes it with code 4410.error— relay → client, carrying acode:unknown-id,game-full,bad-envelopeorno-peer.
The five the relay never parses (from online.js):
move—{v, t:'move', uci, n, fen}, browser to browser through the relay.sig—{v, t:'sig', d}, wheredis a WebRTC offer, a WebRTC answer, or one ICE candidate.rematch—{v, t:'rematch', a:'offer'|'accept'}. An offer changes no board; an accept resets both and swaps the colours, so a second game costs one tap instead of a second link.resign—{v, t:'resign'}. The opponent is told it was a resignation rather than a dropped socket, and neither connection is torn down, which is why the rematch is reachable straight afterwards.draw—{v, t:'draw', a:'offer'|'accept'|'decline'}. Declining leaves both games running.
The last three arrived after this page was first written, on 20 August 2026, and adding them changed nothing in server.js: a paired socket’s frames are copied to its peer with the payload untouched, so a new client frame type is a line in one table in online.js and nowhere else. That is what “blind passthrough” buys, and it is the only part of this design that has been tested by being extended.
sig is the whole point. A data channel cannot carry its own handshake: before it exists there is exactly one pipe between these two browsers, and it is the WebSocket to the relay. So the offer, the answer and every ICE candidate ride sig frames across a third party’s process, and the client caps outgoing frames at 4,096 characters (MAX_FRAME_CHARS in online.js, well under the relay’s own 64 KiB MAX_FRAME) because an SDP that will not fit is a dead handshake rather than a reason to write a reassembler. That is what “signaling is unavoidable” looks like once it has a field name.
The sixth, which no grep for quotes finds (also online.js):
rsm—{v, t:'rsm', pair, n, uci, fen}, the resume handshake, emitted ast: RESUME_FRAMEand therefore invisible to a search for quoted message types. The moment the relay reports both seats filled, each side states its pair id, its ply count, its move list and its FEN, and neither board goes live until the other side’srsmagrees the two hold the same game; until thenhandlePeerFramedrops every other type,moveincluded. If nothing comes back inside 15 seconds (RESUME_TIMEOUT_MS) the resume is refused outright rather than waited on, because an unbounded wait leaves someone staring at a board that only looks live.
rsm is also dispatched a few lines above the frame table rather than out of it, since a resuming session has to read it before the check that gates everything else — so counting the table’s entries misses it too, in the same direction as the grep. The relay is no more involved in it than in a move: opaque bytes to server.js, copied to the peer, never parsed.
The grep pattern also asked for chat. It matched nothing, in either file. There is no chat message type, no chat branch and no chat literal to grow one from — which is why the claim below is about the shape of the code rather than a promise.
What a match costs — arithmetic, from these assumptions
I did not capture packets. Nothing below is a measurement of live traffic; it is arithmetic over stated assumptions, and every assumption is here so you can disagree with a specific one rather than with the total.
- 80 plies per game, the planning figure in the design note. Each ply is one
moveframe. - 110 bytes of JSON per move frame — the middle of the three structural sizes measured above.
- Every move crosses the relay twice: sender → relay, then relay → peer. 80 plies therefore put 160 move packets on the wire.
- Per-packet overhead of 68 bytes up / 64 down: 20 B IPv4 header + 20 B TCP header (no options counted, which understates it) + 22 B TLS 1.3 record (5 B header, 1 B inner content type, 16 B AEAD tag) + the WebSocket frame header, which is 6 B from the browser because client frames must be masked and 2 B from the server because server frames must not be. TCP ACKs are ignored, which understates it again.
- A 20-minute game — 80 plies at about 15 seconds a ply.
- A keepalive ping every 30 seconds, to every open socket. The sweep loop in
server.jswalks the whole connection set; it does not skip sockets that just sent traffic. 20 minutes is 40 sweeps × 2 sockets = 80 pings, each drawing a pong. - Two TLS handshakes per game, one per player, each a full TLS 1.3 handshake at roughly 4 KB — dominated by the certificate chain, not by anything this application chose.
- Signaling of about 4.2 KB: one ~1.4 KB SDP offer, one ~1.3 KB answer, and six ICE candidates at ~250 B. Each of those crosses the relay twice, like a move. These three are estimates of typical WebRTC output, not readings.
| Component | Bytes per match |
|---|---|
| Move frames (160 packets) | 28,160 |
| Keepalive pings and pongs | 10,560 |
| TCP + TLS setup, 2 connections | 8,000 |
WebRTC signaling (sig frames) | 9,456 |
Pairing envelopes (host … peer-joined) | 439 |
| Total | ≈ 56,600 B (55 KiB) |
The pairing row is the one line here with no estimate in it: those five envelopes are 12, 34, 32, 14 and 19 bytes of JSON, JSON.stringify-ed to the exact shapes the two files build, plus the same per-packet overhead as everything else. It is 0.8% of the match, which is the honest answer to “does the handshake cost anything” — no, the twenty minutes after it do.
At the design note’s planning figure of 1,000 games a day, that is ≈ 57 MB/day, or about 1.7 GB a month, counting both directions. Roughly half of that is egress. At commodity egress pricing this is a rounding error next to the always-on Node process the relay needs anyway (~40–60 MB RSS, billed by the hour whether anyone plays or not). The lesson is not that the transport is cheap because it is clever; it is that at two messages a second, bandwidth is not the line item that decides anything.
The protocol tax
The interesting number is inside the move row. The irreducible chess content of a move is a from-square, a to-square and a promotion flag: 14 bits, under 2 bytes. The frame carrying it is 110 bytes, mostly a full FEN carried on purpose as a resync check. The packet carrying that is 178 bytes. So about 2 bytes of chess arrive wrapped in roughly 89 times their own size: the chess payload is nearly two orders of magnitude smaller than what it costs on the wire. And 68 of those bytes are fixed headers that no payload decision can touch — 38% of the packet is IP, TCP, TLS and WebSocket framing that would be there even if the frame were empty.
It is worse than a constant factor, because it is unamortisable. Every move is its own small packet, sent seconds apart, at the moment a human decides. There is no burst across which a compressor can build a dictionary and no window in which headers can be shared. That is why the design note rejected permessage-deflate outright: a 110-byte JSON object with no history behind it frequently comes out of a deflate window larger than it went in, and the 68 bytes of header are untouched either way.
Which means compressing moves is not a lever, and the arithmetic says so numerically: delete the entire chess payload — every move frame down to zero bytes — and the match still costs 69% of what it costs now. The levers that actually move the number are the boring ones:
- Lengthen the ping interval. 30 s → 120 s removes about 7,900 B, ~14% of the match — more than any conceivable payload encoding. The cost is real, though: a dead socket goes unnoticed for up to four minutes instead of one, so the survivor sits staring at a board nobody is on the other end of.
- TLS session resumption. A resumed handshake skips the certificate chain, cutting ~4 KB per connection to a few hundred bytes: ~7,200 B, ~13%. Only helps people who come back.
- Trim the SDP. A data-channel-only offer does not need audio and video media sections or a codec list; ending ICE gathering after the first server-reflexive candidate cuts the rest. That is the ~9.5 KB signaling row, and it is the single largest thing this codebase actually controls.
Every one of those beats making moves smaller, and two of them are configuration rather than code.
What the split buys
The failure is confined to the thing that matters least. Symmetric NAT, blocked UDP and an unreachable STUN lookup all end the same way — no data channel. After a 20-second attempt the code stops trying, the page says plainly that this game has no chat, and the board carries on untouched. Chat is the feature allowed to be absent. The game is not.
What the split costs
Two transports mean two connection state machines and two failure modes. Chat may simply not exist for some pairs, which turns “did my message send?” into a real question. And because nothing is stored, there is no transcript to recover: messages live in the DOM of two open tabs and end when either tab closes.
Stateless on purpose
Checked against the source rather than repeated from the design note: server.js creates the game table as a new Map() inside the relay factory, imports only node:http and node:crypto — there is no fs import anywhere in the file — and its disconnect handler deletes the game from that Map as soon as either of the two sockets goes away, then closes the survivor. All three hold.
The cost of that is one clause, and it is worth stating outright rather than leaving as an inference: a relay restart drops every live game. The Map is process memory with nothing behind it, so a deploy, a crash or an operator restart at move 30 ends every match in flight at once — not just the ones whose players had a network problem. That is the price of the durability figure being zero, and it was the explicit trade: the runner-up design added SQLite purely to survive restarts and lost, because keeping other people’s games on disk creates a retention question, a deletion question and a privacy-page question that not having them answers for free.
When either socket goes away the game is deleted and its id is unknown again. Resuming a dropped game is therefore client-side: the survivor hosts a brand-new relay game, and the position rides in the URL fragment of the shared link — the part a browser never puts on the wire. A lost game is a re-shared link, and no move history or chat message is on disk anywhere.
What is and is not claimed
Moves do transit the relay. The honest claim is structural: a chat message is never relayed, stored or logged, because the relay has no chat message type and a paired socket’s frames are copied to its peer, payload never decoded. Two costs come with that. The signaling server sees which peers paired and when, including their IP addresses — the same thing carrying moves already showed it. And a direct connection means each player’s browser learns the other’s IP address; there is no TURN server, only a STUN reflexive-address lookup. No encryption, privacy or anonymity promise is made beyond those structural facts.
Where this shape would be wrong
For two people taking turns with ~110-byte messages, the split is cheap and its blast radius tiny. For anything high-rate or latency-critical — position updates many times a second, voice, a game decided by a hundred milliseconds — the ordinary advice applies: pick one transport, for the messages that actually matter.