DEV Community

김종현
김종현

Posted on Originally published at prism.adriven.co

I generated 240 puzzle levels backwards, then proved each has one solution

Originally published at prism.adriven.co/en/proven-unique.

The expensive part of making a puzzle game is not laying out the board. It is proving the board can be solved at all. At 240 levels that is not something a person can do. So I reversed the order.

The problem: a hand-built board cannot be checked

PRISM has one rule. Tap a piece and it rotates 90°, and the light instantly finds a new path. Light every crystal with exactly the color it asks for, all at once, and the board is solved.

A simple rule does not make boards easy to author. To check a hand-placed board you have to solve it yourself, and when you fail there is no way to tell a hard board from an impossible one. You cannot build 240 levels that way.

Reversing it: start from the answer

This is the standard approach for rotation puzzle generators. Instead of building a board and searching for its solution, build the solution and then break it.

  1. Place emitters and pieces, then trace the light.
  2. Put a crystal of the matching color wherever the light actually lands. The solved state is now complete by construction.
  3. Rotate pieces backwards by the target number of taps to scramble it.

With this order an unsolvable level cannot exist, because the board started from a solution. Undoing the scramble is the solution.

"Solvable" is not good enough

Stopping there produces a lot of bad puzzles, because some boards have more than one solution. When two arrangements both work, the board cannot be narrowed down by logic and players stumble into the answer by spinning pieces.

So every candidate is brute-forced. The generator enumerates every combination of the rotatable pieces and counts how many of them are solved states. Anything other than exactly one is thrown away.

let total = 1;
for (let i = 0; i < n; i++) {
  total *= radix[i];          // multiply each piece's rotation count
  if (total > maxStates) return null;
}
for (let state = 0; state < total; state++) {
  // unpack state into per-piece rotations, then
  if (tracer.isSolved(scratch)) count++;
}
Enter fullscreen mode Exit fullscreen mode

If the state space exceeds 200,000 the count is abandoned and the candidate is discarded too. Nothing ships on "it is probably unique". A board that could not be proven does not go in.

The check turned out to have a second effect I did not plan for. It eliminates dead pieces automatically. A piece the light never reaches gives the same result at every rotation, which multiplies the number of solved states by that piece's rotation count, pushes the total above one, and fails the board. Every rotatable piece on screen is load-bearing.

Par is not an estimate

Each level displays a minimum tap count. Solve within it and you earn Perfect. That number is not a designer's guess, it is the true minimum.

Uniqueness does the work again here. When exactly one solution exists, the minimum tap count is simply the sum of how many more turns each piece needs to reach it. That means the generator does not have to estimate par, it can choose it: pick a target and rotate backwards by exactly that much.

Then it verifies the choice independently. The scrambled board is solved from scratch with breadth-first search, and if the shortest path found does not match the intended par, the candidate is dropped. Generator bugs surface right here.

Most candidates are thrown away

Passing the proof is not enough to be worth playing, so more gates follow. Boards that are too easy, boards that arrive already solved, boards with too few crystals, boards where the light never bends, and boards where the phenomenon the chapter is meant to teach (dispersion, color mixing) never actually happens are all rejected.

The pass rate is low. In later chapters there are more pieces, empty cells for the light to travel through get scarce, and crystal placement eats most attempts. Filling the 45 levels of chapter VI takes on the order of a million tries. This script runs once while preparing a release, so the result matters more than the runtime.

The generator tallies which gate rejected how many candidates and prints it, because that tally is the only evidence available for tuning a chapter's settings.

Proving it again on the shipped data

"The generator ran correctly" and "the file that shipped is correct" are two different facts. So the same checks run against the final level data on every commit, across all 240 levels: is it solvable, does tapping the solver's path actually clear the board, and is there exactly one solution.

Not having that test cost me once. A rendering change that stops beams slightly short of absorbing pieces made beam segment endpoints non-integral, and the generator gate was deciding "did the light reach here" by testing whether the endpoint was an integer. From that moment every level containing a wall was rejected. The tests stayed green the whole time, because they validated the committed output and never the generation rules.

Reachability is now decided by position and travel direction instead of endpoint coordinates, so it no longer matters how short the renderer draws a beam.

The result

240 levels across six chapters, with average par rising monotonically.

Chapter Levels Par Average
I Reflection 14 1–4 2.6
II Splitting 32 1–6 3.7
III Dispersion 44 1–8 5.1
IV Mixing 52 1–8 5.7
V Filtering 53 1–11 7.2
VI Convergence 45 2–11 8.3

For a player all of this reduces to one promise. If you are stuck, there is a reason. No board yields to luck, and the tap count on screen is genuinely reachable. A level you cannot solve is not a bug, it is logic you have not seen yet.


The game is PRISM: a paid puzzle with no ads, no in-app purchases, no sign-up and no networking. Chapter 1, 14 levels, is playable in the browser with no install.

Happy to answer anything about the level generator or the solver.

Top comments (2)

Collapse
 
denshin profile image
Denshin Team •

Build the answer, then break it. Such a clean trick, and "hard vs impossible" is exactly the wall you hit authoring by hand. Did the uniqueness check get slow on the bigger boards?

Some comments may only be visible to logged-in visitors. Sign in to view all comments.