When a casual puzzle game ships with a randomized 5×7 burst grid, the score people post on social media gets questioned constantly. "How did you clear 800?" "Is the RNG rigged?" "Can you actually plan around the spread?" The fastest way to answer all three is to build a deterministic replay system so any player — and any QA tester — can re-run an identical round, byte for byte, and study the mechanics instead of the mystery. This article walks through the engineering trade-offs I hit while wiring that up, the constraints that shaped the design, and the checklist I'd hand the next engineer on the project.
Why Determinism Matters More Than the High Score
Casual players don't usually care about seed values, but the people who care about your game care a lot. Streamers want verifiable runs. Community moderators want to flag impossible scores. Engineers want to reproduce bug reports. If your burst generation uses Math.random() directly, none of that is possible: the only state you can hand back is the final screenshot, and screenshots don't survive re-rendering because of sub-pixel differences.
The practical fix is a small but rigid contract: every random draw in the round — every fuse delay, every cell chosen for the burst center, every secondary spark offset — must come from a seeded PRNG that you can serialize alongside the round. If the seed is reproducible and the inputs are reproducible, the output is reproducible. That single sentence is the entire architecture.
If you want to read the spec end-to-end before diving in, the in-depth walkthrough for the burst rules lives in the Fireworks Simulator scoring guide. I'll point back to it once or twice; the rest of this article is about the engine underneath.
The Constraints That Shaped the Design
I started with a wishlist and trimmed it against three real-world constraints.
Constraint 1: Browser, not native. The game runs in the browser, which means the PRNG has to be implementable in JavaScript without external dependencies. That ruled out any serious cryptographic generator and pushed me toward a well-documented integer PRNG. I needed an algorithm with stable test vectors across platforms.
Constraint 2: Replay size under a few kilobytes. Players share replays through short links and QR codes. A round of 10 shots with maybe 30 derived events per shot cannot blow past 4 KB if the encoded form is text. That meant I couldn't dump raw float arrays; I had to encode the seed, the player's intent vector, and let the engine reconstruct everything else deterministically.
Constraint 3: No server round-trips during play. A server-seeded PRNG sounds attractive, but it adds latency on every shot and forces an online check. The game must remain playable offline, on a flaky train Wi-Fi, with no token, no JWT, nothing. Seeds are generated client-side from a hashed combination of the round start time and a per-session salt that lives in localStorage.
Those three constraints are why I ended up with a design that's much smaller than what people expect when they hear "replay system."
The PRNG Choice and Why xoshiro128+ Wins
The two candidates I considered were Math.random()-replacement libraries (Mulberry32, splitmix32) and a slightly heavier generator, xoshiro128+. The Mulberry family is fine for graphics demos; it has a 32-bit state and cycles that are short enough that a determined cheater could brute-force a seed given a few output values. For a single-player puzzle that probably doesn't matter, but the moment a replay format exists, you have to assume someone will try to forge a "perfect run."
xoshiro128+ has a 128-bit state and a period of 2^128 − 1. It is fast enough to be invisible to the player and its output distribution is well-documented. More importantly, there is a public-domain reference implementation in C and a clean JavaScript port that produces bit-identical output for the same seed. That last property is what lets me write a deterministic test: I feed the JS engine the seed 0xdeadbeefcafebabe, capture the first 32 outputs, and compare against a stored golden vector. If they ever drift, the test fails and I know a port or browser upgrade broke determinism before any user notices.
If you want the formal properties, the Wikipedia entry on xoshiro generators covers the family history and the periodicity guarantees. The MDN page on the Web Crypto API is worth bookmarking too — even though we don't use crypto.getRandomValues for gameplay seeds, we do use it to generate the per-session salt, and the difference between "PRNG seed" and "CSPRNG salt" is the kind of thing junior engineers mix up.
Encoding a Replay in Under 2 KB
A replay, for this game, is the minimum data needed to reproduce the round. I defined it as four fields:
-
version— a single byte so future format changes can be detected. -
seed— 16 bytes (128 bits) from the xoshiro128+ state. -
intent— a compact bitfield describing, for each of the 10 shots, the cell chosen as the burst center (a 5×7 grid has 35 cells, so this fits in two 64-bit integers). -
hash— a 4-byte checksum so corrupted replays can be rejected before the engine tries to play them.
Total: roughly 23 bytes of header plus 20 bytes of intent data. The checksum is computed by feeding the prior bytes into a small non-cryptographic hash (FNV-1a is plenty) — I am not trying to detect tampering, only bit-rot. If you ever want tamper resistance, you'd swap that for HMAC-SHA256 with a server-side secret, which is the standard pattern described in RFC 2104.
Encoding the replay as a URL-safe base64 string produces about 64 characters. That fits comfortably in a tweet, a QR code at low error correction, and most chat platforms that silently mangle long URLs.
How the Engine Uses the Replay
At replay time, the engine doesn't trust the inputs. It re-seeds xoshiro128+ from the embedded seed, walks the 10-shot intent array, and asks the PRNG for the fuse delays, spark counts, and secondary offsets exactly as it did during the original round. Any divergence between the original run and the replayed run means the engine has a bug — full stop. There is no "close enough."
That last sentence is the operational rule that kept the team honest. We wrote a test harness that captured 200 real player rounds (anonymized, with consent), serialized each one, replayed them through the current build, and asserted that the resulting score arrays were byte-equal. When a refactor of the spark physics accidentally changed the order of two floating-point operations, the test flagged it within a day. Without determinism, that refactor would have shipped and the QA report would have read "feels different" — which is the kind of bug report you can't act on.
Debugging With the Replay System in Practice
Once determinism was real, several workflows that had been impossible became trivial.
- Bug triage. A tester reports "shot 7 exploded wrong on Firefox 119." They attach a replay link. I load it, attach the Firefox build, and the bug reproduces on the first try. No "works on my machine."
- Balance tuning. When we adjusted the score multiplier for corner bursts, we replayed the same 200 rounds and computed the new expected score distribution. Without replays, balance changes were a feeling; with replays, they were a histogram.
- Player support. "I swear I scored 812 and the screenshot says 790" is now resolved by sending the player their own replay URL. They click it, watch the round, and either spot the mistake or escalate with concrete evidence.
- Streaming integrity. Streamers can publish a replay alongside a video. Viewers can verify the run without trusting the video editor.
The Checklist I'd Hand to the Next Engineer
If I were onboarding someone onto this codebase tomorrow, I'd hand them the following list and refuse to discuss the replay system until every item was checked off:
- Confirm the PRNG module exposes only
seed(state),next(), andjump()— noMath.randomreferences anywhere in the gameplay layer. - Add a golden-vector test that compares the first 32 outputs of seed
0xdeadbeefcafebabeagainst a checked-in fixture. - Verify the replay serializer is deterministic: encoding the same replay twice produces byte-equal strings.
- Verify the replay deserializer rejects malformed inputs (bad version, wrong length, failed checksum) with a typed error, not a silent fallback.
- Run the 200-round regression suite and confirm every score is identical to the recorded baseline.
- Profile replay encoding on a low-end Android phone — target under 5 ms for a full round.
- Document the replay format in the repo's
/docs/replays.md, including the byte layout, the PRNG algorithm, and a worked example. - Add a "Copy replay link" button to the post-round screen and a "Load replay" button on the main menu; both are useless if players can't find them.
Items 1–4 are non-negotiable for correctness. Items 5–7 are quality-of-life. Item 8 is the one most often skipped, and it's the one that determines whether the system ever gets used by anyone outside the engineering team.
What I'd Build Differently Next Time
If I were starting over, I'd push the checksum out of the format and into a separate sidecar file. Mixing integrity bytes into the same blob means every UI that wants to display the seed has to strip them out, and that's where bugs hide. A clean separation — replay JSON on one side, an optional .sig sidecar on the other — would also leave room for a future signed-replay feature without breaking the existing format.
I'd also log the browser's User-Agent and canvas backend (webgl, webgl2, 2d) into a debug-only field. Determinism is supposed to be platform-independent, but anti-aliasing differences in font rendering and sub-pixel snapping in canvas paint operations can leak through any visual diff. Knowing the render path at capture time has saved me hours when chasing ghost differences.
Frequently Asked Questions
Why not use the browser's built-in Math.random with a seed?
Browsers do not let you seed Math.random. The implementation is per-engine and intentionally not specified, which means two different browsers can produce different sequences for the same internal state. That violates the "byte-identical replay" requirement. A seeded PRNG you control is the only honest option.
Does determinism break anti-cheat?
No. Determinism only means the engine is predictable given inputs; it says nothing about how inputs are produced. A server-side validator still gets to inspect the replay, replay it, and check that the resulting score is plausible. In fact, a server-side replay check is easier to implement when the client format is deterministic, because the server can run the same code path and compare.
How big should a replay format grow before you split it into a binary blob?
Once you cross roughly 4 KB of base64 text, most chat platforms start mangling links and QR codes become noisy. I would design a v2 format at that point — move to CBOR or MessagePack, keep v1 around for legacy replays, and accept the maintenance cost of two codecs.
Can players share replays without leaking their session salt?
Yes, because the salt is never embedded in the replay. It only exists in the original player's localStorage and is used at round start to derive the seed. The published replay carries the derived seed, not the salt, so two players with the same seed get the same round and neither can compute the other's salt from a shared link.
This article was drafted with AI assistance and reviewed for technical accuracy before publishing.
Top comments (0)