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 RTCDataChannel browser-to-browser, never relayed, stored or logged
  • Durability: zero, deliberately — 0 bytes on disk
  • Live: play a friend on dankchess.com

What you need

The whole link-up is seven files in one repository and one runtime. There is nothing to install: relay/server.js implements the RFC 6455 handshake and frame codec by hand, and imports exactly two modules.

grep -n 'require("node:' relay/server.js
# 34:const http = require("node:http");
# 35:const { createHash, randomBytes } = require("node:crypto");

That is the entire dependency list. No ws, no socket.io, no dependency manifest at the repository root — so there is no install step, no lockfile and nothing that can change underneath the wire format. To run the relay and its tests on your own machine you need:

  • Node. Everything quoted on this page was run on Node v23.11.1. The relay itself only wants node:http and node:crypto and is not fussy about the version; the test harness is, because it drives Node’s global WebSocket client, available unflagged since Node 22. That is the real floor.
  • One free loopback port. DANKCHESS_RELAY_PORT is the only environment variable the process reads (server.js:551), defaulting to 8787. The bind address is not configurable at all: LISTEN_HOST = "127.0.0.1" is a constant at server.js:43, so there is no value you can set that makes the listener answer on a public interface.
  • Nothing else. No build step, no database, no config file, no state directory. Durability is zero on purpose, so there is nothing on disk to create.

For the chat half you also need a browser with RTCPeerConnection and a reachable STUN server — there is no TURN server in this design. For a public wss:// URL you need a reverse proxy that speaks HTTP/1.1 and a service manager. Both of those are in the repository as files, and nothing in the repository installs them.

ls -1 relay/
# DECISION.md
# GO-LIVE.md
# PROTOCOL.md
# dankchess-relay.service
# nginx-dankchess-relay.conf
# server.js
# test-relay.js
FileLinesWhat it is
relay/server.js565The relay: handshake, frame codec, the game Map, the keepalive sweep. The only file here that parses anything.
relay/test-relay.js367End-to-end harness. Starts the real server on an ephemeral port and drives real WebSocket clients through the whole lifecycle and the error paths.
relay/PROTOCOL.md149The wire format: the seven envelopes, and the rule that a socket is parsed exactly once.
relay/DECISION.md173Why this design and not the two runners-up.
relay/GO-LIVE.md161The decision packet — cost, data, failure mode, rollback.
relay/dankchess-relay.service112The systemd unit, written as a file to read before anything runs it.
relay/nginx-dankchess-relay.conf63The location block that turns a loopback listener into a public URL.

The browser side is two more files. html/play/online.js (1,248 lines) owns the socket, the frame table and the resume handshake. html/play/chat.js (445 lines) owns the peer connection and the data channel and never touches the socket at all — it hands its offer, its answer and each ICE candidate to online.js, which wraps them in sig frames.

Run all of it, in one process

relay/test-relay.js is not a mock. It starts the real relay/server.js on an OS-assigned ephemeral port, drives Node’s own WebSocket client through host → join → message → message → disconnect plus the four error paths, then shuts everything down and asserts no handles are left open.

node relay/test-relay.js
# bind
#   ok   the relay listens on an OS-assigned ephemeral port
#   ok   the listener is bound to 127.0.0.1
# ...
#   ok   a frame shaped exactly like a join envelope is forwarded, not interpreted (relay is blind after pairing)
# ...
# 44 passed, 0 failed

That middle assertion is the one the chat claim rests on. A paired socket sends a frame whose bytes are exactly a join envelope — the single shape the relay knows how to parse — and the test requires it to arrive at the peer verbatim rather than being acted on. If the relay ever grew a second parse, that is the check that would go red. The run takes about four seconds on this machine, and the last assertion is that Node exits on its own, which is the only real proof nothing was left listening.

The browser-side transport has its own harness, which also starts a relay and tears it down:

node test/chat.test.cjs
# PASS — 37 passed, 0 failed

And one runner executes every committed guard under test/ and relay/ in a single shot. --fast skips only the two browser-heavy ones, and names both rather than quietly dropping them:

node test/run-all.cjs --fast
# [PASS] test/chat.test.cjs (0.3s)
# [PASS] test/online.test.cjs (7.3s)
# [PASS] test/resume-handshake.test.cjs (0.6s)
# [PASS] relay/test-relay.js (4.2s)
# ...
# run-all: SKIPPED by --fast: test/browser-play.test.cjs, test/claims-audit.cjs
# run-all: 41/41 guards passed in 80.1s (--fast; 2 skipped)

Drop --fast and the two browser-driven guards run too, for 43 in total. The candidate list is not a directory listing: it comes from git ls-files -- test relay, and any committed guard that appears in neither the run list nor the exclusion list is a hard error that names the file. A test nobody wired up turns the runner red instead of being silently skipped.

None of the above touches the live service. Every command here runs against a relay the test process starts on 127.0.0.1 and closes again a few seconds later.

From a loopback port to a public wss:// URL

The relay terminates no TLS and answers only on loopback, so the public endpoint is entirely the reverse proxy’s doing. The location block, from relay/nginx-dankchess-relay.conf:

# from relay/nginx-dankchess-relay.conf
location /relay {
    proxy_pass http://127.0.0.1:8787;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;
    proxy_buffering off;
    access_log off;
}

Every line in it is load-bearing, and the first three are the ones that are easy to get wrong:

  • proxy_http_version 1.1 — an RFC 6455 upgrade cannot happen over HTTP/1.0, which is what the proxy would otherwise speak upstream. Without this line the handshake fails and every online game fails with it.
  • The Upgrade / Connection pair — $http_upgrade carries the client’s websocket token through, and Connection is pinned to the literal "upgrade" rather than the usual $connection_upgrade map variable so that the snippet is self-contained. A map lives in the http{} context, which this file deliberately does not reach into; since this location serves nothing but WebSocket, the literal is correct here.
  • proxy_read_timeout 3600s — a chess game idles. Two people thinking, or one of them answering the door, routinely leaves a socket silent for minutes, and the default 60-second read timeout would tear the board down mid-think. An hour is chosen so that liveness is decided by the relay’s own 30-second keepalive — code we control and test — rather than by a proxy timer that silently kills a live game.
  • proxy_buffering off — a buffered proxy holds a message until its buffer fills, which for a ~110-byte move frame is effectively forever.
  • access_log off — the one line that is about privacy rather than function. A game id is a capability: anyone holding one can take the second seat. Ids travel inside the WebSocket envelope rather than in a URL path, so they would not normally reach the log anyway, but logging is off here so that nothing about a pairing lands in a file at all.

There is no server block, no listen, no server_name and no TLS configuration in that file. dankchess.com already has all of those, and the snippet is scoped to avoid touching them: pasting the block in and reloading is the whole install, removing it and reloading is the whole rollback.

Keeping the process alive is the systemd unit, and the interesting half of it is what it refuses to ask for:

# from relay/dankchess-relay.service
[Service]
User=loopdev
WorkingDirectory=/home/loopdev/dankchess
Environment=DANKCHESS_RELAY_PORT=8787
ExecStart=/usr/bin/node /home/loopdev/dankchess/relay/server.js
Restart=on-failure
RestartSec=5s
NoNewPrivileges=yes
CapabilityBoundingSet=
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes

CapabilityBoundingSet= is empty on purpose — that is how you request the empty capability set. The process needs exactly two things: one unprivileged TCP port (8787 is above 1024, so no CAP_NET_BIND_SERVICE) and read access to its own source. It asks for nothing else, so it is granted nothing else. ProtectSystem=strict makes the entire filesystem read-only with no ReadWritePaths granted, which costs this service nothing for the same structural reason the durability figure is zero: it writes no files. ProtectHome=read-only is the exact permission rather than a generous one — the process must read relay/server.js to run and never writes under /home.

The unit deliberately does not set MemoryDenyWriteExecute, and carries a comment saying so, because V8 generates machine code at runtime and will not start without writable-then-executable pages. That comment exists so nobody “hardens” the unit and then debugs a silent crash.

Restart=on-failure with RestartSec=5s is the honest end of the durability trade rather than a performance setting: a restart drops every game in flight, so the delay is tuned to keep restarts rare and cheap, not to recover quickly. Both files open with the same warning in a header comment — they are files, not installations. Nothing in the repository copies, enables or starts either of them, and relay/GO-LIVE.md is the decision packet a human is meant to read first.

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 a code: unknown-id, game-full, bad-envelope or no-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}, where d is 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 as t: RESUME_FRAME and 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’s rsm agrees the two hold the same game; until then handlePeerFrame drops every other type, move included. 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.

  1. 80 plies per game, the planning figure in the design note. Each ply is one move frame.
  2. 110 bytes of JSON per move frame — the middle of the three structural sizes measured above.
  3. Every move crosses the relay twice: sender → relay, then relay → peer. 80 plies therefore put 160 move packets on the wire.
  4. 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.
  5. A 20-minute game — 80 plies at about 15 seconds a ply.
  6. A keepalive ping every 30 seconds, to every open socket. The sweep loop in server.js walks 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.
  7. 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.
  8. 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.
ComponentBytes per match
Move frames (160 packets)28,160
Keepalive pings and pongs10,560
TCP + TLS setup, 2 connections8,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.

WebRTCWebSocketsNode.jsJavaScriptReal-time

← Back to Projects

Back to the top