← Back to all posts

Cutting Simulation Garbage 73% Without Changing a Single Replay

Published:
candyrushperformancedeterminismunityevidence

In June 2026 I ran two performance rounds on CandyRush's simulation with one non-negotiable rule: anything that touched the simulation had to be bit-identical. Not "plays the same". The replay digests had to match.

The results, all from editor batchmode benchmark runs on one machine:

ScenarioBeforeAfterChange
Walking for 60 s, total garbage (round 1, PR #300)119.3 MB32.7 MB-73%
Walking, mean per tick (round 1)33,156 B9,525 B-71%
Idle combat, 3,600 ticks, mean per tick (round 2, PR #302)8,976 B487 B-95%
Enemy ramp 0 to 246, mean per tick (round 2)71,901 B2,927 B-96%
500-enemy stress, P95 frame time (round 2)9.21 ms7.23 ms-22%

No replay fixture moved in either round. Along the way the work found a pool bug that had been in the toolkit since January, and a test that had been written to expect it.

Why garbage matters here

CandyRush runs a fixed 60 Hz simulation inside Unity. Garbage costs little when it is created; it costs when the collector runs, as a hitch in an otherwise smooth frame. With hundreds of enemies on screen, players notice.

The constraint is what made this harder than a normal allocation sweep. In a deterministic simulation, a "harmless" list reordering can change which enemy is hit first, and from there the whole run. Every fix had to keep the same values, in the same order.

Round 1: measure first, then find the 15-tick pattern

PR #300 started as a sweep of hot paths, the code that runs every tick inside Simulate(). An agent did the sweep under my direction; I set the bit-identical rule and reviewed each round. The early fixes were the familiar ones:

  • No LINQ in per-tick code. Two aura abilities ran OrderBy(...).ToList() every tick, even with no enemies nearby. They now reuse sorted buffers and return early when empty.
  • Buffer-filling queries. "Enemies in radius" only existed as a method that returned a new list. A second overload now fills a caller-owned list, the same convention as Unity's Physics2D.GetContacts, and the allocating version delegates to it, so there is still one scan implementation.
  • Shared comparers. Six deterministic sort sites each passed a lambda, which allocates a comparer wrapper per call on Mono. They now share singleton comparers in one DeterministicOrder class, which also makes it the single authority on how entities are ordered.

The biggest win came from a better instrument. The existing benchmarks never moved the player, so the next round of the PR added a per-tick allocation probe with two scenarios: idle combat with real waves, and a "movement marathon" that walks east for 60 seconds.

One caveat: under Unity's Boehm collector, GC.GetAllocatedBytesForCurrentThread read zero, so the probe records per-tick deltas of GC.GetTotalMemory(false). That is a heap-growth estimate, so results are only compared same scenario, same seed, same machine.

Walking allocated about five times more per tick than standing still, and the spikes were regular: about 370 KB each, and 184 of the 409 gaps between large spikes were exactly 15 ticks. Fifteen ticks is the flow-field recompute interval.

The cause: the flow field that steers enemies is a grid that follows the camera. When the camera window moved, the service allocated a whole new grid, even though its dimensions had not changed. While the player walked, that happened on every recompute.

The fix went into the shared toolkit, so every game using it gets it:

// Moves the grid window to a new origin, reusing the existing cell array.
// The result is state-identical to constructing a fresh grid of the same
// dimensions at the new origin.
public void Recenter(FixedVector2 newOrigin)
{
    Origin = newOrigin;
    ResetAll();   // the same reset the constructor now calls
}

The constructor and Recenter share one reset method, so "identical to a fresh grid" is true by construction rather than by careful copying. The rerun, same scenario, same seed, same machine:

Movement marathonMean per tickp99Spikes 32 KB or moreTotal per 60 s
Before33,156 B401,408 B409119.3 MB
After9,525 B40,960 B16532.7 MB

The 15-tick pattern disappeared. The remaining large spikes matched the walk's chunk transitions one for one, which pointed at the next round.

Round 2: a written protocol

For the second round I wrote the protocol into the issue (#301) before any code changed:

  • Branch from a dev tip with no known test failures.
  • The first commit on the branch is the "before" measurement: every instrument run once on untouched code, raw per-tick CSVs archived in the repository.
  • Fixes go in tiers. Tier 1 is high impact and zero determinism risk. Anything that needs digest verification is flagged as such.
  • The "after" leg uses identical scenarios, seeds and case order.

Committing the "before" leg first matters: numbers quoted only in a pull request cannot be re-checked. These can. For this post I recomputed the idle-combat and movement means from the archived CSVs, and they match the PR exactly.

The single biggest find was a spatial index rebuilt every tick. Its Clear() threw away every bucket list, and the rebuild allocated a new list for every occupied cell: by the issue's estimate, hundreds per tick at 200 enemies. The fix keeps the lists alive and tracks which ones are in use:

// Reset only the occupied lists, in place: O(occupied), no allocation.
for (int i = 0; i < occupiedKeys.Count; i++)
{
    buckets[occupiedKeys[i]].Clear();
}
occupiedKeys.Clear();

Per-cell insertion order is preserved, which matters, because collision push-out iterates those lists. Before the round, even the no-ability baseline scenario allocated 1,143 bytes per tick, and the PR attributes most of that uniform floor to this rebuild. Afterwards the same scenario measured 64.

Other Tier 1 changes, each chosen because it keeps outputs identical:

  • Do the cheap path first. Each enemy computed a normalized chase direction (a fixed-point square root and two divisions), then usually overwrote it with the flow-field direction. Now it asks the flow field first and computes the fallback only when needed. Same output bits.
  • One compaction pass. Removing despawned entities from the simulation loop called List.Remove per item, quadratic when an explosion killed 30 enemies at once. A single read/write pass keeps survivors in their original order.
  • Write on change. Two aura abilities set their collider radius every frame. Per the analysis in the issue, setting a 2D circle collider's radius, even to the same value, makes Box2D rebuild the shape, so they now write only when the size changes.
  • Pool growth outside the tick. Enemy and drop pools are topped up one object per Unity frame, outside Simulate(). Which pooled object you get is not simulation state, because entity IDs are stamped at spawn.

The pool bug, and the test that expected it

The round-2 scan read the toolkit's pool constructor and found this, in all four constructor overloads:

for (int i = 0; i <= startingSize; i++)

Every pool in the game had been created one object larger than requested. The loop dates from the toolkit's early commits in January 2026.

The more instructive part was the test. When the loop was fixed, a pool test failed, because it asserted the bug:

// startingSize=3 creates 4 entities (loop is <=)
Assert.AreEqual(4, childCount, ...);

At some point the extra object had been noticed, and a test was written that locked it in. The test was re-pinned to exact sizes. When a test's comment explains why the obvious answer is wrong, that is worth a second look.

How the replays proved nothing changed

The proof rests on the replay harness from May: entity-state digests, one hash per simulated second, compared against committed fixtures.

  • Round 1: the fixes were checked by stashing the diff, running on the untouched dev tip, and comparing fresh digests with the branch. They matched exactly (for example, tick 60 hashed to 9D039073D0F5338A on both). The flow-field fix was then checked against the fixture committed before the fix: it still passed.
  • Round 2: the gate was stricter. Run-to-run, self-baseline and peer baseline tests, for both the entity and props scenarios, all had to pass against the fixtures from the branch point. The pull request changed no fixture file; I confirmed that from its diff. (All fixtures are Windows ones, so the peer test passes by design here; the self-baseline test is the one doing the work.)

That gate was only trustworthy because of work that landed just before round 1. The test-suite revamp in PR #297 removed every suppression from the suites and fixed two stale PlayMode fixtures, after first verifying that the simulation was deterministic across separate Unity processes. In its before and after runs it also cut PlayMode wall clock from 34.3 to 20.3 minutes, while deleting 1,703 lines and adding 1,220. A digest gate that is already red cannot tell you anything new.

Round 2's final validation: EditMode 3,268 passed, 0 failed; PlayMode 457 passed, 0 failed.

What was deliberately left alone

  • Tier 3 items that could change simulation results were not taken, so the round's digest gate stayed a pure "nothing moved" assertion. Each needs its own digest run.
  • One round-1 fix (topping up the enemy pool on idle frames) could not be shown by the fast-forward benchmark, which runs thousands of ticks in a handful of frames. The PR said so instead of claiming a number.

About the frame times: they are editor batchmode measurements, not player builds. In my experience, deployed player builds typically run at 2-3x the editor's frame rate, but I have not measured that for these runs.

What generalizes

Build the instrument that matches what players do. The largest garbage source was invisible until a benchmark actually walked. Every earlier benchmark stood still.

Commit the "before" leg. Archived raw data turns a claimed improvement into one anyone can recompute.

Make "identical" true by construction. Sharing one reset method between the constructor and Recenter, and preserving insertion order in reused lists, meant the digests confirmed what the design already guaranteed.

Read tests that encode surprises. A test written to expect n + 1 was documenting a bug, not a requirement.