← Back to all posts

Making the Game Deterministic, in Two Attempts

Published:
candyrushdeterminismagent-runevidenceunity

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: #155Attempt 2: #160
TitleFeature/strict determinismcompound seeds, strict determinism, performance tests
Opened, merged (UTC)Dec 17 20:00, Dec 18 14:47Dec 25 09:58, Dec 25 10:16
Size+28,393/-2,192, 273 files+50,455/-6,492, 714 files
Commits86117
Test attributes in added lines485 [Test]651 [Test], 168 [UnityTest], 111 [TestCase]
CI at mergeEditMode tests failed, exit code 2EditMode and PlayMode tests failed, exit code 2
Outcomereverted about 14 hours laterkept; 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/60 s from an accumulator fed by Time.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. Fixed64 is a Q32.32 value in a long, with FixedVector2 and FixedQuaternion on top, so positions do not depend on how a particular CPU rounds a float.
  • Proof tooling. A replay recorder and player, a StateSnapshot, and a DeterminismTestHarness that 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 Random and Time.deltaTime, inside ISimulatable.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.