Guess the Speed: Building a Family Internet Speed Guessing Game
Learn how to build a multiplayer internet speed guessing game using TypeScript, Astro, Cloudflare Workers, Durable Objects, and WebSockets.
In this blog post, we will see how I turned our family's speed test ritual into a family internet speed guessing game that you can play on your own Wi-Fi.
Every family has that one argument. "The internet is slow again." "No, it is fine; you are just streaming in 4K." Nobody really knows who is right, because nobody knows the actual speed.
So we started guessing before every speed test. It became a game, and the game became a website. Let's see how it works and how I built it.
The author describes developing Guess the Speed, a multiplayer game that transforms family internet speed tests into a competitive activity. Players submit secret download and upload guesses before running a real speed test against Cloudflare's network, then earn points based on accuracy using place rankings, bonuses for close guesses, and a cutoff rule for large misses.
The post details features including room codes for multiplayer phone participation, a TV-optimized display mode, live result gauges, and shareable results. It explains the technical implementation using Astro, TypeScript, Cloudflare Workers, and Durable Objects for managing game rooms, alongside privacy measures like automatic data deletion and cookieless analytics. The author reflects on lessons learned about focused game design, the appeal of real data, and privacy as a feature.
Why I Built It
Speed test night on the living-room TV became our family game night. Everyone shouts a guess before I press go.
Here is the funny part. My kids get it right almost every time, and Dad loses on purpose. I started my career in performance testing, then moved into development, so I think I am allowed to be bad at guessing speeds. That is my excuse, anyway.
At some point I realized this was a real game. It needed scores, secret guesses, and a proper winner. So I built it.
How a Round Works
A round takes about a minute. Here are the steps:
- Add players and pick a face for each one.
- Everyone locks in a secret download guess and a secret upload guess.
- Run a real speed test against Cloudflare's network.
- Compare the guesses with the result and score the closest players.
That's it. No accounts, no sign-up, nothing to install.
As shown below, the home screen keeps all of this on one page, with the live gauge in the middle and the player cards around it.
A Sample Round (With Real Scoring)
Let's ground this with one simple example. Say the speed test returns 240 Mbps download and 20 Mbps upload. Three players locked in their guesses:
| Player | Download guess | Upload guess |
|---|---|---|
| Mom | 250 | 22 |
| D&J | 200 | 30 |
| Dad | 500 | 50 |
Now the scoring rules kick in:
- Place points: first place earns 3 points, second earns 2, and third earns 1.
- Spot-on bonus: +1 point for each guess (download or upload) within 5% of the real value.
- 50% cutoff: if your average miss is over 50%, you get no place points.
Here is how our round shakes out:
- Mom is closest overall. Her download guess is only about 4% off, so she also gets the spot-on bonus. Total: 3 + 1 = 4 points.
- Mia lands second with 2 points.
- Dad guessed way too high. His average miss is far beyond 50%, so he gets 0 points.
Dad loses on purpose, remember? In this round, the cutoff rule made sure he lost on merit too.
The Features I am Most Happy With
Once the basic game worked, I kept adding things that make it feel good in a real living room.
Room codes for everyone's phones. Start a room, share a six-letter code or a QR code, and up to 12 players can join. Everyone guesses from their own phone. The server keeps all guesses private until the results are revealed, so nobody can peek and copy. The host also chooses which device runs the test.
TV mode. This is a bigger gauge and bigger text, built for a couch ten feet from the screen. It supports fullscreen and uses a screen wake lock where the browser allows it, so the TV does not fall asleep mid-round.
A live gauge with clear stages. You can follow the test through ping, download, and upload with live values and result cards. Watching the needle move is half the fun.
Share the winner. The champion dialog shows the winner and the scores next to a Share result button. It uses the device share sheet or copies a link.
Festive themes. Pick light, dark, or a seasonal theme, or leave it on Auto (by date) and let the calendar decide.
Dev diagnostics. This one is for the curious. It shows the summary, latency samples, individual test requests, a bandwidth percentile, the test plan, and browser details. You can copy or download the latest run as JSON. Add ?dev=1 to the URL to open it.
How It Is Built
I kept the stack small and boring on purpose:
- Astro and TypeScript build the static site, which is served from Cloudflare Workers.
@cloudflare/speedtestruns the real speed test in the browser.- A
GameRoomDurable Object manages the multiplayer rooms, using WebSocket hibernation so idle rooms cost almost nothing. - A
PlayStatsDurable Object keeps the aggregate play counter. - Game and scoring rules live in
src/liband are covered by Vitest tests.
A few things I want to call out.
Scoring is plain, testable code. Rules like the 50% cutoff and the tie-break are exactly the kind of thing that breaks quietly. Keeping them in src/lib with unit tests meant I could change the rules without fear.
Durable Objects fit this problem well. One room is one object, and it holds the secret guesses until reveal. That is a natural fit for "a small piece of shared state that many phones talk to."
Testing without waiting. Real speed tests take time, so I added a simulated mode. Append ?mock=1 for a short fake test. Use ?tv=1 to jump straight into the TV layout.
To run it locally:
npm install
npm run dev
And to exercise rooms and the play counter, run the Worker locally:
npm run dev:worker
Deployment is handled by Cloudflare Workers Builds. wrangler.jsonc runs npm run build, then npx wrangler deploy uploads dist as static assets. The Durable Object migrations run as part of that deploy.
Everything below comes from the open-source repo at https://github.com/QAInsights/guessthespeed, so you can read the real code.
Architecture at a Glance
- Astro and TypeScript build the static site, served by a Cloudflare Worker.
worker/index.tsis the HTTP entry point. It creates rooms and upgrades WebSocket connections.GameRoom(worker/game-room.ts) is a Durable Object. There is one per room, and it owns the sockets and the stored state.src/lib/room.tsandsrc/lib/scoring.tshold the rules as plain TypeScript functions with no Cloudflare APIs, covered by Vitest.
The design rule I followed: the Durable Object is a thin shell. It loads state, calls a pure function, persists the result, and broadcasts. All the game decisions live elsewhere.
One Durable Object Per Room
A room code maps straight to an object with idFromName. Every phone that knows the code reaches the same instance, and every message for that room is handled in one place. That means there is no distributed locking to think about.
Room codes are six letters from a 19-letter consonant alphabet, which gives roughly 47 million combinations. Creating a room means picking a code and asking its object to initialize itself. If the object already holds a room, it answers 409, and the Worker tries another code.
// worker/index.ts (simplified)
for (let attempt = 0; attempt < 5; attempt += 1) {
const code = generateRoomCode(/* crypto-backed random */);
const hostToken = crypto.randomUUID();
const stub = env.ROOMS.get(env.ROOMS.idFromName(code));
const response = await stub.fetch("https://room.internal/init", {
method: "POST",
body: JSON.stringify({ code, hostToken, now: Date.now(), kind }),
});
if (response.status === 201) return jsonResponse({ code, hostToken }, 201);
if (response.status !== 409)
return jsonResponse({ error: "room_creation_failed" }, 503);
}
return jsonResponse({ error: "room_code_unavailable" }, 503);
The collision check needs no separate database. The object's own storage is the source of truth for whether a code is taken.
The hostToken is a secret returned only to the creator. Presenting it on connect is what makes a socket the host.
WebSockets That Survive Hibernation
A room can sit idle for minutes while a family argues about the result, and I did not want to pay for an always-on object. The WebSocket Hibernation API lets the object be evicted from memory while the sockets stay connected. The catch is that anything held in memory is gone when it wakes up.
Two things keep a room correct across hibernation:
- Game state lives in storage, not in fields. Each message handler starts with
loadRoom(). - Per-socket identity lives in the socket attachment. After the first
hellomessage, the object stores who this socket is withserializeAttachment.
// worker/game-room.ts (simplified)
private async connectWebSocket(): Promise<Response> {
if (!(await this.loadRoom()))
return Response.json({ error: "room_not_found" }, { status: 404 });
if (!roomHasCapacity(this.ctx.getWebSockets().length))
return Response.json({ error: "room_full" }, { status: 503 });
const pair = new WebSocketPair();
this.ctx.acceptWebSocket(pair[1], ["room"]);
return new Response(null, { status: 101, webSocket: pair[0] });
}
private async handleHello(socket: WebSocket, message: Record<string, unknown>) {
// ...validate clientId against a UUID pattern...
const room = await this.loadRoom();
const attachment = {
clientId: message.clientId,
isHost:
typeof message.hostToken === "string" &&
message.hostToken === room.hostToken,
};
socket.serializeAttachment(attachment);
this.sendState(socket, room, attachment);
}
The clientId is a random UUID the browser generates once and keeps in local storage. A returning phone sends the same ID, so the server can match it to its player after a dropped connection or a screen sleep. The reply to hello is always a full state snapshot, not a replay of missed events, so a reconnect cannot drift.
One tradeoff worth naming: my per-socket rate limiter is an in-memory WeakMap, so it resets when the object hibernates. I treat the limits as a safety net, not accounting, so losing them on wake is acceptable.
The Server Is the Referee
Clients never change state directly. They send an action, and the server decides.
// worker/game-room.ts (simplified)
const room = await this.loadRoom();
const result = applyAction(
room, attachment.clientId, attachment.isHost, parsed, Date.now(), { onlineOwners },
);
if ("error" in result) {
sendError(socket, result.error);
this.sendState(socket, room, attachment); // resync the sender
return;
}
await this.ctx.storage.put(ROOM_KEY, result.room);
this.broadcastState(result.room);
applyAction(room, clientId, isHost, action, now) returns either a new room or an error code. It takes now as a parameter instead of reading the clock, which makes it trivial to test. The room tests run actions in sequence and assert on the result, with no sockets involved.
When an action is rejected, the sender gets the error plus the current state. That small habit fixed a whole class of stale-UI problems, because the client always snaps back to the truth.
Keeping Guesses Secret
If the server broadcast one shared state object, anyone could open dev tools and read everyone's guess. So the server never sends the raw room. It builds a view per recipient.
// src/lib/room.ts (simplified)
const reveal =
room.game.phase === "results" || room.game.phase === "champion";
players: room.game.players.map((roomPlayer) => {
const { owner, ...player } = roomPlayer;
delete player.guessedAt;
return {
...player,
guess:
reveal || owner === clientId ? { ...player.guess } : { down: null, up: null },
mine: owner === clientId,
};
}),
// worker/game-room.ts
private broadcastState(room: Room): void {
for (const socket of this.ctx.getWebSockets()) {
const attachment = getAttachment(socket);
if (attachment) this.sendState(socket, room, attachment);
}
}
Each socket gets its own projection: your own guess, nulls for everyone else's, until the results phase. The same function strips internal fields like the owner and the guess timestamp. A room test asserts that other players' guesses stay hidden from both players and the host while guessing and testing, then appear at reveal.
Cleaning Up Rooms With a Sliding Alarm
Rooms should disappear after six hours of inactivity. My first version called setAlarm after every accepted action, which meant a storage write for every guess and every tap. A later commit replaced that with one rule: only schedule an alarm if none exists, and let the alarm re-arm itself.
// worker/alarm.ts
export function alarmToSchedule(
existingAlarm: number | null,
deadline: number,
): number | null {
return existingAlarm === null ? deadline : null;
}
// worker/game-room.ts (simplified)
async alarm(): Promise<void> {
const room = await this.loadRoom();
if (room && Date.now() < room.updatedAt + ROOM_TTL_MS) {
await this.ctx.storage.setAlarm(room.updatedAt + ROOM_TTL_MS); // still active
return;
}
await this.ctx.storage.deleteAll();
for (const socket of this.ctx.getWebSockets()) {
try { socket.close(1000, "Room ended"); } catch { continue; }
}
}
The room stores updatedAt. When the alarm fires, it checks whether the room was active since it was scheduled. If yes, it sets a new alarm for the new deadline. If not, it deletes everything and closes the sockets. The result is a sliding time-to-live with at most one alarm in flight.
Scoring as Pure Functions
Scoring rules are exactly the kind of code that breaks quietly, so they live in src/lib/scoring.ts with no I/O. The core of it is a relative miss for each metric.
function metricMiss(guess: number | null, actual: number): number {
if (!(actual > 0) || !Number.isFinite(actual)) return 1;
return guess === null ? 1 : Math.abs(guess - actual) / actual;
}
// miss = (downMiss + upMiss) / 2
// bonus = Number(rounded(downMiss) <= spotOnPct) + Number(rounded(upMiss) <= spotOnPct)
Looking back at our round: Mom's download miss is |250 - 240| / 240, about 4.2%, which is under the 5% spot-on limit. Her upload miss is 10%, so no second bonus.
Place assignment skips players over the cutoff and handles ties the way a race does: tied players share a place, and the next place is skipped.
let place = 0;
for (let index = 0; index < eligible.length; index += 1) {
if (index === 0 || compare(eligible[index - 1], eligible[index]) !== 0) {
place = index + 1;
}
eligible[index].place = place;
eligible[index].placePoints = settings.placePoints[place - 1] ?? 0;
}
Two small details earned their own tests:
- Rounding before comparing. Misses are rounded to four decimals before ties are detected. Without that, floating-point noise decides who is tied.
- A zero or missing real speed.
metricMissreturns a full miss instead of dividing by zero, so scores stay finite.
The test file has cases for each rule: 3, 2, 1 points, an exactly 50% miss staying eligible, shared ties, the download-then-upload tiebreak, a player with no guess, and fewer than three players.
it("awards 3, 2, and 1 for first, second, and third", () => {
const scores = scoreRound(
[player("one", 100, 20), player("two", 90, 18), player("three", 70, 16)],
actual,
defaults,
);
expect(scores.map(({ place, placePoints }) => [place, placePoints])).toEqual([
[1, 3],
[2, 2],
[3, 1],
]);
});
Race Mode and the Server Clock
Race mode changes the rule: the host starts the test, and players can guess while it runs. A guess locked in later earns a bigger share of the points, but a guess that misses the result earns nothing.
The interesting design question is who owns the clock. Phone clocks drift and phones lag, so every timestamp comes from the Durable Object. Starting the test stores raceStartedAt: now, each guess stores guessedAt: now, and the result computes the timing bonus from the same server clock.
// src/lib/scoring.ts (simplified)
const duration = endedAt - raceStartedAt;
const fraction =
duration === 0 ? 1 : Math.max(0, Math.min(1, (guessedAt - raceStartedAt) / duration));
const timingBonus = 0.5 + 0.5 * fraction;
const total = Math.round(basePoints * timingBonus);
The bonus ranges from 0.5 for a guess at the very start to 1.0 for a guess at the last moment. A table-driven test pins the edges and a rounding case.
it.each([
[0, 0.5, 25],
[50, 0.75, 38], // 37.5 rounds up
[100, 1, 50],
])("applies timing at %s%% of the test", (at, timingBonus, total) => {
const [score] = applyRaceTiming(
[baseScore], [{ id: "racer", guessedAt: at }], 0, 100,
);
expect(score).toMatchObject({ basePoints: 50, timingBonus, total });
});
Applied to our round, with place points scaled to 30, 20, 10 in race mode: Mom's base is 30 plus a 10-point spot-on bonus, which is 40. If she locks in at 80% of the test, the bonus is 0.9, and she scores 36. Mia's base is 20, and locking in at 20% gives 0.6, so she scores 12. Waiting is rewarded, as long as you do not wait too long.
There is one more leak to close. If players could watch the live speed climb, they would copy it in the last second. In race mode, the progress relay forwards only the step counters and drops the measurements.
const keys = race
? ["step", "steps"]
: ["mbps", "pingMs", "step", "steps", "bytes"];
The relay also accepts progress only from the device assigned to run the test, and it is throttled to ten updates per second.
Challenges and Lessons
A few things that needed more than one attempt:
- Redundant alarm writes. Resetting the alarm on every action was correct but wasteful. The sliding alarm in section 7 fixed it.
- Host handover edge cases. Handing the host role to another phone sounds easy. The rules are: only the host can do it, not while a test is happening, not to an offline player, and not to the same device. The offline case shipped as a follow-up fix after a handover to a disconnected player left a room with no live host.
- Interrupted tests. If the device running the test reloads mid-test, the room can get stuck in the testing phase. A small pure function,
interruptedTestStep, decides what the client does next: resend a result it already holds, or abort the round once. - Abuse limits on a public endpoint. Messages are capped at 2 KB, each socket at 20 messages per second, rooms at 12 players, and WebSocket upgrades check the origin. Worker request bodies are size-bounded too.
- Testing real-time behavior without waiting. Because the rules are pure, most tests never open a socket. For the browser, a
?mock=1flag runs a short fake speed test, so a full round takes seconds.
What I would tell someone starting a similar project:
- Keep the Durable Object thin and move every decision into functions that take
nowas an argument. - Never broadcast shared state when players should not see each other's data. Project a view per socket.
- Put the identity in the state in storage, and assume the object can wake up empty at any time.
- Prefer one self-rescheduling alarm over resetting a timer on every message.
Privacy Decisions
A game for families should not feel like a tracking machine, so I made a few choices early:
- In single-screen mode, settings, player names, and scores stay in your browser's local storage.
- Room names, faces, guesses, and scores are deleted after six hours without activity.
- The play counter stores totals only, and Cloudflare Web Analytics is cookieless.
- The speed test itself runs in your browser against Cloudflare's network.
You can read the full details on the privacy page.
Try It On Your Own Wi-Fi
Here is how to play your first round:
- Head to https://guessthespeed.com/.
- Add everyone playing and pick a face for each person.
- Lock in your secret guesses (or have people join from their phones with the room code).
- Press go and watch the gauge.
- Check the scores and share the champion.
If you want ideas for a full evening, there is a family game-night guide on the site. There is also a guide that explains how the real speed test works, and a 30-minute classroom activity for kids.
What I Learned
A few takeaways from this build:
- Small, focused games beat big ideas. The whole concept fits in one sentence, and that makes every decision easier.
- Real network data makes people care. A made-up number is boring. A real measurement on your own Wi-Fi starts arguments, which is exactly what a game needs.
- Privacy is a feature. Deleting rooms after six hours and keeping analytics cookieless cost me very little, and they make the game easier to recommend to other families.
So that is Guess the Speed. A speed test, a few secret guesses, and a family that cannot stop arguing about the Wi-Fi.
Which of your guesses would be furthest off: your download speed or your upload speed? Let me know in the comments below.
Happy gaming!
Published at DZone with permission of NaveenKumar Namachivayam. See the original article here.
Opinions expressed by DZone contributors are their own.