From a static quiz page
to a game the client can't cheat.
QuizClash started as a static site where every browser wrote straight to the database, and the security rules were the only referee. That works until you remember that every player can edit the code running in their own tab. This is how we moved scoring, time and the game itself onto a small Go server, one phase at a time, without stopping live games.
The game, and the problem
QuizClash is a real-time quiz game. You play solo for a topic's leaderboard, challenge one friend, or fill a room of up to eight players. Each question is open for ten seconds; faster right answers score more, and everyone sees the reveal at the same moment.
The first version was a Hugo site talking to Firebase directly: the Realtime Database for live rooms, Firestore for profiles, history and leaderboards. No server code at all. The host's browser created the room, picked the questions, opened each one, read the answer key when the question closed and published it. Every player wrote their own pick, their own score and their own row on the leaderboard.
The security rules kept all of that in shape: a score had to be a number in range, a phase could only move one step forward, timestamps had to be the server's. But rules can't recompute anything. A client that edits its own JavaScript can write any score that passes a shape check. We wrote that down plainly in our first design record:
Rules can check shape, range and order. They can't tell you whether a perfect score was earned. Only code the player can't change can do that.
We had run a security audit over the app and filed the findings by severity. Three of them were the same problem seen from different sides: forged high scores, opponents' picks visible while a question was still open, and answer keys that a script could harvest. The fix for all three was the same: move trust out of the browser.
Constraints we worked inside
A real-time game with a server is not a new idea. What made this interesting were the constraints:
- The game was live. People played every day. Every phase had to ship without stopping a single match, which meant old and new clients had to coexist for a while.
- Free tiers, on purpose. The Firebase project stays on the free Spark plan. Anything we added had to cost nothing at our scale and have a hard cap on what it could cost.
- No always-on process. A game loop that ticks every second needs a machine that is always awake. We wanted something that scales to zero.
- One small team, one codebase. The rules of play already existed as tested JavaScript in the client. A second implementation would only be acceptable if it couldn't drift.
The last point shaped everything. The client keeps its game logic in a core/ folder of pure functions: scoring, phases, seats, timing. No DOM, no Firebase, every module unit-tested. That made it possible to port the rules to another language and prove they matched.
Where the server could live
We wrote the options into a decision record (an ADR) before writing any code. The short version:
| Option | Verdict |
|---|---|
| Keep client writes, tighten the rules | Doesn't close the gap. Rules can't recompute a score. |
| Netlify Functions in JavaScript | Simplest, and could reuse core/ directly. We wanted Go for the server. |
| Netlify Functions in Go | Go runs there only in a deprecated compatibility mode with an announced end date. |
| Firebase Cloud Functions | Needs billing enabled on the database project itself, which we wanted to keep on the free plan. |
| Go on Cloud Run | Native Go, scales to zero, generous free allowance, a hard cap on instances. Chosen. |
Cloud Run lives in its own Google Cloud project with billing and a budget alert; the Firebase project stays on Spark. The service runs as a dedicated service account with roles on the Firebase project, so there is no key file anywhere: not in the repo, not in CI, not on disk.
The browsers keep their real-time listeners; they lose every write that decides an outcome.
Inside, the server is a plain ports-and-adapters layout. The domain package has the rules of play and imports nothing from the project. Use cases ("finish a match", "answer", "advance a round") declare the small interfaces they need. Firebase adapters implement them, and only the composition root knows the concrete types. Standard library first: net/http routing, log/slog, context on every I/O call, errors wrapped and matched with errors.Is, no globals.
That layout earned its keep immediately: every use case is tested against in-memory fakes, and the same tests run against the Firebase emulators with an integration build tag.
Phase 1: the server owns the score
The first step was the smallest one that closed the worst finding. The game still ran in the browsers, but at the end of a match every player's client asks the server to finish it. The server reads the room and the answer keys itself, checks the match really was played to the end, scores every answer from the server-stamped times, and records the result for every player. The rules now deny client writes to scores, stats, history, high scores and leaderboard entries; the server writes them with the Admin SDK, which the rules don't apply to.
Two details mattered more than the endpoint itself.
Idempotency. Every player's client calls finish, concurrently. The per-player result is written in the same transaction as the stats and history it changes, so a second call for the same match changes nothing and returns what was stored. Retrying is always safe.
The rules exist twice, so prove they agree. Until the whole game moved, the rules of play lived in JavaScript (core/) and Go. A small tool exports fixtures from the JavaScript (rooms, answers, timings and the expected outcomes) and the Go tests must produce identical results. Later changes regenerated the fixtures; the host hand-over logic alone is compared on 300 generated cases. If the two ever disagree, CI goes red.
Scoring first, because a forged score is permanent (it sits on a leaderboard) while a peeked answer only affects one question. Fix the finding with the longest-lived damage first.
Phase 2: answers and reveals
With scores safe, two findings were left in the live game. Each player wrote their pick into the room, which every member reads, so a modified client could watch an opponent's pick arrive while the question was still open. And the host's browser read the answer key when the question closed, which meant the key was readable by a client.
We considered tightening the read rules first. It doesn't work: a Realtime Database listener on a room reads the whole room, and rules can't hide some children from a reader they let read the parent. We considered commit-reveal (send a hash now, the pick later), which hides picks but leaves timing and keys on the client. Both were rejected in the ADR with those reasons.
The chosen design: players send their answer to the server. The server checks the question is open by its own clock, stamps the time, and stores the pick somewhere only that player can read. The room only learns that you answered and when, never what. When the question settles (timer over, or everyone answered), a reveal moves the room to the reveal phase, publishes the right answer and copies everyone's picks into the room, all in one transaction.
One question. Any member can ask for the next step; the server only takes it when it's due, and only once.
The pick itself is stored in a transaction on its own path: the first answer wins, the same answer again succeeds (it's a retry), and a different answer is a conflict. The room marker is a merge, so a late retry can't erase a pick that was already revealed. And because answers are timed by the server and starts by the database (both Google-synced), there is exactly one clock that matters.
Moving the browser tests to the real stack in the same phase found three live bugs the offline demo could never have shown. One was in production: presence used onDisconnect().update() on an entry that didn't exist yet, which the rules refused, so a player's online status was never set up at all. That story belongs to Part 3.
Phase 3: the server runs the round
After phase 2 the host's browser still created rooms (choosing the question ids itself), enforced its own room-creation cooldown, and moved the round along: countdown, next question, finish. The rules kept each step in bounds, but a modified host could still pick which questions it plays, and a host who closed the tab froze the room.
The obvious design is a server-side game loop that ticks each room forward. We rejected it: it needs an always-on instance or a task queue, and Cloud Run scales to zero. The insight that made it cheap: clients already know when a step is due. They have the room's timestamps. So any member may ask the server to advance; the server checks its own clock, and takes the step only if it is due and only if the room is still where the caller saw it.
func (s *Service) Advance(ctx context.Context, code RoomCode, round int) error { room, err := s.rooms.Read(ctx, code) if err != nil { return err } step, ok := game.Next(room, s.clock.Now()) // pure: what is due, if anything if !ok { return ErrNotDue } // early callers get a clear 409 // Apply only if the room is still at the step we decided from. err = s.phases.Apply(ctx, code, room.Position(), step) if errors.Is(err, ErrStale) { return nil // someone else took this step: that's success, not an error } return err }
Every client schedules its own call for the moment the step is due by its clock. The host calls first; everyone else waits a short grace period and calls only if nothing happened. One call lands, the rest are no-ops. A host who disappears no longer freezes anything, because any other player's call does the same work.
Two more pieces moved to the server in this phase: room creation (the server draws the questions from the topic's index and generates codes from a cryptographic random source) and the per-player cooldown on creating rooms, which now lives in server-only data instead of in the rules.
A fourth phase moved AI question conversion (an admin or contributor pastes quiz text and Gemini turns it into questions) behind the server too, because a client calling the model directly could skip its own quota. Same principle, different feature.
What broke: n² transactions and a fake CORS error
Weeks later, CI on main failed in the group browser suite. The log said a player's finish call was blocked by CORS, then got a 409 round_mismatch on retry. The same code had passed on the branch. Nothing about CORS had changed.
The root cause was three things stacked on top of each other (PR #37):
http.TimeoutHandler answered with a 503, as designed.Access-Control-Allow-Origin header, so the browser reported a CORS error instead of a timeout. The client retried; by then the room had moved on, hence the 409.The fix had three parts, one per layer:
- One transaction for all players. Recording a match writes every player's result in a single transaction, and a call that finds everyone already recorded returns without opening one.
- One in-flight recording per match per instance. Concurrent calls for the same match share one recording through
singleflight. Across instances the store is still idempotent, so this is an optimisation, not a correctness crutch. - The timeout moves inside CORS and logging. A timeout is now a readable, logged 503 with a body that says
timeout.
// Before: the timeout is outermost, so its 503 skips CORS and logging. handler := http.TimeoutHandler(withCORS(withLogging(mux)), limit, "") // After: CORS and logging wrap everything, including a timeout. handler := withCORS(withLogging( http.TimeoutHandler(mux, limit, `{"error":"timeout"}`), ))
The tests for the fix are outcome-based, with no timing in them: an integration test where eight players finish concurrently and each is recorded exactly once, and a unit test that a timed-out request keeps its CORS headers. The group browser suite went from about 150 seconds on CI to about 124 locally.
"Passed on the branch, failed on main, same code" doesn't mean the merge broke something. It means the test is sensitive to load, and load is telling you something true about production. Don't re-run until green; find out what the slow path is.
Rolling out without stopping games
Each phase changed who is allowed to write what. That makes deploy order part of the design, so every ADR and PR has a "going live" section. For each of the three phases the order was the same:
Publish the rules first and every open tab running the old site stops being able to play. Some later changes only narrowed what clients could write in a way the new client never used; for those the PR says "either order is safe", and says why. Writing that sentence forces you to check.
We also generate the Realtime Database rules from a source file instead of hand-editing JSON, and CI fails if the generated file is out of date. The rules got long enough that a hand edit in the wrong nesting level was a real risk.
How we know it holds
And in production, the server logs one structured line per request with the matched route, plus game events with no user ids in them. That's where we'd see a reveal that never lands or a finish that times out. The monitoring setup is part of Part 4.
What we'd do differently
- Start with the server owning the round. Three phases were the right way to migrate a live game. For a new game, start where we ended: clients ask, the server decides.
- Design the write path for n players from the first commit. "Each player records its own result" was idempotent and correct, and quadratic. Count the transactions per action when you design it, not when CI tells you.
- Put timeouts inside the middleware that shapes responses. Anything that can produce a response must sit inside CORS and logging, or its failures arrive disguised.
Takeaways
Most of this code was written with an AI coding agent (Claude Code), working from ADRs, a house standard and reusable skills. The product and security decisions, the trade-offs and the review were ours.
Play it
QuizClash is live. Start a room, send the code to a friend, and try to get your answer in before the reveal.