The code behind Farboard's online play: the configuration, every message between the browsers and the relay, the WebSocket details, pseudo code and the real code side by side. It builds on Architecture, which explains Workers, Durable Objects and how two players meet in one Room.
wrangler.jsonc, at the top of the repository, declares all four Cloudflare pieces from
Architecture: the Worker, the static site, the two Durable
Object classes and their databases.
wrangler.jsonc (abridged)
{
"name": "farboard",
"main": "relay/src/index.js", // the Worker
"assets": { "directory": "./public" }, // the website
"durable_objects": {
"bindings": [
{ "name": "ROOM", "class_name": "Room" }, // one per game
{ "name": "BUDGET", "class_name": "Budget" } // one per relay: counts new games
]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room", "Budget"] }]
}
A binding is how code reaches a Cloudflare resource. The one named ROOM appears
inside the Worker as env.ROOM, an object for finding Room instances by name.
The migrations entry registers the two classes, and new_sqlite_classes gives
each instance a SQLite database, which is the only kind the free plan offers.
Time runs down the page. The Worker only appears when a connection opens. After that, each WebSocket belongs to the Room, and messages go straight to it.
relayclient.js connects to wss://<relay>/v1/rooms/<roomId>, using the room ID derived from the invite. The Worker checks it is a WebSocket upgrade from an allowed web page, then forwards the request to the Room's stub.WebSocketPair, keeps one end with ctx.acceptWebSocket(), and returns the other end to the browser in a 101 response. The socket is connected but has no seat yet.localStorage. The Room stores a hash of it as seat 0 and replies joined.
Every online game runs over two WebSockets, one per player, and both end in the same Room. Nothing
else carries game traffic. The HTTP routes (/v1/info, /v1/stats,
/v1/verify) only help the app set itself up.
A WebSocket starts as an ordinary HTTP request that asks to upgrade. If the server agrees,
it answers 101 Switching Protocols, and the same TCP connection then stays open as a
two-way channel. Either side can send a message (a frame) at any time, with none of HTTP's
per-request overhead.
Plain HTTP has no way for the server to push a message, so a player would have to keep asking "has my opponent moved yet?". Each question would cost a request and add delay. With a WebSocket, the Room pushes the move the moment it arrives. Cloudflare also bills incoming WebSocket messages at a twentieth of a request each.
The browser writes new WebSocket("wss://…/v1/rooms/<roomId>")
(socketUrl in protocol.js turns https into wss),
and sends a request like this:
# browser → Cloudflare
GET /v1/rooms/Xq3…f9A HTTP/1.1
Host: farboard.ehsanr-web.workers.dev
Upgrade: websocket # index.js refuses anything else with 426
Connection: Upgrade
Origin: https://farboard.ehsanr-web.workers.dev # set by the browser; index.js checks it (403 if not allowed)
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
# Room → browser (the Workers runtime fills in the handshake headers)
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The Worker only checks the request and passes it to the Room. The Room makes a
WebSocketPair, a pair of connected sockets: it keeps one end and returns the other in its
101 response. From then on, the browser's frames go to Cloudflare's edge and on to the
Room, and the Room's frames come back the same way.
Cloudflare offers two ways for a Durable Object to own a WebSocket. Farboard uses the second.
Standard API (not used)
server.accept();
server.addEventListener('message', (e) => {
// …
});
// The object must stay in memory for as
// long as the socket is open, and is
// billed for duration the whole time.
Hibernation API (room.js)
this.ctx.acceptWebSocket(server);
async webSocketMessage(ws, message) { /* … */ }
async webSocketClose(ws) { /* … */ }
async webSocketError(ws) { /* … */ }
// Cloudflare holds the socket, may evict
// the object between frames, and wakes it
// to call these methods.
Because the object can be evicted between two frames, anything kept only in its fields may be gone when the next frame arrives. The Room works within that:
ctx.getWebSockets() returns every socket the Room holds, so it never keeps its own list.ws.serializeAttachment(state) stores a small object on each socket (its seat, its
rate-limit bucket, when it opened), and ws.deserializeAttachment() reads it back after
a wake-up.ctx.storage, the Room's database.
All frames are text, and the Room refuses binary frames and anything over 16 KB. Frames that start
with { are the relay's own JSON messages. The rest are either ping/pong
or sealed frames between the players.
| Frame | Direction | Encrypted | Meaning |
|---|---|---|---|
{"t":"join", seatToken, create?, ownerKey?} | browser → Room | no | Must be the first frame. Claims a seat. A socket still without a seat after 10 seconds is dropped when the next connection arrives. |
{"t":"joined", proto, seat, peer, peerLeft} | Room → browser | no | Seat granted, and whether the other player is connected or has left for good. |
{"t":"peer", online, left?} | Room → browser | no | The other player connected, disconnected, or left the game for good. |
ping → pong | browser → Room → browser | no | Keep-alive: checks the line still works. |
base64url string, e.g. k3Jd9… | browser → Room → other browser | yes, AES-GCM | Moves, state syncs, take-back and draw requests, names, chat messages and delivery confirmations. Forwarded unchanged; the Room cannot tell them apart. |
{"t":"leave"} | browser → Room | no | Leaving the game for good. The seat is closed to this player. |
Phones lock their screens, freeze background tabs and switch between Wi-Fi and mobile data. A socket can
die without the browser noticing, and browsers do not let pages send WebSocket-level ping frames. So
relayclient.js sends its own text "ping" every 50 seconds while the page is on
screen. If nothing at all comes back within 10 seconds, it treats the socket as dead and opens a new
one. It also checks straight away when the page becomes visible again or the network comes back.
The Room answers pings in webSocketMessage like any other frame, so pings go through the
same per-socket rate limit and the room's daily message allowance. Pings are counted like moves (and like
chat messages), so the app sends as few as it can, and none while the tab is hidden.
When the Room ends a connection it gives a reason as a close code. The WebSocket standard reserves
4000–4999 for applications, and Farboard defines its own in relay/src/rules.js. The client
looks each one up (the FATAL table in relayclient.js) to decide whether
reconnecting could help.
| Code | Why the Room closed the socket | What the client does |
|---|---|---|
4409 | The same seat connected again elsewhere (a reload or a second tab). | Stops; the newer connection carries on. |
4429 | Too many messages too quickly (bursts of 30, then 3 a second). | Reconnects after a backoff. |
1009 | Frame too large, or not text. | Reconnects after a backoff. |
4400 | Bad request: a missing or malformed join, or a frame that is not allowed. | Stops. |
4401 | No such game: the room does not exist and this join may not create it. | Stops. |
4403 | The game is full: both seats belong to other tokens. | Stops. |
4410 | The game expired after seven quiet days. | Stops. |
4411 · 4412 | This player left the game · both players left. | Stops. |
4430 · 4503 | Public hosting limits: too many new games from this network · for today. | Stops. |
4508 | The room used its daily allowance of 5,000 messages. | Stops. |
| anything else | A network drop, a Cloudflare restart, a phone going to sleep. | Reconnects after a backoff. |
public/js/relayclient.js: the browser side. It opens the socket, sends the join, pings, reconnects with backoff, and reads close codes.public/js/protocol.js: socketUrl() builds the wss:// address.relay/src/index.js: checks the upgrade header, the page's origin and the room ID, then passes the request to the Room.relay/src/room.js: WebSocketPair, acceptWebSocket, attachments, and the three hibernation handlers.relay/src/rules.js: close codes, the frame size limit, the rate limit, and the socket and message caps.
Chat needs no new relay code. Every chat message is a sealed frame like a move, so the Room forwards it
without knowing what it is. The rules live in the browsers: public/js/chat.js decides what a
name or message may look like and how much one player may send, and online.js does the
talking. Like protocol.js, chat.js has no sockets, storage or page code, so its
tests run in plain Node.
The guest sees "John would like to play" before joining, which is before any encrypted channel exists. So
the host's name is added to the invite link's fragment (#join=…&relay=…&name=John), which
no server ever receives. That name is only a hint: anyone holding the link could have edited it. Once
both devices are connected, each sends a sealed hello with its own name, and that one is
believed. If the two differ, the chat says so.
Every sealed message is {v, from, n, body}. from is the sender's role and
n only goes up, so a message cannot be reflected back or replayed. The chat label
("John:") comes from that verified from, never from anything in the message.
| Body | Sent when | Meaning |
|---|---|---|
{"t":"hello", name, caps:["chat"]} | each time the other player connects | Who I am, and that this app supports chat. An older app ignores it, and the chat box then says the other version cannot chat yet. |
{"t":"chat", text} | a message is sent | One line of text. Shown as plain text only. |
{"t":"ack", n} | a chat message was accepted | Confirms message number n. The sender shows a ✓. Messages that were refused are not confirmed. |
Chat never touches the game state that moves and requests share, so nothing said in chat can change the board. The Room does not queue messages for a player who is away, so the app checks the other player is connected before sending and says "Not delivered" instead of dropping the message quietly.
The relay's rate limit and daily allowance apply to chat like any frame, but it cannot see which frames are chat. So the players' apps enforce the chat limits, and the receiving app enforces them too, because it cannot trust the sender's app to.
| Limit | Value | Where |
|---|---|---|
| Name length | 20 characters | cleaned on send and on receive |
| Message length | 280 characters | cleaned on send and on receive |
| Messages sent per player per game | 150; a rematch is a new game and starts again | sender stops; receiver ignores the excess |
| Pace | a burst of 5, then one a second (8 on the receiving side, so lag never trips it) | token bucket on both sides |
| Flooding | five refused messages and the sender is muted automatically | receiver |
| History kept | the last 100 lines, on this device only, cleared when you leave | own localStorage key |
textContent, never as HTML, and links are not made clickable. The page's Content Security Policy already forbids inline script.cleanText() turns line breaks and control characters into spaces, removes zero-width and direction-changing characters (which can make text read backwards or hide it), keeps the joiners emoji need, collapses spaces, and cuts to the limit in characters rather than UTF-16 units. That is also why the inputs have no maxlength attribute, which would count units and stop emoji early.
The chat is below the board, out of sight, so a bar under the board shows the latest message and an
unread count, and opens the chat as a sheet. The sheet sits above the on-screen keyboard (read from
visualViewport) and the board shrinks to stay in view above it. Quick replies hide while the
keyboard is up to make room.
public/js/chat.js: cleaning, limits, the rate-limit bucket and saved history. Tested by test/chat.test.js.public/js/protocol.js: hello, chat and ack in validBody(), and the host's name in inviteLink() and parseLink().public/js/online.js: sending and receiving, the receiver's limits, mute, acknowledgements, unread counts and the phone sheet.relay/: nothing. The relay is unchanged.Stripped of limits, reconnects and error handling, this is all the multiplayer part does.
# ───────── BROWSER (both players run this) ─────────
if I am the host:
secret = random 16 bytes
share link "https://site/#join=" + secret # the part after # never reaches a server
else: # the guest opened the link
secret = read from the URL after #
roomId = HKDF(secret, "room") # same secret → same roomId on both devices
key = HKDF(secret, "key") # the encryption key; never leaves the device
ws = open WebSocket "wss://relay/v1/rooms/" + roomId
hello = { t: "join", seatToken: mySavedRandomToken }
if I am the host: hello.create = true # only the host may bring a new room into being
# (plus ownerKey, on a private relay)
ws.send(hello)
on my move: ws.send( encrypt(key, move) )
on ws message m:
if m is "pong" or relay JSON ("joined", "peer"): # from the relay itself, not encrypted
update the connection status
else: # a sealed frame from the other player
move = decrypt(key, m); apply it to the board
# ───────── WORKER (runs at the edge nearest each player) ─────────
on request "/v1/rooms/:roomId":
id = ROOM.idFromName(roomId) # same name → same id, worldwide
room = ROOM.get(id) # a stub; the first request brings the object to life
return room.fetch(request) # hand the WebSocket to it
# ───────── DURABLE OBJECT "Room" (at most one per roomId) ─────────
class Room:
storage: seats = [] # hashes of the seat tokens that joined
on fetch(request): # a new WebSocket arrives
client, server = new WebSocketPair()
acceptWebSocket(server) # hibernatable: the object may sleep, sockets stay open
return 101 Switching Protocols with client
on message(socket, msg):
if socket has no seat yet: # its first message is the join
h = hash(msg.seatToken)
if seats is empty and not msg.create:
close(socket, "no such game"); return
if h in seats: socket.seat = index of h # a reconnect
elif len(seats) < 2: seats.append(h); socket.seat = len(seats) - 1
else: close(socket, "game is full"); return
send socket { t: "joined", seat }
tell the other seat's socket { t: "peer", online: true }
return
other = the socket whose seat != socket.seat
other.send(msg) # forward encrypted bytes it cannot read
Short excerpts, with limits and error handling left out. Follow the file names for the full versions.
public/js/netcrypto.js · deriving the room ID and key
function deriveRoom(secret) {
return s.importKey('raw', fromBase64Url(secret), 'HKDF', false, ['deriveBits', 'deriveKey'])
.then(function (base) {
return Promise.all([
s.deriveBits(hkdf('room'), base, 256),
s.deriveKey(hkdf('key'), base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
]);
})
.then(function (parts) {
return { roomId: toBase64Url(new Uint8Array(parts[0])), key: parts[1] };
});
}
relay/src/index.js · the Worker routes a WebSocket to its Room
export default {
async fetch(request, env) {
// …/v1/info, /v1/stats and /v1/verify are handled above this…
const room = url.pathname.match(/^\/v1\/rooms\/([^/]+)$/);
if (room) {
if (request.headers.get('Upgrade') !== 'websocket') return text('Expected a WebSocket', 426);
if (!originAllowed(origin, allowed)) return text('Origin not allowed', 403);
if (!isRoomId(room[1])) return text('Bad room id', 400);
const stub = env.ROOM.get(env.ROOM.idFromName(room[1]));
return stub.fetch(request);
}
return text('Not found', 404);
}
};
relay/src/room.js · the Room accepts sockets and forwards frames
export class Room extends DurableObject {
async fetch(request) {
const [client, server] = Object.values(new WebSocketPair());
this.ctx.acceptWebSocket(server); // hibernation-friendly
server.serializeAttachment({ seat: null, bucket: freshBucket(now), opened: now, network });
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws, message) { // Cloudflare calls this, waking the object if needed
const state = ws.deserializeAttachment();
if (state.seat === null) return this.join(ws, state, message, now);
// …rate limit, daily allowance, 'ping' → 'pong', 'leave'…
if (!isSealedFrame(message)) return closeSocket(ws, CLOSE.badRequest, 'unexpected frame');
const peer = this.seatSocket(1 - state.seat, ws);
if (peer) peer.send(message);
}
seatSocket(seat, except) {
for (const ws of this.ctx.getWebSockets()) { // every socket this Room holds
if (ws !== except && ws.readyState === OPEN && seatOf(ws) === seat) return ws;
}
return null;
}
}
Note what is not in the Room: nothing ever lists the players in an array in memory.
Hibernation can evict the object at any moment and discard its fields. So the Room asks Cloudflare
for its sockets with ctx.getWebSockets() and keeps each socket's seat in a small
attachment (serializeAttachment) that survives hibernation. Anything that must
outlast the sockets, such as which seat tokens belong to the game, goes into
ctx.storage, the object's database.
A Durable Object handles one event at a time, so two people opening the invite at the same
instant cannot both take seat 1. The join also uses ctx.blockConcurrencyWhile(), because
it waits on storage and on the Budget object, and nothing else may slip in during that
wait.
Phones lock, tabs freeze and networks change. relayclient.js retries with backoff (up
to 30 seconds) and sends the same seat token each time. The Room recognises the token's hash and
gives the old seat back. A newer socket for a seat closes the older one, which handles a
reload or a second tab.
The relay never knows the position. Each browser keeps the full move list, checks every incoming
move with its own engine, and after a reconnect the two exchange state and settle on one version
(mergeState in protocol.js). The relay could be restarted mid-game and
no moves would be lost.
Each Room sets an alarm, a timer stored with the object that wakes it later even if no
request arrives. Activity pushes it back. After seven quiet days alarm() closes any
sockets and deletes the room's storage.
Anyone can learn the relay's address, so it is deliberately useless for anything else. Sockets are
accepted only from Farboard's own page (the Origin header), rooms have two seats,
frames must be ciphertext under 16 KB, and each socket and room has a message rate limit. See
relay/src/rules.js.
A private relay creates rooms only for its owner, who has OWNER_KEY. With
PUBLIC_HOSTING on, anyone can, and the single Budget object (another
Durable Object, always named "budget") caps new games per day and per network.
Joining an existing game never needs a key.
Wrangler, Cloudflare's command-line tool, runs the Worker and its Durable Objects locally using the same runtime as production. Open the printed address in two browser windows, one of them private, and play yourself.
npm install
npx wrangler dev --var PUBLIC_HOSTING:true --var DAILY_GAME_LIMIT:3 # a public relay on localhost
npx wrangler deploy # your own copy on workers.dev
relay/README.md lists every setting and the relay's HTTP routes.
idFromName, getwrangler.jsoncFarboard's own code: wrangler.jsonc, relay/src/index.js, relay/src/room.js, relay/src/rules.js, public/js/netcrypto.js and public/js/relayclient.js.