← blog  /  tools

Crossword generator in Python: the runs of letters nobody clued

2026-11-09  ·  7 min read  ·  tools

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#

A freeform crossword grid with no stray runs of letterspython
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 out
Run itpython
words = "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