Creating a Wordle command line clone in Rust
I've been kicking around the idea of creating a Wordle clone for a while, and today I was thinking, wouldn't it be fun to have a version of Wordle for the command line?
Before we get into how I created this step-by-step, here's the repo with my finished code: https://github.com/captainpainway/word_guessing_game
Index
- Parsing the word list
- Getting input from the player
- Comparing words
- Tracking guesses and coloring letters
- Tracking the alphabet
- Creating shareable emoji
- Build and install
Parsing the word list
To avoid any trouble I'm going to shuffle the word list so I can't be accused of stealing the exact word list. Originally I was using the NYT word list and had reverse-engineered the algorithm to get the exact word of the day, but I don't want to get into any trouble! I wrote a quick little PHP script to shuffle the words for me:
<?php
$words = ["blush", "focal", "evade"]; // ...and so on.
shuffle($words);
foreach ($words as $word) {
echo "\"$word\",";
}
I'll create a new Rust project with cargo new word_guessing_game_rust
and then cd word_guessing_game_rust
. cargo run
returns "Hello, world!" as expected.
Note that you need a version of Rust higher than 1.56 for some of this stuff to work, so be sure to rustc --version
and rustup update stable
if you need to. I'm currently using 1.60.0.
Now, I'm going to add the word list to a new file src/words.rs
. I'm adding the daily word list as a static array called WORDS
, and adding the valid word list as a static array called VALID_WORDS
. PHPStorm is a real pal and gave me the correct type and number for the array definitions.
I'll make both word lists public and import them into the main.rs
file using mod words;
pub(crate) static WORDS: [&str; 2309] = ["droll","payer","balmy"...
This will print out all the words in the words list with cargo run
:
#[allow(dead_code)]
mod words;
fn main() {
println!("{:?}", words::WORDS);
}
Now that I have the words list set up, I want to get the correct word for today. Since I'm starting with a shuffled list, I'll go ahead and set today as day number 0. My list will increment by 1 every day after today and get the next word on the list.
#[allow(dead_code)]
mod words;
use chrono::{Utc, TimeZone};
fn main() {
let word = word_of_the_day();
println!("{:?}", word);
}
fn word_of_the_day() -> String {
let start = Utc.ymd(2021, 04, 15).and_hms(0, 0, 0);
let now = Utc::now();
let duration: usize = (now - start).num_days() as usize;
return (words::WORDS)[duration].to_string();
}
Getting input from the player
Next, I want to take some input from the player, check to see if it's a five-letter word, and allow up to six guesses in a game. This loop takes an input but does no checking to see if it's the same word as the word of the day. It does check if the word is the correct length, and if not, promps the player to try again.
fn main() {
let mut guesses = 1;
let word = word_of_the_day();
loop {
if (guesses > 6) {
println!("Out of guesses! The word was {}.", word);
break;
}
println!("Guess #{:?}:", guesses);
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("Failed to read line");
let guess_len = guess.trim().chars().count() as i32;
match guess_len.cmp(&5) {
Ordering::Equal => {
guesses += 1;
},
_ => println!("Enter a 5-letter word")
}
}
}
Comparing words
The next step is to compare the input string and the word of the day and see if they're the same. I added a new function that does just that, and returns a boolean based on if the strings are equal or not. Then, I've added another match statement under the Ordering::Equal
arm of the previous match statement.
fn compare_words(word: &String, guess: String) -> bool {
if word.eq(&guess.trim()) {
return true;
}
return false;
}
Ordering::Equal => {
match compare_words(&word, guess) {
true => {
println!("You win in {} tries!", guesses);
break;
},
_ => println!("Try again")
}
guesses += 1;
},
Before I go much further, I want to make sure that I'm only considering valid words to avoid letter-checking nonsense like aeiou
. I check both word lists to see if they return a positive index for the guessed word. If both return -1, I can assume that the word isn't valid.
I've also added a .to_lowercase()
to each instance of the guessed word, in case it's typed with a capital letter.
// Check to see if the guess is in either word list.
let guess_is_in_valid_words = words::VALID_WORDS
.iter()
.position(|&x| x == guess.trim().to_lowercase())
.unwrap_or(!0) as i32;
let guess_is_in_word_list = words::WORDS
.iter()
.position(|&x| x == guess.trim().to_lowercase())
.unwrap_or(!0) as i32;
// If both checks don't return a positive index, the word is not valid.
if guess_is_in_valid_words == -1 && guess_is_in_word_list == -1 {
println!("Not a valid word.");
} else {
match guess_len.cmp(&5) {
Ordering::Equal => {
match compare_words(&word, guess) {
true => {
println!("You win in {} tries!", guesses);
break;
},
_ => println!("Try again.")
}
guesses += 1;
},
_ => println!("Enter a 5-letter word.")
}
}
Now, if the guessed word passes the checks of length and validity, I want to return the guess with each letter marked as correct and in the right place, correct and in the wrong place, and not in the word.
Tracking guesses and coloring letters
As I began working on this part, I realized that I wanted to keep track of every previous guess, so I've added a vector of guessed words to the main function outside of the loop.
let mut guessed_words: Vec<String> = Vec::new();
When I match compare_words
, in either case, true or false, I'm going to add the new guessed word to the guessed word vector, then run a new function to color the letters when I print them to the screen. I originally only pushed the word to the vector and printed it out if it was wrong, but if you win, you really want to see that word all in green!
match compare_words(&word, &guess) {
true => {
guessed_words.push(guess);
color_letters(&word, &guessed_words);
println!("You win in {} tries!", guesses);
break;
},
_ => {
guessed_words.push(guess);
color_letters(&word, &guessed_words);
}
}
The color_letters
function uses the termcolor crate. I've never used this crate before, and I felt it was a pretty easy to work with, even though I don't have as much experience writing to stdout, as opposed to just using the println!
macro.
fn color_letters(word: &String, guessed_words: &Vec<String>) -> bool {
let mut stdout = StandardStream::stdout(ColorChoice::Always);
let word_vec: Vec<&str> = word.trim().split("").filter(|x| x.len() > 0).collect();
for n in 0..guessed_words.len() {
let g = &guessed_words[n];
let guess_vec: Vec<&str> = g.trim().split("").filter(|x| x.len() > 0).collect();
for i in 0..5 {
if guess_vec[i] == word_vec[i] {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green))).unwrap();
write!(&mut stdout, "{}", guess_vec[i].to_uppercase()).unwrap();
} else if word_vec.contains(&guess_vec[i]) {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow))).unwrap();
write!(&mut stdout, "{}", guess_vec[i].to_uppercase()).unwrap();
} else {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::White))).unwrap();
write!(&mut stdout, "{}", guess_vec[i].to_uppercase()).unwrap();
}
write!(&mut stdout, " ").unwrap();
}
write!(&mut stdout, "\n").unwrap();
}
stdout.set_color(ColorSpec::new().set_fg(Some(Color::White))).unwrap();
write!(&mut stdout, "\n").unwrap();
return true;
}
You'll see that I create an &str vector from the word of the day, filtering out the empty strings at the beginning and end of the vector (Rust is weird). Then, I iterate over the guessed_words
vector, and for each word, do the same thing. Then I iterate over each character (really, an &str) in both vectors. If they match at the same index, the character is printed out in green. If word_vec
contains the character found in guess_vec
, but it's not at the same index, it's printed out in yellow. Otherwise, the character is printed out white because it doesn't occur in the word of the day.
Tracking the alphabet
I also want to add a list of letters after each guess, showing which ones have been used and which ones haven't.
I'm going add a HashMap data structure, which will hold each letter of the alphabet and the string "unused". This will also reside outside of the game loop. In the color_letters
function, as I'm comparing each letter of the guessed word, I'll update the alphabet HashMap to either be "correct" (green), "close" (yellow), or "wrong" (grey). Then, after all of the guesses are printed out, I'll print out the updated alphabet in color.
First, the top part of main.rs
, so you can see all of the crates I've added so far, and the newly-added HashMap:
mod words;
use std::io;
use std::cmp::Ordering;
use std::collections::HashMap;
use itertools::Itertools;
use chrono::{Utc, TimeZone};
use std::io::Write;
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
fn main() {
let mut guesses = 1;
let word = word_of_the_day();
let mut guessed_words: Vec<String> = Vec::new();
let mut alphabet: HashMap<String, &str> = HashMap::from([
("A".to_string(), "unused"), ("B".to_string(), "unused"), ("C".to_string(), "unused"), ("D".to_string(), "unused"),
("E".to_string(), "unused"), ("F".to_string(), "unused"), ("G".to_string(), "unused"), ("H".to_string(), "unused"),
("I".to_string(), "unused"), ("J".to_string(), "unused"), ("K".to_string(), "unused"), ("L".to_string(), "unused"),
("M".to_string(), "unused"), ("N".to_string(), "unused"), ("O".to_string(), "unused"), ("P".to_string(), "unused"),
("Q".to_string(), "unused"), ("R".to_string(), "unused"), ("S".to_string(), "unused"), ("T".to_string(), "unused"),
("U".to_string(), "unused"), ("V".to_string(), "unused"), ("W".to_string(), "unused"), ("X".to_string(), "unused"),
("Y".to_string(), "unused"), ("Z".to_string(), "unused")
]);
Then, the color_letters
function, which has gotten a little unwieldy at this point:
fn color_letters(word: &String, guessed_words: &Vec<String>, alphabet: &mut HashMap<String, &str>) -> bool {
let mut stdout = StandardStream::stdout(ColorChoice::Always);
let word_vec: Vec<&str> = word.trim().split("").filter(|x| x.len() > 0).collect();
write!(&mut stdout, "\n").unwrap();
for n in 0..guessed_words.len() {
let g = &guessed_words[n];
let guess_vec: Vec<&str> = g.trim().split("").filter(|x| x.len() > 0).collect();
for i in 0..5 {
if guess_vec[i] == word_vec[i] {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green))).unwrap();
write!(&mut stdout, "{}", guess_vec[i].to_uppercase()).unwrap();
// Update alphabet vector.
alphabet.insert(guess_vec[i].to_uppercase(), "correct");
} else if word_vec.contains(&guess_vec[i]) {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow))).unwrap();
write!(&mut stdout, "{}", guess_vec[i].to_uppercase()).unwrap();
// A correct letter should always be coded correct.
if alphabet[&guess_vec[i].to_uppercase()] != "correct" {
alphabet.insert(guess_vec[i].to_uppercase(), "close");
}
} else {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::White))).unwrap();
write!(&mut stdout, "{}", guess_vec[i].to_uppercase()).unwrap();
// Update alphabet vector.
alphabet.insert(guess_vec[i].to_uppercase(), "wrong");
}
write!(&mut stdout, " ").unwrap();
}
write!(&mut stdout, "\n").unwrap();
}
write!(&mut stdout, "\n").unwrap();
stdout.set_color(ColorSpec::new().set_fg(Some(Color::White))).unwrap();
return true;
}
And then, I've added another function called color_alphabet
that I'll only print the alphabet when there's a wrong answer:
fn color_alphabet(alphabet: & HashMap<String, &str>) -> bool {
let mut stdout = StandardStream::stdout(ColorChoice::Always);
for key in alphabet.keys().sorted() {
if alphabet[key] == "correct" {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green))).unwrap();
} else if alphabet[key] == "close" {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow))).unwrap();
} else if alphabet[key] == "wrong" {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Rgb(100, 100, 100)))).unwrap();
} else {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::White))).unwrap();
}
write!(&mut stdout, "{}", key).unwrap();
write!(&mut stdout, " ").unwrap();
}
stdout.set_color(ColorSpec::new().set_fg(Some(Color::White))).unwrap();
write!(&mut stdout, "\n\n").unwrap();
return true;
}
Finally, once the game is over, I want to print out the shareable emoji for the puzzle.
Creating shareable emoji
First, I create a mutable vector in the main function to save a string of emoji for each guessed word.
let mut emoji: Vec<String> = Vec::new();
I want to create an emoji string while comparing the word of the day and the guessed word, but I don't want to iterate over the entire guessed_words
vector like in the color_letters
function. So, I'm going to create a new function that just compares the current guessed word and the daily word, creates a string of emoji, and then pushes that to the emoji vector.
fn create_emojis(word: &String, guess: &String, emoji: &mut Vec<String>) -> bool {
let mut emoji_str = String::new();
let word_vec: Vec<&str> = word.trim().split("").filter(|x| x.len() > 0).collect();
let guess_vec: Vec<&str> = guess.trim().split("").filter(|x| x.len() > 0).collect();
for i in 0..5 {
if guess_vec[i] == word_vec[i] {
emoji_str = emoji_str.to_owned() + "🟩";
} else if word_vec.contains(&guess_vec[i]) {
emoji_str = emoji_str.to_owned() + "🟨";
} else {
emoji_str = emoji_str.to_owned() + "⬛";
}
}
emoji.push(emoji_str);
return true;
}
When adding this function to the compare_words
match, I make sure to put create_emojis
before adding the word to the guessed_words
vector, otherwise I'll get an error that the guessed word is borrowed after it's moved to the vector.
Ordering::Equal => {
match compare_words(&word, &guess) {
true => {
create_emojis(&word, &guess, &mut emoji);
guessed_words.push(guess);
color_letters(&word, &guessed_words, &mut alphabet);
println!("You win in {} tries!", guesses);
print_emojis(emoji, &day, &guesses);
break;
},
_ => {
create_emojis(&word, &guess, &mut emoji);
guessed_words.push(guess);
color_letters(&word, &guessed_words, &mut alphabet);
color_alphabet(&alphabet);
}
}
guesses += 1;
},
Finally, I create a print_emojis
function that iterates over the emoji vector and prints out each string of emoji. It also takes the day (in this case 0) and number of guesses and prints that out as well, just like the official Wordle game. If the word isn't guessed, an "X" is used instead.
fn print_emojis(emoji: Vec<String>, day: &usize, guesses: &i32) -> bool {
println!("\n");
let number_of_guesses = if *guesses > 6 {"X".to_string()} else {guesses.to_string()};
println!("NOT THAT WORD GAME {} {}/6\n", day, number_of_guesses);
for i in emoji.iter() {
println!("{}", i);
}
println!("\n");
return true;
}
Since I needed to get the day number, I ended up splitting the word_of_the_day
function into two functions, one that returns the number, and the other returns the word.
let day = day_number();
let word = word_of_the_day(&day);
...
fn day_number() -> usize {
let start = Utc.ymd(2021, 06, 19).and_hms(0, 0, 0);
let now = Utc::now();
let duration: usize = (now - start).num_days() as usize;
return duration;
}
fn word_of_the_day(day: &usize) -> String {
return (words::WORDS)[*day].to_string();
}
Build and install
The last thing I want to do is build a release version. From the root directory, I simply use cargo build --release
. The release binary is found in the /target/release/
directory. From there, I can install the binary on my system by copying it to my /usr/local/bin/
directory.
$ cargo build --release
$ cd target/release
$ sudo cp word_guessing_game_rust /usr/local/bin/wordle
Now I can simply type "wordle" into my terminal and I'm ready to play!