← Blog

BlitzMath: a live classroom maths game with one Durable Object per room

Published 2 Oct 2026 · 8 min read · bm.janaka.me · product page

BlitzMath is a maths game for classrooms and families. A teacher or parent hosts a game on the big screen, children join with a six-digit code on their own device, and everyone answers the same questions at the same time. Children can also practise alone, take a daily challenge and battle friends. It is live at bm.janaka.me, in English, German, French and Italian. This article is about the engineering under it: how a whole class plays in real time with no database and no accounts, and why the server, not the child's device, decides everything that matters.

Takeaways

  • Make the server the referee. A browser sends "I pick answer 2" and nothing else; the questions, the clock and the scores never leave the room object.
  • One Durable Object per room replaces a database, a message broker and a scheduler for this kind of workload.
  • Keep the game rules in pure code with no network in it. The same module then runs on the server, in the browser and under a fake clock in the tests.
  • The safest data about a child is data you never have. Leave it out of the design instead of protecting it afterwards.
  • Write product values as code. "Speed never earns points" is a three-line function with a test, not a guideline.

The problem

Thirty children on thirty devices have to see the same question at the same moment, and the result has to be fair. If the device decided the score, any child who opened the browser tools could win. If the answer travelled with the question, it could be read before the time was up. And the players are children, so the product should know as little about them as possible: no accounts, no email addresses, no chat.

The first prototype planned a static host with a hosted realtime database. That design puts the rules in the browser and the data behind database rules that have to be exactly right. I moved it to a design where one small server object per game owns everything.

Architecture

Teacher's browser ─┐
                   ├─ HTTPS: create, look up, join ──► Worker
Player's browser ──┤                                     │
                   │                                     ▼
                   └─ WebSocket: the live game ──► Room Durable Object
                                                   one per game code
                                                   questions · clock · scores
                                                   rules: shared/engine.ts

The React app (a PWA) is served as static files next to the Worker.

Three parts, all TypeScript:

  • The game core is pure TypeScript with no network or storage code: the question generator, the scoring, the nickname filter and the rules of a room as a state machine, lobby → countdown → question → reveal → … → ended. It takes events (join, answer, host action, clock tick) and says what to send.
  • The server is a Cloudflare Worker with a small HTTP API and one Durable Object per room, addressed by the room code. The object wraps the game core, keeps the state in its own storage and talks to every connected browser.
  • The app is React 19 built with Vite, with no router library and no state library. It installs as a progressive web app, and solo practice works without a network because the browser runs the same question generator.

Both sides compile against one protocol file, so a message the server sends is a type the browser already knows.

The server is the referee

The room object keeps four rules:

  1. The answer and its explanation are never in a message while the question is open.
  2. Points are added when the result is shown, not when an answer arrives, so nobody can see early who was right.
  3. Credentials travel in the first WebSocket message, never in a URL, because URLs end up in logs.
  4. A room deletes itself: two hours after the last activity in the lobby, one hour in a game, 45 minutes after the end.

The fourth rule is why there is no database. A game is short-lived state with one owner. When it is over, the object's storage is cleared and nothing about the game or its players remains on the server.

Alarms are the clock

A Durable Object cannot run a timer loop while it sleeps, and it should sleep: a lobby waiting for a class to arrive must cost nothing. Two platform features make that work. WebSocket hibernation lets the object leave memory while the connections stay open. An alarm wakes it at the moment the current phase ends.

override async alarm(): Promise<void> {
  const engine = this.engine;
  if (!engine) return;
  const now = Date.now();
  if (engine.isExpired(now)) {
    await this.destroy();
    return;
  }
  await this.apply(engine.tick(now), null, now);
  await this.ctx.storage.setAlarm(engine.nextAlarm());
}

The engine knows when the next thing happens; the object only asks. Each message to the browsers carries the server's own time and the time the phase ends, so every device lines its display up with the server instead of trusting its own clock.

One game core, three places to run it

Because the rules have no network in them, the same code runs in the room object, in the browser for solo practice, and in the unit tests with a fake clock. Questions come from a seed, so a test can ask for the same question twice. Wrong choices come from typical mistakes: for 2 + 3 × 4, one wrong choice is 20.

Every question has four different choices and exactly one right answer, and the tests check that for every topic and level. For the exam-level skills, the tests work each task out a second time with different code from the generator, so a mistake has to be made twice to get through. Those skills do all arithmetic in whole numbers (tenths, rappen), never floating point, and written answers are compared exactly, as fractions: 3.5 and 3,50 are the same answer.

Calm competition, written as code

Timed, ranked drills put the most pressure on the children who need the practice most. So the rules are calm on purpose, and they are enforced where the points are made:

export const POINTS_PER_CORRECT = 100;

/** Points for one answer: accuracy only. Speed never earns points. */
export function pointsFor(correct: boolean): number {
  return correct ? POINTS_PER_CORRECT : 0;
}

Time is used only to break a tie between equal scores. No clock is shown unless the adult turns it on. A child sees the top three and their own result, never a list that shows who is last. Nothing is lost by staying away: there is no streak that resets. When heads are tired, the host can start a break in which the whole group lights one shared night sky; it gives no points and ends by itself.

Privacy by leaving things out

The server knows a nickname, an avatar and the answers of the current game, and only while the room is open. An avatar is three numbers picked from fixed lists, so no image travels between children. There is no free text between children either; the only signal is one of six fixed emoji. A child's progress (levels, badges, topic stars) is stored on the child's own device.

I still need to know whether the product is used. The usage dashboard therefore counts things, never people: a second Durable Object keeps daily totals, and a browser reports "first open today" without any id for the device or the person. The server learns "one more", never "who".

Testing and release

One command runs typecheck, lint, the unit tests and a production build; on 1 October 2026 that was 260 unit tests. A second script plays a full live game over WebSockets with robot players, from creating the room to the final results, and runs against production after every deploy. The same robots fill a room during development, so a class game can be tried without a class.

The honest boundary: so far the games have been played by robot players and by hand in a desktop browser. A trial with a real class and a teacher's read of the task wording are the next step, and the French and Italian texts still need a native speaker.

What AI accelerated, and what stayed mine

AI-assisted engineering accelerated the implementation, the investigation, the documentation and the iteration. The architecture, the decisions, the validation and the production delivery stayed engineering-led. In practice that meant written rules the assistant works inside: a priority order that starts with child safety and mathematical correctness, a decision log, and one rule above the others: never claim a check passed that was not run. This is the same practice that took AssetCare to production on Kubernetes, applied to a very different stack.

Practical takeaways

  • For a short-lived multiplayer session, start from one stateful object per session and ask what a database would add. Often the answer is only a liability.
  • Never send what the client must not know yet. Hiding an answer in the interface is not hiding it.
  • Put the clock on the server and send the deadline, not the remaining time.
  • Keep the rules free of I/O. Tests get faster, and offline play comes almost for free.
  • Check generated content with a second, independent calculation.
  • Measure usage with counts that need no identity. It is enough to plan with.

Try it

Open bm.janaka.me, choose "Host a class game" and join from a phone with the code. There is no sign-up. The product page has the facts at a glance, and the Lab lists the other recent builds.

Sources