Lessons in Rust — Hangman

Jack Sim
9 min readMay 1, 2021

It’s been over year since I was first introduced to Rust as a programming language, and I’ve been meaning to write a Medium article for even longer than that…. So now I’ve decided to take the plunge and talk about one of the first projects I created in Rust, Hangman.

What is Rust?

So the first question is what is Rust and how can you get started with it? If you want to know in detail more about Rust as a language, the Rust book provides this for you. However, in short Rust is a programming language that is designed for performance and memory safety. It has a similar syntax to C++, but with the added benefit of being designed with memory safety in mind.

Setting up Rust

Again, taken straight from the Rust Book…. To install Rust on MacOS or Linux operating systems you can run the command below and follow the prompts to complete the installation.

$ curl — proto ‘=https’ — tlsv1.2 https://sh.rustup.rs -sSf | sh

Starting a project

So now that we have Rust installed and setup properly on a computer the first thing to do is create a project workspace. To do this Rust has an inbuilt package manager Cargo that will facilitate this. Simply creating the folder you want to work in:

$ mkdir ./Documents/my_first_rust_program

And then change directory into that and to initialise the Rust project run:

$ cargo init

Lets pause here and look what we’ve got out of the box after initialising the project.

  • cargo.toml: Contains details of the “package” or project that you are working on. It also lists the dependencies that the code relies on. Without listing a dependency in here when running the project there will be a horrible error!!
  • cargo.lock: An automatically generated file with the full details of packages used in the project. DO NOT EDIT THIS.
  • src/: The folder that will contain the code for the project, including a main.rs file which will be the file that is run when the code compiles.

Creating a command line game

Having gone over what Rust does for you, it’s time to get into the details of how to create a command line hangman game. The features of this will be: the ability to use custom wordlists to vary the game, select a word from the list at random, enable user guesses, validate if a letter is in the word and reveal it and to lose lives if the letter isn’t in the word.

Reading in wordlist

To allow for filenames to be given to the program when run, the first stage is to create an Arguments structure, which is a custom data type that can be reused.

pub struct Arguments {
pub flag: String,
pub filename: String,
}

To enable the structure to be created there needs to be an implementation for it. In this case the implementation will contain a function called new that will take in an array of String arguments and return our custom data structure. As we only want certain inputs to our program when it is run in the command line, the new function will also check the inputs and raise errors if they are incorrect.

impl Arguments {

pub fn new(args: &[String]) -> Result<Arguments, &'static str> {
// If there are too few or too many arguments raise an error
if args.len() < 2 {
return Err("Not enough arguments");
} else if args.len() > 3 {
return Err("Too many arguments");
}
// Having checked there are enough arguments, get the second
one from the array as this will be the flag

let f = args[1].clone();
// If the flag contains the help option and the length of
args is equal to 2 display the help menu
if f.contains("-h") || f.contains("-help") && args.len()==2{
println!("Usage:
\n\r -h or -help to show this help menu
\n\r -g or -game to play the game and specify wordlist
\n\r ==========================================
\n\r Examples:
\n\r hangman -g simple.txt
\n\r hangman -g advanced.txt");
return Err("help");
} else if f.contains("-h") || f.contains("-help") { return Err("Too many arguments, use -h or -help to
display usage guide");

} else if args.len() == 3 {
// If arguments has a length of 3 and the flag is -g
then this is a valid game and return the Arguments
data structure with these assigned
if f.contains("-g") || f.contains("-game"){
if args[2].ends_with(".txt") {
let filename = &args[2].clone();
return Ok(Arguments {flag: f, filename:
filename.to_string()});
}else {
return Err("Invalid filename; must end with
.txt");
}
} else {
return Err("Invalid syntax, use -h or -help to
display usage guide")
}
} else {
return Err("Invalid Syntax");
}
}
}

Okay, now we have a way structure to store the arguments given when the program is run and a function to check that they’re valid. Now it’s time to run this in the main script and test the outputs.

use std::env;
use std::fs;
use std::process;
fn main() {
// Collect the arguments supplied when the program is called
let args: Vec<String> = env::args().collect();
let arguments = misc::Arguments::new(&args).unwrap_or_else(
|err| {
if err.contains("help") {
// if error is help close program
process::exit(0);
} else {
// if error is not help print error then close
program
eprintln!("{} problem parsing arguments: {}",
args[0], err);
process::exit(0);
}
}
);
// Read in a wordlist file
let wordlist_raw = fs::read_to_string(arguments.filename)
.expect("Something went wrong reading the file");
let split = wordlist_raw.split("\n");
let wordlist_vec: Vec<&str> = split.collect();
}

Running the project will now allow us to see the error messages that were written in the implementation of Arguments. If the help options are used as inputs then the help menu is shown to provide users with instructions on how to run the game. Finally if the correct inputs are provided, then the file with the wordlist is read in.

Logic of hangman

Having got to the stage where the wordlist is read into the program, it’s now time to think about the logic of hangman. Firstly, a word needs to be picked at random from the wordlist. To do this Rust has a package called Rand that has built in functions to generate a random integer. Using this integer, it is possible to select a word for the list randomly each time the game is initiated.

use rand::Rng;fn main() {
....
let mut rng = rand::thread_rng();
// Select a random word from the wordlist
let index_val = rng.gen_range(0, wordlist_vec.len());
let selected_word = wordlist_vec[index_val];
}

With the word selected, now it needs to be hidden in some way, to make sure the person playing the game knows the number of letters, but cannot see the word. To do this, a mutable variable called masked_word is created to contain repeated “-” to the length of the word. Making this mutable will come in useful later on, when handling player’s guesses that are correct.

use std::iter;fn main() {
....
// Mask the string as a String of "-"
let masked_word = iter::repeat("-")
.take(selected_word.len()).collect::<String>();
}

Now that there are multiple objects to keep track of in relation to the game, it’s a good time to create a structure to store the data of the game. This structure needs to track the lives the player has remaining. Updating the lives remaining allows the correct drawing to be created in the terminal. Next the letters_guessed, which are tracked to make sure a user doesn’t submit the same letter twice. The remaining_letters can also be tracked and updated when letters are guessed. These letters are displayed to players to make sure they know what are available. The final two aspects to track each game is the masked_word and the word_to_guess.

struct Game {
lives: i32,
letters_guessed: String,
word_to_guess: String,
masked_word: String,
remaining_letters: String,
}
fn main(){
....
let mut current_game = Game {
lives: 6i32,
letters_guessed: String::from(""),
word_to_guess: String::from(selected_word),
masked_word: masked_word,
remaining_letters: String::from("a b c d e f g h i j k l m n
o p q r s t u v w x y z"),
};
}

The final thing to prepare before going into the main game loop is creating the terminal drawings for the different stages of the game. For this a function called print_hangman takes in the number of lives remaining and will print out to the terminal the appropriate drawing. Rust has built into it a match statement that execute specific code when a criteria is met. So in the case of this game each time the number of lives decreases, we can match the number to one of the drawings. To use the match statement, there must be a “catch-all” option, which will represent the starting state of the game.

fn print_hangman(lives_left: i32) {
match lives_left {
0i32 =>
{
println!(" _______ ");
println!("| | ");
println!("| XO ");
println!("| /|\\ ");
println!("| / \\ ");
println!("| ");
println!("| ");
println!("____________");
}
....
_ =>
{
println!(" ");
println!(" ");
println!(" ");
println!(" ");
println!(" O ");
println!(" /|\\ ");
println!(" / \\");
println!("____________");
}
}
}

The game loop

Simply put, the game loop is a while loop that is going to check if the conditions that end the game have not been met yet. These are: the number of lives are greater than zero and the masked word not equalling the word to guess. While these conditions are true, the game continues, when either of these conditions are false the game will end.

So what happens in the game…

Firstly the number of lives remaining is presented to the user, based on the number of lives remaining, the state of the game is printed, followed by the remaining letters and then an input to get the player’s guess.

To collect the guess, Rust’s standard package has an input/output module (std::io). This module has a function to collect inputs from the terminal. The inputs are stored as strings and the enter character is removed before the guess is processed.

fn main() {
....
// Main game loop
while current_game.lives > 0 &&
current_game.masked_word != current_game.word_to_guess {

println!("You have {} lives remaining", current_game.lives);
print_hangman::print_hangman(current_game.lives);
println!("{}", current_game.masked_word);
println!("Remaining letters to choose from: {}",
current_game.remaining_letters);
println!("Please guess a letter:");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
guess = guess.replace("\n", "");
// Clear the terminal
print!("{}[2J", 27 as char);
....

At this point the guess does not have a character limit and therefore could contain more letters than the word and defeats the point of the game. It could also be a letter that has already been guessed. Therefore before continuing with checking against the word, the input needs to be checked to ensure it is valid.

fn main() {
....
// Main game loop
while current_game.lives > 0 &&
current_game.masked_word != current_game.word_to_guess {
....
if guess.len() > 1 || guess.len() == 0 {
println!("Invalid guess, enter single character");
} else if current_game.letters_guessed.contains(&guess) {
println!("Invalid guess, you've already tried that
letter");
}
....

If both these checks are passed, then the guess can be checked against the word_to_guess. This check is in two parts, firstly checking the word contains the letter, if this is the case then the masked_word is updated to show the letters in the correct place(s). If not, then the player loses a life. In both cases the letters_guessed and the letters_remaining of the game structure are also updated to reflect the guess.

fn main() {
....
// Main game loop
while current_game.lives > 0 &&
current_game.masked_word != current_game.word_to_guess {
....
} else if current_game.word_to_guess.contains(&guess){
println!("Your guess, {}, is in the word", guess);
current_game.masked_word = String::new();
current_game.letters_guessed.push_str(&guess);

for c in current_game.word_to_guess.chars() {
if current_game.letters_guessed.contains(c){
current_game.masked_word.push(c);
} else {
current_game.masked_word.push('-');
}
}
} else {
println!("Your guess, {}, is not in the word", guess);
current_game.letters_guessed.push_str(&guess);
current_game.lives -= 1;
}
}
}

Winning or losing the game

Okay, onto the home straight now. What happens that when the criteria of the while loop are false and the game breaks out of it?

This is the final piece of logic that will check whether the player has won or lost the game. Given the game structure created earlier, all that needs to be done is check if the number of lives remaining is greater than zero. If this is the case then the player has won the game, otherwise they have no lives left and have lost.

fn main(){
....
if current_game.lives > 0 {
println!("Congratulations, you guess the word: {}",
current_game.word_to_guess);
} else {
println!("Better luck next time, the word was: {}",
current_game.word_to_guess);
}
}

Once the appropriate result has been displayed the only thing left to do is ask if the player wants to have another game.

fn main() {
....
println!("Would you like to play again? (y/n)");
let mut play_again = String::new();
io::stdin()
.read_line(&mut play_again)
.expect("Failed to read line");
if play_again.contains('y'){
main();
} else {
println!("See you again soon!");
process::exit(0);
}
}

Now all that’s left is to run the game and have some fun!!

Running the game in the command line

Conclusions

This for me was one of the first programs I’d written in Rust. For me the experience covered a wide range of concepts in Rust in a way that was fun and enjoyable. I’d coded in other languages before, so that helped with the logic, but for me coding by playing is the way to do it.

I hope that following along with this tutorial has allowed you to experience Rust for the first time and learn something new. The full code can be found in this GitHub repository.

--

--