← Back to all posts

Same seed, four aspect ratios, four different games

Published:
candyrushdeterminismunityevidence

CandyRush is a deterministic simulation: a run is a seed plus the player's inputs, and replaying the same recording must produce the same run, tick for tick. In April that promise had a hole in it. Where enemies spawned depended on the player's screen shape and frame rate, and could depend on the CPU, so the same seed on a 16:9 monitor and on a 4:3 one did not produce the same run. Once one spawn differed, every spawn after it did too.

On April 25, pull request #240 closed it:

BeforeAfter #240
Spawn side-choice thresholdaspect / (aspect + 1) from the live cameraone constant from a fixed reference aspect
RNG draws per spawn attempt loopvariable, stops at the first valid spotfixed, always 10 attempts
Spawn callsUnity Timeline, once per rendered framequeued, drained on the fixed simulation tick
Offscreen-teleport subsetTime.frameCount % ntick % n
Diagonal and rotation mathMathf.Sqrt, Quaternion.EulerFixed64.Sqrt, FixedMath.Rotate

The pull request changed 21 files (+1,115 / -74) and added 29 EditMode tests. Two days later, #251 made the screen aspect a session-locked input that the replay header records, which fixed the one trade-off #240 had accepted.

The code was written by AI coding agents working from the issue; the work was split into three tiers, and I reviewed each one and gated the merge.

How it surfaced

It was found by accident. On April 11 I merged the flow-field pathfinding work (#218), which gave every enemy a direction lookup from one shared grid instead of chasing the player in a straight line. That commit's message estimated the cost at roughly 5 ms of CPU per second and added 74 EditMode tests. While reading its per-enemy spawn diagnostics, the log showed something else: the first enemy in a batch spawned in the same place every run, and every enemy after it diverged. Issue #227 was opened the same day.

Root cause: three ways the screen leaked into the sim

1. The spawn branch read the camera's aspect

Enemies spawn just outside the visible area. To pick which edge, the camera code drew a random number and compared it to a threshold derived from the live camera:

if (rng.Value(RngScopes.Spawning) > mainCamera.aspect / (mainCamera.aspect + 1))

The threshold depends on the player's screen:

ScreenThreshold
1:10.500
4:30.571
16:90.640
21:90.700

A random draw of 0.6 picks a side edge on a 4:3 screen and a top or bottom edge on a 16:9 one. Same seed, same draw, different enemy position. The bounds used to place the point were aspect-dependent too, since the visible width is the orthographic size times the aspect.

2. The retry loop made the divergence spread

A position could land on an obstacle, so the spawner retried up to 10 times and stopped at the first valid one:

while (triesCount < maxTriesCount)   // up to 10
{
    triesCount++;
    float variation = rng.Value(RngScopes.Spawning) * 0.2f;
    position = cameraManager.GetRandomPointOutsideCamera(0.5f + variation);
    if (stageFieldManager.ValidatePosition(position, Vector2.zero))
    {
        foundPosition = true;
        break;   // early exit: the RNG offset now depends on the retry count
    }
}

Each attempt consumed four random numbers. If the first enemy needed two tries on one screen and three on another, the random stream was now four draws out of step, so every later spawn read different numbers. That matches the pattern in the log: the first enemy of a batch agreed, and the ones after it did not. A single wrong value would have been a small bug; a wrong stream offset turns it into a different game.

3. Everything that sized itself to the camera

The same live camera bounds fed two more systems. The flow-field grid covered the camera area plus a buffer, so a wider screen registered a different set of obstacles, and enemies routed and collided differently. Empty test stages hid this, because with no props there was nothing to disagree about. The endless stage also decided when to spawn and remove chunks of scenery, and the props on them, by comparing against the live bounds.

On top of the aspect problems, two frame-rate couplings sat in the same path. Wave behaviours called Spawn from Unity Timeline's ProcessFrame, which runs once per rendered frame, not once per simulation tick, and read a camera position that is interpolated per frame. The offscreen-teleport check, which moves stragglers back in front of the player, picked its subset of enemies with Time.frameCount. At 30 fps and 144 fps those produce different results.

The fix, in three tiers

The pull request landed as three commits, each with its own tests.

Tier 1, aspect-independent. A new pure helper, DeterministicSpawnBounds, does all spawn math against a fixed reference aspect of 16:9. The camera still shows the player's real screen; only the simulation stopped looking at it. The helper draws exactly three random numbers whatever branch it takes. The retry loop became a fixed 10 iterations: it keeps the first valid candidate but keeps drawing until the budget is spent, so RNG consumption per spawn is constant. The flow-field grid, the chunk activation checks and the teleport distance all switched to the same deterministic bounds.

Tier 2, frame-rate-independent. Wave behaviours now call EnqueueSpawn. The spawner drains that queue inside Simulate(tick, dt), so random draws and position reads happen on the fixed tick no matter how fast frames render. The teleport check moved into Simulate as well and strides by tick % n.

Tier 3, CPU-independent. Floating-point square roots and trigonometry are not guaranteed bit-identical across x87, SSE2 and ARM NEON, or across JITs that fuse multiply-adds differently. One ULP is enough to flip a strict less-than comparison and teleport an enemy on one machine but not another. The diagonal now uses Fixed64.Sqrt, the teleport rotation uses FixedMath.Rotate, and a paired change in my shared UTools library (7079b05f) moved the unit-circle helpers of DeterministicRng onto fixed-point sine, cosine and square root, preserving the number and order of draws.

The tier 3 tests assert exact Fixed64.RawValue results, not tolerances. The reasoning, from the pull request: a tolerance on a determinism contract silently accepts a regression to floating-point math that is only approximately right on each platform. The full split is 24 tests in DeterministicSpawnBoundsTests and 5 in WaveBehaviorEnqueueTests, including one that checks both spawn branches consume the same number of draws. The pull request reports 2,600 of 2,602 EditMode tests passing, with the two failures already present on dev, and the issue's status update records a manual check that enemy world positions matched bit for bit at 16:9, 4:3, 1:1 and an ultrawide resolution.

The same day, a separate commit (8d519867e) closed two smaller leaks of the same family: spawn jitter moved to its own random stream so it could not shift the main spawning sequence, and enemy collision radius stopped reading Collider2D.bounds, a physics-engine value that can differ in the last bit across platforms. It added 26 more tests.

The trade-off, and the follow-up

A fixed 16:9 reference had a known cost, written into the helper's own comment: on a wider screen, enemies could spawn inside the visible area at the left and right edges, and scenery chunks activated late.

The follow-up, #251 on April 27, fixed that without giving up determinism. The reference aspect is no longer a constant. It is chosen per session from the player's settings, locked for the duration of a stage, and written into a new version of the replay header. Playback reads it from the header, so a replay uses the aspect it was recorded with, whatever screen it is watched on. Older replays without the field are treated as 16:9, which is what they were recorded with. It added 31 tests, including round trips for each replay header version.

That is the general fix for this whole class of bug: anything that can change the simulation is either removed from it or recorded as an input.

Why it matters for verified scores

Later in the year, CandyRush's weekly leaderboard started accepting a score only after a server re-runs the recording in the shipped game build and compares a rolling digest of the simulation state. The Verified Score covers that system. It only works if the recording holds everything that affects the run. A recording made on an ultrawide monitor and verified in a headless browser at another resolution would, before these fixes, have been two different games. An honest player would have been rejected, and nobody could have told why from the score alone.

The same day, #239 removed the testing cheats from production builds: cheat methods compile away outside the editor and development builds, and the cheat panel destroys itself on load. A verified replay means little if the build that produced it shipped with an invulnerability toggle.

What generalizes

Determinism leaks hide behind the first success. In the log that found this, the first enemy of each batch matched. Test the second, tenth and hundredth spawn, not just the first.

Fix the stream, not just the value. The aspect-dependent branch produced one wrong position. The early break turned that into a shifted random stream and a different game. Constant RNG consumption per operation is a contract worth testing directly.

The player's machine is either recorded or ignored. Screen shape, frame rate and CPU are all things the player's machine chooses. Each one either stays out of the simulation or goes into the replay.

Determinism tests should be exact. A tolerance in a determinism test accepts the very drift it exists to catch.