Word search generator in Python: the two bugs a quick one ships
Placing the words is the easy part. In 900 test grids a one-pass fill left a rude word in more than half and a listed word twice in about one in ten. What causes both, and the fill that fixes them.
A word search generator looks like an afternoon's work: put the words in a grid, fill the empty squares with random letters, done. That version ships two bugs, and both end up in front of a reader. Random filler spells words, including ones you would never print. And a listed word can appear twice, so a solver circles the wrong one and the answer key disagrees.
We measured both on 26 October 2026: 900 grids of 15 x 15 with 20 words each, 300 per level, the words drawn from 103 real theme lists. For rude words we used the public LDNOOBW English list (CC BY 4.0), words of 3 letters or more.
What a one-pass fill leaves behind#
- A rude word somewhere in the grid: 55 to 58 % of grids with plain A to Z filler, 62 to 68 % with filler weighted like English text. Weighted filler looks more like a real puzzle, and it spells more real words, the bad ones too.
- A listed word that reads twice: 8 to 10 % of grids with A to Z filler, 12 to 14 % with weighted filler.
These counts leave out a blocked word that sits inside a listed word ("ASS" inside "CLASS" is fine): only words the filler helped to spell, or that crossing words made, are counted.
The fix, in three parts#
- Refill until it is clean. After filling, scan the whole grid in all eight directions. If a listed word reads more than once, or a blocked word shows outside a listed word, fill the empty squares again. It took a median of 2 fills and at most 47, about 14 ms a grid.
- Place again when the placed letters are the problem. In 38 to 49 of each 300 grids, the bad word was made only of letters that belong to the placed words, where two words cross or run side by side. No refill changes those letters, so the only way out is a new placement.
- Do not pick words that contain each other. PEAR inside PEARL, or RAT backwards inside STAR, can never read exactly once. With our theme lists we had to draw again 74 to 90 times per 300 puzzles.
The scan looks in all eight directions even on an easy puzzle whose words only run right and down. The reader does not know which directions you used, and a word hidden backwards by accident is still there.
The code#
make() returns a clean grid and the cells of every word, for the answer key. BLOCKED is your list of words that must not appear, in capitals.
import random
DIRS = {"easy": [(0, 1), (1, 0)], # right, down
"medium": [(0, 1), (1, 0), (1, 1), (-1, 1)], # + two diagonals
"hard": [(0, 1), (1, 0), (1, 1), (-1, 1), (0, -1), (-1, 0), (-1, -1), (1, -1)]} # all 8, backwards too
# English letter frequencies: filler that looks like a real puzzle, not alphabet soup
FREQ = "EEEEEEEEEEEETTTTTTTTTAAAAAAAAOOOOOOOIIIIIIINNNNNNNSSSSSSHHHHHHRRRRRRDDDDLLLLCCCUUUMMMWWFFGGYYPPBVKJXQZ"
def clashes(words):
"""Pairs that can never read exactly once: PEAR inside PEARL, or RAT backwards inside STAR."""
return [(a, b) for a in words for b in words if a != b and (a in b or a[::-1] in b)]
def place(words, size, dirs, rng, tries=300):
"""Longest words first, each at a random spot where it fits (crossing on the same letter is fine)."""
grid, where = [[None] * size for _ in range(size)], {}
for w in sorted(words, key=len, reverse=True):
for _ in range(tries):
dr, dc = rng.choice(dirs)
r, c = rng.randrange(size), rng.randrange(size)
cells = [(r + dr * i, c + dc * i) for i in range(len(w))]
if all(0 <= y < size and 0 <= x < size and grid[y][x] in (None, ch) for (y, x), ch in zip(cells, w)):
for (y, x), ch in zip(cells, w):
grid[y][x] = ch
where[w] = cells
break
else:
return None, None # start again with another seed
return grid, where
def lines(size):
"""Every full straight line of the grid, in all 8 directions, as a list of cells."""
out = []
for dr, dc in [(0, 1), (1, 0), (1, 1), (-1, 1)]:
for r in range(size):
for c in range(size):
if 0 <= r - dr < size and 0 <= c - dc < size:
continue # not where a line starts
cells, y, x = [], r, c
while 0 <= y < size and 0 <= x < size:
cells.append((y, x))
y, x = y + dr, x + dc
out += [cells, cells[::-1]]
return out
def read(grid, all_lines):
return [(cells, "".join(grid[y][x] for y, x in cells)) for cells in all_lines]
def find_all(texts, word):
"""Every place the word reads, whatever the level: a solver looks in all 8 directions."""
hits = set()
for cells, text in texts:
i = text.find(word)
while i >= 0:
hits.add(frozenset(cells[i:i + len(word)]))
i = text.find(word, i + 1)
return hits
def fill(grid, where, blocked, rng, letters=FREQ, rounds=1000):
"""Fill the empty cells again until every listed word reads exactly once and no blocked word
shows anywhere except inside a listed word (CLASS may hold ASS; the filler may not).
Returns how many fills it took, or None when the placed words alone spell the problem."""
size, all_lines = len(grid), lines(len(grid))
empty = [(y, x) for y in range(size) for x in range(size) if grid[y][x] is None]
words = [frozenset(cells) for cells in where.values()]
placed = frozenset().union(*words)
for n in range(1, rounds + 1):
for y, x in empty:
grid[y][x] = rng.choice(letters)
texts = read(grid, all_lines)
bad = [h for w, cells in where.items() for h in find_all(texts, w) if h != frozenset(cells)]
bad += [h for b in blocked for h in find_all(texts, b) if not any(h <= w for w in words)]
if not bad:
return n
if any(h <= placed for h in bad):
return None # no refill can change placed letters: place the words again
return None
def make(words, size, level, blocked, rng, attempts=100):
"""A clean grid: place, fill, and place again when the placement itself is the problem."""
assert not clashes(words), clashes(words)
for attempt in range(1, attempts + 1):
grid, where = place(words, size, DIRS[level], rng)
if grid and fill(grid, where, blocked, rng):
return grid, where
raise RuntimeError("no clean grid: use fewer or shorter words, or a bigger grid")rng = random.Random(42)
words = ["APPLE", "BANANA", "CHERRY", "GRAPE", "LEMON", "MANGO", "MELON", "OLIVE", "PEACH", "PEAR",
"PLUM", "LIME", "KIWI", "GUAVA", "PAPAYA", "APRICOT", "FIG", "DATE", "QUINCE", "BERRY"]
grid, where = make(words, 15, "hard", BLOCKED, rng)
print("\n".join(" ".join(row) for row in grid))Every one of the 900 finished grids then passed a second check written separately: it tries every square and every direction, with no shared code, and confirms each listed word reads exactly once, at the cells the answer key gives, and no blocked word appears outside a listed word.
Levels are directions#
The usual way to set difficulty is by direction. Easy runs right and down. Medium adds the two diagonals that read left to right. Hard uses all eight, backwards too. In our test every level placed 20 words in a 15 x 15 grid without a restart, so the grid size is not what limits a level; the reader is.
A whole book, not one grid#
For a KDP book you need a hundred of these, laid out with the margins KDP asks for, with answers at the back. Our word search book maker does that, with the same two rules: each listed word appears exactly once, which a second program checks from scratch in all eight directions, and the filler never spells a word from our blocked list. You pick the level, 163 themes, large print, the trim size and the cover, and the book is checked against KDP's paperback specs before you can download it. How to make a word search book covers the KDP side, from the low-content box to the price.
written
5 min readWebsite screenshot in Python: what still goes wrong, measured
A screenshot with Playwright is two lines. Some old warnings about full-page shots no longer hold in today's Chromium; others do, and they cost you the images and charts on the page. We tested each one.
6 min readMaze generator in Python: three algorithms, measured
Depth-first backtracking, Prim and Wilson all make mazes with exactly one way through. They do not make the same puzzle. We built 900 mazes and measured what a solver meets: dead ends, the length of the route, the forks on it.
5 min readHTML to PDF in Python with Playwright: the defaults that bite
A headless browser is a common way to turn HTML into a PDF. Its defaults catch people out: US Letter, no backgrounds, no margins, an invisible footer. Each one measured, and a function that sets them right.