Part 1. Wordle (35 points)

In this part, you’re going to implement a Wordle game. Wordle is a word guessing game. A word is chosen at random from a small list of 5-letter words at the start of the game. The player has 6 chances to guess the word.

Guesses that are not valid words are rejected and do not count toward the guess total.

After each (valid) guess, the word appears with each letter colored as follows:

  1. If a letter is in the correct position, it is colored green.
  2. If a letter is in the word but in the wrong position, it is colored yellow (but see the example below).
  3. If a letter does not appear in the word, it is colored gray.

The rules given above aren’t quite precise. See this example from the New York Times’s implementation that shows four guesses.

Wordle example

The first guess was LEAST. From the colors, we know that the word contains an L, an E, and a T, but not in those locations. We also know that the word doesn’t contain an A.

The second guess was ELATE. This was a bad guess because it contained an A and we knew from the first guess that there were no As. We did learn that the E, L, and T also cannot be in those locations. Notice that the second E is gray and not yellow. This is because the word we’re guessing contains a single E and it has already been colored yellow in the word.

The third guess was TITLE. This was also a bad guess because we know that E cannot appear at the end of the word because otherwise the second guess would have a green E at the end and a gray E at the beginning. We can see that the T is in the right place but the E and L are not.

The fourth and final guess was HOTEL. This is the correct word and so all of the letters are colored green.

Your task

From inside the assignment repository, run cargo new wordle to create a new application just as you did for the guessing game in Lab 3.

Using git, move the words.rs file from the root of the assignment repo into the wordle/src/ directory. You can do this via

$ git mv words.rs wordle/src

You want to use git mv rather than mv because git mv is telling Git that the file has been moved to a new location. If you just use mv, then Git will see that a file has been deleted and that there’s a new, untracked, file. git mv is just easier.

Add the line

mod words;

to the top of main.rs. Doing so gives you access to the two public functions in words.rs: words::random_word() and words::is_word_valid(). The first uses the rand crate to pick a random word and the second returns true if the argument is a valid word.

Tip

In order to use the rand crate, you’ll need to add rand as a dependency. In Lab 3, we did this by editing the Cargo.toml file by hand. There’s actually an easier way of doing it using cargo.

$ cargo add rand

This will add the most recent version of rand as a dependency.

Note

Notice that the argument to the is_word_valid() function is a &str and not a String.

/// Returns `true` if the passed word is a valid 5-letter guess.
pub fn is_word_valid(word: &str) -> bool {
    let word = word.to_uppercase();
    WORD_LIST.contains(&word.as_str()) || ALLOWED_LIST.contains(&word.as_str())
}

As we saw in Lab 3, the &str type is a way to let us pass a reference to a string without having to make a copy of the string to pass to the function.

The is_word_valid() function doesn’t deal with the newline character that you may have read from stdin. You can use the trim() method to return a reference to the portion of the string not containing leading and trailing whitespace characters.

is_word_valid(guess.trim())

At a high level, your task is as follows.

  1. Select a secret word using words::random_word().
  2. If the user has made fewer than 6 valid guesses, prompt the user to enter a guess, otherwise print a message that the player failed to guess the secret word and what the secret word is.
  3. If the guessed word is not a valid guess, go back to step 2.
  4. Color the guessed word.
  5. Print out all of the (colored) guesses so far.
  6. If the guessed word is correct, stop, otherwise go back to step 2.

Example

Here’s an example run of the game.

Enter your guess: stern
STERN
Enter your guess: logic
STERN LOGIC
Enter your guess: reset
STERN LOGIC RESET
Enter your guess: terse
STERN LOGIC RESET TERSE

Step 2, prompting for input is a little tricky. We want to print out the prompt without a newline. Rust will, by default, buffer characters written to stdout until it sees a new line character so we’ll need to print the prompt (without a newline) and then ask Rust to flush its buffer which will cause it to be printed. Give this a shot:

#![allow(unused)]
fn main() {
// Put this line near the top of `main.rs`, under your `mod words;` line
use std::io::Write;

print!("Prompt text: "); // Notice print!(), not println!().
std::io::stdout().flush().unwrap();
}

Note

std::io::Write is a trait. Traits are similar to a Java Interface in that they specify functions that types which implement the trait must implement. Unlike in Java, to use the functions defined in a trait, you have to first inform the compiler about the trait using a use statement.

In this case, the Write trait defines a flush() function which returns a Result. Note that we call unwrap() on the result so that if it is an Err(err), the program will exit.

Click on the link at the beginning of this note to see what other functions the Write trait defines.

The remaining tricky parts are in step 4, coloring the guessed word. There are two parts to this, (a) figuring out which colors to apply; and then (b) actually creating colored text.

Let’s start with (b), creating colored text. We’re going to use the colored crate to create colored text. Take a look at the documentation. You’ll need to add colored as a dependency just as you did for rand.

As the documentation says, you need to add the line

use colored::Colorize;

with the other use statements in main.rs. The Colorize trait gives you access to some functions you can call on strings to produce colored strings.

You will need to color individual characters. Unfortunately, the char type does not implement Colorize and so it doesn’t have access to the Colorize functions like black() and on_green(). So let’s write a function that takes a char and returns a String containing the character colored with black text on a green background.

/// Colors `ch` black on green.
fn green(ch: char) -> String {
    ch.to_string()
      .black()
      .on_green()
      .to_string()
}

The first line, ch.to_string() converts the char into a String so that the colored functions can operate on it. The .black() function returns a new type representing a string of colored text. The .on_green() function returns another of the colored strings but with a green background. And finally, the second .to_string() converts the colored string into a normal String, but one containing special control characters which instruct the terminal to color the text appropriately.

Note

Strings don’t have any notion of colors. They’re little more than a mutable collection of chars (except they’re encoded using UTF-8). So how does colored text work? Well, your terminal emulator (the program that pretends to be a 1980s era hardware video terminal) reads all of the text written to standard out (and standard error) and looks for control characters. Some of those control characters let you change the color of the text or background in a limited fashion.

For example, run this in your terminal (but not in the VS Code terminal which seems to mess the colors up when both foreground and background colors are present).

$ echo -e '\033[36;45;1mHello!\033[0m Goodbye!'

The -e argument tells echo to interpret escape characters. In this case, \033 is the escape sequence for the ASCII esc character which has decimal value 27 (hex 0x1B and octal 033). The [36;45;1m says use a cyan foreground color (the 36), a magenta background color (the 45) and make it bold (the 1). The ; separate arguments and the m ends them. Take a look at Wikipedia for the supported colors.

We can do the same thing in Rust without using the colored crate.

#![allow(unused)]
fn main() {
println!("\x1B[36;45;1mHello!\x1B[0m Goodbye!");
}

Notice that I specified the esc character in hexadecimal \x1B rather than octal. (If you click the Run button, you won’t see colored text because HTML uses a completely different way to select colors. View the source code for this webpage to see how I did it for the Wordle example above, if you’re curious.)

I recommend writing similar functions yellow() and gray() which produce black on yellow and black on white. After you do that, you can combine the strings to produce multi-colored string. Here’s an example which prints ABC but in colors.

let mut s = String::new();
s.push_str(&green('A'));
s.push_str(&yellow('B'));
s.push_str(&gray('C'));
println!("{s}");

There is no on_gray() function. You’ll want to use on_white() in your implementation of gray() instead.

Notice the & being used? That’s because the String function push_str() takes a &str as an argument, that is a reference to a string, but green() and the others return a String. So just like with words::is_word_valid(), we need to use & to get a reference.

Okay, so you can get a secret word, you can prompt for input, and you can color text. So all that remains is to decide which letters for a guess should get which colors.

Here was my approach. I wrote a function

#![allow(unused)]
fn main() {
fn color_guess(guess: &str, secret: &str) -> String {
    todo!()
}
}

that takes a reference to the guess and a reference to the secret and it returns a String that is appropriately colored.

Inside color_guess, I created a mutable Vec<char> (which will hold letters from the secret that don’t match the guess) and added each letter of secret to it.

Tip

Remember, to access the individual letters of a string, we use the .chars() function which returns an iterator. E.g.,

#![allow(unused)]
fn main() {
let secret = "LEAST"; // Example secret.
for ch in secret.chars() {
    println!("{ch}");
}
}

will print each character in secret on its own line.

We can also use the .next() function on the iterator directly rather than using it in a loop.

#![allow(unused)]
fn main() {
let s = "ABC";
let mut iterator = s.chars();

iterator.next(); // returns Some('A')
iterator.next(); // returns Some('B')
iterator.next(); // returns Some('C')
iterator.next(); // returns None
}

You can use the .unwrap() function to turn a Some(x) into x.

My color_guess() creates a mutable vector and adds all the characters of secret to it. Next, it iterates over both guess and secret and for each letter in guess that is in the correct place in secret, remove one instance of that character from the vector of unmatched characters. You can use the remove() method to remove the element at a specified index from a vector.

#![allow(unused)]
fn main() {
let guess = "BLAST"; // Example guess.
let secret = "STERN"; // Example secret.
let mut secret_iter = secret.chars();
for ch in guess.chars() {
    if ch == secret_iter.next().unwrap() {
        // Remove `ch` from the vector of unmatched characters
    }
}
}

Finally, we want to iterate over both guess and secret a second time and construct our colored guess string. If the character of guess matches the character in the corresponding position of secret, then color the character green and append it to the result string.

If the character of guess does not match the corresponding character of secret, then we need to color it either yellow or gray. Iterate through the unmatched character vector and if the character from guess matches any of those, color the character yellow in the result string and remove the character from the vector of unmatched characters. Otherwise, color it gray.

That was a lot of work! Maybe play a few games.