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:
- If a letter is in the correct position, it is colored green.
- If a letter is in the word but in the wrong position, it is colored yellow (but see the example below).
- 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.
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.
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.
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.
- Select a secret word using
words::random_word()
. - 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.
- If the guessed word is not a valid guess, go back to step 2.
- Color the guessed word.
- Print out all of the (colored) guesses so far.
- If the guessed word is correct, stop, otherwise go back to step 2.
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(); }
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.
String
s don’t have any notion of colors. They’re little more than a mutable
collection of char
s (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.
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.