tohuwabohu In technology, chaos reigns supreme.

Building a CLI wordle game in Rust: Part 2


Last time we created a simple Rust CLI program that somewhat represents a wordle like game. To achieve that, we learned how to read user input from the terminal, process a String and evaluate the input against a solution. However, it would be much more interesting to have the program choose from a preferably external big pool of words instead of hardcoding the solution. For fairness, the player should also receive a message that tells him whether the word guessed in these attempts are even part of the dictionary underneath and a failed guess should not count towards his attempts.

Table of Contents

  1. Prerequisites
  2. Getting started
  3. Extending the dictionary
  4. The fuzz about struct, trait and self
  5. The fuzz about Result
  6. Reading with BufReader
  7. Discarding an Error
  8. Rolling the dice
  9. Stitching everything together
  10. Wrapping it up

Prerequisites

This part requires you to have completed the first part: Building a CLI wordle game in Rust: Part 1 with your Rust installation and IDE of your choice set up.

Additionally, we use a new package that help achieve the goals of this tutorial. Open your Cargo.toml and add the last crate shown below.

[package]
name = "fancy-hangman"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at 
# https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
colored = "2"
rand = "0.8.5"

rand is a crate that provides random number generation.

Run cargo build and wait for the process to be finished. Open main.rs in the IDE of your choice.

Getting started

Over the next few minutes, we will

Extending the dictionary

As mentioned in the intruduction, it would be nice if we had an external dictionary as the solution itself should not be part of the code but rather chosen from the program. As first step, a simple text file in the project structure should suffice. Go ahead and create new folder named res in the project. Place an empty file named dictionary.txt into it. You now have the following structure.

fancy-hangman
|- Cargo.toml
|- res
|- dictionary.txt
|- src
|- main.rs

For now think of a few words with 5 letters and put them into the file. Separate them with a newline character. For example, it could look like this:

rusty
steps
first
hasty
gusty
mushy
linux

This should be enough for us to test the program with winning and losing scenarios.

The fuzz about struct, trait and self

A struct helps us with code readability and to organize our values. Rather than just passing the dictionary_file_path into our program’s logic, we encapsulate it and use the instance of TextDictionary to propagate.

pub struct TextDictionary {
    pub dictionary_file_path: String
}

This struct can be instantiated, for example, in a function.

pub fn new(file_path: String) -> TextDictionary {
    TextDictionary { dictionary_file_path: file_path }
}

That helps us to create shared behavior by implementing a trait, allowing the implementation of other types of dictionaries later on. Maybe we are not satisfied with a simple text file later on and want the dictionary to be located in a database. A database would allow easier maintenance and additional features, but this a topic will be handled in another part of this tutorial.

For this purpose also create a struct to represent a single dictionary entry. Let’s call it DictionaryEntry. The dictionary implementation should always return a DictionaryEntry when accessing it.

pub struct DictionaryEntry {
    word: String
}

For now the dictionary is a predefined file the program only needs to read from. As mentioned in the introduction, for each program start the solution should be different. Therefore, create a get_random_word function that will choose a random line in the file. Also, the program should look into the file to check if a guessed word by the player is there, so also add a find_word function.

pub trait Dictionary {
    fn get_random_word(&self) -> Option<DictionaryEntry>;
    fn find_word(&self, text: &str) -> Option<DictionaryEntry>;
}

You may have discovered something new: The self keyword. Usually somebody would assume that the trait had implicit access to the members. Yes it does, but only in the trait implementation part. The trait itself does not know about its own implementation when declaring it. The same applies to the struct: We only can access its members over the self reference. So if we want access to those members, we need to make sure to pass a self reference to the function.

Doing so, the function becomes a method. For methods, the parameter list is converted automatically and the self parameter becomes optional when invoking it with a method call operator.

For now, just provide a rudimentary trait implementation that looks like this.

impl Dictionary for TextDictionary {
    /// Get [DictionaryEntry] from a random line of the Dictionary
    /// using reservoir sampling
    fn get_random_word(&self) -> Option<DictionaryEntry> {
        None
    }

    /// Search the Dictionary for a specific [DictionaryEntry]
    fn find_word(&self, text: &str) -> Option<DictionaryEntry> {
        None
    }
}

Maybe you remember Option from Part 1 where we just evaluated a function’s return value. Now an own implementation that returns an Option becomes necessary for us to cover the following cases.

The fuzz about Result

The Rust File API allows easy access to a given file path. The file path we need is a member of the TextDictionary implementation we created before. If you paid attention, you know that find_word has access to it by calling &self.dictionary_file_path. A simple file access code snippet in Rust can look like this.

use std::fs::File;
use std::io::{Error, Result};

// ...
fn find_word(&self, text: &str) -> Option<DictionaryEntry> {
    let file_result: Result<File> = File::open(&self.dictionary_file_path);

    match file_result {
        Ok(file) => None,
        Err(error) => None
    }
         
}

// ...

The open function does not directly return reference to the File, but rather a Result<File>. More specifically, the function returns a std::io::Result which is not to be confused with std::Result. In either way, a Result needs to be either unwrapped with unwrap() or processed by an exhaustive match control workflow struct, just like Option needs to be.

I recommend choosing the latter. unwrap() means that the program can panic if an Error has been returned, preventing us from recovering. The game would end instantly in our case. Use the error to print an error message and return None.

Err(error) => {
    println!("Error when looking for '{}' in the dictionary:\n{}",
        text, error);

    None
}

Reading with BufReader

Because our words in the text file are separated with new line characters, the best solution is to use a BufReader . BufReader provides a lines() function that allows iterating over the file entries, thus makes it possible for us to compare those with our given player guess.

Create a BufReader from the opened file. Use an Iterator Loop to check the single lines with the text parameter of the find_word method. The return value is expected to be an Option<DictionaryEntry>, so declare a word_option variable with value None. If one of the entries matches the text parameter, set word_option to Some and wrap a DictionaryEntry into it.

use std::fs::File;
use std::io::{Error, Result};

// ...
    /// Search the dictionary for a specific [DictionaryEntry]
    fn find_word(&self, text: &str) -> Option<DictionaryEntry> {
        let file_result: Result<File> 
            = File::open(&self.dictionary_file_path);
    
        match file_result {
            Ok(file) => {
                let buf_reader = BufReader::new(file);
                let mut word_option: Option<DictionaryEntry> = None;
    
                for line_result in buf_reader.lines() {
                    let line = line_result.unwrap();
    
                    if text.eq(line.trim()) {
                        word_option = Some(DictionaryEntry { 
                            word: String::from(line)
                        });
    
                        break;
                    }
                }
    
                word_option
            }
            Err(error) => {
                println!("Error when looking for '{}' in the dictionary:\n{}",
                    text, error);
    
                None
            }
        }
    }
// ...

Discarding an Error

In some cases, the std::io::Error part of a std:io::Result can be discarded with unwrap().

for line_result in buf_reader.lines() {
    let line = line_result.unwrap();

    // ...
}

`buf_reader.lines() returns an Iterator, the i/o will be handled somewhere internally and is highly unlikely to fail due to overflows. Still, it allows us to implement a recovery mechanism but that’s unnecessary in my eyes for this use case.

Rolling the dice

Now that we prepared the first part of our change in game logic, allowing the user from an allegedly failed attempt, it’s time to start with the second part: Randomly selecting a solution from the dictionary. Jump to get_random_word in TextDictionary and create a BufReader like you did before.

use std::fs::File;
use std::io::{Error, Result};
use rand::seq::IteratorRandom;

// ...
    /// Get [DictionaryEntry] from a random line of the Dictionary
    /// using reservoir sampling
    fn get_random_word(&self) -> Option<DictionaryEntry> {
        let file_result = File::open(&self.dictionary_file_path);

        match file_result {
            Ok(file) => {
                let buf_reader = BufReader::new(file);
        
                None
            }
            Err(e) => {
                println!("Error reading from the dictionary:\n{}", e);
                None
            }
        }
    }
// ...

Maybe you noticed a hint to the implementation details of this method. Because the text file underneath the TextDictionary could be very large, it would be unwise to load the whole file into the memory. Reservoir sampling is a family of algorithms the choose method uses to do prevent that. This way, just the lines are being read, but no Vec needs to be created thus saving memory.

That conveniently narrows down the Ok part of our match construct to a few lines.

Ok(file) => {
    let random_line = buf_reader.lines().choose(&mut rand::thread_rng());

    match random_line {
        Some(line) => Some(DictionaryEntry { word: line.unwrap() }),
        None => None
    }
}

And the dice are ready to be rolled.

Stitching everything together

The program now has its first Dictionary implementation TextDictionary that provides the desired functionality I described in the introduction. It’s time for the exciting part where this new functionality is added to the program. Scroll to the main() function.

fn main() {    
    let solution: String = String::from("gusty").to_lowercase();
    let max_attempts = 6;

    let mut full_match: bool = false;

    let mut counter = 0;
    while counter < max_attempts {
        let attempt: String = read_input(5);

        let guesses = max_attempts - counter - 1;

        full_match = check_word(&solution, &attempt);

        if full_match == true {
            break;
        } else {
            if guesses > 1 {
                println!("You now have {} guesses.", guesses);
            } else {
                println!("This is your last guess.");
            }
        }

        if guesses == 0 { println!("Better luck next time!") }

        counter += 1;
    }

    if full_match == true {
        println!("Congratulations! You won!");
    }
}

Somehow we need to get rid of the marked lines. solution should come from the get_random_word implementation and before guesses gets decremented, find_word should be called and the return value checked accordingly. That means, the first line of the function should create a TextDictionary instance and the second one should invoke the get_random_word method. The return value should be processed with a match construct, where the Some part handles the game logic. None could be returned because i/o errors or if no entry has been found.

fn main() {
    let dictionary = TextDictionary::new(String::from("res/dictionary.txt"));
    let solution_option = dictionary.get_random_word();
    
    match solution_option {
        None => println!("Maybe the dictionary is empty?"),
        Some(solution) => {
            // game logic
        }
    }
}

The program now initially selects the solution. Put the existing game logic into the Some(solution) arm. To avoid incrementation when a word does not exist, bind the counter increment to the Some arm of the dictionary.find_word(&attempt) match construct. In this case the wrapped value can be discarded with _ because only the presence of the word is relevant.

fn main() {
    let dictionary = TextDictionary::new(String::from("res/dictionary.txt"));
    let solution_option = dictionary.get_random_word();

    match solution_option {
        None => println!("Maybe the dictionary is empty?"),
        Some(solution) => {
            let max_attempts = 6;
            let mut full_match: bool = false;
            let mut counter = 0;

            while counter < max_attempts {
                let attempt: String = read_input(5);

                match dictionary.find_word(&attempt) {
                    Some(_) => {
                        let guesses: i32 = max_attempts - counter - 1;
                        full_match = check_word(&solution.word, &attempt);

                        if full_match == true {
                            break;
                        } else {
                            if guesses > 1 {
                                println!("You now have {} guesses.", guesses);
                            } else {
                                println!("This is your last guess.");
                            }
                        }

                        if guesses == 0 { println!("Better luck next time!") }

                        counter += 1;
                    },
                    None => println!("The guessed word is not in the word list.")
                }
            }

            if full_match == true {
                println!("Congratulations! You won!");
            }
        }
    }
}

Finally, we can feast on the fruits of our work. Run cargo build and cargo run to start the game.

Wrapping it up

This is the end of part 2. We added improvements for the player so that only words in the dictionary count towards the attempts and added a random solution selection. However, we still have problems like user input containing special characters and what about localization? What if we change anything and it breaks? Those topics will be covered in part 3 where we will write unit tests and rework String sanity after refactoring the project.

You can find the code at this stage on my github page

Tagged as: game rust tutorial