Generating Word Search Puzzles
My daughter (age 11) was writing an article this week for a local student newsletter, and had the idea to include a word search puzzle. After spending ten minutes looking online and being fairly disappointed in the quality of what we found, I decided to take a stab at writing a word search puzzle generator myself.
I mean, how hard could it be?
Fortunately for me it wasn’t too hard at all, though I’m sure my implementation is far from optimal. It generates puzzles that look like this:
(Bonus: the letters left after finding all the words spell out one of my favorite features of Ruby…)
The core of the algorithm (after deciding on the word list and the size of the puzzle grid) is just this:
- Pick the next word in the list.
- Add it to a random location in the grid.
- If the word fails to fit anywhere on the grid, backtrack and try from step #2 with the previous word.
- If there is no previous word, fail (because the words cannot all fit on the grid).
- Once the word has been placed successfully, repeat from step #1.
- Once all words have been placed, fill in the unused squares with random letters.
The first thing to do was to represent the grid itself. Visually, it’s a two-dimensional grid of rows and columns:
grid = WordSearch::Grid.new(rows, columns)
Since the algorithm requires that I be able to try a word against every possible location in the grid, I chose to implement the grid as a one-dimensional array. This way I can represent each location as a single integer, which is cleaner than trying to juggle (row, column)
tuples.
module WordSearch
class Grid
attr_reader :rows, :columns, :size
def initialize(rows, columns, grid=nil)
@rows, @columns = rows, columns
@size = @rows * @columns
@grid = grid || Array.new(@rows * @columns)
end
def index(row_or_pos, column=nil)
if column
row_or_pos * @columns + column
else
row_or_pos
end
end
def at(position)
row = position / @columns
col = position % @columns
[row, col]
end
def [](row_or_pos, column=nil)
@grid[index(row_or_pos, column)]
end
# ...
end
end
It also makes it really easy to duplicate the grid, so that the state of the grid can be saved and restored as the algorithm backtracks:
def dup
self.class.new(@rows, @columns, @grid.dup)
end
The algorithm itself begins by initializing a list of possible directions that each word might be drawn in (right-to-left, top-to-bottom, etc.), as well as building a list of all possible positions in the grid:
directions = %i(right down)
directions += %i(rightdown) if @diagonal
directions += %i(left up) if @backward
directions += %i(leftup leftdown rightup) if @diagonal && @backward
positions = (0...grid.size).to_a
I could implement this algorithm via recursive function calls, but I opted to use a stack, instead. I initialize it like this (words
is the list of vocabulary words that will be used to build the puzzle):
stack = [ { grid: grid,
word: words.shift,
dirs: directions.shuffle,
positions: positions.shuffle } ]
Each stack element remembers the current state of the grid, the word that is currently being placed on the grid, the list of possible directions that haven’t yet been tried, and the list of possible positions that haven’t yet been tried.
Once all of that’s ready, the algorithm itself runs in a loop:
while true
current = stack.last
raise "no solution possible" unless current
# get the next direction to try
dir = current[:dirs].pop
if dir.nil?
# if we've tried all possible directions at
# this position, pop the current position off
# and reset the list of directions.
current[:positions].pop
current[:dirs] = directions.shuffle
dir = current[:dirs].pop
end
# get the position in the grid that we're
# trying the word against.
pos = current[:positions].last
if pos.nil?
# If there are no more available positions,
# put the current word back in the vocab
# list and backtrack by popping the stack.
words.unshift(current[:word])
stack.pop
else
# otherwise, try to place the word on the grid,
# at the given position, in the given direction.
grid = _try_word(current[:grid], current[:word], pos, dir)
# if it succeeded, grid will be a copy with the
# word placed on it.
if grid
# If there are any more words left in the vocab
# list, push the next one onto the stack.
if words.any?
stack.push(grid: grid,
word: words.shift,
dirs: directions.shuffle,
positions: positions.shuffle)
else
# Otherwise, we've placed all the words
# successfully, and we're done!
break
end
end
end
end
That _try_word
method isn’t too magical:
def _try_word(grid, word, position, direction)
copy = grid.dup
row, column = copy.at(position)
# DIRS is a hash that maps a direction name
# (like "right") into a tuple (like [0, 1])
# which tells which directions the position
# should move at each step.
dr, dc = DIRS[direction]
# break the word into its component letters
letters = word.chars
while (row.between?(0, rows-1) && column.between?(0, columns-1))
letter = letters.shift || break
# If there is no letter at the given position, or if the
# letter matches what is already at that position, add
# the letter to the grid and move to the next position.
if copy[row, column].nil? || copy[row, column] == letter
copy[row, column] = letter
row += dr
column += dc
else
# if the letter can't be placed, abort.
return nil
end
end
# If all the letters in the word were placed, return the
# new grid.
letters.any? ? nil : copy
end
Once all of the words have been placed, we just fill in the unused squares with random letters:
letters = ('a'..'z').to_a
grid.size.times { |pos| grid[pos] ||= letters.sample }
All that’s left, then, is to display the puzzle:
rows.times do |row|
columns.times do |col|
print grid[row, col], " "
end
puts
end
Given a vocabulary list of “mazes,” “word,” “search,” “puzzle,” “games,” and “program,” and a 7x7 grid, our puzzle might look something like this:
e e d r o w h
m g l m w c g
v a s z r k w
t m z a z w w
f e e e y u b
e s p m s n p
m a r g o r p
(For this puzzle, the words are drawn in all possible directions: forwards, backwards, and diagonally.)
The version I wrote for my daughter actually emits the puzzles as PDF (so that she could embed them in her article). If you’d like to check it out, it’s hosted on GitHub here: http://github.com/jamis/wordsearch.