Snake Legends
Made a laggy multiplayer game run smooth — and published the optimization that failed.
- Performance Optimization
- Game Development
- Multiplayer Networking
- Lua
A live multiplayer game where players control snakes that grow as they eat. The more players and the longer the snakes, the more work the game has to do every single frame — and past a certain size, it stuttered.
I spent weeks measuring where the time actually went. The most useful thing I found was that my own first fix made it worse.
The optimization that failed
The obvious move was to spread the work across multiple CPU cores. I built it, measured it, and it came out at 16.95 ms per frame against about 6 ms for the original — three times slower.
The reason: I had moved the cheap half of the work onto other cores, and the cost of shipping data back and forth — roughly 8,700 individual writes per frame — was larger than the work I had moved. I wrote up the result, left the branch unmerged, and went looking for the real bottleneck.
Finding the real bottleneck
Measured at 865 body segments: the math took 6.07 ms, but applying the results to the game world took 9.14 ms. That second number was the problem, and it could not be moved off the main thread at all.
So the fix was not "use more cores" — it was "touch fewer objects."
What actually worked
- A misdiagnosed memory leak. The stutter was blamed on memory. It was really a CPU cache problem: position history was stored as a list of objects, so reading one position meant chasing two pointers. Flattening it into a single numeric array removed the chase and the temporary objects entirely.
- Food updates batched. About 1,500 individual per-frame updates became one batched call.
- A broad-phase check. The simulation radius was far wider than the pickup range, so about 96% of the items being checked every frame could never have been picked up. Adding a distance check first cut the per-frame work by roughly 3×.
- Visual effects rebuilt. Instead of attaching an emitter to every one of up to 600 segments, a small pool of objects rides along the body — one batched move per frame.
The bug underneath the bug
Two separate root-cause writeups assumed the background worker was running. It never had been: it waited forever on an object lookup that could not resolve once the code was copied into a worker at runtime. Everything measured before that discovery was measuring the wrong thing.
Why this one matters to me
Shipping the failed experiment was more useful than hiding it. It is the reason I found the real bottleneck instead of optimizing something that was never the problem.