Game Development

Why Our Multiplayer Game Froze at 500 Players, and What Actually Fixed It

A beta test two weeks before launch exposed a networking bottleneck that standard Unity tooling couldn't solve. Here's the architecture change that got us to 1M+ concurrent players at 60fps.

Adrian K. · Founder & Lead Engineer

7 min read

City building mobile game on a smartphone screen, illustrating the multiplayer networking problem this post covers

The problem showed up two weeks before launch

A beta test is supposed to catch small things: a misaligned button, a typo in a tooltip. Ours caught something that could have killed the launch. Past roughly 500 concurrent players in a single session of the city-building sim we were shipping, the game didn't slow down gracefully. It froze. Client frame rates on mid-range Android devices dropped from a stable 60fps to something unplayable, and the freezes got worse the longer a session ran.

The design called for thousands of dynamic entities, buildings, vehicles, NPCs, all updating state in real time and syncing across every connected client. At small player counts that's a solved problem. At the density this game needed, it wasn't.

Where the time was actually going

The instinct when multiplayer lags is to blame bandwidth. That wasn't it. Profiling showed two separate problems stacking on top of each other. First, standard Unity networking (built on MonoBehaviour-driven object updates) carries per-object overhead that's fine for dozens of networked entities and expensive at thousands. Second, and worse, Unity's garbage collector was pausing the entire game thread every few seconds to clean up the allocation churn from constant object instantiation and state diffing. A GC pause during a live multiplayer session isn't a stutter, it's every client's world freezing at once.

Standard WebSocket or TCP-based sync made the bandwidth side worse too: full state updates instead of deltas, and no tolerance for the packet loss that mobile networks produce constantly. We weren't going to tune our way out of this. The object model itself was the bottleneck.

The fix: DOTS, not more optimization

We rebuilt the networking layer on Unity's Data-Oriented Technology Stack instead of patching the existing MonoBehaviour approach. DOTS processes entities as flat data arrays instead of individually allocated objects, which eliminates most of the per-object overhead and, more importantly, most of the garbage collection pressure that was causing the freezes in the first place.

On top of that, we wrote a custom UDP layer tuned specifically for high-frequency state sync: delta compression so clients only receive what actually changed, not full snapshots, and predictive client-side interpolation so the game stays smooth for the player even when a packet is late or dropped. UDP's tolerance for loss is exactly what a real-time simulation needs, you'd rather miss one position update and interpolate through it than stall waiting for a guaranteed-delivery TCP retransmit.

None of this was a drop-in library. The DOTS rewrite touched how every networked entity in the game stored and synced its state, which meant re-architecting systems that had already been built the conventional way.

What it held up to

At launch, the backend scaled to over 1 million concurrent users without manual intervention. Mid-range Android devices, the same class of hardware that had been freezing at 500 players in beta, held a stable 60fps through the session. Server uptime stayed at 99.9%, and AWS GameLift's scaling rules handled a 10x traffic spike over projected load without degradation, which cut the projected server cost by 35% since we weren't over-provisioning for a worst case that never happened.

The lesson we keep applying since: when a multiplayer system chokes at scale, the fix is rarely a tuning pass. It's usually that the data model was never built for the density you're now asking of it. Profile first, but be honest about what the profiler is telling you.

Want the full client story behind this?

Read the Case Study →