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
- Prerequisites
- Getting started
- Extending the dictionary
- The fuzz about
struct
,trait
andself
- The fuzz about
Result
- Reading with
BufReader
- Discarding an
Error
- Rolling the dice
- Stitching everything together
- 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
- Define and implement a simple
struct
andtrait
- Read from a
File
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.
None
:get_random_word
orfind_word
had problems accessing the file and threw an errorNone
:get_random_word
had no error, but did not find any entrySome
:find_word
had no error, but did not find a word that matches the parameterSome
:get_random_word
orfind_word
successfully read the file
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