Devlog

Proving every level is solvable before it ships

5 min read

A puzzle level is only worth shipping if someone has already won it. In Sunny Sort no level reaches a phone until an offline search has played it through, scored it, and cleared it against a set of filters — and levels that fail are discarded rather than patched.

The failure that forced it

Levels used to come straight from a random seed. The generator existed as a stub that printed generate: not implemented, and the catalogue was whatever the seeds happened to produce.

Testing ten hard levels found that four of them could not be won at all, even by a beam search with a width of 2000. A player reaching level 13 would hit a wall, and the game would announce OUT OF SPACE as though the loss were theirs.

That is worse than a difficulty problem. A hard level teaches you something when you retry it. An impossible level teaches you that the game is lying.

Why a raw seed cannot ship

The seed determines the feed — which piles arrive, in what order. Nothing about a seed knows whether the resulting level can be cleared inside its board, its blocked cells and its target. Solvability is a property of the whole configuration, and the only reliable way to learn it is to play.

So the catalogue became something generated, not something sampled.

The pipeline

generate runs one loop per curve stage:

GENERATE → SOLVE → ANALYZE → SCORE → FILTER → KEEP

Candidate seeds are produced, played by a beam solver, measured by the analyzer, scored, and then filtered. The twelve best seeds of each stage are kept and written into LevelCatalog.cs, which is generated code — never edited by hand.

Four rejection rules do the work:

  • impossible — no search line wins it;
  • trivial — outside the tutorial, a level every dumb bot beats is not a puzzle;
  • dominated — one strategy wins from a corner, so position stops mattering;
  • zero slack — exactly one winning line exists.

Tutorial levels get their own filter instead: at most 14 moves, and at least one three-wave cascade. The first minute has to demonstrate the mechanic, not test it.

What it rejected

Across the published curve, 96 seeds were validated. Six impossible levels and fifty-two trivial ones were thrown away during generation.

More than half of what a random seed produces is not a puzzle. That ratio is the argument for the pipeline on its own.

Solvable was not enough

Levels being winnable turned out to be a weaker guarantee than it sounds. Someone playing the shipped build stalled on level 13 and said it was impossible. It was not — but only just:

Optimal line on level 13 11 moves
Move budget 11 moves
Slack zero

Exactly one winning sequence existed. One wasted move ended the level. And it was not only level 13 — 7, 10, 13, 14 and 15 all had zero slack.

The cause was a bad premise in the budget calibration. It widened the budget only until a deliberately weak searcher could also win. When that searcher happened to find the optimal line first try, the budget never widened at all. "The weak searcher found it easy" is not "a human will find it easy" — a searcher sees the whole board at once and is not simultaneously learning the rules. The player is doing both.

The fix was a floor: MinimumSlack = 2. Every level grants at least two moves beyond the shortest solution, however easy a searcher found it.

What it cost

I expected to pay for that in depth. Earlier measurement said a budget of optimal+2 pushes decision width to 97%, meaning almost no move loses. It did not cost that:

Before After
Levels requiring a perfect line 5 0
Trivial levels (7-20) 0 0
Levels that defeat the greedy bot 5 5

The reason is that the generator did not only loosen the ceiling — it re-picked the seeds under the new ceiling, keeping the ones still demanding with more air. Loosening the budget alone would have cost depth. Doing both together did not.

What holds it in place

A test, CatalogCoversEveryStage, fails if any stage of the curve ends up with no validated seeds. The pipeline cannot quietly stop producing levels for a stage and leave the gap for a player to find.

The guarantee that reaches the player is narrow and worth stating precisely: no shipped level is impossible, and none demands a single perfect sequence. You can still lose by filling the board. That part is yours.

All devlog posts