Delhi, India
← Back to blogs

Building GeoNerds: A Real-Time Geography Quiz Engine

6 Mar 2026


GeoNerds is a real-time multiplayer geography quiz app think competitive flag guessing, capital city matching, and map identification, all happening over WebSockets with sub-second latency. This post breaks down the architecture, the "gnarly" problems I ran into, what I learned, and where the project goes from here.

What GeoNerds Actually Does

At its core, GeoNerds is a quiz platform with three game modes:

  • Flag Mode : identify a country from its flag
  • Capital Mode : match countries to their capitals
  • Map Mode : identify a highlighted country on a 110m world map

Players can practice solo (infinite questions, no pressure) or jump into competitive 1v1 matchmaking with ELO ratings, streaks, leaderboards, and match history. Rooms support up to 8 players, there's a ready-up countdown system, and you can create private rooms with short 5-character join codes.

The Architecture

The backend is a Go server running inside Docker on an AWS EC2 instance, reverse-proxied through Caddy for automatic HTTPS and transparent WebSocket proxying. The frontend is a Next.js app deployed on Vercel.

Vercel (Next.js) → Caddy (:443, auto-TLS) → Go Server (:8080) → Redis + MongoDB Atlas

The key design decision: all game state lives in Redis. Room state, player state, question data, scores, streaks - everything transient is in Redis with TTLs. MongoDB handles persistent user profiles, match history, and ELO ratings. This split means any backend instance can recover state and continue a match after a process restart, which is critical for a real-time system.

The Engine

The geonerd package is intentionally mode-agnostic. It defines a Mode interface that each game mode implements:

  • Generate(ctx, difficulty) : produce a new question
  • Validate(question, answer) : check if the answer is correct
  • TimeLimitMs(difficulty) : per-mode time limits
  • ScoreWeight(difficulty) : scoring multiplier

Modes are registered into a ModeRegistry at startup. Adding a new game mode means implementing that interface and calling modes.Register(). The engine doesn't care what the mode does internally , it just orchestrates rooms, rounds, and scoring. This was my first practical use of my OOPS concepts that I had learned . Generalizing a specific problem so that it could be reused across different modes was a key insight.

Room Lifecycle

Every room goes through a strict state machine:

  • lobby → players join, ready-up
  • countdown → 10-second countdown before the game starts
  • in_round → questions are being served, answers are being validated
  • between_rounds → brief pause between questions
  • completed → game over, scores are finalized

Each phase transition is stored in Redis. The RoomManager handles all WebSocket connections per room and broadcasts state changes to every connected client.

Scoring

The scoring formula rewards both accuracy and speed:

  • time_factor = max(0, 1 - (response_time / time_limit))
  • score = base_points × difficulty × (0.5 + 0.5 × time_factor)
  • Streak bonus: min(max_bonus, streak × 5) — capped at 25 to prevent farming
  • Wrong answers get a flat -30 penalty with streak reset

For competitive matches, ELO is computed post-game using the standard formula with a K-factor of 32. Ratings floor at 100 so you can't go negative. The social network window algorithm is the ELO rating algorithm which was/is used to rank Chess players.

Challenges & Bottlenecks

WebSocket State Management

The hardest part wasn't the WebSocket protocol itself , it was managing what happens when connections drop. A player closing their browser tab mid-match needs to:

  • Be removed from the in-memory connection map
  • Have their ready-state cleared
  • Cancel any active countdown timers for the room
  • In competitive mode: penalize -10 rating, give opponent +5, and end the game immediately
  • Broadcast the disconnect to remaining players

Getting this right required careful locking (sync.RWMutex on the RoomManager) and explicit cleanup in RemoveClientFromRoom . Without the lock two goroutines could simulateously modify the Stream map and corrupt it. But this alone is not enough . Locks will not work against "Double game over execution" in which HandleGameOver is called two times . Player 1 finishes their last question and calls HandleGameOver but Player 2 finishes thier last question 50ms later and again calls HandleGameOver . OR Player 1 finishes last question and at the same moment Player 2 disconnects which will trigger completion. ELO computed twice , match history saved twice - Not good. GameOverProcessed[roomId] if true return immediately , do nothing else proceed with ELO computation ,saving of match history and then mark it True. This acts as a guard , no matter on how many goroutines race to call HandleGameOver , only the first one actually executes it , the rest see True in the map and immediately returns.

Map Mode Edge Cases

Map mode uses Natural Earth 110m resolution data. This sounds fine until you realize that at 110m, small island nations are either invisible or rendered as single pixels. Azerbaijan was consistently misrendered. The solution: a curated MapCatalog that only includes countries that actually render clearly at this resolution, separate from the main CountryCatalog.

Anti-Cheat: Suspiciously Fast Answers

If someone answers in under 300ms, they're either superhuman or cheating. The engine rejects any answer with ResponseTimeMs < AntiCheatMinResponseMs. There's also a Redis-backed rate limiter (RateLimiter) using a fixed-window approach , if you're spamming answers faster than the window allows, you get throttled. There are other auth checks also but i am still learning many auth concepts will update this blog when I implement them. Basic token verification for WebSocket connections is in place, but there's room for improvement with JWTs or session tokens to prevent impersonation I think ... Recommend me some auth resources if you have any ✌️.

Practice Mode "Already in a Room" Bug

This was annoying. If a player left a practice room (browser back button, closed tab) without a clean WebSocket close, the server still thought they were in a room. The next time they tried to create a practice room, they'd get "already in a room." Fixed by aggressively clearing client.RoomCode on removal and ensuring practice room cleanup runs even on ungraceful disconnects.

CORS + WebSocket Proxying

Caddy sees that Upgrade: websocket header and knows " this isn't a normal HTTP request, I need to keep this connection alive and pipe bytes both ways. " It does this automatically , Caddyfile is just reverse_proxy localhost:8080, nothing WebSocket-specific. Caddy upgrades the connection and proxies it through to Go server transparently.

Key Takeaways

  • Redis as game state store is excellent. TTLs handle cleanup automatically. Sorted sets make leaderboards trivial. Pipeline/transactions keep multi-step operations atomic. The only downside: if Redis goes down, every active game dies. Acceptable for a side project, not for production at scale.
  • Mode-agnostic engine design pays off. Adding Capital Mode after Flag Mode took maybe 2 hours because the engine didn't care , it just called the interface methods. Map Mode was harder because of the data curation, not the engine integration.
  • WebSocket cleanup is 70% of the WebSocket work. The happy path (connect, play, disconnect cleanly) is easy. The unhappy paths (tab close, network drop, race conditions on room completion) are where all the complexity lives.
  • Docker + Caddy + EC2 is a solid deployment stack for side projects. Caddy's auto-TLS alone saves some time of certbot certificate work.
  • Firebase auth works but it is too tightly dependent imo. The serviceaccount.json has to be mounted into the container, credential rotation requires redeployment, and Firebase's client SDK adds bundle size on the frontend.

Limitations Right Now

  • Single instance. The Go server runs on one EC2. Redis state is local to that instance. There's no horizontal scaling , if that instance goes down, everything goes down.
  • No question difficulty progression. Difficulty is hardcoded to 1.0 for practice mode. The engine supports it (ScoreWeight, TimeLimitMs both accept difficulty), but nobody's wired up adaptive difficulty yet.
  • Matchmaking is basic. It's first-come-first-served queue matching. There's no ELO-range-based matchmaking , a 1500 player can get matched against a 800 player. Me vs Magnus.
  • No question deduplication in a session. It's possible (though unlikely) to get the same country twice in a single practice session. The RNG doesn't track what's been served.

What's Next

  • ELO-range matchmaking: Only match players within ±100 ELO. Expand the range after 30 seconds in queue to prevent indefinite waiting.
  • Adaptive difficulty: Track per-user accuracy per mode. If someone's getting 90%+ on flags, start giving them obscure flags (Comoros, Eswatini) instead of easy ones (USA, Japan).
  • Question dedup: Track served question IDs per session in a Redis set. Filter them out during generation.
  • More modes: Currency mode, language mode, continent grouping , the Mode interface makes this plug-and-play.
  • Spectator mode: Allow non-playing users to watch competitive matches live via read-only WebSocket streams. Just like Clash Royale .
  • Horizontal scaling: Move to Redis Cluster or a managed Redis (ElastiCache), run multiple Go instances behind an ALB, and use sticky sessions for WebSocket affinity. Read about these topics in theory , Lets hope for some concurrent users which will make this a real problem to solve .

End

GeoNerds started as a flag guessing game and turned into a proper real-time multiplayer engine with pluggable modes, ELO, leaderboards, and anti-cheat. The codebase is clean enough that adding a new mode is just implementing an interface. The biggest lesson: real-time systems are 20% building the happy path and 80% handling every way things can go wrong. I think building real-time systems is a joyful ride for pessimists, since they naturally think about everything that could go wrong.