What Farboard's online play is made of, and why it is built this way: the problem a relay solves, the Cloudflare pieces it uses, how two players meet without anyone introducing them, and what a game costs here and on AWS or Vercel. Implementation shows how it is coded.
A web page can make outgoing connections, but it cannot listen on a port, and most devices sit behind a home router or a mobile network (NAT) that blocks incoming connections anyway. So when two players are in different places, neither can reach the other directly.
The usual answer is a relay: a server with a public address that both players connect out to. Whatever one player sends, the relay passes to the other. The relay needs two properties:
On a traditional host you would run one server process and point everyone at it. Farboard does not run a server at all. It uses Cloudflare, where the "meeting point" is a built-in feature, the Durable Object.
Cloudflare runs data centres in hundreds of cities. A request from a player goes to the one nearest
them, which is called the edge. Four features matter here, and all of them are set up by one
file, wrangler.jsonc, at the top of the repository
(shown in Implementation).
A Worker is a JavaScript module that exports a fetch(request, env) function. Cloudflare
calls it for each HTTP request, in whichever data centre received the request, and it returns a
Response. There is no process for you to start or keep running. Farboard's Worker is
relay/src/index.js.
Like an Express route handler, but deployed everywhere at once and started on demand.
"assets": { "directory": "./public" } tells Cloudflare to serve the files in
public/ directly from the edge. Worker code runs only when no file matches, which here
means the /v1/… routes. One deployment is therefore both the chess app and its relay,
at one address.
Like a CDN in front of your app, with your app as the fallback.
A Worker keeps nothing between requests, and two requests may run in two different cities. A Durable Object (DO) is an instance of a JavaScript class that you address by name. Cloudflare guarantees that at most one instance with a given name is running anywhere in the world, and every request for that name is routed to it. It handles one event at a time and has its own small private database.
Like a singleton object per key that stays reachable over the network, with storage attached.
A Durable Object can accept WebSockets. With the Hibernation API, Cloudflare keeps the connections open at its edge but can take the object out of memory between messages. The next message wakes it up again. A chess game spends nearly all its time waiting for someone to think, so it spends nearly all its time hibernating, and hibernation is not billed.
Like a server that is started for each message, while the sockets never notice.
Those four pieces make up one Cloudflare Worker named farboard. Files in
public/ are served straight from Cloudflare's edge without running any code. Any
other request runs the Worker's own code, in relay/src/index.js. That code answers
the four /v1/ routes and returns a 404 for everything else, such as a mistyped
address or a bot probing for common paths. Each game gets its own Durable Object: a small, single-threaded server with its
own database, which both players' WebSockets connect to.
The GitHub Pages copy (emrahman.github.io/Farboard) serves the same page but has no relay of its
own. public/js/config.js points it at the Cloudflare relay, which lists that address in
ALLOWED_ORIGINS.
This is the whole trick. Both players connect to /v1/rooms/<roomId>. Each connection
reaches the Worker at the data centre nearest that player, and each Worker asks for the Room named
roomId. The name is the same, so Cloudflare sends both connections to the same single
instance. Once there, passing a move from one player to the other is one send() call:
no message broker, and no lookup of where the other player is connected. Because the object handles
one event at a time, it also settles races such as two people opening the same invite at once.
Cloudflare decides where the Room runs, normally near whoever reached it first. The other player's traffic crosses Cloudflare's own network to get there. Nobody has to say in advance where a game lives.
There is no "create" call. env.ROOM.idFromName(name) turns any string into an ID, and
env.ROOM.get(id) returns a stub, a local handle for talking to that object. The
first request to reach a name brings the object into existence; if it has stored anything, it finds
that data again the next time it wakes. Farboard adds its own rule on top: only the host's first join,
marked create: true, may save a new room. A guest who arrives at a room with no stored
record is told the game does not exist.
The two players have to agree on a room name without any server introducing them, and the relay should not be able to read their moves or their chat. Both come from a single random value in the invite link.
# in
requests. Opening https://…/#join=SECRET&relay=… fetches the page, while the
secret stays in the browser. The app then removes it from the address bar.
#fragment, and after that inside sealed messages.
Cost is what shaped the choices above: one object per game keeps the relay to a single service, and hibernation means a game costs almost nothing while both players think. This section and the next three put numbers on that.
The counts assume a typical game: 40 moves each (80 plies) over about 25 minutes, both tabs on screen, and one reconnect per player (a phone going to sleep, say). They are worked out from the relay code, not measured from billing, so treat them as estimates.
/v1/info and /v1/stats.Rounded totals per game: 8 Worker requests, about 16 Durable Object requests (the ~156 incoming WebSocket messages count as about 8), about 250 rows read and about 15 rows written. The row-read count is an upper bound: it assumes the room hibernates between every message and so reloads its message counter each time.
Cloudflare's free plan measures Farboard's relay in five units. Each resets at 00:00 UTC, except storage, which is a running total. On the free plan, reaching a limit makes further operations of that kind fail until the reset. Nothing is ever charged.
One run of the Worker's code for an incoming HTTP request. A WebSocket counts once, when it opens, however long it then stays open. Requests for the page's own files are served as static assets and are not counted, but a request for a file that does not exist runs the Worker and is counted, even though it only gets a 404.
Opening Farboard's home screen costs 2: /v1/info and /v1/stats. The HTML, CSS, JS and icons are free.
Each HTTP request, RPC call or alarm that reaches a Durable Object. Incoming WebSocket messages count too, but at a 20:1 ratio: 20 messages cost 1 request.
A game's ~156 incoming messages (moves, pings, syncs) count as about 8 requests. Chat adds one incoming message per chat message plus one delivery confirmation. Even a game that uses the whole chat allowance (150 messages each, so 300 messages and 300 confirmations) adds about 30 requests.
A gigabyte-second: memory multiplied by time, not network bandwidth (it is not a gigabit). Every Durable Object is billed as if it used 128 MB, or 0.128 GB, for each second it is awake. One object awake for one second costs 0.128 GB-s, so 13,000 GB-s is about 28 hours of one object awake per day.
A 25-minute game whose room stayed awake throughout would cost 1,500 s × 0.128 = 192 GB-s, so only about 67 such games would fit in a day. Farboard's rooms use the WebSocket Hibernation API, which means only the milliseconds spent handling each message count, well under 1 GB-s a game.
Between messages, Cloudflare can take a room out of memory while keeping both players' WebSockets open on its side. The next message wakes the room. A room that is able to hibernate is not billed for duration, even before it actually hibernates.
A game where both players have wandered off costs nothing at all while it waits.
Each Durable Object has a small SQLite database. Farboard uses the simple key-value calls on top of
it: each get() reads a row, and each put(), delete() or
setAlarm() writes one.
Joining a game reads the room's record (1 read) and saves the new seat (1 write). A move usually reads the message counter and the alarm time (2 reads) and writes nothing.
The size of all Durable Object databases added together, at any moment. This is a running total rather than a daily allowance, and data you delete stops counting.
A room keeps a few hundred bytes of data. It is deleted a week after its last message, so 100 games a day holds at most about 700 rooms. Even with generous database overhead that is under 20 MB.
Move the slider to change the number of games in a day. Each bar shows that day's use of one allowance, using the per-game estimates above. Visitors who look at the home screen without playing add 2 Worker requests, 1 Durable Object request and 1 row read each.
The relay's own cap (DAILY_GAME_LIMIT, default 100) stops new games long before any
allowance runs out. At 100 games a day the busiest allowance, Durable Object requests, sits near 2%.
The page is static and would host for free almost anywhere. The part that changes is the relay: something has to hold two WebSockets, pass frames between them, and remember seats and game counts. On Cloudflare that is one object per game. Elsewhere, the same jobs are split across several services.
| Cloudflare (today) | AWS | Vercel | |
|---|---|---|---|
| Page | Worker static assets | S3 + CloudFront | Static deployment |
| WebSockets | Room Durable Object (hibernating) | API Gateway WebSocket API | Vercel Functions WebSockets (public beta since June 2026) |
| Passing a move to the other player | Same object, one send() |
A Lambda per message looks up the peer's connection in DynamoDB, then calls PostToConnection |
The two sockets may sit on different instances, so frames go through Redis pub/sub (Upstash) |
| Seats, counters, clean-up | Object's SQLite + alarm | DynamoDB conditional writes, TTL for expiry | Redis keys with expiry |
| Connection limits | None that affect a game | 10-minute idle timeout, 2-hour maximum | 5 minutes per connection on Hobby (300 s max duration), then reconnect |
| Free allowance that matters | 100k DO requests/day; hibernation makes idle games free | Lambda 1M requests/month and DynamoDB 25 GB always free. API Gateway's 1M messages/month is a 12-month offer; accounts opened since July 2025 get credits instead | Hobby: 1M invocations, 4 h Active CPU, 360 GB-hours memory per month. Upstash free: 500k commands/month |
| Rough cost at 100 games/day | $0 | ≈ $1 a month ~950k API Gateway messages at $1/M; connection-minutes, Lambda and DynamoDB add cents or stay free | $0–2 a month Upstash's free tier covers ~40 games a day; above that about $1.40/month. Memory held open by sockets may outgrow Hobby's 360 GB-hours |
| When usage spikes | Free plan fails closed at the limit; online play pauses until 00:00 UTC; never billed | Keeps serving and bills per use; AWS Budgets can alert but do not stop spending | Hobby stops serving past its included usage until the next cycle; Pro ($20/month) bills overage |
| Terms | Any use | Any use | Hobby is for personal, non-commercial use only |
| Porting effort | — | Large: rewrite room.js as three Lambda handlers with DynamoDB, plus infrastructure as code and IAM |
Medium: a Node WebSocket server plus Redis for seats and relaying; handle a reconnect every 5 minutes |
API Gateway meters messages in both directions: about 156 in and 160 out per game, so roughly 316 × 3,000 games a month ≈ 950k messages, or about $0.95. Two players for 25 minutes is 50 connection-minutes a game, about $0.04 a month at $0.25 per million. The ~470k Lambda invocations and their DynamoDB lookups fit in the always-free tiers.
Relaying a frame through Redis takes about 2–3 commands (publish, counters), so about 400 commands a game. 500k free commands a month is about 1,250 games, or 40 a day. The remaining ~700k commands at $0.20 per 100k come to about $1.40. Function memory is billed while an instance holds open sockets, which is hard to predict with Fluid compute sharing instances.
The short version: AWS and Vercel would each run Farboard for about a dollar or two a month. Cloudflare's advantages are that the design is simpler (one object per game instead of a function, a database and a pub/sub service), idle games cost nothing, and the free plan stops at its limits instead of sending a bill.
Design alternatives compares this design with a relay on your own server (VPS) and with WebRTC peer-to-peer.
Room and Budget.GET /v1/stats on the live site: games started today, the cap and when it resets.game-created or game-refused line per new game.If the real figures differ from the estimates here, the per-game constants live at the top of this page's script and are easy to adjust.
Prices and allowances checked on 26 September 2026 from Workers pricing, Durable Objects pricing, API Gateway pricing, Vercel pricing, Vercel WebSockets, Vercel Functions limits and Upstash Redis pricing.
Farboard's own numbers are estimates from relay/src/room.js, relay/src/budget.js, relay/src/index.js and public/js/relayclient.js.