Wordle in Rust

Implementing Wordle in the console using Rust lang

I have recently been very interested in learning Rust and thought I would write some blog posts on my efforts to understand and use this language.

My first dive into Rust will be to write a console version of Wordle. It's a fairly simple implementation but it will allow me to work with many aspects of the language.

The rules of wordle are:

  1. Each day the code will randomly select a 5-letter word from the English Dictionary
  2. You have six tries to guess the word correctly
  3. If you guess the exact position of a letter in the word it will turn GREEN
  4. If you guess a letter that exists in the word of the day, but it is in the wrong position it turns ORANGE
  5. If the letter does not exist, it will stay gray

I will change the first rule to just be for the time that the program is running and there will be a fixed list of words to choose from. I might do a second blog post on implementing the time and selection of the word from the dictionary.

First, let's initialize the project by creating a new rust repo using cargo.

cargo init rust_wordle
cd rust_wordle

Next, let's open the project in VSCode and start implementing.

The first objective is to choose 5 letter word. In order to do this, we start in the Cargo.toml file. Here we have to add a crate called rand, and we do this by adding it under the dependencies.

[dependencies]
rand = "0.8.5"

Then we will switch to the main.rs file and implement a function to select a random word from a list. We will start by removing the contents of the file and replacing it with this:

use rand::seq::SliceRandom;

fn choose_word() -> String {
    let words: Vec<&str> = vec!["apple", "green", "brown", "elder"];
    let chosen_word: Option<&&str> = words.choose(&mut rand::thread_rng());
    return chosen_word.unwrap().to_string();
}

fn main() {
    let word = choose_word();
    println!("Chosen word: {}", word);
}

Here we first import the rand package. Then we create a function to select a random word from a list. This will allow us to easily modify it later, as well as test it.

Now that we have the function to choose a word we will create a test for it by adding a function with a test macro below the choose_word function.

#[test]
fn test_choose_word() {
    let word = choose_word();
    assert_eq!(word.chars().count(), 5);
}

We can use cargo test to confirm that the test is passing

Next, we will ask the user to enter their word. For this, we will start by adding the use std::io; library at the top of the main.rs file. Next, we will modify the main function to ask the user for input and print the two words.

fn main() {
    let word = choose_word();
    println!("Please enter your first word");

    let mut input = String::new();
    io::stdin()
        .read_line(&mut input)
        .expect("error: unable to read user input");

    println!("Words: {} / {}", word, input);
}

First, we will move the input selection functionality into a separate function.

fn read_one() -> String {
    let mut words = String::new();
    io::stdin()
        .read_line(&mut words)
        .ok()
        .expect("Please enter a word");
    words
}

Next, we will change the main function to loop over the user's input until we are satisfied that the input matches our criteria, at the moment just that it has to be 5 characters long.

fn main() {
    const WORD_LENGTH: usize = 5;
    let word = choose_word();

    let mut input: String;
    loop {
        println!("Please enter your first word");
        input = read_one().trim().to_lowercase();

        if input.chars().count() == WORD_LENGTH {
            break;
        }

        println!("Invalid word. Please enter a word thats 5 characters long.")
    }

    println!("Words: {} / {}", word, input);
}

Now that we have the player's input we want to match it with the chosen word character by character. This requires us to iterate through each character of the word and match it with the same position of the chosen word.

First, let's create an enum to keep track of our possible states. Enums help us avoid typos in our implementation by having fixed values for our states.

#[derive(PartialEq)]
enum CharState {
    Correct,
    Wrong,
    Exists,
}

Then we will create a struct to track the status of each character in the user's entered word.

struct CharacterMap {
    character: char,
    value: CharState,
}

In this CharacterMap we will set the correct character and one of the three states that the character can have in relation to the hidden word. Next, we implement three functions to match the two words.

fn check_word_correct(word: &str, chosen_word: &str) -> Vec<CharacterMap> {
    let mut state: Vec<CharacterMap> = Vec::new();
    for (i, c) in chosen_word.chars().enumerate() {
        let mut map = CharacterMap {
            character: c,
            value: CharState::Wrong,
        };
        if does_character_exist(c, word) {
            map.value = CharState::Exists;
            if is_position_correct(c, i, word) {
                map.value = CharState::Correct;
            }
        }
        state.push(map);
    }
    return state;
}

fn does_character_exist(char: char, word: &str) -> bool {
    return !word.find(char).is_none();
}

fn is_position_correct(char: char, index: usize, word: &str) -> bool {
    return char == word.chars().nth(index).unwrap();
}

First, we create a Vector to store the full set of characters. Then we iterate through the chosen word by getting the list of characters and we use an enumerate to receive both the index and the character. First, we assume that the character isn't found, this is our base state of the CharacterMap. Then we check first if the character even exists, and if the character was found we check if the position is correct. The two functions to check the character have been isolated so we can write tests for them at a later state. This also makes our implementation easy to read and change if we need to. Lastly, we add the check to the end of the main.rs function by adding an iteration through the vector we return from the check_word_correct function.

let correct = check_word_correct(&word, &input);
for char in correct {
    if char.value == CharState::Correct {
        println!("Character {} is correct", char.character);
    }
    if char.value == CharState::Exists {
        println!("Character {} exists", char.character);
    }
    if char.value == CharState::Wrong {
        println!("Character {} not found", char.character);
    }
}

Now that we know the state of each character we have to print the characters in the correct color for the user. For this, we will change the implementation in the main function to print a new line for each try colored according to the requirements. We will use the colored crate for coloring the characters.

Add colored = "2.0.0" to the dependencies in Cargo.toml and use colored::*; at the top of main.rs. Then change the main function like below.

fn main() {
    const WORD_LENGTH: usize = 5;
    let word = choose_word();

    let mut input: String;
    println!("Please enter your first word");
    loop {
        input = read_one().trim().to_lowercase();

        if input.chars().count() == WORD_LENGTH {
            let correct = check_word_correct(&word, &input);
            let mut isCorrect = true;
            for char in correct {
                let mut character = String::from(char.character.to_string());
                if char.value == CharState::Correct {
                    print!("{}", character.green());
                }
                if char.value == CharState::Exists {
                    isCorrect = false;
                    print!("{}", character.yellow());
                }
                if char.value == CharState::Wrong {
                    isCorrect = false;
                    print!("{}", character.truecolor(109, 109, 109));
                }
            }
            println!("");
            if isCorrect {
                println!("You win!");
                break;
            }
        } else {
            println!("Invalid word. Please enter a word thats 5 characters long.")
        }
    }
}

First, we ask the user to enter a word. Then for each entry, we check the word and print each character in the appropriate color. If the user manages to find the word we then print "You win!".

Last is to allow only 6 attempts, this requires a slight modification of the main.rs file. We will add a variable to keep track of the number of attempts and if we reach 6 then we print "You lose!".

fn main() {
    const WORD_LENGTH: usize = 5;
    let word = choose_word();

    let mut input: String;
    let mut number_of_guesses = 0;
    println!("Please enter your first word");
    loop {
        input = read_one().trim().to_lowercase();
        number_of_guesses += 1;

        if input.chars().count() == WORD_LENGTH {
            let correct = check_word_correct(&word, &input);
            let mut is_correct = true;
            for char in correct {
                let character = String::from(char.character.to_string());
                if char.value == CharState::Correct {
                    print!("{}", character.green());
                }
                if char.value == CharState::Exists {
                    is_correct = false;
                    print!("{}", character.yellow());
                }
                if char.value == CharState::Wrong {
                    is_correct = false;
                    print!("{}", character.truecolor(109, 109, 109));
                }
            }
            println!("");
            if is_correct {
                println!("You win!");
                break;
            }
            if number_of_guesses == 6 {
                println!("You lose!");
                break;
            }
        } else {
            println!("Invalid word. Please enter a word thats 5 characters long.")
        }
    }
}

You can find the final code in my repo here.