Wordle with Python's repr

Funny ways of making Wordle is all the rage these days. There's semantle and letterle, for example.

Programmers have taken the joke in another direction - how bizarre of programming techniques can we use to make 'Wordle'? Inspired by wordlexpr, I give you:

reprdle

reprdle

Try it out if you have python installed:


$ pip install reprdle
$ python3
Type "help", "copyright", "credits" or "license" for more information.
>>> from wordle import *
>>> hello
  

... how?

I love Python, but simply programming Wordle in Python would be too appropriate of a use of the language. I wanted to find a way to really abuse Python mechanics, so I started thinking about how I would like the game to run. A user should simply be able to type a guess:


>>> crane
  

And the resulting printout should hint the user how close that word is. How to achieve this mechanic?

__repr__

Well, in the Python REPL, when you type in the name of any object, you get something back:


>>> print
<built-in function print>
  

Let's create a class called _Guess and then instantiate crane, for example:


class _Guess:
    pass

crane = _Guess()
  

Let's type out crane in the REPL, just to double check:


>>> crane
<__main__._Guess object at 0x000002B1E745BB80>
  

What we really want to be able to do is print out something more helpful than that. The printout above is actually the representation of that object. Equivalent would be to call:


>>> print(repr(crane))
<__main__._Guess object at 0x000002B1E745BB80>
  

The repr() function delegates to the __repr__() dunder method of crane. So, we should be able to:


class _Guess:
    def __repr__(self):
        return "Hello Wordle!"

crane = _Guess()
  

And if we test in the REPL:


>>> crane
Hello Wordle!
  

This gives us a way to get input from the user, and give output to the terminal!

Now, this is pretty bad in terms of how we're abusing Python's __repr__, but I assure you: We can do worse.

Programmatic object instantiation

See, the main problem with what we've come up with is that we need to write out


crane = _Guess()
house = _Guess()
hello = _Guess()
  

for every 5-letter word allowed. If that were the solution we end up with, I would not have published this package. So how can we write Python code... that writes Python code?

Enter exec().


exec('crane = _Guess()')
  

With exec, we can pass in Python code as a string and call it. We like this, because we can generate strings at runtime:


# assume we have some list_of_words
for word in list_of_words:
    exec(word + ' = _Guess()')
  

So now, we can on-the-fly generate an object for each possible word guess! We grab our list of words from some file (Spoilers, though not in order!) of Wordle options I found online:


_MODIFIED_KWLIST = [kw.lower() for kw in keyword.kwlist] + ["print"]
with open(ANSWERS_PATH, "r") as f:
    words = [w for w in f.read().splitlines() if not w in _MODIFIED_KWLIST]
  

The reason I need that _MODIFIED_KWLIST is because some of the Wordle words are valid Python reserved keywords... and the program simply wouldn't work with trying to assign those to objects. So there is a limit to how crazy we can go with this 😅.

Just for fun, we can actually create objects for all reasonable case styles (crane, Crane, and CRANE):


# assume we have some list_of_words
for word in list_of_words:
    for case in [word.lower(), word.upper(), word.capitalize()]:
        exec(case + ' = _Guess()')
  

Edit: My friend Greg pointed out that there's actually an easier way to instantiate objects from strings in Python using globals():


>>> print(globals())
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>}
  

When you type any command into python, the interpreter looks that up in the globals() dictionary. That means that these lines are all equivalent:


crane = _Guess()
exec('crane = _Guess()')
globals()['crane'] = _Guess()
  

However, this globals() method runs faster than exec(), so we'll use that

Pretty colors

One of the key features of Wordle is the pretty colors that let you know whether your letters are in the right place, wrong place but in the word, or not in the word at all. To add this in, I used the colored package for Python. We can color letters pretty easily with:


# for green
letter_styled = stylize(letter, bg("green_4"))

# for yellow
letter_styled = stylize(letter, bg("gold_3b"))
  

We can check this at __repr__ call by adding some member variables to our _Guess class:


class _Guess:
    def __init__(self, true_word: str, this_word: str) -> None:
        assert len(this_word) == _WORDLE_LENGTH
        self.this_word = this_word
        self.true_word = true_word
  

Yes, I type-annotate my personal projects - I'm not a monster.

Now, in our __repr__ function:


def __repr__(self) -> str:
    ...
    for this_letter, true_letter in zip(self.this_word, self.true_word):
        l = this_letter
        if this_letter == true_letter:
            l = stylize(this_letter, bg("green_4"))
        elif this_letter in self.true_word:
            l = stylize(this_letter, bg("gold_3b"))
        ls.append(l)
    styled_word = " ".join(ls)
    return styled_word
  

We iterate over both the chosen 'winning' word and the guess we're currently repr'ing, and if the letters in the same position match exactly, make it green. If the letter in the repr'd string exists somewhere within the winning word, make it yellow.

Edit: My friend Greg pointed out that the Wordle algorithm is a little more complicated than this, illustrated by two edge cases:

  1. A guess of 'aaabb' should be colored like this for a winning word of 'bbbaa':

This is because the number of yellow duplicate letters should not exceed the number of that letter in the winning word.

  1. A guess of 'aaaab' should be colored like this for a winning word of 'abbba':

This is because green letters also count for the above case, so we only have a 'budget' of one more yellow 'a', and that gets used in the second spot.

We can encode this by storing the number of highlights for each letter we come across:


n_matched = defaultdict(lambda: 0)
  

Then, in the for loop from before, we can check this:


if this_letter == true_letter:
    l = stylize(this_letter, bg("green_4"))
    n_matched[this_letter] += 1
elif this_letter in self.true_word and n_matched[this_letter] < self.true_word.count(this_letter):
    l = stylize(this_letter, bg("gold_3b"))
    n_matched[this_letter] += 1
  

By the way, if you're not already using defaultdicts in your code, they're amazing. Just like a regular dict, but if a lookup is not found, it generates a default value for you instead of throwing an error.

Global state

This is great (read: terrible), but it doesn't handle winning, number of guesses left, resetting, any of that. For that, we need to break another widely-accepted guideline: global state in a module is probably a bad idea.

Ok so now that that's out the window, what do I mean? At the top of the file, we'll add:


_current_guess_idx = 0
  

Each time the user calls a __repr__ function on one of our _Guess objects, we want to increment that:


def __repr__(self) -> str:
    ...
    # stuff from before
    _current_guess_idx += 1
    return styled_word
  

We'll also add a check at the beginning of our __repr__ function to see if the game is over:


def __repr__(self) -> str:
    if self.this_word == self.true_word:
        return f"You got it in {_current_guess_idx + 1} guesses."
    elif _current_guess_idx + 1 == _TOTAL_NUMBER_OF_GUESSES:
        return f"The word was {self.true_word}."
    # stuff from before
    _current_guess_idx += 1
    return styled_word
  

Finishing it up

Finally, we just need a resetting function to set everything up initially, so let's put everything together:


def _reset():
    global _previous_guesses
    _previous_guesses = ["# " * _WORDLE_LENGTH] * _TOTAL_NUMBER_OF_GUESSES
    global _current_guess_idx
    _current_guess_idx = 0
    global _generated
    selected_answer = "error"
    with open(ANSWERS_PATH, "r") as f1:
        words = [w for w in f1.read().splitlines() if not w in _MODIFIED_KWLIST]
        selected_answer = random.choice(words)

        with open(ALLOWED_PATH, "r") as f2:
            words += [w for w in f2.read().splitlines() if not w in _MODIFIED_KWLIST]
            for word in words:
                casings = [word.lower(), word.upper(), word.capitalize()]
                for case in casings:
                    try:
                        globals()[case] = _Guess(selected_answer, word.lower())
                    except:
                        ...
            _generated = True
  

This adds a few new things:

  1. We use the global keyword to modify variables defined at the global level.
  2. We define a list of previous guesses as ['#####', '#####', '#####', '#####', '#####', '#####'] - this will look nice when printed out as placeholders.
  3. We keep track of whether we've already generated the objects. If we have, we only need to update their self.true_word field with the new winning word.
  4. We use ANSWERS_PATH and ALLOWED_PATH for the list of allowed words and potential answers.

And because the fun isn't quite over, we'll go ahead and make another __repr__ class to reset the game whenever you type 'wordle':


class _Reset:
    def __init__(self) -> None:
        _reset()

    def __repr__(self) -> str:
        _reset()
        return ""

wordle = _Reset()
  

I'm having way too much fun with this.

That's it!

And that's pretty much all that's involved. The final code is around 92 lines (with a bunch of empty lines for formatting). Check it out here.