Same seed, four aspect ratios, four different games
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:
| Before | After #240 | |
|---|---|---|
| Spawn side-choice threshold | aspect / (aspect + 1) from the live camera | one constant from a fixed reference aspect |
| RNG draws per spawn attempt loop | variable, stops at the first valid spot | fixed, always 10 attempts |
| Spawn calls | Unity Timeline, once per rendered frame | queued, drained on the fixed simulation tick |
| Offscreen-teleport subset | Time.frameCount % n | tick % n |
| Diagonal and rotation math | Mathf.Sqrt, Quaternion.Euler | Fixed64.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:
| Screen | Threshold |
|---|---|
| 1:1 | 0.500 |
| 4:3 | 0.571 |
| 16:9 | 0.640 |
| 21:9 | 0.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.