Building a Word Search Generator That Refuses to Ship Broken Puzzles
I’ve spent the last stretch building a generator that produces a complete, print-ready puzzle book: 133 puzzles, an answer key, front matter, contents, 172 pages, straight to PDF in about fifteen seconds.
The interesting part wasn’t the grid algorithm. Placing words in a letter grid is a solved problem you can write in an afternoon. The interesting part was everything that let broken output look correct.
What it does
The pipeline takes a plan of themes and a curated word corpus and produces a finished book. Roughly a thousand lines of Python across eight modules: selection, placement, filling, verification, pool validation, difficulty profiles, text metrics, and the render pipeline that drives headless Chromium over Jinja2 templates and print CSS.
Each puzzle has to satisfy a contract before it’s accepted:
- exactly 20 words, 4 to 13 letters, no digits
- at most 5 long words, so the grid doesn’t become a wall of overlaps
- no word contained inside another word in the same puzzle
- at most 3 words sharing a 3-letter prefix
- at most 3 words sharing a 5-character substring
- every word appears in the grid exactly once, proven by exhaustive scan
- no word repeats anywhere else in the same decade
- every word fits its column in the rendered word list
A puzzle that can’t meet the contract is rejected outright rather than shipped degraded. That turned out to matter, because most of those rules exist as scar tissue.
The verification bug that hid every other verification bug
The verifier scans every line of the grid in both directions and counts matches. It compared against a baseline of 2, assuming a placed word is found once forward and once in the reversed line.
That baseline was wrong. The scanner already enumerated each line in both orientations, so a correctly placed word matched exactly once. The check was comparing against a count no correct word could produce — which meant a word that genuinely appeared twice in the grid sailed through silently.
Every puzzle generated before that fix had unverified duplicates.
Then, with duplicate detection finally working, ABBA started failing. And it
should: a reversed line containing a palindrome still contains it, so the
scanner legitimately sees it twice at the same position. The expected match
count depends on the word. Palindromes expect 2, everything else expects 1.
Two bugs, one nested inside the other, and the first was masking the second.
Clustering: passing every check and still unsolvable
A puzzle from a roller-skating theme once drew thirteen words sharing the root
SKATE. No duplicates, no containment, correct word count — every constraint
green. The result was unsolvable in practice, because the grid was a wall of
near-identical letter runs and every candidate match looked like every other.
That produced the shared-root cap. A smaller version of the same failure, from
words beginning THE, produced the shared-prefix cap.
The root cap has a limitation I decided not to fix. It matches substrings of 5
or more characters, so a 4-letter root — TAPE across TAPE HISS, BLANK
TAPE, MIX TAPE — slips underneath it entirely. Lowering the threshold to 4
would bind far too aggressively across every pool, so it’s handled editorially
and documented as a known trade-off rather than quietly left as a surprise.
The bug class I didn’t have a test for
Three separate defects shared one cause, and it’s the thing I’d take to the next project:
Every validator I’d written asked whether a puzzle generates. Not one asked whether it fits on the page.
All three passed the full automated suite. All three were found by looking at rendered pages.
Measuring text is harder than it looks
Long entries in the word list were overflowing their column, wrapping to two lines, and leaving a hole in the layout grid.
My first fix used character count as a width proxy. Wrong in both directions:
a 13-character entry can be wider than a 15-character one, because M and W
carry roughly twice the advance width of I and L. That approach would have
shrunk a hundred entries unnecessarily while missing the ones that actually
overflowed.
Second attempt: measure real advance widths from the font file with
fontTools. Better — except I measured the variable font at its default weight
of 400, when the stylesheet requests 500. That understates every string by
about 2%, which is enough to mark a wrapping word as safe.
Third attempt measured at the correct weight and matched every case I’d observed. It still shipped one failure: an entry clearing the column by nine thousandths of an inch wrapped anyway, because the browser hints glyphs and rounds sub-pixels in ways the font tables don’t capture.
The final fix wasn’t more precision. It was a 2% safety margin on top of the measurement. Some quantities you can compute exactly; some you can only bound.
That check now lives in the pool validator, so an overflowing word fails validation instead of reaching a page.
A number that was right and then quietly wasn’t
Word count per puzzle was originally drawn from a 20–25 range. The word list renders in four columns — and only 20 and 24 divide evenly, so 120 of 133 puzzles had a ragged final row. Including, ironically, every 25-word puzzle.
Fixing it at 20 also closed the last generation failure mode. 20 was already the floor the selector would accept, so a pool that could reach 20 but not the higher number it happened to draw would previously fail outright rather than degrade.
Four puzzles that were never there
For several builds the render summary reported four placeholder puzzles. All 133 pools existed and were valid.
The orchestrator locates each pool by slugging its theme title. Four files had
picked up -70s / -80s filename suffixes, added to avoid a collision that
didn’t exist — the era directories already disambiguated them. The lookup
missed, and fell through to the placeholder path without complaint.
Found by reading a number in the build summary that had been sitting there, wrong, for days.
A tri-state flag that behaves like a delete
Corpus entries carry a confidence of high, medium or flag. Selection
admits the first two and silently excludes the third. flag was meant as “come
back to this.”
An audit found twelve entries still sitting in it. Six had been reviewed and approved much earlier — but the edits promoting them used exact-text replacement that failed silently, so the approval never reached the file.
The trap is that a flagged word is invisible to the engine. An approval that never landed is indistinguishable from a rejection, and every pool still worked fine without those words, so nothing ever surfaced it. Every edit in the pipeline now asserts on its own result rather than assuming the write landed.
What I’d take away
Validators encode the failures you’ve already imagined. Mine were thorough about generation and completely blind to layout, because generation was the part I thought was hard.
Silent exclusion is worse than loud failure. The flag tier, the slug mismatch, and the off-by-one all shared a shape: the system quietly produced less than it should have, and produced something plausible anyway.
Some things can only be checked by looking. Every layout bug in this project was found on a rendered page, not by a test. The fix isn’t to stop writing tests — it’s to know which class of problem your tests structurally cannot see, and to build the habit of actually looking at the output.
Status and source
The interior is complete: 133 of 133 puzzles generating from the real corpus, zero placeholders, 172 pages. The cover is in production with a designer, front matter needs a final pass, and a physical proof is pending before it goes to print.
The source is in a private repository. The engine is fairly generic, but the corpus and the editorial framework that produced it represent the bulk of the work and aren’t something I’m publishing. Happy to walk through the architecture or share code on request — get in touch.