I wanted to add a crossword to my site's games, generated differently every round instead of pulled from a pre-made template. Between the idea and the first playable round there was a backtracking fill algorithm that always failed, a dictionary that grew from 200 to 671 words without fixing anything, and the discovery that the vocabulary had never been the real problem.
Three ways to generate a crossword, and the right question to ask first
There are essentially three routes for a game like this.
- Fixed template: prepare N grids with words and clues already crossed by hand, and the game draws one at random. Guaranteed quality, but finite content that repeats after a handful of rounds.
- Dynamic generation: keep only a dictionary of words with clues, and a fill algorithm (backtracking over a grid with black squares) crosses random words together every round. Infinite variety, but a genuinely hard constraint-satisfaction problem, with client-side generation times that can spike badly.
- Semi-dynamic: fixed grid shapes, but the words that go into them are drawn at random from a length-compatible pool every round. A reasonable compromise between variety and simplicity — and the route I started from.
The first engine: fixed templates and backtracking fill
I built the full game — grid, clues, timer, keyboard, local high-score saving — with an initial dictionary of about 200 words with clues (3-8 letters) and three fixed-shape grid templates, filled every round by a backtracking algorithm: try a length-compatible word in each open slot, and if a crossing doesn't work out, back up and try another.
An automated stress test in Node — 200 iterations for each of the three templates — gave a clear result: failure almost every single time, even after growing the dictionary to nearly 400 entries.
The real problem wasn't the dictionary, it was the rigidity of the grid
The cause wasn't the amount of available words, but the structure of the templates themselves. One of the three had a vertical word running through every row of the grid, crossing six horizontal words at once — and at each of those points, the last letter of a horizontal word needs to exactly match the letter the vertical word requires at that position.
Italian words almost always end in a vowel and often start with a consonant: a combination of simultaneous constraints structurally too rigid for any reasonable amount of extra words to fix. It would take thousands of entries, not hundreds, to cover every possible combination at that spot in the grid.
The lesson that changed the approach. A bigger dictionary doesn't fix a structurally over-dense crossing constraint. Past a certain point the problem stops being "how many words do I have" and becomes "how many simultaneous letter-by-letter combinations am I asking to satisfy at one single spot in the grid".
Generating the grid at runtime wasn't enough on its own
The next attempt was dropping the three fixed templates and generating the grid's shape procedurally every round, retrying with a different layout on failure — same already-validated backtracking algorithm, but with the ability to switch templates instead of always hitting the same wall.
Even here, though, stress tests showed the same limit: some crossing combinations remain mathematically too constrained for a hand-written dictionary, no matter how many different templates get generated.
The fix: a freeform interlocking engine
At that point I changed approach entirely: instead of solving an already-drawn empty grid (the classic constraint satisfaction problem, or CSP, that most crossword generators are built on), the engine builds the grid as it plays.
It starts from a word drawn at random from the dictionary, then adds others one at a time, anchoring each new word to a letter already present in a word already placed, and checking that no conflicts arise with neighboring cells. The final shape of the grid emerges from the words themselves: there's never a pre-existing template that has to work out, so there's no "impossible" state the fill can get stuck in — it only ever builds what actually fits together.
Validated offline in Python, where computation time isn't a constraint: 100 out of 100 attempts succeeded, on both difficulty levels, with compact, well-interconnected grids. Then ported to JavaScript to run in the browser.
A bug that looked structural, and was just an execution order issue
During the JavaScript port, a stress test extracted separately to check the exact browser code kept failing for an apparently serious reason: the list of all available words came out empty, even though the dictionary loaded correctly.
It wasn't a flaw in the algorithm, but a problem in my own extraction script for the test: the order in which the two script blocks were read outside the page's real context didn't match the actual execution order in the browser. Once the extraction was fixed, the final stress test gave 200 successes out of 200 on both difficulties, at roughly 26 milliseconds per generation.
Two interface bugs born straight out of the intersections
The first: typing horizontally onto a cell shared between two words would unexpectedly flip direction to vertical, as if that cell had been deliberately clicked twice. The cause was that the focus-moving function set a cell as "active" before actually giving it focus — so when the focus handler ran, the code saw "same cell already active" even during normal sequential typing, not only during a deliberate double click.
The fix was separating the two cases: focus, even automatic focus during typing, no longer touches direction; the direction toggle only fires on a genuine click on a cell that was already the active one before that click, tracked separately through direct pointer-press events.
The second: on a letter already written by a crossing word, typing the same letter again blocked automatic advancement. The text field had a one-character limit, and if you type the same character already present without the content being selected first, the browser doesn't count it as a change — so no event, no advancement.
The fix had two parts: selecting the cell's content on every focus, so typing always replaces it, plus a direct keypress handler as a safety net for some mobile keyboards that don't fire the standard event.
The game isn't just the algorithm: background, dictionary, PWA
With the engine stable, what remained were the details that separate "it works" from "it belongs on the site". The background animation shared with the other games (animated lines on canvas) was missing, and got added using the exact same pattern used elsewhere.
The dictionary grew from 399 to 671 words, concentrating the growth on the thinner length buckets rather than the ones already well covered, and every addition was checked against duplicates before merging into the existing pool. With the bigger dictionary, a new stress test gave 300 successes out of 300 on both difficulties, at roughly 35 milliseconds per generation.
Last step, the PWA side: icons generated with the same squircle convention used for every other app on the site, keeping the original artwork whole — it wasn't square to begin with — instead of cropping it, placing it on a square canvas with a thin border in the site's own color. On iPhone, three targeted fixes: removed the gray tap highlight on cells and buttons, enabled an immediate tap response instead of the browser's default delay, and disabled autocorrect on the text cells — otherwise iOS tries to "correct" crossword letters while you're playing.
Shipping the game: redirects, sitemap, and an SEO audit that caught stale copy
Integrating the game into the site's games page also required a small layout change: slightly shrinking the cards so three fit per row instead of two, leaving the mobile behavior untouched. Then the less visible but equally necessary work: the redirect from the extensionless URL to the .html one, and the matching sitemap entry.
The final SEO audit found something more interesting than a simple character-limit overrun in the meta description: in three spots on the page — the intro paragraph, the visible FAQ section, and the same FAQ's structured data — the copy still said "almost 200 words" and described the engine as "validated fixed templates with backtracking". True sentences when they were written, turned false the exact moment the algorithm changed — and left silently in place, because nobody had gone back to check them after that change. All three were corrected, so the copy now says exactly what the game does today.
What I take away from this
A bigger dictionary doesn't fix a structurally over-dense crossing constraint: past a certain threshold, the problem stops being "how many words do I have" and becomes "how many simultaneous combinations am I asking to satisfy at one single spot in the grid" — at that point the robust fix is no longer more data, it's a different algorithm.
Building the grid word by word instead of solving one already drawn removes every "impossible" state at the root — it's not a quality compromise, it's simply a different and easier problem.
And finally, the most annoying bugs in this work were never the algorithm decisions, but the small, silent details of real interaction — a focus state set a moment too early, an identical character the browser doesn't register as a change, descriptive copy nobody updates after the underlying logic changes.
FAQ
Why can a backtracking algorithm on a fixed grid fail to generate a crossword even with hundreds of words in the dictionary?
Because the constraint that matters isn't how many words exist in total, but how many simultaneous intersections are needed at one spot in the grid. A vertical word crossing six horizontal words at once needs a coverage of letter-by-letter combinations that only a dictionary of thousands of entries can reliably provide.
What is a "freeform interlocking" crossword generator?
Instead of filling an already-drawn empty template, the generator builds the grid: it starts from one word, then adds others one at a time, anchoring each to a letter already present in a previously placed word. The grid's final shape emerges from the words themselves, so the puzzle is solvable by construction.
Why is it better to generate the grid at runtime instead of using a handful of pre-drawn fixed templates?
Because every fixed template carries a rigid crossing pattern that the dictionary either always satisfies or never does — with only a few templates, the puzzle ends up failing at the same spot every time.
Why is it worth redoing an SEO and content audit after a radical change to a page's algorithm?
Because copy written to describe "how a page works" becomes false the exact moment the implementation changes, and it survives silently both in the visible text and in the structured data — worth explicitly rechecking after every substantial change, not just at first publish.
You can play the finished game for free at roversia.it/giochi/cruciverba.
Originally published on roversia.it
Top comments (0)