# Wordle solver challenge

Wordle is a daily online word game that has received considerable attention recently.

# The Game

The object is to guess a secret word in the fewest attempts. Consider the following instance of the game:

The secret word is rebus, and the player's first guess was arise. The letters r, s, and e are displayed in yellow to indicate that these letters appear in the secret word, but not at these locations. Meanwhile, the letters in gray do not appear in the secret word at all. The player uses this information to formulate a second guess: route. Here, the r appears in green, indicating that r appears in the secret word at this location. Notice that each guess is an English word.

A comment on duplicated letters: If the first guess above were river, then the first r would appear in green, but the second r would appear in gray. In general, the duplicated letters are only highlighted if they are similarly duplicated in the solution, and letters appear in green whenever possible. As another example, suppose the secret is inane and we guess nanny. Then our first n will be marked in yellow, the second in gray, and the third in green. In this case, only two of our three ns are highlighted since the secret only has two ns.

# The Challenge

In this challenge, you will write code that plays Wordle as well as possible. The following link contains a curated list of 2315 common 5-letter words:

words.txt

Notably, every Wordle solution to date appears on this list, and I suspect future solutions will, too.

Your code will play Wordle by iteratively selecting words from the above .txt file and receiving Wordle-style feedback on which letters appear in the secret word. To evaluate your code, play this game 2315 times (each time using a different secret word from the above .txt file), and record the number of guesses it took to win. (While the official game terminates after six guesses, in this challenge, you may take arbitrarily many guesses.)

In the end, your score will be the histogram of the number of guesses required, arranged in a tuple. For example, (1,1000,900,414) is a 4-tuple that indicates that the code solved every word in only 4 guesses, with 414 words requiring all 4 guesses, 900 taking only 3, 1000 taking only 2, and one word being identified after a single guess. Your code must be deterministic (i.e., your first guess will be fixed), and so the first entry of your score tuple will necessarily be 1.

A comment on implementation: There are a few ways to organize things (and to encourage participation, I don't want to impose any one way), but one approach is to write three programs: the game, the player, and the evaluator. The game receives a secret and a guess (both from words.txt) and outputs Wordle-style feedback. The player interacts with the game for some fixed secret word. The evaluator makes the player play the game for each secret word from words.txt. (One might use submissions from Build a Mastermind engine to write the game program, but for this challenge, the interesting program is the player.)

# The Winner

Scores will be compared in lexicographic order. That is, shorter tuples are better, and tuples of the same size are first compared in the last entry, then second-to-last entry, etc. For example:

(1,1000,900,414) < (1,1000,899,415) < (1,999,900,415) < (1,998,900,415,1)

Lowest score wins.

• So, just to clarify, our program will take in a word from the provided list as input, receive some feedback on the accuracy of the letters of the input (how will the program get this feedback?), then output the next guess? This looks like a very interesting challenge, but seems to be unclear (at least to me) on a few bits. For example, would the interactive tag apply here, for the feedback part? Jan 14 at 16:04
• @cairdcoinheringaahing - There are a few ways to organize things (and to encourage participation, I don't want to impose any one way), but one approach is to write three programs: the game, the player, and the evaluator. The game receives a secret and a guess (both from words.txt) and outputs Wordle-style feedback. The player interacts with the game for some fixed secret word. The evaluator makes the player play the game for each secret word from words.txt. (Here, the interesting program is the player.) Jan 14 at 16:14
• Out of curiosity, why is the metric not "lowest total number of guesses"? Jan 15 at 5:52
• @Jonah - I think by "total" you mean add the numbers of guesses required for each of the 2315 secret words. Minimizing this is equivalent to minimizing the average number of guesses required. I'm more interested in the worst-case behavior, i.e., I want a player that solves every instance in at most 4 guesses, say. But given two such players, the better player is the one who spends 4 guesses on fewer words, etc. This is what the lexicographic order encodes. Jan 15 at 11:29
• Does this answer your question? Build a Mastermind engine Jan 15 at 21:04

# Python 3, (1, 149, 945, 1118, 102)

from collections import Counter

# history: a list of tuple of (guess, feedback)
# feedback: a list of length 5, each element can be MISS, CLOSE, or MATCH
def play(history):
# Hard coded first guess
turn = len(history) + 1
if turn == 1:
return 'trace'

# When there are few words left
remaining_words = [word for word in WORDS if all(get_feedback(guess, word) == feedback for guess, feedback in history)]
if len(remaining_words) <= 2:
return remaining_words[0]

# Hardcoded hard case
if turn == 3 and history == [('trace', [MISS, CLOSE, MISS, MISS, CLOSE]), ('risen', [CLOSE, MISS, MISS, MATCH, MISS])]:
return 'howdy'

guess_list = WORDS if turn > 2 and len(remaining_words) < 100 else remaining_words
return find_best_guest(guess_list, remaining_words)

def find_best_guest(guess_list, secret_list):
R = []
secret_set = set(secret_list)
for guess in guess_list:
c = Counter([tuple(get_feedback(guess, secret)) for secret in secret_list])
R.append([guess, len(c), guess in secret_set, -max(c.values())])
best_guess = max(R, key=lambda t:t[1:])
return best_guess[0]


Try it online!

Scorer

# Big idea

Each time, I pick the guess from the list of remaining words (words matching all previous guesses), such that the guess maximize the number of possible outcomes. For example, at the start, we guess "trace". This splits the possible secrets into 150 groups, only one of which is possible based on the feedback. For the second guess, if we guess a word from that group, then there is 1 universe where we guess correctly. Thus in total, we have 150 universes where we guess correctly within the first 2 turns. By maximizing the number of groups each turn, we can make sure that there are more universes where we guess correctly early on.

Following the naive strategy above, the result is (1, 149, 1044, 904, 178, 31, 7, 1). If we score by lexicographical order from left to right, without caring about the length of the tuple, then this is the largest tuple possible. Heuristically, this is good since if the left entries of the tuple are as large as possible, then the right most entries will be small. However, because the most important criteria is the length of the tuple, I have to tweak this algorithm a bit to get rid of all cases that have >5 turns.

# Optimization

• If we have too few remaining words, then we might have to guess a word outside of those, to get the best split. Specifically, I allow selecting from outside if we're at the 3rd guess (or after) and there are less than 100 remaining words.
• I brute forced search a some hard subtrees (e.g. 'trace' -> 'risen') to get the worst case at most 5 guesses.
• If instead of the negative of the maximum count you consider the negative of the reverse sorted counts (keeping everything else the same) you'll get 111 as the final value. That is R.append([guess, len(c), [-1*v for v in sorted(c.values(), reverse=True)]]) Jan 16 at 21:44
• Oh, and of course best_guess = max(R, key=lambda t:t[1:]) ...Tiny TIO tinyurl.com/2p8v6zw6 Jan 16 at 21:57
• In your TIO scorer, I commented out the condition after guess_list = WORDS, so that it would always pick from the full dictionary, and obtained [1, 81, 1181, 968, 81, 3]. Maybe it is possible to hardcode those 3 cases too, for a nicer count of fives? Jan 24 at 14:55

# BQN

┌─
╵ 1  2   3   4   5   6  7  8 9
1 56 407 861 722 210 44 13 1
┘


The strategy is very basic, this just chooses the first word in the word lists that is still possible according to the previous results. I will improve on that later, but for now I just wanted to get the "infrastructure" up.

#!/usr/bin/env bqn

# Judge takes the correct and word and a guess, and returns a pair of vectors
# The first has 1's at green letters, the second one 1's at yellow letters
Judge ← { word 𝕊 guess:
wrong ← ¬correct ← word=guess
misplaced ← (+⊸×wrong)⊏0∾word(⊒∘⊢<·+˝=⌜)○(wrong⊸/)guess
correct⋈misplaced
}

# Game takes a path to the word list,
# plays every single word, and returns an histogram
Game ← { 𝕊 file:
words ← •FLines file
1↓˘⊒˜⊸≍≠¨⊔˜ {𝕩⊸Judge _Player words}¨ words
}

# The Player takes a monadic judge as its left operand and a word list as the right argument
# It has to guess until it finds the right word and return its score ( Don't cheat ;) )
_Player ← { Ju _P words:
score ← Ju attempt←⊑words
¬∧´⊑score ? 1+Ju _P words/˜(score≡Judge⟜attempt)¨ words ; 1
}

•Show Game "words.txt"


This should be run in the same directory as the words.txt file. You can try a modified version with only 100 words online here, the full program is a bit too much for the JS interpreter.

• Cool. Apparently, some people online play "hard mode" Wordle, wherein you're only allowed to guess words that are consistent with all of the information obtained so far. This strategy plays in hard mode. Unfortunately, that means that the worst-case performance isn't so great, because it gets stuck on words like "tatty" after learning that it is of the form "_a__y". Jan 15 at 20:31

# JavaScript (Node.js)

## [ 1, 101, 650, 1000, 465, 87, 11 ]

A simple algorithm computing the list of remaining possible words and picking either the first one if there are less than 3 of them, or the one located at a precomputed index otherwise. (This is therefore playing in 'hard mode'.)

The precomputed values are based on a hash of the list of remaining words and were simply found with a naive random search running for a while. They are probably very far from optimal ones.

const GRAY    = 0;
const YELLOW  = 1;
const GREEN   = 2;

let stat = [];

wordList.forEach(secret => {
let hist = solve(word => guess(word, secret)),
n = hist.length;

console.log('[' + secret + '] ' + hist.join(' '));
stat[n] = -~stat[n];
});

console.log(stat.slice(1));

function solve(blackBox) {
let list = [...wordList],
hist = [],
w, j;

for(j = 1;; j++) {
let key = list.join(''),
md5 = require('crypto').createHash('md5').update(key).digest('hex').slice(0, 8);

w = list[list.length < 3 ? 0 : LOOKUP[md5]];
hist.push(w);

let res = blackBox(w);

if(res.every(c => c == GREEN)) {
break;
}
list = filter(list, w, res);
}
return hist;
}

function filter(list, w, res) {
let cnt = {};
let exact = {};
res.map((c, i) => c == GRAY ? (cnt[w[i]] = ~~cnt[w[i]], exact[w[i]] = true) : cnt[w[i]] = -~cnt[w[i]]);
let pattern = RegExp(res.map((c, i) => c == GREEN ? w[i] : '[^' + w[i] + ']').join(''));
let newList = list.filter(word => pattern.test(word));

res.forEach((_, i) => {
if(cnt[w[i]] !== undefined) {
newList = newList.filter(word => {
let n = [...word].reduce((p, c) => p += c == w[i], 0);
return exact[w[i]] ? n == cnt[w[i]] : n >= cnt[w[i]];
});
}
});
return newList;
}

function guess(word, secret) {
let misplaced = {};

[...secret].forEach((c, i) => c != word[i] ? misplaced[c] = -~misplaced[c] : 0);

return [...word].map((c, i) => c == secret[i] ? GREEN : misplaced[c] ? (misplaced[c]--, YELLOW) : GRAY);
}


Try it online!

# Python

[1, 108, 696, 952, 436, 101, 17, 4]

Of course, the fact that there are 8 elements sucks, but I'll update the answer as I improve my algorithm.

Complete code, including scoring:

# Wordle challenge.

with open('words.txt', 'r') as wordsFile:

resultsDict = {} # dictionary format is easier, but then it will be converted to a tuple.

def best(pool): # it's a function that determines the word with the "most common" letters.
best_word = ''

for _ in range(5):
best_word += max('abcdefghijklmnopqrstuvwxyz', key=lambda letter: len([w for w in pool if w.startswith(best_word + letter)]))

return best_word

def get_feedback(guess, wo): # it's a function to give feedback.
return [
'LET' * (guessed in wo) + 'POS' * (guessed in wo and guessed == actual)
for guessed, actual in zip(guess, wo)
]

for word in words:
guesses = 0
cur_words = words

while True:
cur_guess = best(cur_words) # guess the best word.
feedback = get_feedback(cur_guess, word) # "word" is only accessed here. Nowhere else.
guesses += 1
if all([x == 'LETPOS' for x in feedback]): # mission accomplished
break

# set "cur_words" to a new set based on the feedback.

cur_words = [
w
for
w
in cur_words
if
all([w[i] == cur_guess[i] for i in range(5) if feedback[i] == 'LETPOS'])
and
all([cur_guess[i] in w and w[i] != cur_guess[i] for i in range(5) if feedback[i] == 'LET'])
and
all([cur_guess[i] not in w for i in range(5) if feedback[i] == ''])
]

if resultsDict.get(guesses):
resultsDict[guesses] += 1
else:
resultsDict[guesses] = 1

print(resultsDict, words.index(word))

resultsTuple = [resultsDict.get(i) or 0 for i in range(1, max(resultsDict.keys()) + 1)]
print(resultsTuple)


All I do here is pick the word with the most common letters each time. For instance, if there are 5 words:

abcde
abcfg
aedri
bbbbb
cdegh
`

It goes through the first letters: a, a, a, b, and c. It sees that a is the most common, so it starts with a. Its guess so far is: a

Same for the second letters: as it has isolated a as the first letter, it only considers b, b, and e. (of the first three words). It sees that b is more common, so it adds b. Its guess so far is: ab

Same for third letters: abc

Fourth letter: both d and f are equally common, so in picking the maximum it just chooses the first one: d

Now the only option is e, so its guess is abcde.