playbuns
2026-09-05 · making-of

The board was dying in silence

Chroma Drop is a falling-block puzzle with colour mixing on top. Filling a row is not enough — the whole row has to be the current target colour to clear. When the MVP was done I wrote a probe that photographs the board at game over and checks each row. 27% of the filled rows were already in a state that could never be cleared, and the player got no signal at all.

한국어로 읽기 →

One rule, one irreversible state

Mixing had to be predictable, so I made it very narrow. Colours blend only at the moment a piece locks, only downward, and only once. mix(R,B)=P purple, mix(B,Y)=G green, mix(R,Y)=O orange. Then, to keep cascades from running away, I added one more line: mixed colours never mix again.

That single line quietly freezes the future of every cell.

CellColours it can still become
emptyanything
R red{R, P, O}
B blue{B, P, G}
Y yellow{Y, G, O}
mixed colour X{X} — itself, forever

Which makes the verdict trivial to compute: take the filled cells of a row, intersect those sets, and if the intersection is empty the row can never become a single colour by mixing. The classic way it happens is two different mixed colours landing in the same row — a P and an O. Three different primaries in one row kill it too.

I had filed "no re-mixing" under things that prevent chaos. Measurement told me it was also the rule that manufactures dead rows.

How much of the board was already dead

scripts/deadrow-probe.mjs runs a heuristic bot through free mode repeatedly, freezes the board at game over, and computes that intersection for every filled row.

The percentage is not really the point. The point is that the player had no way to see it and no way to undo it. All you see on screen is a row that is full and refuses to clear. Not knowing why, you keep trying to save that row, the stack keeps rising, and you top out.

Shrinking the board from 8 columns to 6 does not remove this — fewer cells per row just delays it. So I stopped treating it as balance and called it what it was: a structural fault in the ruleset.

Splitting the cure so I could measure it

There were two candidate fixes. A signal — make dead rows visible — and a repair path — give the player a way to undo one. Shipping both and reporting "it got better" teaches nothing, so the probe got three modes and I measured them separately.

Mode (30 runs each, ink-free floor bot)Unfixable rows
base — baseline36%
+ bleach only (repair after the fact)28%
+ bleach and dead-row avoidance (a player who sees the warning)9%

Under the 10% target. But break it apart and the signal is what did the work — bleach alone only moves 36% to 28%.

The reason is reach. A bleach piece costs 2 ink and, instead of mixing, overwrites the cell below with its own colour. But overwriting only touches the surface of the stack — the topmost filled cell in each column. A dead row already buried under other rows is out of range. So bleach is a last resort, not prevention, and prevention is entirely the warning's job. The two fixes only make sense together.

The warning itself could not be sloppy either. A dead row gets desaturated, covered with a translucent hatched band, and tagged with a ⚠ badge at its left edge. The hatching has to stay translucent: which cell you bleach is decided by the colours in that row, so a warning that hides the colours would sabotage the repair it is warning you to make. (There was a nasty implementation trap here too. Make that band a CSS grid item and explicitly placed items get laid out before auto-placed ones, so a full-width band shoves all 112 cells downward and doubles the board height. I hit that live and moved the band to position:absolute.)

And yet the clear rate did not go up

Here is the second lesson. The bot in the deadrow probe never spends ink, which makes it a floor baseline that clears zero rows in every mode. So I re-measured with a stronger greedy bot that actually uses ink, rerolls and bleach — scripts/clearrate-probe.mjs, 40 runs.

ModeRows cleared per runUnfixable
control1.3821%
treatment (avoidance + bleach)1.136%

Clears went down. Avoidance is a constraint, so a greedy bot pays for it. Sweeping the avoidance weight, w=150 lands at 1.46 rows / 9% over 24 runs — level with the control — while w=900 overcorrects to 0.88 rows / 1%.

What the fix actually repaired, then, was not difficulty but "losing without knowing why". It was a comprehensibility bug, not a balance bug — and the clear-rate bottleneck was still sitting there completely untouched. Had I been watching one metric, I would have declared 9% a win and closed the ticket.

Diagnosing the bottleneck: A or B?

At 1.3 rows per run, the only reward signal in a three-to-six-minute session fires once or twice. Players bounce without ever learning what they did wrong. Two hypotheses:

To separate them I extended the probe to track every reserved row (a full single-colour row) from creation to death, in per-colour queues. Row indices shift every time something clears, so the counting is done on changes in per-colour counts rather than on y.

Diagnosis (width 8, 44 runs)Value
Reserved rows created per run1.30
Median wait before clearing4 pieces
Reserved rows still unclaimed at game over0.50
Rerolls actually spent0.93 of 4

Short waits, rerolls left over. Not B — A. The rows were never being built.

Fixing A exposed B

First treatment: board width 8 → 6. Fewer cells to fill in a single colour hits A directly. Reserved-row creation went 1.30 → 2.07 per run (+59%) and unfixable rows fell from 9% to 3%.

But clears only reached 1.41, and reserved rows still unclaimed at game over rose from 0.50 to 1.09. The board was now producing rows faster than the target colour could rotate to meet them. Fixing A had exposed B.

Second treatment: three B levers. Target-colour auto-advance from every 10 pieces to every 4; rerolls 3 → 4, refunded every 2 cleared rows instead of every 3; and double weighting for colours already reserved on the board, which doubles as a message that the game notices the row you built. The advance interval came from an 88-run sweep: t=6 2.09 rows / t=5 2.33 / t=4 2.51. At 44 runs the result was non-monotonic — the sample had to grow before it separated.

MetricBefore (width 8)Width 6 onlyFinal (width 6 + B)
Rows cleared per run1.301.412.47–2.51
Runs ending with zero clears23% (10/44)23%6.8% (6/88)
Unfixable rows9%3%8%
Reserved rows created per run1.302.072.10
Reserved rows that got cleared59%46%82%

The last row is my favourite number in the table. 82% of the rows a player builds now turn into a reward. With the width change alone it was 46% — meaning more than half of the good work was being buried.

The same instrumentation settled a side question. I suspected the width-4 I4 piece was too strong on a 6-wide board; it turns out to be a lever, since four same-coloured cells cover two thirds of a row in one placement. Removing I4 drops clears from 2.47 to 2.03 per run and worsens zero-clear runs from 6/88 to 15/88. It stays.

What I am leaving broken, on purpose

Mixed-colour rows still happen 0.07 times per run. A 2.5× score multiplier is effectively dead content — but I read that as a ceiling problem, not a floor one. Building a six-cell mixed row requires a two-move plan: lay one single-colour row, then cover it with a different primary so both rows convert at once. The 1-ply greedy bot used for all this measurement cannot find that in principle. So it stays as human skill headroom.

I also wrote an overfitting warning into the spec. Every number above comes from an unplanning 1-ply greedy bot. The advance interval of 4 in particular was chosen to suit that bot, and for a human the target colour may well flow past too fast to plan around. If that is how it plays, the documented fallback is t=5 (2.33 rows / 10.2% zero-clear runs), and the sweep is there to justify it. Recording that I tuned against a bot was the most honest thing I could hand to whoever reverts this later.

A bonus bug: daily mode was already lying

The measurement work turned up something unrelated. Daily mode promises "same day, same board" from a date seed — but piece and colour draws shared the same random stream as the target-colour draws.

Which means one extra reroll changes the piece order from that point on. Two people opening the same daily were quietly playing different boards; the promise was already broken. The fix was to split the streams: pieces and colours are now fixed by the date regardless of how you play, while only the target colour reacts to the board through reserved-colour weighting. The FAQ copy was corrected to match.

For whoever retunes this next

If I keep one rule from this project, it is this: always watch clear rate and unfixable rate together. Tune against one and the other will break, guaranteed. The middle of this piece is exactly that story — the fix that took unfixable rows to 9% also cut clears from 1.38 to 1.13, and I only know that because a second metric was running.

Try it

Other making-of notes

Every number here comes from §9 of the repository's SPEC.md and the output of the measurement scripts themselves. If you spot something inaccurate, tell us through contact.