Every demo on this site is a plain static HTML page talking to the same production relay documented in the protocol reference - view source on any of them for the full, unminified implementation. This page is the short version: what each one demonstrates, the exact message shapes involved, and (for the two that use WebRTC) the fallback protocol that kicks in when a direct peer-to-peer connection can't be established.
All three are pure relay demos - no WebRTC, nothing peer-to-peer, no NAT dependency at all. Each pairs two visitors with matchmaking under a game-specific tag, then exchanges plain send/deliver messages for the whole game. Reproduce any of them as a thin client in a few lines:
ws.send(JSON.stringify({ t: "find_match", game: "pong" })); // or "tictactoe" / "rps"
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "matched") peer = m.peer;
if (m.t === "deliver") handleMove(JSON.parse(m.ct)); // your game's own move shape
};
function sendMove(move) {
ws.send(JSON.stringify({ t: "send", to: peer, ct: JSON.stringify(move) }));
}
// pong: move = { y: paddleY }, sent on every local paddle update
// tic-tac-toe: move = { cell: 4 } - a board index, 0-8
// rock-paper-scissors: move = { choice: "rock" } - sent once, no commit/reveal
// (a motivated rps opponent could wait to see yours before answering -
// this demo is casual, not cheat-proof; see /rps.html for the caveat)
A single shared room every visitor to /chat.html joins automatically - see broadcast to a room for the exact join_room/room_send/room_deliver shapes. It also polls global presence's list_online every 5 seconds to show a relay-wide count alongside the room's own member count - the one demo-specific twist.
Side-by-side Alice/Bob panels on /mailbox.html demonstrating store-and-forward: send to an address while it's offline, then watch the backlog flush the moment that address registers. This is exactly the offline-tolerant delivery ack recipe; the only thing the demo adds is a second independent WebSocket in the same page to simulate two separate visitors.
A live dashboard on /topics.html for the pub/sub recipe - subscribe with +/# wildcards, publish with optional retain, watch messages from every other visitor currently running the same demo (you share a relay with them, not a private topic namespace).
/identity.html generates a random keypair client-side as a stand-in "identity" and derives its address live - see self-certifying identity for the exact base32(sha256(pubkey)) derivation and the optional alias-binding registration.
Two visitors matched the same way as pong, then negotiate a WebRTC connection using the WebRTC signaling recipe (TURN credentials included) - the relay only ever sees SDP/ICE payloads for this tier, never the video itself.
Tier 2 - Pipe: even with TURN, some pairs still won't connect directly. /screenshare.html watches for that: if pc.connectionState doesn't reach connected within 8 seconds, or ever reports failed, it opens a Pipe to the peer and tunnels a downscaled, JPEG-compressed screenshot through it every ~400ms - a slideshow, not video, but it keeps working anywhere the relay connection works:
// sender, once P2P has given up - draw the local <video> onto a hidden
// canvas, scale it down, and compress until it fits the relay's fixed
// 64KB max message size
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
setInterval(() => {
canvas.width = Math.round(video.videoWidth * scale); // e.g. scale to 480px wide
canvas.height = Math.round(video.videoHeight * scale);
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
let quality = 0.5, dataUrl = canvas.toDataURL("image/jpeg", quality);
while (dataUrl.length > 60000 && quality > 0.1) {
quality -= 0.15;
dataUrl = canvas.toDataURL("image/jpeg", quality);
}
if (dataUrl.length > 60000) return; // still too big - skip this frame, don't block the loop
const b64 = dataUrl.split(",", 2)[1];
ws.send(JSON.stringify({ t: "pipe_data", pipe_id: pipeId, kind: "frame", b64 }));
}, 400);
// receiver
if (msg.t === "pipe_data" && msg.kind === "frame") remoteImg.src = "data:image/jpeg;base64," + msg.b64;
This is fire-and-forget on purpose - a dropped or stale frame is simply skipped rather than retried, since the next frame 400ms later supersedes it anyway. Low latency matters more than any single frame arriving here, which is exactly why Pipe (no ack layer at all) suits this better than the legacy method below.
Tier 3 - legacy relay: the one thing that can still make Pipe itself fail to set up is the peer disconnecting in the handoff window (open_pipe requires them to be connected right now, no queuing). If that happens, the page falls back once more to sending the exact same slideshow frames as plain send messages instead of pipe_data - a true last resort, kept only for that edge case.
Same matchmaking, signaling, and TURN credentials as screenshare, but once connected the two sides open an RTCDataChannel instead of a media track and exchange a file in 16KB chunks directly, peer-to-peer. See /filedrop.html for the full chunking and backpressure logic.
Tier 2 - Pipe: when P2P doesn't come up within 8 seconds (same watchdog as screenshare), file drop opens a Pipe to the peer and streams the file through it in 32KB base64 chunks. Unlike a video frame, a chunk can't just be dropped - but unlike the legacy method below, that's not something to solve with an app-level ack/retry loop, because the WebSocket connection underneath Pipe already guarantees in-order, lossless delivery. The only flow control needed is checking ws.bufferedAmount before sending the next chunk:
function sendNextChunk() {
const buf = readNextSlice(); // ArrayBuffer, or null if done
if (buf === null) {
ws.send(JSON.stringify({ t: "pipe_data", pipe_id: pipeId, kind: "done" }));
return;
}
ws.send(JSON.stringify({ t: "pipe_data", pipe_id: pipeId, kind: "chunk", b64: toBase64(buf) }));
if (ws.bufferedAmount > 1_000_000) {
setTimeout(sendNextChunk, 20); // back off, don't outrun the socket's own send buffer
} else {
sendNextChunk();
}
}
// receiver - no ack sent back at all, nothing to confirm
if (msg.t === "pipe_data" && msg.kind === "chunk") appendChunk(base64ToBuf(msg.b64));
Compare that to the legacy method it replaced as the primary fallback:
// legacy tier 3 - windowed, acked, retried. Kept only for the case
// where open_pipe itself fails (peer disconnects in the handoff window).
function sendChunk(item, attempt) {
ws.send(JSON.stringify({
t: "send", to: peer, id: item.id,
ct: JSON.stringify({ kind: "chunk", seq: item.seq, b64: item.b64 }),
}));
item.timer = setTimeout(() => {
if (attempt >= MAX_RETRIES) return giveUp(item);
sendChunk(item, attempt + 1); // no ack within RELAY_CHUNK_TIMEOUT_MS - resend
}, RELAY_CHUNK_TIMEOUT_MS);
}
// on {"t":"ack","id":...}: clearTimeout the matching entry, then send the
// next queued chunk to keep a small window full.
// receiver - ack every frame by id, in order, regardless of kind
if (msg.t === "deliver" && msg.id) {
ws.send(JSON.stringify({ t: "ack", to: msg.from, id: msg.id }));
const frame = JSON.parse(msg.ct);
if (frame.kind === "chunk") appendChunk(base64ToBuf(frame.b64));
}
Same job, roughly a third the code, because Pipe doesn't fight the transport it's running on top of.
Both tiers' chunks stay well under the relay's fixed 64KB max message size - Pipe's 32KB chunks base64 to roughly 43KB, the legacy tier's 16KB chunks to roughly 22KB. The legacy tier additionally has to handle {"t":"error","msg":"rate limit exceeded"} with a backoff before retrying, since its ack timeouts can otherwise hammer the same limiter right after a rejection. Pipe doesn't need that dance; ws.bufferedAmount alone keeps it from ever outrunning the connection's own send buffer in the first place.
The honest tradeoff of every fallback tier past WebRTC: once the relay (or Pipe, which is still this relay) is carrying your file or your screen instead of just signaling, it's no longer blind to that traffic the way the rest of this protocol is designed to be - the same plaintext-over-the-relay tradeoff rock-paper-scissors already makes for simplicity. Worth knowing before you build something that assumes the relay never sees payload bytes.
Every other messaging demo on this site sends plaintext through the relay for simplicity - pong's moves, chat's messages, rps's choices. /whisper.html is the odd one out: it actually implements the encryption recipe live, client-side, with tweetnacl-js standing in for the server's own pynacl. Two visitors are matched, each announces its X25519 public key with an empty-ciphertext first-contact ping (trust-on-first-use, no out-of-band verification - same model as SSH's first connection to a new host), then every message after that is a real NaCl Box ciphertext. The page's own "wire log" panel prints exactly what crosses the relay, so you can confirm for yourself that it's never your plaintext.
A standalone command-line client exists too, outside the browser - see the command-line client cookbook entry.