A Python Wordle Solver

Wordle is a daily online word-guessing game. The goal is to deduce the hidden five-letter word in as few guesses as possible: after each guess, the player is told which letters which match in the correct position (highlighted in green), which letters are present in the hidden word, but in a different position (highlighted in yellow) and which letters are not present (after those highlighted in green or yellow; these letters are presented on a grey background).

In the example, above, the first guess (STRAP) reveals that the letter A is present in position 4 and the letters S, T, R and P are not present in the hidden word; the second guess (GLEAM) indicates that the letter L is present somewhere other than position 2 and that the letters G, E and M are not present. From the third guess (LOCAL) it is clear that L is the last letter, there are no other occurences of the letter L, and that O and C are not present. The fourth guess (BANAL) is correct and the puzzle was solved in four attempts.

The command-line script below solves Wordle puzzles using the Scrabble word list SOWPODS. The program prompts for the clues provided at each guess, which are entered as a string of five characters: = for a green tile, + for a yellow tile and - for a grey tile. For example,

$python wordle.py Try the word "cupel": ----= Try the word "nahal": +=-== Try the word "banal": ===== The word is banal, found in 3 attempts.  Unfortunately, the word list has a large number of rather obscure words, so it sometimes takes a rather long time to hit on the correct one: $ python wordle.py
Try the word "luces": +----
Try the word "flirt": -+---
Try the word "jalop": -=+--
Try the word "gayal": -=-==
Try the word "kahal": -=-==
Try the word "naval": +=-==
The word is banal, found in 6 attempts.


If this is taking too long, try this list of the most common 2500-or-so words, common-5-words.txt.

import sys
import random
from collections import defaultdict

FIRST_WORD = 'orate'

class Rule:
def __init__(self, letter, i=None):
self.letter, self.i = letter, i

class RuleMatch(Rule):
code = '='
def apply(self, words, matched_counts):
words = [word for word in words if word[self.i] == self.letter]
return words

class RuleContainsElsewhere(Rule):
code = '+'
def apply(self, words, matched_counts):
# Only keep words which contain letter (not in position i, or else
# it would be an exact match (= not +) and which don't contain the
# letter more often than the number of counted matches.
words = [word for word in words if self.letter in word
and word[self.i] != self.letter
and matched_counts[self.letter] <= word.count(self.letter)]
return words

class RuleExcludedLetter(Rule):
code = '-'
def apply(self, words, matched_counts):
_words = []
for word in words:
if not matched_counts[self.letter] and self.letter in word:
# letter has not been matched anywhere in the word:
# don't include any words which have this letter.
continue
if matched_counts[self.letter] > word.count(self.letter):
# letter has been matched n times: we can't include
# words that don't include it at least as many times.
continue
_words.append(word)
words = _words[:]
return words

RuleCls = {'=': RuleMatch, '+': RuleContainsElsewhere, '-': RuleExcludedLetter}

class Wordle:
def __init__(self, target_word=None, word_length=5):
self.target_word = target_word
self.word_length = word_length
if target_word:
self.word_length = len(target_word)

self.wordlist_name = 'sowpods'
# Uncomment the line below to use only the most common 5-letter words.
#self.wordlist_name = 'common-5-words.txt'

"""Read in words of length word_length from our word list."""

with open(self.wordlist_name) as fi:
# NB the inclusion of the end-of-line character (which we
# subsequently strip) increases the target word length by one.
self.words = [word.strip() for word in fi
if len(word) == self.word_length + 1]

def assess_word(self, test_word):

target = list(self.target_word)
matched_counts = defaultdict(int)
rules = [None] * self.word_length
# Test test_word for the "exact match" and "excluded letter" rules.
for i, letter in enumerate(test_word):
if letter == target[i]:
rules[i] = RuleMatch(letter, i)
target[i] = '*'
matched_counts[letter] += 1
elif letter not in target:
rules[i] = RuleExcludedLetter(letter, i)

for i, letter in enumerate(test_word):
if rules[i]:
continue
if letter in target:
# NB exact matches have already been filtered out.
rules[i] = RuleContainsElsewhere(letter, i)
target[target.index(letter)] = '*'
matched_counts[letter] += 1
else:
rules[i] = RuleExcludedLetter(letter, i)

rule_str = ''.join(rule.code for rule in rules)
return rules, matched_counts, rule_str

def parse_rule_codes(self, rule_codes, test_word):
rules = []
matched_counts = defaultdict(int)
for i, letter in enumerate(test_word):
rules.append(RuleCls[rule_codes[i]](letter, i))
if rule_codes[i] in '+=':
matched_counts[letter] += 1
return rules, matched_counts

def apply_rules(self, rules, matched_counts):
for rule in rules:
self.words = rule.apply(self.words, matched_counts)

def get_test_word(self):
k = random.choice(range(len(self.words)))
return self.words[k], k

def get_rules_input(self, test_word):
return input(f'Try the word "{test_word}": ')

def interactive(self):
j = 0
init = FIRST_WORD, self.words.index(FIRST_WORD)
while len(self.words) > 1:
test_word, k = self.get_test_word() if j else init
j += 1
rule_codes = self.get_rules_input(test_word)
rules, matched_counts = self.parse_rule_codes(rule_codes,test_word)
self.apply_rules(rules, matched_counts)

if len(self.words) == 0:
sys.exit('I think you made a mistake: no words match this set'
' of rules.')
elif len(self.words) == 1:
break
if test_word in self.words:
del self.words[self.words.index(test_word)]
print(f'The word is {self.words[0]}, found in {j} attempts.')

if __name__ == '__main__':
wordle = Wordle()
wordle.interactive()

Current rating: 3.1