← Back to all posts

The Idempotence Trap

Published:
candyrushunitytoolingevidence

A bug report about three panels of buttons with strange-looking insets turned into a systematic rework of how UI geometry is authored, and then into a full day of chasing a bug that the rework itself created. Both halves are worth writing down: the first is a pattern I would repeat, the second is a trap I would like other people to avoid.

The problem: geometry as folklore

Unity's uGUI has no styling layer. A button's size lives in its RectTransform as raw numbers, typed by whoever made that button, in that prefab, on that day. Multiply by a couple of dozen prefabs and a year of iteration and you get drift nobody introduced deliberately.

Measuring ours was sobering. Button widths across the project clustered at 400, 420, 430, 446, 450, 385 and 360. Heights at 186, 180, 175, 160, 150, 145, 120, 110 and 100. One modal panel sat at 1000×1400, about 2% off a four-instance cluster at 980×1360. None of these differences were decisions. They were typos and drift wearing a plausible face.

Coming from fifteen years of web work the shape of the answer was obvious: this is what design tokens are for. CSS custom properties, a Tailwind spacing scale, a Material type ramp — pick a closed set of legal values, name them, and make it impossible to type a number outside the set.

Three closed vocabularies

Hand-typed geometry was replaced with three enums:

  • ButtonSize — Small (120), Medium (160), Large (186)
  • ButtonWidth — Narrow (210), Compact (300), Wide (446)
  • ModalSize — panel surfaces, plus a FullBleed case

Each carries a UseRectTransform member meaning "this element sizes itself, leave it alone." That value is deliberately not the default. An element whose size was never chosen keeps whatever it was authored at and gets flagged by the auditor — silently substituting a size would hide exactly the population the change exists to find.

Migration tooling applied the vocabulary across roughly thirty prefabs, and an auditor enforces it going forward. The numbers live in one file so a value can be changed in one place rather than thirty.

The insight that made it worth doing

The original bug — buttons that "looked wrong" at smaller sizes — was not about size at all. It was about proportion. The coloured plate inside each button sat at a fixed pixel inset from the button's edge. At the design height of 186 that left the plate at about 81% of the button. At height 120 it left 70%, and at 100 it left 64%. The button got smaller and the plate got disproportionately smaller with it.

So the inset is now derived from height rather than preserved from the instance:

public const float DesignHeight = 186f;
public const float PlateBottomRatio = 44.9f / DesignHeight;
public const float PlateTopRatio    =  8.9f / DesignHeight;

A short button is now proportioned like a tall one instead of merely scaled badly. Remember those two ratios. They come back.

The trap

Enforcing the vocabulary means something has to write the numbers onto the transforms. We did it in OnValidate, so the editor shows what the game will show:

private void OnValidate()
{
    if (Application.isPlaying) return;
    EditorApplication.delayCall += () =>
    {
        Undo.RecordObject(transform, "Apply button size");
        ApplySize();
        EditorUtility.SetDirty(this);
        EditorUtility.SetDirty(transform);
    };
}

That looks harmless. It cost a day.

Two symptoms. The main menu scene would not stay saved — Ctrl+S wrote the file, source control showed no pending change, and the unsaved-changes asterisk came straight back, surviving an editor restart. Separately, every prefab containing a button stamped two new property overrides the moment it was opened: m_fontSize: 44 and m_TextStyleHashCode.

Four wrong answers

What followed is the instructive part. Four hypotheses, all derived by reading source code, all wrong:

  1. The serialized value is stale. Set the source prefab's m_fontSize to 44 so it matches what instances compute. The override came back — with a value identical to the source.
  2. A text-fitting component is writing it. Found a plausible culprit by reading code, never checked whether it was present. It was in none of the affected prefabs.
  3. It stamps once and converges. Never measured. Simply untrue.
  4. An idempotence guard will fix it. Right idea. Did not work, for a reason worth its own section.

Each cost a build-test-observe cycle. None involved measuring anything.

Measuring, finally

What ended it was thirty lines of editor code subscribing to three Unity events: Undo.postprocessModifications (synchronous — reports the exact property path, before and after values, and a stack trace naming the caller), ObjectChangeEvents.changesPublished (catches writes that bypass the undo system), and EditorSceneManager.sceneDirtied (names what dirtied a scene).

One capture. One stack trace:

UnityEditor.EditorUtility:SetDirty (UnityEngine.Object)
CandyRush.Runtime.UI.Buttons.ButtonBehavior:<OnValidate>b__54_0 ()
UnityEditor.EditorApplication:Internal_CallDelayFunctions ()

Our own code, named, on the line that dirtied the scene. Four hypotheses had produced nothing; one measurement produced the answer.

What was actually happening

Three facts combine into the bug, and each is worth knowing independently.

1. A RectTransform setter assigns, it does not compare

Writing an identical value to rect.sizeDelta still marks layout dirty. Unity does not check whether the value changed. That triggers a layout rebuild, which re-runs TextMeshPro's auto-size solver on the button caption, which assigns m_fontSize.

2. Unity records "was modified", not "differs"

This is the load-bearing fact, and it explains every symptom at once. Unity's prefab override system is a ledger of properties that were assigned on an instance — not a live diff against the source. An assignment creates an entry even when the value is identical to the prefab's.

You can watch it happen: change a property on a prefab instance, then set it back to its original value. The blue override bar in the Inspector stays. The entry still exists; it just now holds the source's value. Only an explicit right-click → Revert removes it.

That single fact retroactively explains why hypothesis #1 could never have worked. It attacked difference, which was never the trigger.

3. Layout-driven values were being baked into files

Along the way we found a ContentSizeFitter on a child of a LayoutGroup — a configuration Unity documents across four separate issue-tracker entries as marking a scene dirty on open and never staying clean. Unity's own inspector warns about it: "A child of a layout group should not have a Content Size Fitter component." Ours had both fit axes set to Unconstrained, so it did nothing at all except register with the driven-property tracker and dirty the scene.

Deleting one inert component removed a wall of noise.

The float that could not survive YAML

The idempotence guard — compute the target, return early if it already matches — was the correct fix. It did not work, and the reason is my favourite bug of the year.

The guard compared the plate inset with exact float equality. Recall PlateBottomRatio = 44.9f / 186f. Multiply that by a button height and serialize it. Unity's YAML keeps seven significant digits. Read it back and compare it against a freshly computed value:

HeightComputedSerializedExactly equal?
120 (Small)28.96774292028.967740000no
160 (Medium)38.62365722738.623660000no
186 (Large)44.90000152644.900000000yes

For every Small and Medium button the guard reported "changed" forever. It fired on every deserialization, wrote the rect, dirtied the scene, and re-ran the auto-sizer — the exact behaviour it was written to prevent. The adjacent sizeDelta check was fine, because Unity overloads Vector2's operator== with an epsilon. Only the two raw float comparisons were wrong.

The fix is Mathf.Approximately. The lesson is broader: never compare a computed float against a serialized one with ==. The serialization round trip is lossy and your equality test is not.

Was the original decision right?

Spending a day debugging a problem your own architecture created is a strong argument that the architecture was wrong. I do not think it is, and it is worth being precise about why.

The strategy — closed vocabularies, single source of truth, an auditor — is what the industry converged on. Unity's own UI Toolkit exists to do exactly this with USS stylesheets, and uGUI is in maintenance mode. Large teams solve it with prefab variants, component presets, or a naming-convention'd prefab matrix; Microsoft's MRTK ships buttons as PressableButton_SIZE_STYLE permutations. There is no world in which "let everyone type numbers" was the better call.

The enforcement mechanism is where we picked the hardest available lever. Writing RectTransforms from OnValidate touches three documented sharp edges at once: SetDirty marks the whole scene dirty, root RectTransform properties get special-cased by the prefab system, and Unity's manual explicitly requires PrefabUtility.RecordPrefabInstancePropertyModifications after Undo.RecordObject on an instance — a call our code did not make.

But uGUI gives you nowhere else to put it. If you want tokens in uGUI you must build the enforcement yourself, and a runtime component with an editor-time OnValidate cannot use the SerializedObject path Unity recommends, because the same method also runs from Awake. Given that menu, the choice was defensible. The defect was roughly ten lines, not the design.

Three things worth stealing

Any tool that writes assets must no-op when nothing changed. Not as an optimisation — as a correctness property. A tool that rewrites identical values produces phantom diffs, dirty files, and downstream side effects in whatever else watches those objects.

Two failed guesses means stop guessing and build an instrument. Reading code and inferring is seductive because it feels like progress and costs nothing up front. It cost a day here. The probe cost thirty minutes and produced a stack trace with a name in it. That is now a standing rule, and the probe is still in the project.

Know what your editor's indicators actually mean. A great deal of confusion came from conflating three unrelated signals: the asterisk (in-memory changes not on disk), the blue override bar (an entry exists in the instance's modification list), and value equality (whether that entry happens to match the source). Saving never removes a blue bar. They are different systems, and treating them as one sends you looking in the wrong place.

The vocabulary survived. So did the auditor, the single-source numbers, and a diagnostic that will find the next bug of this shape in one capture instead of four guesses. That is a reasonable trade for a day.