The room code is the server address: a real-time multiplayer game with no matchmaking service and no database
I wanted a playable game of Avalon on my own site.
Five people open a page on their phones, type a room code, see their own secret role, then vote and lie to each other. The rules are not the hard part. What the rules need is.
I wrote the list out and nearly gave up:
| Requirement | Traditional answer |
|---|---|
| Room code resolves to one game | Matchmaking service + room table |
| Game state stays alive | Redis or a database |
| Push to five clients at once | A long-running WebSocket server |
| Concurrent actions must not interleave | Locks, or a single-threaded room worker |
| Abandoned rooms disappear | A cron or TTL cleanup job |
Five moving parts for a board game played among friends.
I built it on Cloudflare Durable Objects instead, and that table collapsed into two lines of code.
The room code is the server address
The least intuitive thing about a Durable Object is how you address it. You do not start a server and then look up where it went. You hand a string over and get an instance back:
const id = env.AVALON_ROOMS.idFromName(code); // code is a room code like 'K7M2P'
const stub = env.AVALON_ROOMS.get(id);
return stub.fetch(request);The same string always resolves to the same instance, globally unique and deterministic. Nothing anywhere needs to record which machine holds room K7M2P, because the code itself is the address.
The matchmaking service disappeared.
And because each room is an independent single-threaded instance, two players voting at the same moment cannot interleave. Concurrency control disappeared too — not because I solved it, but because the problem does not exist in this model.
I later reused the same trick for the typing test leaderboard. That time the string was a date:
| Object | idFromName key | One instance is |
|---|---|---|
| AvalonRoom | Room code ('K7M2P') | One game |
| TypingBoard | Date ('2026-07-27') | That day's leaderboard |
The leaderboard needs no "fetch today's board" query, because today's date is the address of today's board. Old boards need no deletion either — they remove themselves, which I will come back to.
Cold start: I expected this to hurt
Instances created on demand sound elegant, but if entering a room costs a second the game is unplayable. So I measured it.
Three requests down a single TLS connection: first a date that had never existed (a brand-new instance), then one already alive, then the new one again.
| Request | TLS | TTFB | Total |
|---|---|---|---|
| Brand-new DO (never existed) | 25 ms | 710 ms | 711 ms |
| Existing DO (connection reused) | — | 54 ms | 54 ms |
| Same new DO, second hit | — | 54 ms | 54 ms |
The first request took 711 ms. I assumed that was DNS and TLS. It was not — TLS accounted for 25 ms, and the remaining 685 ms was server side. That is what a cold start actually costs.
The third row is the important one. The same instance answered its second request in 54 ms, identical to one that was already alive. The cold start is a one-off, not a per-request tax.
For a game that lands in an acceptable place: the first person to open a room waits about 0.7 seconds while looking at a "creating room" screen, where waiting is explained. Every action after that, for everyone, is tens of milliseconds.
To be precise: that 711 ms also contains the worker's own cold start, and from outside you cannot separate the two. So it is an upper bound, not the cost of the Durable Object alone.
The WebSocket trap: your object gets evicted while the socket stays open
This is the concept I got most wrong.
Cloudflare has a mechanism called hibernation. When an object is doing nothing except holding open WebSockets, it is evicted from memory while the connections stay up, and woken when a message arrives.
That is excellent for the bill — idle rooms cost nothing. It also has one blunt consequence: anything you kept in a JavaScript variable is gone, at a moment you were not told about.
So "which player owns this socket" cannot be this.sockets.set(socket, playerId). It has to be handed to the runtime:
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.serializeAttachment({ playerId }); // survives hibernation
this.ctx.acceptWebSocket(server); // not server.accept()
return new Response(null, { status: 101, webSocket: client });Later, asking who owns a socket means asking the runtime rather than memory:
for (const socket of this.ctx.getWebSockets()) {
const { playerId } = socket.deserializeAttachment() || {};
...
}Message handling changes shape too. Ordinary WebSocket code uses socket.addEventListener('message', ...), but that listener does not survive hibernation. The hibernation form is a class method the runtime calls when it wakes you:
async webSocketMessage(socket, message) { ... }
async webSocketClose(socket, code, reason) { ... }
async webSocketError() { ... }None of this is visible in local development, because local instances do not hibernate. It shows up in production after a room has been quiet for a few minutes, and the symptom is "some players stopped receiving updates" — which points nowhere near the cause.
One state, five different truths
Avalon runs on asymmetric information. The spies know each other, Merlin knows the spies, everyone else knows nothing.
That cannot be the front end's job. If the server sends the full state to five clients and asks each to hide what it should not show, opening DevTools wins the game.
So a broadcast is not one payload. It is one payload per socket:
for (const socket of this.ctx.getWebSockets()) {
const { playerId } = socket.deserializeAttachment() || {};
socket.send(JSON.stringify({
type: 'state',
state: sanitizeState(state, playerId, connectedIds), // different per player
}));
}sanitizeState is the only place that decides who may see what, and it runs on the server. The data a client receives does not contain the fields it should not know — they are absent, not hidden.
That is why I did not broadcast a single shared payload. A little extra CPU removes an entire category of cheating.
Expiry without cron: setAlarm
Rooms have to disappear, or they accumulate forever.
The traditional answer is a scheduled job that scans for rooms that should be dead. The Durable Object answer is that every instance carries its own alarm clock:
async persist(state) {
state.expiresAt = Date.now() + 12 * 60 * 60 * 1000; // every action pushes it back
await this.ctx.storage.put('state', state);
await this.ctx.storage.setAlarm(state.expiresAt);
}
async alarm() { // the runtime calls this
for (const socket of this.ctx.getWebSockets()) socket.close(1000, 'room_expired');
await this.ctx.storage.deleteAll();
}Every action pushes the alarm forward, so a room still being played never expires, and an abandoned one deletes itself twelve hours later and closes any sockets left behind.
The leaderboard uses the same mechanism, but sets three days once on first write:
if (existing.size === 0) {
await this.ctx.storage.setAlarm(Date.now() + 3 * 24 * 60 * 60 * 1000);
}So I never track which dates still need keeping. Each day's board knows when to leave. There is not one line of cleanup scheduling in the system.
The bug that hid for two weeks: one missing await
On the day the leaderboard shipped I ran a round of smoke tests. Valid requests were fine, but submitting an out-of-range score — 9,999 characters per minute — returned internal_error where it should have returned invalid_score.
My first instinct was that the validator was broken. Then I read wrangler tail, and the answer was nowhere near my guess:
"outcome": "exception",
"entrypoint": "TypingBoard",
"exceptions": [{
"stack": "GameError: invalid_score\n at validateScore (index.js:323:61)\n at TypingBoard.score (index.js:376:24)",
"message": "invalid_score"
}]The error code was correct. GameError had been thrown properly. What went wrong is that my try/catch never saw it — it escaped as an unhandled exception for the whole object.
The cause was in the routing line:
async fetch(request) {
try {
if (url.pathname === '/score') return this.score(request); // wrong
if (url.pathname === '/score') return await this.score(request); // right
} catch (error) {
if (error instanceof GameError) return json({ error: error.code }, error.status);
}
}return this.score(request) returns a Promise. The function ends on that line and the try block ends with it. The Promise rejects afterwards, when nobody is listening any more. With await, the function stays inside the try while it settles, and the rejection becomes an exception that same try can catch.
This is basic JavaScript and I already knew it. Inside a Durable Object it has two particularly nasty properties.
First, the error does not vanish — it mutates. The caller does not see "uncaught". It sees an object-level exception, which the outer worker then translates into internal_error. So the symptom reads as "my error handling is not working", not "a Promise went unawaited".
Second, it spreads. I wrote the leaderboard router by copying my own Avalon router from two weeks earlier — and that copy already had the bug. Which means that since its first day in production, Avalon had never once returned room_not_found, host_only, or wrong_phase correctly.
Nobody reported it, because the front end renders a generic message for those errors. It was quietly wrong for two weeks.
With await added on both sides, the same requests behave:
| Request | Before | After |
|---|---|---|
| Score of 9,999 CPM | internal_error | invalid_score |
| Blank nickname | internal_error | invalid_name |
| Join a room that does not exist | internal_error | room_not_found |
If you wrap error handling around a Durable Object, this is worth keeping as a rule: routing inside a try block always uses return await, never return promise.
Room code collisions: let the object answer
One more part I find neat.
Room codes are five random characters from an alphabet that deliberately drops I, O, 0 and 1 — the ones people misread when copying by hand. That leaves 32 characters, or 33,554,432 combinations.
Will codes collide? Yes. But I need no table to check whether a code is taken, because idFromName is deterministic and the object at that code is the only authority. So I ask it directly:
async create(request) {
const previous = await this.ctx.storage.get('state');
if (previous && previous.expiresAt > Date.now()) return json({ error: 'room_exists' }, 409);
...
}The outer worker sees the 409, picks a new code and retries, up to eight times. Collision handling is eight lines and no extra storage.
Note the expiresAt condition: an expired room counts as absent, so codes recycle themselves. That came for free — the check already existed to express "this room expired".
How big the whole backend is
Two Durable Objects, a WebSocket broadcast layer, an Avalon rules engine, a leaderboard, CORS and token verification:
| Item | Size |
|---|---|
| Total upload | 26.96 KiB |
| Gzipped | 6.52 KiB |
No Redis, no database, no long-running server, no scheduler. When nothing is happening every room is hibernating and holding no memory.
When not to do this
The model fits things that naturally split into small independent units: one game, one room, one day of a leaderboard, one collaborative document. If it splits cleanly, you get single-threading and automatic addressing for free.
It also has real limits.
First, cross-instance queries are hard. I can fetch today's leaderboard instantly, but "who was fastest this month" spans thirty instances and there is no JOIN. Queries like that need a separate rollup, or should not live in Durable Objects at all.
Second, one instance is single-threaded. That is the benefit and also the ceiling — a room that goes viral cannot be fixed by adding machines, because it is one instance.
Third, someone pays that 0.7 seconds. After a button press it is fine. On the critical path of a page load, think it through.
Fourth, this is Cloudflare-specific. idFromName, hibernation and setAlarm have no standard equivalents, so moving means rewriting.
If you are starting out
In this order, because each step trips over something different:
One, decide what goes into idFromName. It is the most important decision in the design — it determines how your data splits and what you can query. Changing it later is painful, because the address is that string.
Two, use the hibernation WebSocket API from the start. ctx.acceptWebSocket with serializeAttachment, not socket.accept with memory variables. Retrofitting touches every connection-related line you have.
Three, expire with setAlarm rather than cron. Pushing the alarm forward on each action is far simpler than reconciling against a schedule.
Four, always return await when routing inside a Durable Object's fetch. That is the cheapest lesson here and my most expensive bug.
Five, actually read wrangler tail in production. I could never have guessed the invalid_score bug from the response body. The stack trace in the log pointed straight at the line.
The game in this article: Avalon onlineThe leaderboard in the same worker: Chinese typing daily challenge