DEV Community

Cover image for Building a Wordle Clone with Lua! 🕹
DR
DR

Posted on

Building a Wordle Clone with Lua! 🕹

Introduction

Hey there! 👋

The topic of today's post is Wordle - specifically, creating a console version of the game with our language of the month, Lua. I've created a demo and published it, along with this write-up detailing how it was built and the concepts we can learn from it.

If you're new to the 12 in 24 series, I'm learning and building projects with a new programming language every month - this month, it's the Lua scripting language. You can find source code for the projects I build in the official GitHub repository (check it out, this week's folder contains code for both this and two other bonus projects!).

With all that in mind, let's jump right in!

Preparation

I already have a Lua environment set up from the beginning of the month, but if you don't you could take a look at my introductory post where I walk through what the language is and how to install a suitable environment.

To create this program, I needed to think through several details to translate the original game to a console version.

  1. I need a word list from somewhere.
  2. The original game relies heavily on the use of color, so I need to find some way to color the text from the terminal (all white won't work).
  3. I need to set up some advanced logic to handle edge cases (repeated words, words that aren't in the dictionary, win conditions). There's a lot more to this game than meets the eye.

File Handling & Reading

To begin, I set up a basic program structure.

mkdir WordleClone
cd WordleClone

touch words.txt # Words will be added later
touch main.lua

code main.lua # Open the file in VSCode
Enter fullscreen mode Exit fullscreen mode

To fill the word list, I chose to use a Wordle list that I found on GitHub. You can use it or not, the program works regardless of the choice of word list you pick.

Next, we'll navigate to our main.lua file and process the file in the following manner.

file = io.open("words.txt", "r")

words = {}

for line in file:lines() do
   table.insert(words, string.upper(line))
end

file:close()
Enter fullscreen mode Exit fullscreen mode

This opens the file, initializes a table (words), and inserts every line (one word to one line) to our words table. Finally, the file is closed.

After this, we'll pick a word and then transform the table to a set-like form (this is to make our lives easier when we start validating words).

mystery_word = string.upper(words[math.random(#words)])

word_bank = {}

for _, value in ipairs(words) do
    word_bank[value] = true
end

-- Clear the original words array to conserve memory in use.
words = {}
Enter fullscreen mode Exit fullscreen mode

Terminal Colors

Normally text in the terminal is a very light gray (that's the default for standard output). However, there's a way we can change this - with ANSI escape codes.

These codes provide a way to change the foreground and background color of text by adding additional characters to the beginning and end of a string. In the below image, you can see the different number codes (especially 90, 92, and 93, which will be used later in the program) - how these codes are used is explained below.

ANSI escape codes

Source: Wikipedia

ANSI escape codes for creating colored text are created in the following manner.

"\27[COLORm" .. text .. "\27[0m"
Enter fullscreen mode Exit fullscreen mode

A color code itself is a string in the format \27[COLORm - where COLOR is the code found above in the table. The string above is formed when I color the text (\27COLORm), append the text (text), and append a default color code (0) to make sure the original code doesn't affect anything except the text I've specified it to.

I created a function in Lua to do just this. It takes an uncolored string and a color code, and returns the colored string (remember, it can be stored as a string because all it is is an extra padding on the front and back of the original string).

function colorText(text, color)
    return "\27[" .. (color or 0) .. "m" .. text .. "\27[0m"
end
Enter fullscreen mode Exit fullscreen mode

I also made some global variables representing the color codes, to make it easier to understand in the code itself. Any time you can avoid using magic numbers like 90 or 92, go for it.

GRAY, GREEN, YELLOW = 90, 92, 93
Enter fullscreen mode Exit fullscreen mode

Coloring Words

This is pretty heavy, so bear with me.

The classic Wordle coloring is as follows.

  • Green if the letter is in the right place in the word.
  • Yellow if the letter is in word, but in the wrong place.
  • Gray if the word does not contain the letter in any place.

I've come up with the following scheme to color words according to the pattern.

  • Iterate through the word. If the current character is the same in both the target word and the guessed word, mutate both the strings to have "-" instead of that character in that position. Finally, color the letter green and push it to a table, indexing it at the same position as it was in the string.
  • Iterate through the word again. If the current character is in both the target word and the guessed word, do the same replacement, color the letter yellow, and push it to the table at the same indexed position as it was in the string. If the check fails and the letter is not in each word, color it gray and push it in the same fashion.
  • Return the table, concatenated so that it forms a single, multicolored string.

Here's the function I came up with that accomplishes this.

function colorWord(word, target)
    str = {}
    word, target = string.upper(word), string.upper(target)

    for i = 1, #word, 1 do
        char = string.sub(word, i, i)
        
        if char == string.sub(target, i, i) then
            word = string.sub(word, 0, i - 1) .. "-" .. string.sub(word, i + 1, #word)
            target = string.sub(target, 0, i - 1) .. "-" .. string.sub(target, i + 1, #target)
            str[i] = colorText(char, GREEN)
        end
    end

    for i = 1, #word, 1 do
        char = string.sub(word, i, i)

        if string.find(target, char) ~= nil and char ~= "-" then
            word = string.sub(word, 0, i - 1) .. "-" .. string.sub(word, i + 1, #word)
            str[i] = colorText(char, YELLOW)
        elseif str[i] == nil then
            str[i] = colorText(char, GRAY)
        end
    end

    return table.concat(str, "")
end
Enter fullscreen mode Exit fullscreen mode

It's a bit convoluted, but it accomplishes the purpose (as far as I've seen). My algorithm might just be super inefficient - a possible circumstance.

Drawing the Table

Drawing the classic Wordle grid is easy - we'll create six lines based off the gameTable table and just fill the line with "-----" if there's no grid entry. Finally, we'll color the line with our colorWord function to create the actual color outputted to the terminal.

In addition to this, I also wanted to make it look good by creating a fancy text box around it. Check out the function below, which I think creates some pretty decent output.

function drawTable(refTable)
    print(" _________ ")
    print("|         |")

    for i = 1, 6, 1 do
        print("|  " .. (colorWord(refTable[i] or "-----", mystery_word)) .. "  |")
    end

    print("|_________|")

end
Enter fullscreen mode Exit fullscreen mode

Validating Guesses

To validate guesses, we have to confirm two things: that the word is five letters long, and that the word is found in our word bank of legal words. I've created another function to handle this - it's pretty straightforward and gets the job done.

function validateGuess(guess)
    return #guess == 5 and word_bank[string.upper(guess)] ~= nil
end
Enter fullscreen mode Exit fullscreen mode

Game Loop

Now it's time to actually get the game running. We'll start by initializing a few variables to keep track of the game state.

counter = 0 -- number of guesses used
gameTable = {} -- table holding all the guesses
msg = "\nWelcome to Wordle! Type a five-letter word to start." -- message displayed
Enter fullscreen mode Exit fullscreen mode

I snuck a message variable in there because we're going to use it later - I think the game makes more sense when I'm giving the user details about what they're doing (in the future, it'll display error messages for repeats and invalid words).

Here's the main loop. I'll explain bits and pieces are we go.

while counter < 6 do

    io.write("\27[2J\27[1;1H")
    drawTable(gameTable)
    print(msg)

    io.write("Enter a guess: ")
    userGuess = string.upper(io.read())

    if validateGuess(userGuess) then
        if word_bank[userGuess] then
            table.insert(gameTable, userGuess)
            word_bank[userGuess] = false
            msg = ""

            if userGuess == mystery_word then
                break
            end

            counter = counter + 1
        else
            msg = "\nYou already guessed that - try another word."
        end
    else
        msg = "\nThat's not a valid word according to the list."
    end
    
end
Enter fullscreen mode Exit fullscreen mode

While there are still guesses to go (up to six, just like in the original Wordle), we'll do a few things.

  • io.write("\27[2J\27[1;1H") clears the terminal. It's an easy way to declutter some of the hustle and bustle of the screen, and I like using it for games like this - it really smooths everything out and adds that extra sparkle to the finished product.
  • Draw the table (drawTable) and print our message.
  • Grab the user's guess, store it, and convert it to uppercase (string.upper).
  • If the guess is a valid one according to the validateGuess function, we'll check if it's contained in the word bank and if it hasn't been used before (word_bank[word] == false means that it's been used and so the current guess is a repeat - in this case, we'll issue the first error message from the top about the repeated word).
  • If all of the above is true, then we'll put the guess in gameTable, set the value in word_bank to false, and set the message to a new line (no special message required). If the guess is completely equal to the mystery chosen word, we'll break out of the loop and go straight to the end screen (more on that in a bit). If it's not completely equal, we'll increment the counter by 1.
  • If the message isn't a valid word (according to the validateGuess function), we'll issue the last error message about an invalid input.

With that, we've completed our main game loop. Let's move on to the last part of the game, creating our end screen.

End Screen

The function to write our end screen to the console takes a single variable, the guesses counter that keep track of how many guesses have been used in the game. Let's take a look.

function endGameScreen(guesses)
    io.write("\27[2J\27[1;1H")
    drawTable(gameTable)
    print("")

    if guesses >= 6 then
        print("That's too bad! The word was " .. mystery_word .. ".")
        print("Better luck next time :)")
    else
        print("That's right! The word was " .. mystery_word .. ".")
        print("It took you " .. guesses + 1 .. (guesses ~= 0 and " guesses. Nice!" or " guess. Wow!"))
    end
end
Enter fullscreen mode Exit fullscreen mode

At the start, we clear the terminal and draw the game table (drawTable). After, we move to a conditional that describes two cases - a win and a loss.

  • If the number of guesses used is greater than or equal to 6, we know the user has lost (because the guesses passed starts at 0, so 6 real guesses is equal to a value of 5). In that case, we print the mystery word and issue a consolation.
  • If the number of guesses is in the winning number (0-5), we know that the user has won and issue a celebratory statement (grammatically altered because I need it to be absolutely correct for all edge cases).

With that, the program is completed.

Code and Conclusion

I'm not going to overwhelm you with the full 100+ lines of code here, but I'll encourage you to check it out in the GitHub repository (along with code for two other mini-projects I built this month).

Stay tuned for a final project coming at the end of the month, where I'll be using the LOVE2D game engine to create a game with a GUI (should be a refreshing change from console apps)!


Enjoyed this post? Check out some others from this month!


Top comments (0)