Tre modi di generare un cruciverba, e la domanda giusta da farsi per prima
Per un gioco del genere ci sono in pratica tre strade. Schema fisso: si preparano N griglie con parole e indizi già incrociati a mano, e il gioco ne pesca una a caso — qualità garantita, ma contenuto finito che si ripete dopo poche partite. Generazione dinamica: si tiene solo un dizionario di parole con indizio, e un algoritmo di riempimento (backtracking su una griglia con caselle nere) incrocia parole a caso a ogni partita — varietà infinita, ma è un problema NP-hard non banale, con tempi di generazione lato client che possono esplodere. Semi-dinamico: schemi di griglia fissi nella forma, ma le parole che ci entrano vengono pescate a caso da un pool compatibile a ogni partita — un compromesso ragionevole tra varietà e semplicità, ed è la strada da cui sono partito.
Il primo motore: schemi fissi e riempimento a backtracking
Ho costruito il gioco completo — griglia, indizi, timer, tastiera, salvataggio dei record in locale — con un dizionario iniziale di circa 200 parole con indizio (3-8 lettere) e tre schemi di griglia fissi nella forma (celle bianche e nere), riempiti a ogni partita da un algoritmo di backtracking: prova una parola compatibile per lunghezza in ogni spazio libero, e se un incrocio non torna, torna indietro e ne prova un'altra. Uno stress test automatico in Node — 200 iterazioni per ciascuno dei tre schemi — ha dato un risultato netto: fallimento praticamente sempre, anche dopo aver ampliato il dizionario a quasi 400 voci.
Il vero problema non era il dizionario, era la rigidità della griglia
La causa non era la quantità di parole disponibili, ma la struttura degli schemi stessi. Uno dei tre aveva una parola verticale che attraversava tutte le righe della griglia, incrociando sei parole orizzontali insieme — e in ognuno di quei punti serve che l'ultima lettera di una parola orizzontale coincida esattamente con la lettera richiesta dalla verticale in quella posizione. In italiano le parole finiscono quasi sempre in vocale e iniziano spesso in consonante: una combinazione di vincoli simultanei strutturalmente troppo rigida, che nessuna quantità ragionevole di parole aggiuntive avrebbe risolto — servirebbero migliaia di voci, non centinaia, per coprire ogni combinazione possibile in quel punto della griglia.
La lezione che ha cambiato l'approccio. Un dizionario più grande non risolve un vincolo di incrocio strutturalmente troppo denso: a un certo punto il problema non è più "quante parole ho", ma "quante combinazioni simultanee lettera-per-lettera sto chiedendo di soddisfare in un solo punto della griglia".
Generare la griglia a runtime non è bastato da solo
Il tentativo successivo è stato smettere di usare tre schemi fissi e generare la forma della griglia proceduralmente a ogni partita, ritentando con una disposizione diversa in caso di fallimento — stesso algoritmo di backtracking già validato, ma con la possibilità di cambiare schema invece di restare bloccati sempre sullo stesso. Anche qui, però, gli stress test hanno mostrato lo stesso limite: alcune combinazioni di incroci restano matematicamente troppo vincolate per un dizionario scritto a mano, indipendentemente da quanti schemi diversi si generano.
La soluzione: un motore a incastro libero
A quel punto ho cambiato completamente impostazione: invece di risolvere una griglia vuota già disegnata (il classico problema di soddisfacimento di vincoli, o CSP, con cui si costruiscono la maggior parte dei generatori di cruciverba), il motore costruisce la griglia mentre gioca. Parte da una parola pescata a caso dal dizionario, poi ne aggiunge altre una alla volta, agganciando ciascuna nuova parola a una lettera già presente in una parola già piazzata, e verificando che non si creino conflitti con le celle vicine. La forma finale della griglia emerge dalle parole stesse: non c'è mai uno schema pre-esistente che deve per forza tornare, quindi non esiste uno stato "impossibile" in cui il riempimento può bloccarsi — si costruisce solo ciò che effettivamente si incastra.
Validato offline in Python, dove il tempo di calcolo non è un vincolo: 100 tentativi su 100 riusciti, su entrambi i livelli di difficoltà, con griglie compatte e ben interconnesse. Portato poi in JavaScript per girare nel browser.
Un bug che sembrava strutturale, ed era solo un ordine di esecuzione
Durante il porting in JavaScript, uno stress test estratto a parte per verificare l'esatto codice del browser continuava a fallire per un motivo apparentemente serio: l'elenco di tutte le parole disponibili risultava vuoto, anche se il dizionario risultava caricato correttamente. Non era un difetto dell'algoritmo, ma un problema nel mio script di estrazione per il test: l'ordine con cui i due blocchi di script venivano letti fuori dal contesto reale della pagina non rispettava l'ordine di esecuzione effettivo nel browser. Corretta l'estrazione, lo stress test finale ha dato 200 successi su 200 su entrambe le difficoltà, in circa 26 millisecondi a generazione.
Due bug di interfaccia nati proprio dalle intersezioni
Il primo: scrivere in orizzontale su una cella condivisa tra due parole faceva scattare inaspettatamente la direzione verticale, come se quella cella fosse stata cliccata due volte di proposito. La causa era che la funzione di spostamento del focus impostava la cella come "attiva" prima ancora di darle davvero il focus — così, quando partiva la gestione del focus, il codice vedeva "stessa cella già attiva" anche durante la digitazione automatica in sequenza, non solo durante un doppio click volontario. La correzione è stata separare i due casi: il focus, anche quello automatico durante la digitazione, non tocca più la direzione; il cambio di direzione scatta solo se c'è un vero click su una cella che era già quella attiva prima del click, tracciato a parte con eventi di pressione diretti sul puntatore.
Il secondo: su una lettera già scritta da una parola incrociata, digitare di nuovo la stessa lettera bloccava l'avanzamento automatico. La casella di testo aveva un limite di un carattere, e se si digita lo stesso carattere già presente senza che il contenuto sia selezionato in anticipo, il browser non lo conta come una modifica — quindi nessun evento, nessun avanzamento. La correzione è stata doppia: selezionare il contenuto della cella a ogni focus, così digitare lo sostituisce sempre, più una gestione diretta del tasto premuto come rete di sicurezza per alcune tastiere mobili che non emettono l'evento standard.
Il gioco non è solo l'algoritmo: sfondo, dizionario, PWA
Con il motore stabile, sono rimasti i dettagli che fanno la differenza tra "funziona" e "sta bene nel sito". L'animazione di sfondo condivisa con gli altri giochi (linee animate su canvas) mancava, ed è stata aggiunta con lo stesso identico pattern usato altrove — nessuna reinvenzione. Il dizionario è cresciuto da 399 a 671 parole, concentrando l'ampliamento sulle lunghezze più povere invece che su quelle già coperte, e ogni aggiunta è stata validata contro duplicati prima di essere unita al pool esistente. Con il dizionario più grande, un nuovo stress test ha dato 300 successi su 300 su entrambe le difficoltà, circa 35 millisecondi a generazione.
Ultimo passaggio, il lato PWA: icone generate con la stessa convenzione squircle usata per tutte le altre app del sito, tenendo l'artwork originale intero — non quadrato in partenza — invece di ritagliarlo, mettendolo su una tela quadrata con un piccolo bordo del colore del sito. Su iPhone, tre correzioni mirate: rimosso l'alone grigio al tocco su celle e pulsanti, attivata una risposta immediata al tap invece del ritardo di default del browser, e disattivata l'autocorrezione sulle caselle di testo — altrimenti iOS prova a "correggere" le lettere del cruciverba mentre si gioca.
Pubblicare il gioco: redirect, sitemap, e un audit SEO che ha trovato testo bugiardo
Integrare il gioco nella pagina dei giochi del sito ha richiesto anche un piccolo intervento di layout: ridurre leggermente le dimensioni delle card per farne stare tre per riga invece di due, mantenendo invariato il comportamento mobile. Poi il lavoro meno visibile ma altrettanto necessario: il redirect da URL senza estensione a quello con .html in configurazione, e la voce corrispondente nella sitemap — senza questi due passaggi, il motore di ricerca finisce per vedere due varianti della stessa pagina come pagine diverse.
L'audit SEO finale ha trovato un problema più interessante di un semplice limite di caratteri superato nella meta description: in tre punti della pagina — il paragrafo introduttivo, la sezione FAQ visibile, e i dati strutturati JSON-LD della stessa FAQ — il testo diceva ancora "quasi 200 parole" e descriveva il motore come "schemi fissi validati con backtracking". Frasi vere quando erano state scritte, diventate false nel momento esatto in cui l'algoritmo è cambiato — e sopravvissute in silenzio, perché nessuno le aveva più ricontrollate dopo quel cambio. Corrette tutte e tre, in modo che il testo raccontasse esattamente cosa il gioco fa oggi.
Cosa mi porto a casa
La lezione più utile è che un dizionario più grande non risolve un vincolo di incrocio strutturalmente troppo denso: superata una certa soglia il problema smette di essere "quante parole ho" e diventa "quante combinazioni simultanee sto chiedendo di soddisfare in un solo punto della griglia" — a quel punto la soluzione robusta non è più dati, è un algoritmo diverso. Costruire la griglia parola per parola invece di risolverne una già disegnata elimina alla radice ogni stato "impossibile": non è un compromesso sulla qualità, è semplicemente un problema diverso e più facile. E infine, i bug più fastidiosi in questo lavoro non sono mai stati le decisioni sull'algoritmo, ma i dettagli piccoli e silenziosi delle interazioni reali — un focus impostato un attimo troppo presto, un carattere identico che il browser non registra come cambiamento, un testo descrittivo che nessuno aggiorna dopo aver cambiato la logica sottostante.
Se ti interessa un altro gioco costruito da zero in JavaScript vanilla per il sito, ho scritto anche a proposito delle regole e dell'IA a tre livelli del Burraco online. E se ti va di vedere il risultato finale, il cruciverba è giocabile gratis nella sezione giochi del sito.
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.
Domande frequenti
Perché un algoritmo di backtracking su una griglia fissa può fallire nel generare un cruciverba anche con centinaia di parole nel dizionario?
Perché il vincolo che conta non è quante parole ci sono in totale, ma quante intersezioni simultanee servono in un punto della griglia. Una parola verticale che attraversa sei parole orizzontali insieme, in punti dove serve che l'ultima lettera di una orizzontale coincida con la prima di una verticale, richiede una copertura di combinazioni lettera-per-lettera che solo un dizionario di migliaia di voci può garantire in modo affidabile — poche centinaia non bastano, indipendentemente da quante se ne aggiungono.
Cos'è un generatore di cruciverba "a incastro libero" e come funziona?
Invece di riempire uno schema vuoto già disegnato, il generatore costruisce la griglia: parte da una parola, poi ne aggiunge altre una alla volta agganciandole a una lettera già presente in una parola piazzata in precedenza, verificando che non ci siano conflitti. La forma finale della griglia emerge dalle parole stesse, quindi il puzzle è risolvibile per costruzione — non esiste uno stato "impossibile" in cui il riempimento può bloccarsi.
Perché conviene generare la griglia di un cruciverba a runtime invece di usare pochi schemi fissi pre-disegnati?
Perché ogni schema fisso porta con sé un pattern di incroci rigido — ad esempio una parola che ne attraversa sei altre insieme — che il dizionario o soddisfa sempre o non soddisfa mai: con pochi schemi il puzzle finisce per fallire sempre nello stesso punto. Generare (o pre-risolvere) più combinazioni permette di ritentare con un'impostazione diversa invece di sbattere sempre contro lo stesso vincolo.
Perché una cella di intersezione tra due parole in un cruciverba web può scambiare la digitazione normale per un doppio click?
Perché se il codice imposta la cella come "attiva" prima ancora di spostarci il focus, quando scatta l'evento di focus il sistema vede "stessa cella già attiva" anche durante la digitazione sequenziale normale, non solo durante un doppio click volontario. La correzione è separare la navigazione (il focus, anche automatico) dal vero click deliberato, tracciato a parte con mousedown o touchstart.
Perché in una casella di testo con maxlength=1 scrivere di nuovo la stessa lettera già presente a volte non fa avanzare l'input?
Perché il browser non genera un evento di cambiamento se il carattere digitato è identico a quello già presente e il contenuto del campo non è selezionato in anticipo. La correzione affidabile è selezionare il contenuto della cella a ogni focus, così la digitazione lo sostituisce sempre, più una gestione diretta del tasto premuto come rete di sicurezza su alcune tastiere mobili.
Perché conviene rifare un audit SEO e dei contenuti dopo un cambio radicale dell'algoritmo o della logica interna di una pagina?
Perché un testo scritto per descrivere "come funziona" una pagina — numeri, nomi di tecniche, conteggi — diventa falso nel momento esatto in cui l'implementazione cambia, e sopravvive in silenzio sia nel testo visibile sia nei dati strutturati JSON-LD, che i motori di ricerca e gli assistenti AI leggono entrambi come fonte di verità. Vale la pena ricontrollarlo esplicitamente dopo ogni cambio sostanziale, non solo alla prima pubblicazione.
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.