Winning Wordle’s Hard Mode

Jason M. Heim
17 min readJan 29, 2022

This is a follow up to A Weekend with Wordle, a retrospective I wrote after tinkering on a solver for Wordle. After sharing with friends and colleagues, someone suggested I let my solver play in “hard mode” and see what happens.

My first thought was, “what’s hard mode?!”

No idea how I missed this

Normal vs. hard mode

In normal mode you’re free to guess any word at any time, which means that you can “burn” a guess that you know will be wrong, but will give you more clues to the solution. For example, let’s say that you guess the word CHEST as your first word, and Wordle responds as follows:

In hard mode, this is trouble

Based on these clues, you might be thinking that the solution ends with “TCH”. There are other possible words, but that combination causes trouble.

The problem is that there are a lot of words that end in TCH that are still potential solutions. HATCH, PATCH, BATCH, LATCH, BUTCH, DUTCH, HUTCH, PITCH, WITCH, DITCH, HITCH, BOTCH, NOTCH, and so on are all potential solutions. You could guess one of these and hope to get lucky, but in the worst case you’ll keep missing and run out of guesses.

In normal mode, you can pick a word that does not contain “TCH” and narrow things down more quickly. A word like AUDIO will cover all four remaining vowels in one guess.

Now, we know the only vowel is an A

Now you can reasonably assume the word ends in “ATCH”. Unfortunately, there are still at least six such words: BATCH, HATCH, LATCH, MATCH, PATCH, WATCH. So we’re still in a state where random guessing from just these words could lose. Fortunately we still have four guesses left, so we can burn another word in order to narrow this down more. The leading letters on the words above are: B, H, L, M, P, W. Cover as many of these as possible with a word like BLIMP:

This narrows things down a lot

Even in this worst case where we missed every letter, we managed to eliminate four of the six words that it could be. Now, we can safely guess from HATCH or LATCH, since they’re the only two words left.

Hard mode does not let you do this! In this mode, any letters you match have to be used in subsequent guesses. In particular, any green letters have to stay in that same green spot for each guess after that.

Revisiting my “normal mode” solvers

All three of my existing solvers that worked for normal mode fail in hard mode. As noted before, I define a “valid” solution as one that can solve every game using six or fewer guesses.

Unfortunately, even my best scoring algorithm had problems. For 8 of the 2315 possible solutions, it needed seven or eight guesses. That’s a 99.7% win rate, which is pretty good, but not quite good enough.

For example, it takes eight guesses when the solution is HOVER:

First guess: TRACE
Worst game: HOVER; [["TRACE":_o__o], ["SILER":___MM], ["FEWER":_._MM], ["DUPER":___MM], ["GOMER":_M_MM], ["BORER":_M.MM], ["JOKER":_M_MM], ["HOVER":MMMMM]]
Average score: 3.61036717062635
Games with 1 guesses: 1
Games with 2 guesses: 79
Games with 3 guesses: 998
Games with 4 guesses: 1028
Games with 5 guesses: 171
Games with 6 guesses: 30
Games with 7 guesses: 6
Games with 8 guesses: 2

You can see how it gets stuck once it hits a match on “ER” at the end, and then matches the O on the second letter. There are too many words that fit this and the solver can’t burn a guess to narrow it down.

Again though, this algorithm gets pretty close, so from here on in, let’s call this the fallback algorithm, since it’s still quite good and can still be useful in certain circumstances.

Side note: my solver prints M for a match, o for a letter that’s present in another position, _ for a letter that is not in the target, and . for an “overflow” letter, where it’s present in the target word but it’s a duplicate letter that was already represented.

Clarifying the rules

Let’s back up. Before I could run any tests in hard mode, I had to replicate the way it works. The instructions say “Any revealed hints must be used in subsequent guesses.” The question is, what’s a “revealed hint”? For example:

  • Can you reuse a grey result letter? Knowing that it’s eliminated is a hint.
  • If a letter shows up yellow, can you use it again in the same spot? You know that it’s in the wrong position, which is a hint.

If you experiment with Wordle’s UI (use incognito mode to start over), you’ll find that not all hints are enforced:

This is allowed in hard mode

To be clear, in this situation it’s a very bad idea to guess HEARS right after guessing HEART. I’m only testing this so that I can get the rules right in code:

  • If a letter is green, it has to continue being green.
  • If a letter is present but elsewhere, you have to continue including it, but you don’t have to move it.
  • If a letter is absent, you can still use it again if necessary.

Adapting to hard mode

Once you understand why hard mode is hard, you can adapt accordingly. Since my fallback algorithm can’t win every game yet, it makes sense to look for pitfalls and find ways to avoid them.

The most obvious pitfall is a situation where you have four matched letters, but not enough guesses left to cover all the potential last letters.

To help make this clearer I wrote a simple loop to take the set of possible solutions and build a custom map that identified overlaps of four letters. To keep things simple I focused only on exact match overlaps. Handling four exact matches is both easy to code and fast. First, just add a method that takes a string and converts itself to a list of strings with a “wildcard” in each position:

/** 
* "Fractures" a String into five "shards", one for each index, with
* a wildcard '*' in each index. For example, convert "TEACH" into
* "*EACH", "T*ACH", "TE*CH", "TEA*H", and "TEAC*".
*/
fun String.fracture(): List<String> {
return listOf(0, 1, 2, 3, 4).map {
val chars = toCharArray()
chars[it] = '*'
String(chars)
}
}

Now you can quickly fracture every possible word and use these shards as keys in a Map<String, Set<String>>:

val collisionMap = mutableMapOf<String, MutableSet<String>>()
possibleWords.forEach { word ->
word.fracture().forEach {shard ->
collisionMap.getOrPut(shard) { mutableSetOf() }.add(word)
}
}

(Note: I’m still new to Kotlin, I’m sure there are more elegant ways to do these)

So now, given a list of possible words remaining, I can find the pitfalls. If you start with all possible words and print this map in ascending order by the size of the collected set, you’ll see that there are several sets of seven or more words that collide:

*OWER=[SOWER, POWER, LOWER, TOWER, COWER, MOWER, ROWER]
*ATCH=[HATCH, WATCH, CATCH, LATCH, PATCH, BATCH, MATCH]
SHA*E=[SHAKE, SHAME, SHADE, SHARE, SHAPE, SHALE, SHAVE]
*OUND=[POUND, ROUND, FOUND, MOUND, SOUND, BOUND, WOUND, HOUND]
*IGHT=[LIGHT, NIGHT, MIGHT, EIGHT, WIGHT, SIGHT, FIGHT, RIGHT, TIGHT]

Since we only have six guesses, using any of these words as a first guess would be guaranteed to have at least one loss.

This isn’t the whole story though. Remember that there is a whole set of words that are not possible solutions, but are allowed as guesses. Since there are also plenty of sets of six, it’s possible that one of the allowed words would collide with one of those, which would guarantee a loss, since there would only be five guesses left! So now we have to look at the sets of six words:

GRA*E=[GRADE, GRATE, GRAVE, GRACE, GRAPE, GRAZE]
*ATTY=[BATTY, PATTY, TATTY, FATTY, RATTY, CATTY]
*AUNT=[JAUNT, TAUNT, GAUNT, HAUNT, DAUNT, VAUNT]
S*ORE=[STORE, SWORE, SCORE, SPORE, SHORE, SNORE]
*ASTE=[WASTE, PASTE, CASTE, HASTE, TASTE, BASTE]
STA*E=[STALE, STAGE, STARE, STAKE, STAVE, STATE]
*ILLY=[SILLY, DILLY, FILLY, BILLY, HILLY, WILLY]

Each of these words is technically safe, since we have six guesses. However, if one of the allowed guesses that is not in this list collides with these they would guarantee a loss.

Attempt 1: Keep the same algorithm, just avoid pitfalls

Given the map of shards to their sets, we can avoid this specific type of trap by excluding these words from our potential first guess.

From there, this check can work continuously as the game progresses. Once you know the score of your first guess, the list of remaining possible solutions shrinks. Since building the collision map is cheap, I didn’t bother trying to maintain it as the game progressed, I just rebuild it after each guess, and then build two sets of shards to avoid:

/** 
* Any word that fractures into one of these shards
* should be avoided.
*/
val shardsToAvoid =
collisionMap.entries.filter {
it.value.size > guessesRemaining
}.map { it.key }.toSet()

/**
* Any word that is not in the "possible solutions"
* set and fractures into one of these shards
* should be avoided.
*/
val nonPossibleShardsToAvoid =
collisionMap.entries.filter {
it.value.size == guessesRemaining
}.map { it.key }.toSet()

After that its’ easy to add a filter that avoids troublesome words:

}.filter { word ->
word.fracture().none { shard ->
shardsToAvoid.contains(shard) ||
(nonPossibleShardsToAvoid.contains(shard) &&
!possibleWords.contains(word))
}
}

RESULTS: Fail

The word that my algorithm prefers as a first guess, TRACE, is not in this list, so that stays unchanged. However, the new filter creates causes the program to reach a no-win situation. In my case this was when it tried to solve HATCH:

Now analyzing: GameState(guesses=[["TRACE":o_oM_], ["BRACT":__oMo], ["COACT":._oMo], ["EPACT":__oMo], ["TALCS":oM_M_], ["TALCY":oM_M_]], hardMode=true), with 3 possible words remaining.
Exception in thread "main" java.util.NoSuchElementException: Collection is empty.

It’s an interesting bug. Since there are no guesses remaining, the new filter will not allow any possible word to be guessed, because every possible word will at least match with itself in the collisionMap.

I hacked in a fix where, if the filter removes all guesses, then retry with the filter disabled. This prevents crashing, allowing the game to fail more gracefully by continuing to guess until it finally wins.

Attempt 2: Same as Attempt 1 but cheat on the first guess

This is “cheating” in the sense that the algorithm isn’t picking the first guess directly, rather we can just scan the list of all allowed guesses and keep trying until one of them doesn’t fail. To get here I added some code to handle a loss more gracefully.

RESULTS: Fail (even with additional cheating!)

This might have worked, but running a full test across every possible first word was slow enough that I got bored and killed it. If you give my algorithm the first guess, it can take up to 15 seconds to test it to see if it wins every game. Since there are roughly 12,972 possible words and no parallelization (running this locally on a laptop), that adds up to many many hours of wait time. Since Wordle changes every 24 hours, I’d have to set up a distributed pipeline to process these in parallel and finish in a reasonable time.

There has to be a better way. To learn more, I revisited the worst collisions:

*OWER=[SOWER, POWER, LOWER, TOWER, COWER, MOWER, ROWER]
*ATCH=[HATCH, WATCH, CATCH, LATCH, PATCH, BATCH, MATCH]
SHA*E=[SHAKE, SHAME, SHADE, SHARE, SHAPE, SHALE, SHAVE]
*OUND=[POUND, ROUND, FOUND, MOUND, SOUND, BOUND, WOUND, HOUND]
*IGHT=[LIGHT, NIGHT, MIGHT, EIGHT, WIGHT, SIGHT, FIGHT, RIGHT, TIGHT]

I pulled out the unique letters from each set and arranged them as follows, looking for overlaps:

SPLTCMR   ->   C     LM PRST
HWCLPBM -> BC H LM P
KMDRPLV -> D KLM PR V
PRFMSBWH -> B FH M PRS W
LNMEWSFRT -> EF LMN RST W
Overlaps: 2211221451443212

In theory, I can break up collisions by guessing words that use as many letters from these sets as possible. Based on the counts above, the most common letter overlaps are M, L, P, R, and S. The only vowel is E. The only words I could think of from these letters were PERMS and SPERM.

This did not go well:

First guess: PERMS
Worst game: GOLLY; [["PERMS":_____], ["TONAL":_M__o], ["DOWLY":_M_MM], ["COALY":_M_MM], ["FONLY":_M_MM], ["COOLY":_M.MM], ["DOILY":_M_MM], ["DOYLY":_M.MM], ["LOWLY":oM_MM], ["JOLLY":_MMMM], ["HOLLY":_MMMM], ["GOLLY":MMMMM]]
Average score: 3.7321814254859613
Games with 2 guesses: 50
Games with 3 guesses: 807
Games with 4 guesses: 1222
Games with 5 guesses: 205
Games with 6 guesses: 24
Games with 7 guesses: 3
Games with 8 guesses: 1
Games with 10 guesses: 1
Games with 11 guesses: 1
Games with 12 guesses: 1
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

It lost seven games, and in one case it took 12 guesses.

I was hand-crafting the first guess based on which word would “break up” collisions the best, which is really just falling back on the a clumsy and arbitrary scoring mechanism. I burned more time trying to come up with more-and-more convoluted ways to prioritize finding these letters, but this became too tedious and frustrating.

Attempt 3: Prioritize breaking up collisions

Back to the drawing board!

It’s not enough to just avoid the biggest collisions. My best “normal mode” algorithm worked because it chooses words that do the best job of breaking up the problem space into smaller and smaller pieces. For this attempt, I looked for ways to do something similar, but with a focus on the getting the collisions sets to be smaller than the number of guesses remaining.

The goal isn’t necessarily to eliminate collision sets entirely. If we have enough guesses remaining to guess every word in a collision, then we’re safe. So the first thing we want to do is just find all the collision sets that are too big. This is easy, since we already have the code for it. We just need to make it usable as part of the analysis, not just at the start. That gives me this function:

fun findCollisions(
wordSet: Set<String>,
maxSize: Int
): Map<String, Set<String>> {
val resultMap = mutableMapOf<String, MutableSet<String>>()
wordSet.forEach { word ->
word.fracture().forEach {shard ->
resultMap.getOrPut(shard) { mutableSetOf() }.add(word)
}
}
// Filter out collision sets that are safely small,
// and while we're here, make the result immutable.
return resultMap.entries.filter {
it.value.size >= maxSize
}.map { it.key to it.value.toSet() }.toMap()
}

Now that we can use this as a general utility method, we can use it to evaluate how well each guess does with reducing the collisions. If we reach a point where all of the collision sets are “safe”, then we can switch to the fallback algorithm.

At the start of any game, our collision map looks like this:

GRA*E=[GRADE, GRATE, GRAVE, GRACE, GRAPE, GRAZE]
*ATTY=[BATTY, PATTY, TATTY, FATTY, RATTY, CATTY]
*AUNT=[JAUNT, TAUNT, GAUNT, HAUNT, DAUNT, VAUNT]
S*ORE=[STORE, SWORE, SCORE, SPORE, SHORE, SNORE]
*ASTE=[WASTE, PASTE, CASTE, HASTE, TASTE, BASTE]
STA*E=[STALE, STAGE, STARE, STAKE, STAVE, STATE]
*ILLY=[SILLY, DILLY, FILLY, BILLY, HILLY, WILLY]
*OWER=[SOWER, POWER, LOWER, TOWER, COWER, MOWER, ROWER]
*ATCH=[HATCH, WATCH, CATCH, LATCH, PATCH, BATCH, MATCH]
SHA*E=[SHAKE, SHAME, SHADE, SHARE, SHAPE, SHALE, SHAVE]
*OUND=[POUND, ROUND, FOUND, MOUND, SOUND, BOUND, WOUND, HOUND]
*IGHT=[LIGHT, NIGHT, MIGHT, EIGHT, WIGHT, SIGHT, FIGHT, RIGHT, TIGHT]
Sum of all collision set sizes: 80

So there are 80 words that have to be avoided as a first guess. The idea now is to find the word or words that give us the smallest set of word-to-avoid after the guess is done. Since each guess breaks up the problem space into smaller potential sets, we judge the guess by the worst of its results. To get here, we start by building up a map of “match score” to the set of words that all have the same match score:

// Instead of just building a set, build a map of matches -> set.
val matchMap = mutableMapOf<List<LetterMatch>, MutableSet<String>>()
possibleWords.forEach {
val matchKey = word.tryGuess(it).matches
matchMap.getOrPut(matchKey) { mutableSetOf() }.add(it)
}

Now, matchMap contains all the sets of remaining possible words after guessing word. With that, we can compute the set of collisions for each of those sets, and report the biggest count of collision words:

val maxCollisionCount = matchMap.entries.map { entry ->
// Use "guessesRemaining - 1" because matchMap was
// produced after using one guess.
val newCollisions =
findCollisions(entry.value, guessesRemaining - 1)
newCollisions.entries.sumBy { it.value.size }
}.max()

As an intermediate step, I ran this over all allowed words. The “best worst” score that it found was 7. Unfortunately, there was a 27-way tie on this score:

[CLITS, DERTH, FACTS, FRITH, HAINS, HANDS, HARDS, HARED, NOAHS, RATHS, ROWTH, SHAND, SOWTH, THANS, THAWY, THEGN, THRID, WROTH, FORTH, TRASH, FROTH, SLOTH, THIRD, WORTH, SHORT, BRASH, HEARD]

But we’re not done! We also have the fallback algorithm, which we can use to choose from this set. The fallback algorithm still ties, but the resulting set is down to just 7 words:

[FRITH, ROWTH, SOWTH, WROTH, FORTH, FROTH, WORTH]

This is still more than I’d like, but it’s small enough that I can just run over all of the guesses and see if any of them are capable of winning every game by using the two algorithms combined.

RESULTS: mixed

Getting closer! Of the seven suggested first words, six of them failed, though just barely:

Losses:
First guess: SOWTH
Worst game: BOXER; [["SOWTH":_M___], ["DORMS":_Mo__], ["COYER":_M_MM], ["ROVER":.M_MM], ["POKER":_M_MM], ["GONER":_M_MM], ["BOXER":MMMMM]]
Average score: 3.864794816414687
Games with 2 guesses: 54
Games with 3 guesses: 615
Games with 4 guesses: 1273
Games with 5 guesses: 338
Games with 6 guesses: 33
Games with 7 guesses: 2
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
First guess: WORTH
Worst game: FOYER; [["WORTH":_Mo__], ["ROBED":oM_M_], ["LORES":_MoM_], ["COMER":_M_MM], ["POKER":_M_MM], ["GONER":_M_MM], ["FOYER":MMMMM]]
Average score: 3.8475161987041036
Games with 1 guesses: 1
Games with 2 guesses: 56
Games with 3 guesses: 649
Games with 4 guesses: 1229
Games with 5 guesses: 350
Games with 6 guesses: 29
Games with 7 guesses: 1
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
First guess: WROTH
Worst game: COVER; [["WROTH":_oo__], ["MORRA":_Mo._], ["BONER":_M_MM], ["SOLER":_M_MM], ["POKER":_M_MM], ["FOYER":_M_MM], ["COVER":MMMMM]]
Average score: 3.8233261339092874
Games with 2 guesses: 50
Games with 3 guesses: 662
Games with 4 guesses: 1267
Games with 5 guesses: 320
Games with 6 guesses: 15
Games with 7 guesses: 1
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
First guess: FRITH
Worst game: JOKER; [["FRITH":_o___], ["PURLS":__o__], ["READY":oo___], ["ROWME":oM__o], ["BONER":_M_MM], ["COVER":_M_MM], ["JOKER":MMMMM]]
Average score: 3.819438444924406
Games with 2 guesses: 52
Games with 3 guesses: 661
Games with 4 guesses: 1275
Games with 5 guesses: 309
Games with 6 guesses: 16
Games with 7 guesses: 2
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
First guess: FORTH
Worst game: WAVER; [["FORTH":__o__], ["CEDER":_._MM], ["ABLER":o__MM], ["YAGER":_M_MM], ["MASER":_M_MM], ["PAPER":_M_MM], ["WAVER":MMMMM]]
Average score: 3.803887688984881
Games with 1 guesses: 1
Games with 2 guesses: 56
Games with 3 guesses: 665
Games with 4 guesses: 1296
Games with 5 guesses: 270
Games with 6 guesses: 25
Games with 7 guesses: 2
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
First guess: FROTH
Worst game: MATCH; [["FROTH":___oM], ["CHETH":o._oM], ["PITCH":__MMM], ["BUTCH":__MMM], ["CWTCH":._MMM], ["LATCH":_MMMM], ["MATCH":MMMMM]]
Average score: 3.7904967602591793
Games with 1 guesses: 1
Games with 2 guesses: 51
Games with 3 guesses: 672
Games with 4 guesses: 1326
Games with 5 guesses: 239
Games with 6 guesses: 25
Games with 7 guesses: 1
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

However, leading with ROWTH was able to win every game:

Wins:
First guess: ROWTH
Worst game: BOOBY; [["ROWTH":_M___], ["COMFY":_M__M], ["GOLDY":_M__M], ["POSEY":_M__M], ["COBBY":_MoMM], ["BOOBY":MMMMM]]
Average score: 3.857451403887689
Games with 2 guesses: 44
Games with 3 guesses: 657
Games with 4 guesses: 1228
Games with 5 guesses: 357
Games with 6 guesses: 29
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

So at this point, this is sort of a valid solution, so long as you force the first guess to be ROWTH.

The algorithm still recommends six words that ultimately fail, so to me, this is still a failure. Each failure is so close to a win, it seemed worthwhile to invest just a little more time on this to see if I could improve it just a bit more.

Attempt 4: Proactively break up even more collisions

Looking over the failures, there are some obvious patterns. There are several groupings of three letters that still trip up the algorithm: *O*ER, *A*ER, and **TCH.

For some time, I tried to figure out how to extend my approach to include three-letter matches like this. It’s tricky to reason about these. Obviously, computing the collisions on three-letter matches leads to much bigger collision sets. However, unlike four-letter matches, you are not constrained to just one new letter per guess. This makes it much more complicated to know whether a set is is actually a “trap” or if it can be managed by just being careful about guesses.

I did some testing to see what the three-letter collision sets would look like, and what stood out to me is that the big sets include combinations of large four-letter sets. For example, **TCH has 18 collisions:

**TCH=[HATCH, DUTCH, RETCH, WATCH, BUTCH, HUTCH, CATCH, PITCH, NOTCH, HITCH, FETCH, LATCH, PATCH, BOTCH, DITCH, BATCH, WITCH, MATCH]

Just looking at the first vowel, you can see some big subsets in here.

*ATCH=[HATCH, WATCH, CATCH, LATCH, PATCH, BATCH, MATCH]
*ITCH=[PITCH, HITCH, DITCH, WITCH]
*UTCH=[DUTCH, BUTCH, HUTCH]

This is one of the easier cases though. The biggest three letter collision set is *A*ER with 29 collisions:

*A*ER=[PAPER, CATER, GAMER, PAYER, PARER, BAKER, SAFER, TAMER, HATER, EATER, PALER, LAYER, LATER, TAPER, CAPER, WAGER, RACER, LAGER, GAZER, SANER, WATER, MAKER, EAGER, RARER, WAVER, BALER, WAFER, GAYER, TAKER]

Again, you can see plenty of subsets in there, e.g:

*APER=[PAPER, TAPER, CAPER]
*ATER=[CATER, HATER, EATER, LATER, WATER]
*AYER=[PAYER, LAYER, GAYER]
*AKER=[BAKER, MAKER, TAKER]

What’s missing from all this analysis is that these collisions sets are only made up of the “possible” words. There are many more allowed words than these, and none of those collisions are accounted for here.

It occurred to me here that I can make the collision breaker more aggressive simply by filtering out fewer sets when building the map of collisions to be used in scoring.

This means a one-line change to the findCollisions() method:

fun findCollisions(
wordSet: Set<String>,
maxSize: Int
): Map<String, Set<String>> {
val resultMap = mutableMapOf<String, MutableSet<String>>()
wordSet.forEach { word ->
word.fracture().forEach {shard ->
resultMap.getOrPut(shard) { mutableSetOf() }.add(word)
}
}
// Filter out collision sets that are safely small,
// and while we're here, make the result immutable.
return resultMap.entries.filter {
// HACK: reduce maxSize by 1 to be more aggressive!
it.value.size >= maxSize - 1
}.map { it.key to it.value.toSet() }.toMap()
}

RESULTS: WINNER!

After the small adjustment to the findCollisions() method, the algorithm now recommends just two starting words: CLOTS and COLTS. Both of these words can win every game:

First guess: CLOTS
Worst game: STOVE; [["CLOTS":__Moo], ["STONK":MMM__], ["STORM":MMM__], ["STOOD":MMM._], ["STOUT":MMM_.], ["STOVE":MMMMM]]
Average score: 3.838876889848812
Games with 2 guesses: 50
Games with 3 guesses: 668
Games with 4 guesses: 1214
Games with 5 guesses: 371
Games with 6 guesses: 12
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
First guess: COLTS
Worst game: FAVOR; [["COLTS":_o___], ["DINGO":____o], ["ARBOR":o._MM], ["PAYOR":_M_MM], ["MAJOR":_M_MM], ["FAVOR":MMMMM]]
Average score: 3.8211663066954644
Games with 2 guesses: 51
Games with 3 guesses: 680
Games with 4 guesses: 1231
Games with 5 guesses: 338
Games with 6 guesses: 15
=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

So there you have it. If you play hard mode and start with COLTS or CLOTS, you can win any game. The program runs end-to-end in under 2 minutes, which is fast enough for what I need.

Out of curiosity, I did try making the algorithm even more aggressive (subtract 2 from max size), which chose AROSE and ALTER as first words. Both of those words ended up losing pretty badly though, so I’d clearly gone too far.

--

--

Jason M. Heim

I write things. Software, mostly. Sometimes fiction, blogs, tweets, music, poems, but mostly, software.