← News

Why we rebuilt Gammon42's real-time online backgammon server in Go

How we made our online multiplayer backgammon faster under load — moving the real-time dice-and-moves hot path onto a small Go service.

technical July 1, 2026

Every online game lives or dies on one thing: how it feels when you tap a checker and wait for the board to move. If that moment is instant, the game feels alive. If it stutters — even by a second — the whole thing feels broken, no matter how good everything else is.

This is the story of how we made that moment faster and steadier in Gammon42 — our free online multiplayer backgammon game — by moving the real-time part of our backend off the framework that runs the rest of the app and onto a small, purpose-built service written in Go. It’s written for two kinds of readers: if you just want to know why the game feels better now, the first half is for you. If you want the engineering detail — the load tests, the architecture, the trade-offs — that’s all in the second half, clearly marked. And if you’d rather just play backgammon online right now, go ahead — the rest of this will still be here afterwards.

The short version

Our backend was a single Dart application built on the Serverpod framework. It did everything: accounts, friends, tournaments, in-app purchases, chat — and the live gameplay itself. Serverpod is genuinely excellent for this — it let a small team ship a full, typed client-server product fast, and it still runs the majority of Gammon42 today. But live gameplay is a fundamentally different kind of work from the rest, and as more people played at the same time, the two started to compete for the same resources. The part of the system that most needs to be fast — moving pieces in a live match — was sharing a lane with everything else.

So we carved the live-gameplay part out into its own dedicated service. It handles only the real-time hot path: dice, moves, board state, and keeping both players in sync. Everything else stayed exactly where it was. The result is that a busy evening of matchmaking no longer slows down the moment you make a move — and the two halves of the system can now grow independently.

Why the wait matters

In an online multiplayer backgammon match, the messages flying between your phone and our server are tiny and constant: I rolled the dice, here’s what came up; I moved this checker; is that a legal move; here’s the new board; your turn. Dozens of these per game, all latency-sensitive, all needing to reach both players almost instantly — that’s what real-time multiplayer means when you’re rolling dice against a live opponent.

That’s a very different shape of work from, say, loading your friends list or processing a purchase. Those happen occasionally and can tolerate a few hundred milliseconds. Live moves cannot — a one-second delay on a move is the difference between a game that feels responsive and one that feels laggy.

When one system handles both, the occasional-but-heavy work can get in the way of the constant-but-light work. That’s exactly what we started to see.


The technical story

From here on, this gets into the engineering. If that’s not your thing, the takeaway above is the whole point: the game is faster because its most time-sensitive work now runs on its own.

The starting point

The original backend is a Serverpod application written in Dart, and it’s worth saying plainly: Serverpod is a great framework. It gave us a lot for free — generated client SDKs, a typed protocol shared between the Flutter app and the server, auth, database access, WebSocket streams — and it let a small team move remarkably fast. It still runs the majority of the product today: authentication, users, friends, tournaments, in-app purchases, chat, and admin tooling, and we have no plans to change that.

Real-time gameplay ran inside that same process. The server was authoritative: it rolled the dice, validated every move against the backgammon rules, held the canonical board state, and pushed updates to both clients over WebSocket streams. Correct, and clean — but it shared a runtime with every other endpoint in the product.

Finding the wall

Before changing anything, we wanted numbers, not hunches. So we built a load-test harness that spawns synthetic players who authenticate, enter matchmaking, get matched into real games, and play random legal moves end-to-end — going through the exact same WebSocket streams a real Flutter client uses. No shortcuts, no mocked endpoints.

A few deliberate choices made the results trustworthy:

  • Cross-region, separate hardware. The load generator ran in one datacenter (Nuremberg) and hit the server in another (Helsinki), roughly 30 ms apart. That’s closer to a real user than a same-machine benchmark, and it guaranteed the load generator couldn’t steal CPU from the server we were measuring.
  • Auto-ramping concurrency. The harness doubled the number of concurrent players on a fixed schedule and recorded a snapshot every five seconds: authentication latency, matchmaking latency, and move-acknowledgement latency at p50/p95/p99, plus error and game-timeout counts.
  • Honest break thresholds. We defined “broken” up front — move-ack p95 above 1.5 s, or move error rate above 2% — so the test called failure objectively instead of us eyeballing a graph.

The shape of the results was clear. At low concurrency the server was perfectly healthy: with a few dozen players, move acknowledgements came back in roughly 100–180 ms. But as the harness ramped up — 80, then 160, then 320 concurrent players — the real-time path degraded sharply. Move-ack p95 climbed into the multi-second range, the p99 tail stretched far worse, matchmaking slowed, and games began dying on the four-minute forfeit timeout: a handful at first, then dozens. Somewhere around the 320-player mark, the break thresholds tripped.

The important insight wasn’t “Dart is slow” or “Serverpod can’t handle it” — neither is true, and both comfortably run the rest of Gammon42 to this day. It was simpler: a general-purpose request-handling model, carrying every heavyweight endpoint in the product at once, isn’t the ideal shape for a firehose of tiny, latency-critical game messages. That’s true of almost any all-in-one framework — the hot path had grown its own set of requirements, and it needed both a concurrency model tuned for exactly that and its own runtime so it stopped competing with everything else.

The decision: a sidecar, not a rewrite

The tempting move — rewrite the whole backend — was also the wrong one. Most of Serverpod’s surface area (accounts, IAP, tournaments, chat) worked fine and had nothing to do with the bottleneck. Rewriting it would have been enormous risk for zero benefit.

Instead we used the sidecar pattern. We built a small, separate service that owns only the real-time game hot path, and left Serverpod owning everything else. The two run side by side on the same VPS and share the same Postgres and Redis, so there’s no data-sync problem between them — just a clean split of responsibilities. The blast radius was small, the rollout could be incremental, and if it went wrong we could fall back.

For that service we chose Go, specifically because of how it handles concurrency.

The architecture: one goroutine per room

The core design is a goroutine per game room.

In Go, a goroutine is an extremely lightweight unit of concurrent execution — it costs a few kilobytes, not a full operating-system thread — and the Go runtime multiplexes many thousands of them across the available CPU cores. That property is what makes the design possible: every active match gets its own goroutine that exclusively owns that match’s state.

This buys two things that matter enormously for a game server:

  • No locks on the hot path. Because a single goroutine owns each room’s state, every dice roll, move, and board update for that match is processed one at a time by that goroutine. Other parts of the system talk to a room by sending it a command, not by reaching in and touching its data. There’s no shared mutable state to contend over on the hot path, which means no lock contention and no data races — the concurrency bug class is designed out rather than debugged out.
  • Natural isolation. One busy or misbehaving room can’t corrupt or block another. Ten thousand quiet rooms cost almost nothing while they wait.

Around those room goroutines sits a thin supporting cast:

  • A process-wide room hub — a simple map behind a read-optimized lock — that gives O(1) lookup of a room by ID. The lock is held only for the microscopic lookup; all the real per-room work happens behind each room’s own goroutine, so the hub never becomes a bottleneck.
  • A non-blocking WebSocket layer. The connection’s read loop does as little as possible: it decodes an incoming message envelope and hands it off to the right room or lobby goroutine. Heavy work never happens on the socket read path, so a slow operation can’t stall the connection.
  • A connection pool to Postgres (via pgx) and a compact, versioned wire protocol whose message types deliberately mirror Serverpod’s enums. That last detail is what let the Flutter app speak to the new service using the same shared vocabulary it already knew — we added a dedicated game client on the app side and were able to migrate live traffic onto the Go service game by game, rather than in one risky switchover.

The whole thing builds into a tiny container: a multi-stage Docker build producing a distroless runtime image. Small image, fast start-up, and a modest memory footprint — which matters when it’s sharing a box with the main backend.

Why this is faster under load — honestly

We’re being deliberately careful here: we have a rigorous baseline for the old path (the load tests above), and we’re not going to quote precise “after” numbers we haven’t published the data for. But the reasons the new design holds up better under concurrency are structural, not hopeful:

  • Concurrency that matches the workload. A live game is naturally modeled as an independent, long-lived, message-driven actor. Goroutine-per-room maps that one-to-one. Thousands of simultaneous matches become thousands of cheap goroutines the runtime schedules across every core — instead of thousands of requests queueing through a model built for a different shape of traffic.
  • No hot-path contention. Serializing each room’s state through its own goroutine removes locking from the part of the system that runs most often and matters most. Less coordination overhead means more predictable latency, especially in the p99 tail where the old path hurt worst.
  • Resource isolation. The real-time service and the main backend no longer share a runtime. A surge of matchmaking on a busy evening can’t starve the code that validates your move, and vice versa. Each can be scaled, profiled, and reasoned about on its own.
  • A compiled, predictable runtime. A native Go binary with a low-latency garbage collector handles bursts of tiny, frequent messages evenly — exactly the traffic profile of live backgammon.

What we kept, and what we’d tell you to copy

The part we’re most happy with isn’t the language choice — it’s the restraint. We didn’t rewrite a working system. We identified the one component whose requirements had diverged from everything around it, gave that component the runtime it actually needed, and left the rest alone. Shared database, shared cache, compatible protocol, incremental migration, easy rollback.

If you take one thing from this: before you reach for a rewrite, load-test honestly enough to find the real wall, and then move only the thing that’s actually hitting it.


Want to feel the difference yourself? You can play backgammon online for free — no account needed — against real opponents in fast, reliable multiplayer matches, right in your browser at play.table-online.ro. Prefer your phone? Grab Gammon42, our online backgammon dice game, from the App Store or Google Play and roll against players around the world.

Roll well. 🎲

Ready to play?

Play Now
Play Now