Playing Wordle with Python

Dominic Fox
6 min readJan 1, 2022

--

Wordle is a fun word-guessing game with similar rules to Bulls and Cows. The player has six turns in which to guess a five-letter word, and after each turn some hints are given: a letter in the guessed word will turn green if it is the right letter in the right place, yellow if it is in the target word but in a different position, and grey if it is not in the target word at all. Based on these hints, the player should be able to converge on the target word within the six turns provided.

Can we teach a computer to play Wordle? What’s the best a computer player can do? Let’s investigate, using the programming language Python. (The complete source code discussed here is available in a gist).

The first thing we need is a list of words. Wordle won’t let you play combinations of letters that it doesn’t think are words, and its word list is not comprehensive. Also, as it’s meant to be fun, the target words are likely to be words most people have heard of. So ideally we’d like a list of well-known five-letter English words.

There’s a useful list online of a third of a million English words together with the number of times they appear in a trillion-word corpus, generated by Peter Norvig at Google. Let’s download that, if we haven’t already:

if not os.path.isfile('count_1w.txt'):
request.urlretrieve(
"https://norvig.com/ngrams/count_1w.txt",
"count_1w.txt")

They’re already listed in descending order by frequency in the corpus, so we can just grab the first 10,000 that are the right length. We also store a dictionary of letter counts in each word, to save constantly having to re-calculated them later

def get_letter_counts(word):
result = dict()
for c in word:
result[c] = result.get(c, 0) + 1
return result


with open('count_1w.txt') as file:
five_letter_words = list(itertools.islice((
(word, get_letter_counts(word)) for word, _ in (line.strip().split('\t') for line in file)
if len(word) == 5), 10000))

In an earlier iteration of this code, I made the assumption that Wordle never picked target words in which the same letter appeared twice, because it wasn’t entirely unambiguous what should happen with the letter hints in that case. However, it turns out that this assumption is wrong, and we need to reason about hints a little more cleverly. A grey letter doesn’t mean that there are no instances of that letter in the target word; it means there are no more instances of that letter, after you count those that have been hinted with green and yellow hints.

On that basis, here’s a function that generates the hints for a guessed word, based on the target word:

def get_hints(target_word, letter_counts, guess):
green_counts = dict()
for position, guess_letter in enumerate(guess):
if target_word[position] == guess_letter:
green_counts[guess_letter] = green_counts.get(guess_letter, 0) + 1

available_for_yellow = {letter: count - green_counts.get(letter, 0) for letter, count in letter_counts.items()}

for position, guess_letter in enumerate(guess):
if guess_letter in target_word:
if target_word[position] == guess_letter:
yield 'g', position, guess_letter
else:
if available_for_yellow[guess_letter] > 0:
available_for_yellow[guess_letter] -= 1
yield 'y', position, guess_letter
else:
yield '.', position, guess_letter
else:
yield '.', position, guess_letter

We use the character gfor a green hint, yfor a yellow hint, and a dot, ., for a grey hint (as g is already taken). We also return the position and guessed letter associated with the hint.

Given a list of hints, we can filter down a list of words to just those words that match the constraints defined by the hints. Here’s how we do that:

def next_guesses(word_list, hints):
counted_hint_letters = dict()
for hint_type, position, letter in hints:
if hint_type == 'g' or hint_type == 'a':
counted_hint_letters[letter] = counted_hint_letters.get(letter, 0) + 1

for word, letter_counts in word_list:
in_list = True

for hint_type, position, letter in hints:

if hint_type == 'g' and word[position] != letter:
in_list = False

if hint_type == 'y' and (word[position] == letter
or letter_counts.get(letter, 0) < counted_hint_letters[letter]):
in_list = False

if hint_type == '.' and letter_counts.get(letter, 0) > counted_hint_letters.get(letter, 0):
in_list = False
if in_list:
yield word, letter_counts

Each hint is given a chance to invalidate each word. A green hint will invalidate a word if it doesn’t have that exact letter in that exact position. A yellow hint will invalidate a word if the letter appears in the right position (since the hint would be green in that case) or if the number of times the letter appears in the word is less than the number of times suggested by the hints. A grey hint will invalidate the word if it contains the letter in it more times than the hints suggest.

Now we have the outline of a simple algorithm. Pick a word from the list of five-letter words, play it and get the hints back. Use the hints to narrow down the list of possible words. Play the next turn picking a word from the narrowed-down list, getting more hints. Repeat until you get the target word. This approach will succeed eventually, but some word choices will get us there more quickly than others. Here’s that algorithm as a Python function:

def play(target_word, best_guess_f):
word_list = five_letter_words
target_word_letter_counts = get_letter_counts(target_word)

for turn_count in itertools.count(1):
next_guess = best_guess_f(word_list)
hints = list(get_hints(target_word, target_word_letter_counts, next_guess))
yield turn_count, next_guess, hints

if all(hint_type == 'g' for hint_type, _, _ in hints):
return

word_list = list(next_guesses(word_list, hints))

Note that this function takes a parameter best_guess_f, which picks the word from the reduced word list to play each time. The simplest best_guess_f will just play the most frequently-occurring word — which, as it happens, will be the first in the list:

def most_popular(guesses):
return guesses[0][0]

How good is this approach? We can find out by taking the average number of turns it takes to guess the right word, for all of the words in our word-list:

difficulty = list(reversed(sorted(((word, len(list(play(word, most_popular))))
for word, _ in five_letter_words),
key=lambda a: a[1])))

It comes out at around 4.8 for the “most popular” best guess function. Can we improve on that? Here’s a function that asks for the word whose letters appear in the same position with the greatest frequency in the remaining word-list:

def by_letter_and_position_frequency(guesses):
by_position_and_letter = dict()
for word, _ in guesses:
for position, letter in enumerate(word):
by_position_and_letter[(position, letter)] = by_position_and_letter.get((position, letter), 0) + 1

return max(((word, sum(by_position_and_letter.get((position, letter), 0)
for position, letter in enumerate(word)))
for word, _ in guesses), key = lambda t: t[1])[0]

This gets our average down to around 4.59— ok, but not great.

There is an optimal approach, but it’s very slow to work out — for each word in the word list, get the hints you’d receive if each word in the word list was the target word, then pick the word whose average reduction in word list size based on those hints was the greatest. You can make this less painfully slow by picking a subset of words at random to be the target words.

Finally, if you want to use this algorithm to help you play Wordle, here’s a function that prompts you with a guess, and asks you to input the hints returned by the game as a string:

def play_as_guesser(best_guess_f):
word_list = five_letter_words
while True:
word = best_guess_f(word_list)
print("Try:", word)
hint_str = input("Type result as string of hints (g for green, y for yellow, . for grey): ")
if hint_str == 'ggggg':
break
hints = [(h, i, word[i]) for i, h in enumerate(hint_str)]
word_list = list(next_guesses(word_list, hints))

Because Wordle only gives you one target word a day to play with, I haven’t yet been able to test this extensively against its own word list — it will be interesting to see how it fares.

The algorithm struggles most with words that have lots of similar words in the word list, for example “match”, “watch”, “patch”, “batch”, and “latch”. Having quickly nailed down the “-atch” suffix, it cannot do better than cycling through guesses at the first letter. This suggests that some target words are more fun to try to work out than others — ideally, the player should feel that it should always be possible to reason their way to an answer within the six allowed turns without having to rely too much on luck. I would be interested to learn whether Wordle has any algorithmic criteria for picking each day’s target word: it’s often the case (as with Sudoku) that investigating algorithmic solving techniques can suggest ways for the setter to quantify the difficulty or fairness of a puzzle.

--

--