tohuwabohu In technology, chaos reigns supreme.

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

  1. Getting started
  2. Making it pretty
  3. Showing progress
  4. Greetings
  5. Finishing the game
  6. Wrapping it up

Getting started

Over the next few minutes, we will

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.

Tagged as: game rust tutorial