Building a CLI wordle game in Rust: Part 6
Welcome to the sixth and last part of this Rust tutorial. I hope you enjoyed the ride so far, because now it’s coming to an end. In Building a CLI wordle game in Rust: Part 5 we nearly finished everything. The game can now use a sqlite database as dictionary. That allows us to implement more features. However, those are not fully implemented yet.
The “word of the day” should only be guessed once and marked as guessed
afterwards. That means issuing an UPDATE
statement on the selected row. When starting the game, we will congratulate the player for his achievement and hint
towards visiting the game again tomorrow.
Also, the import tool severely lacks eye-candy. Thankfully, there are Rust community crates to help us make it more beautiful.
Table of Contents
Getting started
Over the next few minutes, we will
- Add progress bars
- Fully complete the game logic
Making it pretty
Let’s start with the fun and colorful stuff first. Because we replaced the colored crate with console, the build will
complain about missing dependencies and functions that can’t be found. Remove the line use colored::*;
and scroll down
to the check_word
function.
Some(_index) => {
if solution_characters[i] == guessed_characters[i] {
print!("{} ", guessed_characters[i].to_string().color("green"))
} else {
print!("{} ", guessed_characters[i].to_string().color("yellow"))
}
}
Those lines need to be replaced. According to the console documentation, we
achieve this with the style
function.
Some(_index) => {
if solution_characters[i] == guessed_characters[i] {
print!("{} ", style(guessed_characters[i].to_string()).green())
} else {
print!("{} ", style(guessed_characters[i].to_string()).yellow())
}
}
Now, run cargo build
. Start the game and look if the colors suit you. When you’re finished, open import.rs
.
I’ll try to aim for the yarnish
example from indicatifs github page. We will have 2 indicators that are always shown
that represent the steps that need to be done. In the end, a line is shown displaying a sparkling “finished” message.
I’ll leave it up to you what symbols you want to display here. You can find and search for Emojis in
the Emojipedia.
I chose the bookmark 🔖 for polishing, a minidisc 💽 for indicating the import and leave the sparkle ✨ to be displayed in the end. Define them statically in the code like this:
static BOOKMARK: Emoji<'_, '_> = Emoji("🔖 ", "+");
The second parameter for Emoji is a fallback character in case of the CLI not being able to display those characters.
Because we can’t tell the size of the list we will import beforehand, a spinner should indicate the polishing
process. polish
should also count the processed lines while working. With that additional information, we then know
how many lines import
has to process and thus are able to render a real progress bar.
Go to the polish
function and implement a counter
variable. It should increment after writing a line. Change the
return value to return a Tuple wrapped in the Result
and return counter
in addition to tmp_file_name
.
fn polish(
source_path: &str,
app_language: AppLanguage
) -> Result<(String, u64), Error> {
// ...
Ok((tmp_file_name, counter))
// ...
}
Change the polish
call in the main function. Use the Tuple
to set the usual parameters for import
.
let meta_data: (String, u64) = polish(&args.source_file, app_language)?;
let counter = import(meta_data.0, dictionary)?;
In the yarnish example a duration is displayed at the and. We can do that, too, usually by subtracting two timestamps;
one determined at the start of the program and the other one at the end. But Rust provides a convenience
method elapsed
in std::time::Instant. So assign the value
of Instant::now()
to started
.
let started = Instant::now();
After that, set the two indicators you the Emojis for before.
println!(
"{} {}Polishing file...",
style("[1/2]").bold().dim(),
BOOKMARK
);
println!(
"{} {}Importing file...",
style("[2/2]").bold().dim(),
MINIDISC
);
Run cargo build
and run the importer tool with cargo run --bin import res/source_file.txt en db
. The output should
look somewhat like this.
Showing progress
Time to add the spinner. Add a new function setup_spinner
where you create
a ProgressBar
and style it. Be sure to check out the
documentation for additional settings.
fn setup_spinner() -> ProgressBar {
let progress_bar = ProgressBar::new_spinner();
progress_bar.enable_steady_tick(120);
progress_bar.set_style(ProgressStyle::default_bar()
.template("{prefix:.bold.dim} {spinner:.green} {msg}"));
progress_bar
}
This will result in an animated green spinner that can display a message next to it. I’ll leave it up to you again to style it as you please. Usually this kind of stuff is where I waste most of my time.
Go back to main.rs
and set up the ProgressBar
. Put the function call before polish and add
a std::thread::sleep for 5 seconds, or you won’t see
anything because of the small source file.
let progress_polish = setup_spinner();
progress_polish.set_message(format!("Processing {}...", &args.source_file));
let meta_data = polish(&args.source_file, app_language)?;
progress_polish.finish_with_message(
format!("Finished processing {}. Importing...", &args.source_file));
Again, test it with cargo run --bin import res/source_file.txt en db
.
Alright! Ditch the thread now. Because now the total count of the lines to import is known, import
can take a
reference &ProgressBar
and continuously call
the inc method on that. Prepare the
function and add a std::thread::sleep
for maybe a second between each line.
fn import(
tmp_file_name: String,
dictionary: Box<dyn Dictionary>,
progress_bar: &ProgressBar
) -> Result<i32, Error> {
// ...
for line_result in buf_reader.lines() {
// ...
thread::sleep(Duration::from_secs(1));
progress_bar.inc(1);
}
// ...
}
Test it with the usual command.
Of course, don’t forget to wait for the sparkle.
Whoops, seems like we forgot to call diesel migrate redo
between the tests, but that doesn’t matter. Remove
the sleep
call from the code, and we’re finished!
Greetings
Starting the game looks plain and anticlimactic. If I had not developed it myself, I wouldn’t know what to do next. The
least we can do ist print a welcome message and a short description. In main.rs
, add a new function print_welcome
that does exactly that and call it in the main
function after the arguments have been parsed. It could look like this:
fn main() -> {
// parse arguments
// ...
print_welcome();
// ...
}
// ...
fn print_welcome() {
println!(r#"
____ __ ____ ______ .______ _______ __ _______ .______ _______.
\ \ / \ / / / __ \ | _ \ | \ | | | ____| | _ \ / |
\ \/ \/ / | | | | | |_) | | .--. || | | |__ ______| |_) | | (----`
\ / | | | | | / | | | || | | __| |______| / \ \
\ /\ / | `--' | | |\ \----.| '--' || `----.| |____ | |\ \----.----) |
\__/ \__/ \______/ | _| `._____||_______/ |_______||_______| | _| `._____|_______/
Welcome! Guess today's word in 6 guesses.
_ _ _ _ _
"#)
}
A raw string literal helps to print some ASCII
art in the terminal. It does not process any escapes and comes in handy when you need to write a multi-line String
into the CLI. Let’s try how it looks.
Finishing the game
When the player correctly guessed the word, it should be marked like the column guessed
suggests. Right now the same
entry is loaded every time the player starts up the game. He should be greeted with the winning message instead and the
game should end.
Add a member named guessed
to the DictionaryEntry
.
/// Represents a dictionary entry
pub struct DictionaryEntry {
pub word: String,
pub guessed: bool
}
Extend the Dictionary
trait
by adding a function called guessed_word(&self, word_entry: DictionaryEntry)
.
For TextDictionary
, just implement the method with an empty body. It does not serve any purpose there.
In DbDictionary
, issue an UPDATE
statement in the guessed_word
implementation. Because the importer prevents
duplicates from being inserted into the database, we can assume that the word and language combination is unique.
Your implementation should look like this:
fn guessed_word(&self, word_entry: DictionaryEntry) {
match diesel::update(dictionary::dsl::dictionary
.filter(dictionary::word.eq(word_entry.word)))
.filter(dictionary::language.eq(&self.app_language.to_string()))
.set(dictionary::guessed.eq(true))
.execute(&self.conn) {
Ok(_) => {},
Err(error) => { println!("Error updating the solution.\n{}", error) }
}
}
This method is to be invoked when a full match occurs. Open main.rs
and add it after the winning notification.
fn main() -> {
// ...
if full_match {
println!("Congratulations! You won!");
dictionary.guessed_word(solution);
}
// ...
}
Back to DbDictionary
, the method get_word_of_today
matches on the guessed column and looks for an alternative in the
database. Remove the line with guessed.eq(false)
. By doing so, the already guessed word will always be returned when
starting the game on the same day, and we can handle it further in the game logic.
fn get_word_of_today(
&self,
current_day: NaiveDate
) -> Result<Option<DbDictionaryEntry>, Error> {
match dictionary::dsl::dictionary
.filter(dictionary::used_at.eq(current_day))
.filter(dictionary::language.eq(&self.app_language.to_string()))
.limit(1)
.get_result::<DbDictionaryEntry>(&self.conn)
.optional() {
// ...
}
}
}
In find_word
, make sure to set the guessed
flag at the DictionaryEntry
creation.
Some(entry) => Some(DictionaryEntry {
word: entry.word,
guessed: entry.guessed
}),
For all other occurrences, this value should be hardcoded to false. Especially in TextDictionary
where we can’t
evaluate that flag because of missing information and in import.rs
when calling create_word
.
Back to main.rs
, right after get_random_word
returned the word of the day, assure it has not been guessed.
Otherwise, deliver the player your dearest congratulations. Use the check_word
function to render the word in a green
color, just as if the player just inserted the word.
match solution_option {
None => println!("Maybe the dictionary is empty?"),
Some(solution) => {
if solution.guessed {
check_word(&solution.word, &solution.word);
println!("You won! Come back tomorrow!");
} else {
let max_attempts = 6;
// ...
}
}
}
Prepare a test data set, run cargo build
, start the game and test it. I’ll use a German word I imported just before
with the new pretty progress bars.
Wrapping it up
I’m so glad everything came together in the end. It’s a deep satisfaction if you work on something over a few weeks and
finally see the finished product. There are a few things that could be added, for example additional cli arguments that
reset the Dictionary
and maybe some more information on how to use the arguments. But you don’t need me for
accomplishing that, I gave you the tools to expand this project by yourself.
Have fun with this little game, feel free to extend it, expand it, change it to your liking. I hope you enjoyed the journey!
You can find the code at this stage on my github page.