The Idempotence Trap
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 aFullBleedcase
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:
- The serialized value is stale. Set the source prefab's
m_fontSizeto 44 so it matches what instances compute. The override came back — with a value identical to the source. - 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.
- It stamps once and converges. Never measured. Simply untrue.
- 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:
| Height | Computed | Serialized | Exactly equal? |
|---|---|---|---|
| 120 (Small) | 28.967742920 | 28.967740000 | no |
| 160 (Medium) | 38.623657227 | 38.623660000 | no |
| 186 (Large) | 44.900001526 | 44.900000000 | yes |
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.