Crossword generator in Python: the runs of letters nobody clued
A quick freeform crossword placer checks that crossing letters match. In 1,000 grids that left words nobody clued in every one, 13 a grid. Checking what a new word touches fixes it, at a cost you can buy back.
The crosswords in most puzzle books are freeform: a handful of words on one theme, laid across and down so that each crosses another, with numbered clues. A Python generator for that is short. The usual one tries each new word at every square where it shares a letter with the grid, and places it where the crossing letters match.
That is not enough, and the grid will not tell you, because every word you asked for is in it. What is also in it is letters that sit side by side by accident: a solver reads a run of letters across or down as an answer, and those runs have no clue. On 30 October 2026 we measured how often that happens, with 14 words a grid drawn from 43 of the 103 themes of our crossword books.
The short version#
- The quick placer (crossing letters must match, nothing else checked): runs of letters with no clue in 1,000 grids out of 1,000, 13.2 a grid on average, 25 at most. Some were two letters, like ZP. Others glued a letter onto an answer: TURKEYX, FISHINGI.
- The checked placer (a new word may touch the grid only where it crosses): 0 grids out of 1,000 with such a run.
- The cost: with fewer places allowed, all 14 words fitted in 48% of grids, against 92% for the quick placer.
- Buying it back: build the grid 30 times with different random choices and keep the one with the most words. All 14 fitted in 96% of 300 grids, still none with a stray run, in about 12 ms a grid.
The code#
import random
ACROSS, DOWN = (0, 1), (1, 0)
def can_place(grid, word, r, c, d):
"""Whether `word` fits with its first letter at (r, c), going `d`. It must cross at least one letter already in
the grid, every crossing letter must match and run the other way, and it must touch nothing else: no letter
right before or after it, and none beside a square it adds (that would make a run of letters no clue answers)."""
dr, dc = d
if (r - dr, c - dc) in grid or (r + dr * len(word), c + dc * len(word)) in grid:
return False
crosses = 0
for i, ch in enumerate(word):
y, x = r + dr * i, c + dc * i
if (y, x) in grid:
letter, ways = grid[y, x]
if letter != ch or d in ways:
return False
crosses += 1
elif (y + dc, x + dr) in grid or (y - dc, x - dr) in grid:
return False
return 0 < crosses < len(word)
def build(words, rng=random):
"""{(row, col): (letter, {directions})} and the placed answers [(word, row, col, direction)]."""
words = sorted(words, key=len, reverse=True)
grid, placed = {}, []
def put(word, r, c, d):
for i, ch in enumerate(word):
cell = (r + d[0] * i, c + d[1] * i)
grid[cell] = (ch, grid.get(cell, (ch, set()))[1] | {d})
placed.append((word, r, c, d))
put(words[0], 0, 0, ACROSS)
for word in words[1:]:
spots = [(y - d[0] * i, x - d[1] * i, d) for (y, x), (ch, ways) in grid.items()
for i, wc in enumerate(word) if wc == ch for d in (ACROSS, DOWN) if d not in ways]
rng.shuffle(spots)
for r, c, d in spots:
if can_place(grid, word, r, c, d):
put(word, r, c, d)
break
return grid, placed
def best_grid(words, tries=30, rng=random):
"""build() places words in a random order of spots; keep the grid that fits the most words, then the smallest."""
best = None
for _ in range(tries):
grid, placed = build(words, rng)
rows = [y for y, x in grid]
cols = [x for y, x in grid]
key = (len(placed), -(max(rows) - min(rows) + 1) * (max(cols) - min(cols) + 1))
if best is None or key > best[0]:
best = (key, grid, placed)
return best[1], best[2]
def show(grid):
rows = [y for y, x in grid]
cols = [x for y, x in grid]
return "\n".join(" ".join(grid[y, x][0] if (y, x) in grid else "." for x in range(min(cols), max(cols) + 1))
for y in range(min(rows), max(rows) + 1))
def runs(grid):
"""Every run of two or more letters, across and down, as (text, row, col, direction): what a solver sees."""
out = []
for d in (ACROSS, DOWN):
for (y, x) in grid:
if (y - d[0], x - d[1]) in grid:
continue # not the start of a run
text = ''
while (y + d[0] * len(text), x + d[1] * len(text)) in grid:
text += grid[y + d[0] * len(text), x + d[1] * len(text)][0]
if len(text) >= 2:
out.append((text, y, x, d))
return outwords = "PIG HEN GOAT SHEEP DONKEY TURKEY RABBIT HORSE".split()
grid, placed = best_grid(words, rng=random.Random(4))
print(show(grid))
print(len(placed), "of", len(words), "words;", len(runs(grid)), "runs of letters")With that seed it printed this grid, 7 of the 8 words, and 7 runs of letters: one for each answer and no others.
P I G . . . . . .
. . O . . . . . .
. R A B B I T . .
. . T . . . U . .
. . . . H O R S E
. . . . E . K . .
. . D O N K E Y .
. . . . . . Y . .SHEEP did not fit anywhere it could cross without touching another word. best_grid() keeps trying; with these 8 words, 7 was the most it found.
What a new word may touch#
can_place() checks three things beyond the crossing letters:
- The square before the first letter and after the last must be empty. Otherwise the word runs into another one, and TURKEY becomes TURKEYX.
- The squares beside each new letter must be empty, except where the word crosses. Otherwise the new letter and its neighbour make a run of two or more letters down (for a word across) that is no answer.
- The crossing letter must belong to a word going the other way. Two words that overlap in the same direction read as one long run.
It also asks for at least one crossing, so the grid stays in one piece. All 2,600 grids we checked, quick and checked placer alike, were one piece.
Check the grid, not the placer#
runs() reads the finished grid the way a solver does: every run of two or more letters, across and down. The test is that those runs are exactly the answers you placed, no more. It shares no code with the placer, which is the point: it would catch a mistake in can_place() that a test of can_place() itself would repeat. Every count above comes from it.
Words that do not fit#
The checked placer leaves out words more often, because it allows fewer places. build() places the longer words first. Building the grid several times and keeping the best is the cheap fix: 10 tries fitted all 14 words in 91% of grids in about 4 ms, 30 tries in 96% in about 12 ms. For a book, a word that still does not fit is better swapped for another word of the theme than squeezed in.
A whole book, not one grid#
For KDP you need a hundred of these, each a numbered grid with its clues, and the answers at the back. Our crossword book maker does that. A separate program reads every run of letters back from each finished grid, across and down, and checks that the runs are exactly the puzzle's answers, numbered in reading order, that the grid is one piece and that letters touch only where two answers cross. The clues were drafted with an AI writing tool for each answer in its theme's sense, checked by rules and read again in a separate AI review, and no answer repeats in a book. You pick the level, 103 themes, the number of puzzles and the trim size, and the book is checked against KDP's paperback specs before you can download it. One crossword book costs 500 credits, from $3.30 in the big pack. How to make a crossword puzzle book covers the KDP side.
written
19 min readKDP coloring book size and bleed: check your interior PDF in Python
The trim sizes KDP lists, when a coloring book needs bleed, why the page count doubles and what 300 ppi means. Then a Python check that reads your interior PDF and says where it breaks KDP's published rules.
4 min readWord scramble generator in Python: when LEMON is also MELON
A shuffle can hand back the word itself or another real word, and often leaves a third of it in place. The bigger catch is in the word list: 40% of the short words in 43 of our puzzle-book themes have letters that spell another word.
5 min readExtract text from a PDF in Python: two columns, scans and speed
We drew PDFs whose right text we knew, then read them back with pypdf, PyMuPDF and pdftotext. All of them got the words. Two columns drawn row by row beat all but one, and a scanned page came back empty with no error.