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 NP-style 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 (white and black squares), 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 — no reinvention. 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 in the configuration, and the matching sitemap entry — without these two steps, a search engine ends up treating two variants of the same page as separate pages.

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 JSON-LD 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

The most useful lesson is that 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.

If you're interested in another game built from scratch in vanilla JavaScript for the site, I also wrote about the rules and three-level AI behind the online Burraco. And if you'd like to see the finished result, the crossword is free to play in the site's games section.

Frequently asked questions

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, at points where a horizontal word's last letter must match the letter a vertical word requires there, needs a coverage of letter-by-letter combinations that only a dictionary of thousands of entries can reliably provide — a few hundred isn't enough, no matter how many more get added.

What is a "freeform interlocking" crossword generator and how does it work?

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, checking that no conflicts arise. The grid's final shape emerges from the words themselves, so the puzzle is solvable by construction — there's no "impossible" state the fill can get stuck in.

Why is it better to generate a crossword's grid at runtime instead of using a handful of pre-drawn fixed templates?

Because every fixed template carries a rigid crossing pattern — for example one word crossing six others at once — 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. Generating (or pre-solving) more combinations lets you retry with a different layout instead of always hitting the same wall.

Why can an intersection cell between two words in a web crossword mistake normal typing for a double click?

Because if the code marks a cell as "active" before actually moving focus to it, when the focus event fires the system sees "same cell already active" even during ordinary sequential typing, not only during a deliberate double click. The fix is separating navigation (focus, even automatic) from a genuine deliberate click, tracked separately via mousedown or touchstart.

Why does typing the same letter already present in a maxlength=1 text field sometimes fail to advance the input?

Because the browser doesn't fire a change event if the typed character is identical to what's already there and the field's content isn't selected beforehand. The reliable fix is selecting the cell's content on every focus, so typing always replaces it, plus a direct keypress handler as a safety net on some mobile keyboards.

Why is it worth redoing an SEO and content audit after a radical change to a page's algorithm or internal logic?

Because copy written to describe "how a page works" — numbers, technique names, counts — becomes false the exact moment the implementation changes, and it survives silently both in the visible text and in the JSON-LD structured data, both of which search engines and AI assistants read as a source of truth. It's worth explicitly rechecking after every substantial change, not just at first publish.