Making the Game Deterministic, in Two Attempts
In December 2025 CandyRush stopped being a Unity game that happened to look the
same twice and became a simulation that, given the same seed and inputs, runs
the same way. It took two attempts a week apart. Every number below is from the
GitHub API or git.
| Attempt 1: #155 | Attempt 2: #160 | |
|---|---|---|
| Title | Feature/strict determinism | compound seeds, strict determinism, performance tests |
| Opened, merged (UTC) | Dec 17 20:00, Dec 18 14:47 | Dec 25 09:58, Dec 25 10:16 |
| Size | +28,393/-2,192, 273 files | +50,455/-6,492, 714 files |
| Commits | 86 | 117 |
| Test attributes in added lines | 485 [Test] | 651 [Test], 168 [UnityTest], 111 [TestCase] |
| CI at merge | EditMode tests failed, exit code 2 | EditMode and PlayMode tests failed, exit code 2 |
| Outcome | reverted about 14 hours later | kept; the base of everything since |
The test counts are lines starting with a test attribute in each squash diff, so they include tests that were moved or rewritten. Treat them as approximate.
What "strict determinism" meant
The game had been written the usual Unity way. Randomness came from
UnityEngine.Random. Movement scaled by Time.deltaTime. Delays were
UniTask.Delay. Collision handling ran inside OnTriggerEnter2D. Dictionaries
were iterated in whatever order they happened to hold. Each of those is a
source of divergence: two runs with the same seed drift apart, and a recorded
run cannot be replayed.
The work was written down first as a backlog of 60 numbered tasks, TASK-001 to TASK-060, and executed by AI agents under my direction, roughly one commit per task. In the branch history the trail reads like a checklist:
TASK-001 deterministic RNG + fixed-timestep simulation loop
TASK-003 tick-based scheduler
TASK-004 entity ID system
TASK-005 input command buffer
TASK-006 time scale / pause handling
TASK-009..029 replace Random everywhere, then audit (029)
TASK-030..035 replace Time.deltaTime and UniTask.Delay
TASK-036..043 sort dictionaries before iterating
TASK-044..050 buffer OnTrigger callbacks
TASK-058..060 test harness, replay system, fixed-point math
What those add up to:
- A fixed 60 Hz loop. The simulation advances in ticks of
1/60s from an accumulator fed byTime.unscaledDeltaTime, capped at ten ticks per frame so a slow frame cannot start a spiral of catch-up. Frame rate changes how often the game draws, never what the simulation computes. - Seeded, scoped randomness. One player seed, split into 13 independent scopes (spawning, abilities, drops, enemies, projectiles, props and so on). A draw in one scope never shifts the sequence of another, so adding a random call to the drop code does not change which enemies spawn.
- Ticks instead of timers. Delays become a tick scheduler, so "in 0.5 seconds" means "at tick N+30" on every machine.
- Ordered resolution. Unity fires trigger callbacks in no defined order. The callbacks now only record the contact; the simulation resolves the buffer inside the tick, sorted by entity ID. Dictionary loops that change state iterate in sorted order.
- Fixed-point math.
Fixed64is a Q32.32 value in along, withFixedVector2andFixedQuaternionon top, so positions do not depend on how a particular CPU rounds a float. - Proof tooling. A replay recorder and player, a
StateSnapshot, and aDeterminismTestHarnessthat runs the same inputs twice and compares state.
Attempt one: merged red, reverted
#155 carried TASK-001 through TASK-060. Its branch history shows 85 of its 86
commits dated December 17. It merged into dev on December 18 at 14:47 UTC.
The merge commit's own CI run says what was wrong:
Tests failed with exit code: 2
EditMode tests completed with failures (Exit code: 2)
dev had been green on the commit just before it. The next morning, at 04:27
UTC, the merged state was pushed to a dev_backup branch, and seconds later
dev was reverted (commit a3523ba32, the exact inverse: +2,192/-28,393 across
the same 273 files). It went in directly, without a pull request. The push CI on
the revert came back green.
I can't tell you which tests failed. GitHub's run logs from that period have expired (the API answers HTTP 410), and only the annotations survive. I am not going to reconstruct names from memory.
What I can say is what the revert protected. A 273-file change to how the game
computes everything, sitting red on the integration branch, blocks every other
change behind it. Snapshotting first meant nothing was lost; reverting meant
dev was releasable again while the work continued where it belonged, on its
branch.
Between the attempts
Work resumed on the branch within hours. The first commit after the revert, on
December 19, fixed projectile lifetimes that were being truncated by the new
tick-based timing: a 2.0 s lifetime is 119.999... ticks in float math, and a
(uint) cast made it 119 instead of 120, so projectiles expired one tick early.
From there it fanned into two new branches,
feature/playmode-tests and feature/more-playmode-tests, which put the main
menu and stage flows under PlayMode tests and wired them into CI, and a
feature/performance-tests branch with frame-time, GC-allocation and draw-call
collectors, baseline comparison, and stress scenarios (merged into the
playmode branch as #159 on December 25).
Two design changes landed in the RNG itself.
The per-tick seed stopped losing bits. The first version derived each draw's seed like this:
uint combinedSeed = _baseRng.state ^ (uint)((_currentTick << 16) | (scopeId & 0xFFFF));
With a 32-bit tick, shifting it left by 16 discards its upper 16 bits, so tick
65,536 (about 18 minutes at 60 Hz) seeds exactly like tick 0. The second version
gives each scope its own base seed, computed with a stable FNV-1a hash (never
string.GetHashCode, which is not stable across runtimes), and rotates the
tick instead of shifting it:
uint combinedSeed = baseSeed ^ (uint)((currentTick << 16) | (currentTick >> 16));
Compound seeds. A seed can now be written as GLOBAL.SCOPE1.SCOPE2, for
example ABC.HLT.FBL: scope 1 (spawning) seeded from HLT, scope 2
(abilities) from FBL, every other scope derived from ABC. A plain seed like
CANDY123 still works. The point is search: to find a seed with a good spawn
sequence and a good ability sequence, you can search each scope on its own and
combine the winners, instead of searching every pair.
Attempt two: landed
#160 merged on December 25 with all of that: the 60 tasks, the fixes, the PlayMode and performance suites, and compound seeds.
Its CI was also red at merge, with both EditMode and PlayMode reporting exit
code 2. The difference from the first attempt was not a green check. In the
months that followed the check was almost never green: of the 216 dev push
runs GitHub still holds for January to July 2026, 3 succeeded. A CI that is
usually red tells you very little, and by then red no longer stopped a merge.
The suite became a trustworthy gate only with the
August 2026 pipeline rework.
What it enabled, and what came later
Determinism was not the goal in itself. A deterministic simulation can be replayed from a seed and an input log, and a replay can be checked by someone who does not trust the player. Eight months later that became a server that re-simulates submitted runs and a weekly leaderboard fed only by verified scores; that story is in The Verified Score.
Three things followed directly from this work:
- A reusable package. Issue #158, opened December 22, proposed extracting the determinism system into a UPM package. It now lives in my UTools packages, where CandyRush consumes it.
- A compiler rule. On December 30 I started
AI-Analyzers, a public pack of
Roslyn analyzers for patterns I kept finding in agent-written Unity code.
ISU4000 is an error for non-deterministic APIs, such as
RandomandTime.deltaTime, insideISimulatable.Simulate. The rule that the retrofit enforced by hand became one the compiler enforces. - A later fix to the RNG. Both attempts built a fresh sub-generator on every draw from the same tick and scope, so two draws in one tick from one scope returned the same value. A per-scope call counter fixed that in April 2026.
What generalizes
Write the cross-cutting change down as a numbered list first. Sixty tasks, one commit each, made a 273-file change reviewable task by task and made it obvious where the audit (TASK-029) had to sit.
Snapshot, then revert, then keep going. Reverting a red integration branch is cheap when the work is preserved on a branch. Leaving it red is expensive for everyone behind it.
A gate that is always red is not a gate. Both attempts merged on red checks. The real fix was not more care at merge time; it was making the check mean something.
Seed derivation is a design surface. Shifting a tick and re-seeding on every draw both looked harmless. One repeats after 65,536 ticks; the other made same-tick draws identical. Scoped, stably hashed, rotation-based derivation is what replays now rely on.