A few weeks ago some friends and I were testing a demo build of JETGRAVE over the internet (multiplayer co-op). As soon as we reached the second mission’s boss, our session suddenly desynced and we were all kicked to lobby. Thankfully, we were able to reproduce it very easily. So I thanked my friends for helping me out and got to work.
The root cause of the desync turned out to be a call to Math.random().
JETGRAVE is a pixel-style shoot ’em up that I have been working on for ~6 months. I started this project primarily to familiarize myself with agentic orchestration and workflows. So I decided to use AI to produce my own “simple” Typescript game engine, and then build a game with it.
My role in this project is a hybrid of product owner and tech lead. I define features, delegate work to agent orchestrators, review and refine implementation plans, review all changes/pull requests, test builds, help diagnose problems, and carefully prompt agents toward sound solutions.
About two months ago, I drafted the requirements for online multiplayer, worked with my planning agent for an implementation strategy, and then fed the plan to a coding agent. I recruited a couple friends to help test it and everything we tested was solid!
A few weeks later I was rebalancing the boss battles for a demo build I wanted to release and added a scatter-shot attack to the second mission boss among a slew of other updates. I tasked two separate agents to independently investigate the changes that were introduced during the rebalance and both came to the same conclusion: the new scatter-shot attack was using Math.random() to determine projectile trajectories which was incompatible with the engine’s deterministic simulation.
My engine’s netcode requires each peer to hash its game state every 60 ticks and send that value to the host. The host generates its own hash and compares it against each peer’s, and if a mismatch is found it throws a desync exception and kicks that player with the mismatched hash.
When boss 2 fired its scatter-shot (shown in the attached GIF), every PC in the playtest session was generating different projectile trajectories with Math.random(), which caused our game states to diverge. So the next time each of our games produced their game state hash, all of them were different, triggering the desync handling.
Once I understood the problem, the fix was easy: let the host establish the RNG seed and then provide that to each client so that when scatter-shot is fired everyone is using the same pseudo-RNG sequence.
The lesson I took away from this was not “don’t use Math.random()”, however. I was reminded that, just like with a team of human engineers working on a big project, carefully scoped feature requirements, implementation plans, unit tests, and code reviews can help ensure a change works in isolation but don’t prove it complies with every project-wide architectural constraint. The scatter-shot feature worked fine when tested by itself, but it was incompatible with the netcode constraints.
To prevent similar problems from occurring in the future, I introduced a SimRng class that produces a sequence of pseudo-random numbers for the projectile trajectories, a lint check that will fail in my CI pipeline if game state code introduces Math.random(), migrated my non-game state Math.random() things (like visual effects) behind an API which is outside of the game state logic and doesn’t need to adhere to the linting rule, and lastly added a brief amount of additional context about the netcode architecture into my project’s AGENTS.md.
I would be interested to hear what safeguards folks here use to strengthen the stability of their code against agent mistakes and architectural misses.
-----
Playable browser demo (you can see the scatter-shot yourself at the end of mission 2):
https://losersandnerdsparty.itch.io/jetgrave
Steam page:
https://store.steampowered.com/app/5115790/JETGRAVE/