CASE STUDY
Blockade · 封锁线
A puzzle whose answer you draw, and whose clues are allowed to lie
The Problem
Every level needs exactly one solution, and you should need the testimonies to find it.
Blockade uses Slitherlink's rule: each number counts the edges drawn around its cell, and all drawn edges form one closed loop. I turned it into a detective story. A city was cordoned off overnight, and you reconstruct the cordon from patrol counts and witness testimony. The loop splits the map into inside and outside, so a testimony like “the pharmacy was inside” or “the courier and the doctor were on opposite sides” adds a constraint to the same drawing.
The hard part is writing the levels. If a board has more than one solution, you can draw a valid answer and be told it's wrong. The testimonies also have to do work: if the puzzle still has a single solution after you delete them, they were decoration.
Architecture
The author writes a mask, and the loop is computed from it.
- Levels are hand-written data. The author supplies only an inside/outside mask, and the loop is drawn wherever two neighbouring cells differ. Nobody writes the answer as a list of edges, so an authored answer is always a loop.
- The solver is plain JavaScript with no dependencies: edge-ordered backtracking with unit propagation. Once a cell's count is met, its remaining edges close; once a vertex has two edges, its remaining edges close; if either goes over, that branch is pruned.
- tools/author.mjs verifies levels and prunes clues, tools/bench.mjs times the solver on each level, and tools/smoke.mjs signs in and round-trips progress against a running server.
- You play on an SVG board: drag to draw, live consistency feedback, regions that fill in when the loop closes, a reveal that checks testimonies one at a time, and an accusation at the end.
- Progress lives in localStorage and syncs to Postgres once you sign in. The server schema has one application table, progress.
Engineering Decisions
Choices in the data, the solver and the tools.
- The mask is the only thing an author writes, and the loop is computed from it. It's the same idea as the spoiler boundary in BookRAG: if an invalid solution can't be written down, there's nothing to validate.
- Testimonies are constraints inside the solver. The search checks them at each leaf, and “exactly N statements are false” is modelled by counting the false ones. The authoring tool also checks whether the puzzle still has one solution with every testimony removed. If it does, the testimonies aren't needed, and the tool flags the case.
- I wrote the backtracking search myself instead of using a SAT or ILP solver. Hints have to be computed in the browser while you play, and authoring has to verify every level quickly; a solver library would have turned both into external calls and added a dependency to maintain.
- The hot search path doesn't allocate objects. Board state is a flat Int8Array, edges are identified by short string keys, and a Map holds the indices.
- The solver decides the edges around numbered cells first and the rest in row order, so cell and vertex propagation kick in early and pruning starts sooner.
- To minimise clues, the tool removes one, solves again, and keeps the removal if the solution is still unique, stopping at 8 clues. A seeded PRNG sets the removal order, because the tool has to give the same output on every run for me to reproduce and diff it.
- Progress merges field by field: counters take Math.max, and solved and accused are OR-ed, because they only ever go from false to true. Sync is debounced by 800 ms, and a reset cancels the pending timer. Without that, a late write would bring back progress the player had just cleared.
Evaluation
I test levels with the authoring tool.
tools/author.mjs verify goes through every level and checks four things: there is exactly one solution, it's the loop I designed, the number of false statements is right, and how long the check took. If any level fails, it exits non-zero. For a game built on hand-made boards this is the test suite that matters, and it lets me edit a level and find out right away if I broke it.
tools/bench.mjs times the solver on each level, but I haven't committed any results. The “about 2 ms on 6×6” figure elsewhere on this site is what I saw on my own machine, and no regression test tracks it.
There's no test framework and no CI, so these checks run only when I run them. The repository has lint and typecheck scripts, but nothing runs the authoring tools automatically.
Lessons Learned
What the authoring tool taught me.
- Computing the loop from a mask removed a whole class of bugs. An author can't write a solution that isn't a valid loop.
- The authoring tool did more for me than unit tests would have. No unit test can tell you whether a puzzle is good, but “exactly one solution, and the clues are needed” is something a program can check. Once that check existed, I could edit levels safely.
- The most useful check turned out to be the one for unneeded testimonies. If a puzzle stays unique after you delete them, the story text I wrote was filler, and I'd never have found that by reading the code.
Failure & Trade-offs
Limits I set myself, and things I haven't built.
- I chose not to build 7×7 boards. My design notes flagged that solving might get slow and said to stay at 6×6 if it did. Every level is 5×5 or 6×6, so puzzles can't get bigger than that.
- There are no levels with more than one liar. Levels declare 0 or 1 false statement. The solver counts false statements, so it could handle more, but those levels are much harder to write and I haven't written any.
- Daily Case exists only as a design document. It describes a procedural level generator, a scheduler, leaderboards, streaks, share text and an LLM-assisted story step, and none of it is in the repository yet.
- The tutorial stays out of the level list on purpose, so it never appears in the case index, saved progress or the chapter list. It runs on its own code path instead of being level zero. That means more code to maintain; making it level zero would have put the tutorial into the player's completion record.
- One piece of code works around a problem I ran into. The opening dialogue needs two separate effects, because a timer scheduled in the first one gets cleared by that effect's own cleanup. I left a comment so the next person doesn't hit it again.