← Farboard

How two browsers and one Durable Object play a game, message by message

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 WebSocket Hibernation API Close codes Names and chat Pseudo code and real code
Configuration

One file sets up the site, the Worker and the Rooms

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.

Message by message

From an empty room to the first move

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.

Sequence of a host and guest joining a room The host opens a WebSocket, the Worker hands it to the Room, the host joins seat 0. Later the guest opens a WebSocket to the same room, joins seat 1, and the host is told the guest is online. A sealed move from the host is forwarded unchanged to the guest. Host Worker Room Guest GET /v1/rooms/R (Upgrade) ROOM.get(idFromName(R)).fetch() 101 Switching Protocols: socket open {"t":"join","seatToken":…,"create":true} {"t":"joined","seat":0,"peer":false} Room hibernates. The host shares the invite link or QR code by any means: chat, email, in person. GET /v1/rooms/R (Upgrade) same name → same Room {"t":"join","seatToken":…} {"t":"joined","seat":1,"peer":true} {"t":"peer","online":true} sealed move: "k3Jd9…" (ciphertext) peer.send(same bytes)
  1. The host opens a WebSocket. 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.
  2. The Room accepts it. It creates a 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.
  3. The host joins. Its first message carries a random seat token saved in localStorage. The Room stores a hash of it as seat 0 and replies joined.
  4. The guest opens the invite and does the same. Its browser derives the same room ID, reaches the same Room, and gets seat 1. Both players are told the other is online.
  5. Moves flow. Each move is encrypted in the browser and sent. The Room checks only that it looks like ciphertext and fits the size and rate limits, then sends the same bytes to the other seat. The receiving browser decrypts it and checks that the move is legal with its own chess engine.
WebSockets

The connection every move travels over

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.

What a WebSocket is

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.

MDN: The WebSocket API · RFC 6455

Why not plain HTTP?

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 handshake

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.

Hibernation: how the Room holds sockets without staying awake

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:

What travels over the socket

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.

FrameDirectionEncryptedMeaning
{"t":"join", seatToken, create?, ownerKey?}browser → RoomnoMust 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 → browsernoSeat granted, and whether the other player is connected or has left for good.
{"t":"peer", online, left?}Room → browsernoThe other player connected, disconnected, or left the game for good.
ping → pongbrowser → Room → browsernoKeep-alive: checks the line still works.
base64url string, e.g. k3Jd9…browser → Room → other browseryes, AES-GCMMoves, state syncs, take-back and draw requests, names, chat messages and delivery confirmations. Forwarded unchanged; the Room cannot tell them apart.
{"t":"leave"}browser → RoomnoLeaving the game for good. The seat is closed to this player.

Keeping the connection alive on phones

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.

The client connection's states The client starts connecting and becomes connected when the joined frame arrives. If the socket closes or a ping goes unanswered, it moves to reconnecting, waits with growing backoff and connects again. A fatal close code or leaving the game moves it to stopped from any state. connecting connected reconnecting stopped "joined" arrives socket closed, or no reply to a ping in 10 s wait 1 s, 2 s, 4 s … up to 30 s, or retry at once when the tab is shown or the network returns fatal close code leave fatal close code

Close codes

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.

CodeWhy the Room closed the socketWhat the client does
4409The same seat connected again elsewhere (a reload or a second tab).Stops; the newer connection carries on.
4429Too many messages too quickly (bursts of 30, then 3 a second).Reconnects after a backoff.
1009Frame too large, or not text.Reconnects after a backoff.
4400Bad request: a missing or malformed join, or a frame that is not allowed.Stops.
4401No such game: the room does not exist and this join may not create it.Stops.
4403The game is full: both seats belong to other tokens.Stops.
4410The game expired after seven quiet days.Stops.
4411 · 4412This player left the game · both players left.Stops.
4430 · 4503Public hosting limits: too many new games from this network · for today.Stops.
4508The room used its daily allowance of 5,000 messages.Stops.
anything elseA network drop, a Cloudflare restart, a phone going to sleep.Reconnects after a backoff.

Where the WebSocket code lives

Names and chat

Talking while you play, over the same sealed channel

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.

Who is playing

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.

The sealed message bodies for names and chat

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.

BodySent whenMeaning
{"t":"hello", name, caps:["chat"]}each time the other player connectsWho 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 sentOne line of text. Shown as plain text only.
{"t":"ack", n}a chat message was acceptedConfirms 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.

Limits, checked on both sides

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.

LimitValueWhere
Name length20 characterscleaned on send and on receive
Message length280 characterscleaned on send and on receive
Messages sent per player per game150; a rematch is a new game and starts againsender stops; receiver ignores the excess
Pacea burst of 5, then one a second (8 on the receiving side, so lag never trips it)token bucket on both sides
Floodingfive refused messages and the sender is muted automaticallyreceiver
History keptthe last 100 lines, on this device only, cleared when you leaveown localStorage key

Making text safe to show

On a phone

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.

Where the chat code lives

Pseudo code

The whole thing on one screen

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
The real code

The same three pieces, as written

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.

Details

What makes it hold up in real use

One event at a time

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.

DurableObjectState API

Reconnects keep the seat

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 browsers own the game

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.

Rooms clean themselves up

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.

Alarms API

Only good for chess

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.

Who may open a room

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.

Try it

Run the relay on your own machine

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.

Further reading

Guides worth reading next

Cloudflare

Web platform

Sources and notes