Quordle: Strategies#

Following up from last week where I worked on adapting the Wordle game engine to also play Quordle. I wanted to take some time to see if we can play Quordle smarter. To do this, I wanted to design a few “cross-board strategies” to play Quordle with. When playing 4 simultaneous Wordle boards where each guess you make is applied to all boards, there is an important decision to make: which board to I focus my attention on for any given turn? Do I attempt to solve one board entirely before attempting to guess another? Do I sequentially rotate amongst these boards each turn? These are examples of what I mean by a “cross board strategy”

Quordle Engine Code#

To get started, let’s port over all of the Quordle Engine code from last weeks post, and check to see that it still works.

from enum import Enum
from dataclasses import dataclass

def load_words(fobj):
    wordlist = []
    for line in f:
        word = line.strip()
        conds = [
            len(word) == 5,
            word.islower(),
            word.isascii(),
            word.isalpha(),
            not all(l in 'xvci' for l in word)
        ]
        if all(conds):
            wordlist.append(word)
    return wordlist

class Check(Enum):
    CorrectPos = 42
    IsInWord   = 43
    NotInWord  = 40

@dataclass
class Status:
    letter: str
    check: Check

    def __str__(self):
        return f'\033[97;{self.check.value};1m{self.letter}\033[0m'
    
def compare(guess, unknown):
    for gl, unkl in zip(guess, unknown, strict=True):
        if gl == unkl:
            yield Status(gl, Check.CorrectPos)
        elif gl in unknown:
            yield Status(gl, Check.IsInWord)
        else:
            yield Status(gl, Check.NotInWord)

class Quordle:  
    @staticmethod
    def render(results, finished=None):
        if finished is None:
            finished = [False] * len(results)
            
        buffer = []
        for res, fin in zip(results, finished):
            if fin is True:
                buffer.append(' ' * 5)
            else:
                buffer.append(''.join(map(str, res)))
        return '\N{box drawings heavy vertical}'.join(buffer)
    
    @staticmethod
    def compare(guess, unknowns):
        return tuple([*compare(guess, unk)] for unk in unknowns)

with open('words') as f:
    wordlist = load_words(f)
unknowns = ['hello', 'world', 'tests', 'value']
finished = [False] * len(unknowns)

for guess in ['stair', 'hello', 'bound']:
    results = Quordle.compare(guess, unknowns)
    print(Quordle.render(results, finished))
    
    for i, res in enumerate(results):
        if all(r.check is Check.CorrectPos for r in res):
            finished[i] = True
stairstairstairstair
hellohellohellohelloboundboundbound

Looking good so far! If you want a discussion on how this code works please see the separate posts on the Wordle & Quordle game engines.

Gameplay Strategies - Smart Guessers#

First up, I wanted to re-implement the smart_guesser from the original Wordle game. This is a within board strategy to help us iteratively find the unknown word in a standard game of Wordle. To re-implement this strategy (which is based on a coroutine), I simply need to initiate a smart_guesser instance to track the state of each board. By doing this, I receive a feedback from all boards based on a specific guess.

The easiest way to navigate these candidates is to simply play off the first unsolved board. This enables me to test if my implementation of smart_guesser works. I had to change two prominent features of this function to work:

  1. If the guesser does not receive results tied to the word it generates, update the state but do not advance the guesser

  2. If the guesser does receive the word it generated, break the above loop and advance the word iteration

These two features are highlighted by the introduction of while (…conditions…) loop as well as the if ''.join(m.letter for m in result) == word: statements. Without these statements, the guesser naievely assumes you use the guess it generated, and the feedback (result) is directly tied to that word. These assumptions work for Wordle, but not Quordle where we have 4 guessers producing different guesses. We simply needed to add functionality to update the guesser’s state while not advancing its guesses (unless the current guess is invalidated by a set of incoming result).

Cross Board Strategy: Left to Right#

def smart_guesser(wordlist):
    bad_letters   = set()
    correct_pos   = set()
    incorrect_pos = set()
    
    for word in wordlist:
        while (
            all(l not in bad_letters for l in word)
            and all(word[pos] == l for pos, l in correct_pos)
            and all((word[pos] != l) and (l in word) for pos, l in incorrect_pos)
        ):
            result = yield word

            for i, match in enumerate(result):
                if match.check is Check.CorrectPos:
                    correct_pos.add((i, match.letter))
                elif match.check is Check.NotInWord:
                    bad_letters.add(match.letter)
                elif match.check is Check.IsInWord:
                    incorrect_pos.add((i, match.letter))
            
            if ''.join(m.letter for m in result) == word:
                break

unknowns = ['hello', 'world', 'tests', 'value']
finished = [False] * len(unknowns)

# Guesser for each board
guessers = [smart_guesser(wordlist) for _ in range(len(unknowns))]
results = [None] * len(unknowns)

for _ in range(9):
    guess_candidates = [gu.send(res) for gu, res, fin in zip(guessers, results, finished) if not fin]
    guess = guess_candidates[0] # select the first board
    
    results = Quordle.compare(guess, unknowns)
    print(Quordle.render(results, finished))
    
    for i, res in enumerate(results):
        # Update `finished` state if guess was 100% correct
        if all(r.check is Check.CorrectPos for r in res):
            finished[i] = True
            
    if all(finished):
        print('Winner!')
        break
else:
    print('You lost.')
abackabackabackaback
ddingddingddingdding
elopeelopeelopeelope
hellohellohellohelloworldworldworld
     ┃     ┃festsfests
     ┃     ┃jestsjests
     ┃     ┃teststests
     ┃     ┃     ┃value
Winner!

Looks good to me! And it impressively solved each board without exhausting all of the available turns. In the above game our “cross board strategy” was to simply play each board from left to right.

Cross Board Strategy: Random Board Selection#

Let’s now try randomly selecting a board to play from on each turn instead of simply selecting the first one.

from random import choice, seed
seed(0)

unknowns = ['hello', 'world', 'tests', 'value']
finished = [False] * len(unknowns)
guessers = [smart_guesser(wordlist) for _ in range(len(unknowns))]
results = [None] * len(unknowns)

for turn in range(9):
    guess_candidates = [gu.send(res) for gu, res, fin in zip(guessers, results, finished) if not fin]
    
    # Randomly choose a board to play off of
    guess = choice(guess_candidates)
    
    results = Quordle.compare(guess, unknowns)
    print(turn, Quordle.render(results, finished))
    
    for i, res in enumerate(results):
        if all(r.check is Check.CorrectPos for r in res):
            finished[i] = True
            
    if all(finished):
        print('Winner!')
        break
else:
    print('You lost.')
0 abackabackabackaback
1 daddydaddydaddydaddy
2 eerieeerieeerieeerie
3 feelsfeelsfeelsfeels
4 halvehalvehalvehalve
5 valuevaluevaluevalue
6 worldworldworld┃     
7 genes┃     ┃genes┃     
8 jests┃     ┃jests┃     
You lost.

As you can see this strategy didn’t play to our favor. Maybe we need some more logic behind which board we want to play off of for a given turn. Let’s examine the state of each board and see if we can choose the one with the least number of correct letters to play from. This idea should play in our favor as our guesses should be revealing more information about unknown words on each turn than our previous strategies.

Cross Board Strategy: Least Correct Board#

unknowns = ['hello', 'world', 'tests', 'value']
finished = [False] * len(unknowns)
guessers = [smart_guesser(wordlist) for _ in range(len(unknowns))]
results = [None] * len(unknowns)
use_cand = 0

for turn in range(9):
    guess_candidates = [gu.send(res) for gu, res, fin in zip(guessers, results, finished) if not fin]
    results = Quordle.compare(guess_candidates[use_cand], unknowns)
    print(turn, Quordle.render(results, finished))
    
    ncorrect = []
    for i, res in enumerate(results):
        if all(r.check is Check.CorrectPos for r in res):
            finished[i] = True
            
        if not finished[i]:
            ncorrect.append(sum(r.check is Check.CorrectPos for r in res))
            
    if all(finished):
        print('Winner!')
        break
    
    # Think: numpy.argmin but on a Python list
    #   selects the candidate board with the least number of correct letters
    use_cand = ncorrect.index(min(ncorrect))

else:
    print('You lost.')
0 abackabackabackaback
1 ddingddingddingdding
2 elopeelopeelopeelope
3 hellohellohellohello
4      ┃worldworldworld
5      ┃     ┃festsfests
6      ┃     ┃valuevalue
7      ┃     ┃jests┃     
8      ┃     ┃tests┃     
Winner!

That worked out quite well! We were able to solve the board within the allowed number of turns. Did this strategy really make a difference though? Let’s try inverting this strategy and play the board with the maximum correct letters to see if this approach works.

Cross Board Strategy: Most Correct Board#

unknowns = ['hello', 'world', 'tests', 'value']
finished = [False] * len(unknowns)
guessers = [smart_guesser(wordlist) for _ in range(len(unknowns))]
results = [None] * len(unknowns)
use_cand = 0

for turn in range(9):
    guess_candidates = [gu.send(res) for gu, res, fin in zip(guessers, results, finished) if not fin]
    results = Quordle.compare(guess_candidates[use_cand], unknowns)
    print(turn, Quordle.render(results, finished))
    
    ncorrect = []
    for i, res in enumerate(results):
        if all(r.check is Check.CorrectPos for r in res):
            finished[i] = True
            
        if not finished[i]:
            ncorrect.append(sum(r.check is Check.CorrectPos for r in res))
            
    if all(finished):
        print('Winner!')
        break
    
    # Essentially numpy.argmin but on a Python list
    #   selects the candidate board with the least number of correct letters
    use_cand = ncorrect.index(max(ncorrect))

else:
    print('You lost.')
0 abackabackabackaback
1 ddingddingddingdding
2 elopeelopeelopeelope
3 falsefalsefalsefalse
4 halvehalvehalvehalve
5 valuevaluevaluevalue
6 hellohellohello┃     
7      ┃worldworld┃     
8      ┃     ┃jests┃     
You lost.

Oh no! We weren’t able to win the game, it seems that our previous strategy indeed helped us win the game. To fully test this I’d want to do a simulation and examine win/loss ratios for each of these strategies, but we’ll save that for another week.

Wrap Up#

That’s all for today! I hope you all enjoyed some more word games and can use this strategy to enhance your own Quordle gameplay. Make sure you keep an eye out for a future post where I examine the performance of these strategies! Talk to you all later.