DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Newsletter
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • When TypeScript Types Meet Untrusted AI: Building Type-Safe LLM Pipelines With Runtime Validation
  • Beyond the Chatbot: Engineering a Real-World GitHub Auditor in TypeScript
  • Resilient API Consumption in Unreliable Enterprise Networks (TypeScript/React)
  • Supercharge AI Workflows on Azure: Remote MCP Tool Triggers + Your First TypeScript MCP Server

Trending

  • Vision-Language Models in 2026: From Research Breakthroughs to Cloud-Native Production Systems
  • To Switch, or Not to Switch the AI Model
  • PWA on iOS, Part 2: HTTPS, Manifest and Service Worker
  • How You Clear Your HTML5 Canvas Matters

Draft This article hasn't been published, so readers can't see it.

In moderation This article is waiting for review, so readers can't see it yet.

Deleted This article has been deleted, so readers can't see it.

  1. DZone
  2. Coding
  3. Languages
  4. Guess the Speed: Building a Family Internet Speed Guessing Game

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.

By 
NaveenKumar Namachivayam
DZone Core CORE ·
Nov. 03, 26 · Tutorial
Post on X (opens in a new tab) Share on LinkedIn (opens in a new tab)
109 Views

Join the DZone community Save articles, join the discussion, and download Refcards and Trend Reports with a free account.

Join for free Already a member? Log in

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.

Guess the Speed: Building a Family Internet Speed Guessing Game


How a Round Works

A round takes about a minute. Here are the steps:

  1. Add players and pick a face for each one.
  2. Everyone locks in a secret download guess and a secret upload guess.
  3. Run a real speed test against Cloudflare's network.
  4. 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/speedtest runs the real speed test in the browser.
  • A GameRoom Durable Object manages the multiplayer rooms, using WebSocket hibernation so idle rooms cost almost nothing.
  • A PlayStats Durable Object keeps the aggregate play counter.
  • Game and scoring rules live in src/lib and 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:

Shell
 
npm install
npm run dev


And to exercise rooms and the play counter, run the Worker locally:

Shell
 
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.ts is 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.ts and src/lib/scoring.ts hold 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.

TypeScript
 
// 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:

  1. Game state lives in storage, not in fields. Each message handler starts with loadRoom().
  2. Per-socket identity lives in the socket attachment. After the first hello message, the object stores who this socket is with serializeAttachment.
TypeScript
 
// 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.

TypeScript
 
// 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.

TypeScript
 
// 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,
  };
}),
TypeScript
 
// 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.

TypeScript
 
// worker/alarm.ts
export function alarmToSchedule(
  existingAlarm: number | null,
  deadline: number,
): number | null {
  return existingAlarm === null ? deadline : null;
}
TypeScript
 
// 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.

TypeScript
 
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.

JavaScript
 
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. metricMiss returns 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.

TypeScript
 
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.

TypeScript
 
// 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.

TypeScript
 
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.

JavaScript
 
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=1 flag 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 now as 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:

  1. Head to https://guessthespeed.com/.
  2. Add everyone playing and pick a face for each person.
  3. Lock in your secret guesses (or have people join from their phones with the room code).
  4. Press go and watch the gauge.
  5. 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!

Internet (web browser) TypeScript

Published at DZone with permission of NaveenKumar Namachivayam. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • When TypeScript Types Meet Untrusted AI: Building Type-Safe LLM Pipelines With Runtime Validation
  • Beyond the Chatbot: Engineering a Real-World GitHub Auditor in TypeScript
  • Resilient API Consumption in Unreliable Enterprise Networks (TypeScript/React)
  • Supercharge AI Workflows on Azure: Remote MCP Tool Triggers + Your First TypeScript MCP Server

Partner Resources

×

Comments

Loading comments

The comments didn't load

Please try again in a moment.

Likes

Loading

The next likes didn't load. Please try again.

The likes didn't load

Please try again in a moment.

See who liked this

Log in or join DZone for free to see who liked this article.

Log in Join for free
  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook