I run a game where you get a brand name — Spotify, IKEA, Home Depot — and rebuild its primary color from memory with three sliders: hue, saturation, lightness. On submit, the guess and the target both go to CIE Lab and the score comes from the distance between them.
The scoring line was this:
const score = Math.max(0, 100 - deltaE * 2);
It looks defensible. ΔE around 2 is the classic just-noticeable-difference; ΔE 50 is "those are not the same color." Map 0–50 onto 100–0, done.
It was wrong for three months and I couldn't see it, because I kept reading the formula instead of the distribution.
The number I couldn't query
The scores were on screen the whole time and nowhere in my analytics. GA4 only reports event parameters you have registered as custom dimensions or metrics, and registration is not retroactive — the day you create it is the day data starts existing. So there was no query to run against the past. I registered score as an event-scoped custom metric and waited.
Eight days later, 232 first-round samples, mean score per round:
| Round | Mean score | Rating shown | Share square |
|---|---|---|---|
| 1 | 21.3 | Miss | ⬜ |
| 2 | 22.1 | Miss | ⬜ |
| 3 | 29.2 | Miss | ⬜ |
| 4 | 18.6 | Miss | ⬜ |
| 5 | 40.0 | Miss | ⬜ |
The rating bands are ≥96 Perfect, ≥86 Great match, ≥70 Close, ≥45 Off, below that Miss. Every round averaged below the worst threshold. An average player finished a five-round game without once being told they did anything but badly, and the Wordle-style result they were invited to share was five white squares.
Nobody shares five white squares. Result-page arrivals: 247. Shares and copies: 4.
What the formula was actually asking for
Work backwards from the bands through 100 - ΔE × 2 and you get the ΔE each rating required:
| Rating | Old curve needs | New curve needs |
|---|---|---|
| Perfect | ΔE < 2 | ΔE < 9 |
| Great match | ΔE < 7 | ΔE < 22 |
| Close | ΔE < 15 | ΔE < 38 |
| Off | ΔE < 27.5 | ΔE < 59 |
| zero points | ΔE ≥ 50 | ΔE ≥ 90 |
Perfect required a guess a human eye could not distinguish from the target. The whole rating vocabulary — five labels — lived inside ΔE 0 to 27.5, and everything past 50 was a flat zero.
Measured play sits at a mean ΔE of about 37.
That is the entire bug. Not a coefficient that was slightly off — a curve whose expressive range and the actual distribution of play barely overlapped. 27.8% of guesses scored exactly 0, which also means the formula stopped conveying anything at all for more than a quarter of its inputs: badly-off and hopelessly-off scored the same.
And ΔE 37 is not a bad guess. Two independent errors compound in this game — your memory of the color, and your ability to steer toward a remembered color through hue/saturation/lightness, which are not the axes perception uses. Landing 37 away is what a competent human does. The formula was calling normal human performance a failure.
The fix
const MAX_DELTA = 90;
const CURVE_EXPONENT = 1.4;
export function scoreForDelta(delta) {
const normalized = Math.min(Math.max(delta, 0), MAX_DELTA) / MAX_DELTA;
return Math.max(0, Math.round(100 * (1 - normalized ** CURVE_EXPONENT)));
}
Two knobs, each doing one job.
MAX_DELTA decides where zero lives. At 90 it sits out past anything a player who is genuinely trying will produce, so the score keeps carrying information across the whole real range instead of saturating inside it. Zeros went from 27.8% of guesses to 2.3%.
The exponent above 1 bends the curve so it is generous early and steep late. At ΔE 37 you now get 71 (Close) instead of 26 (Miss); to be told you're Perfect you still have to land inside ΔE 9. Difficulty didn't leave, it moved to the top of the range where it belongs.
| ΔE | Old | New |
|---|---|---|
| 10 | 80 | 95 |
| 20 | 60 | 88 |
| 30 | 40 | 79 |
| 37 | 26 | 71 |
| 45 | 10 | 62 |
| 50 | 0 | 56 |
| 60 | 0 | 43 |
| 75 | 0 | 23 |
Simulated against the real brand pool before shipping: mean score 35.7 → 68.4, median game total 152 → 339 out of 500, squares 🟩23% / 🟨34% / 🟧25% / ⬜18%.
The discipline that made the change measurable
I did not touch the 45/70/90 thresholds. That was tempting — since I was in there anyway, why not retune the bands too?
Because score is the metric I'm judging the change by. Move the curve and the bands in the same deploy and the before/after comparison answers nothing: you cannot tell whether players started scoring better or you just repainted the ruler. Change the thing under measurement or change the measuring instrument, never both in one release.
Same reason the outcome of a share attempt is encoded in the event name (share_image_copied, share_image_downloaded) instead of a parameter. Parameters need registered dimensions, registration isn't retroactive, and the quota is 50. Event names cost nothing and work the day you ship them.
Two things that bit me
Verifying in a hidden browser panel. The per-round number counts up through a requestAnimationFrame animation. rAF is throttled to a stop in a background or hidden tab, so the round score renders 0 forever and never moves. I nearly concluded the deploy hadn't taken. The rating text next to it is not animated and neither is the game total — read those. Anything driven by rAF is not evidence when nothing is compositing.
Reading the local file as though it were production. The fix sat uncommitted in my working tree for two days while a different change went out around it. The live bundle still contained Math.max(0,Math.round(100-2*n)), and GA4 agreed: daily first-round means of 14–30 the whole time. The five-second version of this check is to grep the deployed JS for your formula. Local source is a claim about production, not an observation of it.
The color-nerd footnote
This is ΔE*76 — plain Euclidean distance in Lab, D65 white point. CIEDE2000 is more perceptually faithful, particularly for saturated blues, and I may move to it. It would not have changed anything above: at the distances real play produces, the correction is small next to being wrong by a factor of three about where the scale should end.
The game is at logocolorquiz.com. Every number here came out of it.
Top comments (0)