DR
Posted on January 25, 2024
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.
- I need a word list from somewhere.
- 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).
- 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
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()
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 = {}
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.
Source: Wikipedia
ANSI escape codes for creating colored text are created in the following manner.
"\27[COLORm" .. text .. "\27[0m"
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
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
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
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
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
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
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
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 inword_bank
tofalse
, 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
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!
- Gearing up for Lua (1/1/24)
- The Lua Tutorial (1/5/24)
- Lua Quick Reference (1/5/24)
Posted on January 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.