Rules of Hangman game should be well-known to everybody. The player is guessing a word or sentence letter by letter and if he manage to guess the whole secret sentence the hangman’s life is saved. Here is the step by step solution of how I created a very simple Hangman game in Rust to play in the console.
This is my first program in Rust so it very likely that the code is not faultless nor optimal. Feel free to comment if you see some errors! The whole code can be found on Github.
1. Creating a project with Cargo
As the Cargo docs say, Cargo is a build tool, which creates metadata files for your project, fetches and builds the project’s dependencies, builds the project and introduces conventions.
I wanted to keep my program simple, so first I decided that I will not use Cargo and I will invoke rustc manually. I thought it will be easier this way. It was a terrible mistake! Soon it became hard or impossible to build the project without Cargo. I needed to include a library for random and the only one obvious solution was to add it as the dependency in Cargo.toml. So here is my advice: don’t be afraid to use Cargo from the beginning, since it’s really nothing scary and you gain a lot by using it.
Here is my short tutorial of how to use Cargo:
It’s not very hard to master these three steps.
Benefits of using Cargo:
- Nice structure of folders,
- Initialized git repository with .gitignore file,
- Implemented HelloWorld as a skeleton,
- Cargo.toml which handles dependencies in an easy way. These dependencies will arrive sooner than you think. It seems to be the most important point.
Check also: Day 1 of 24 days of Rust: Cargo.
2. Input sentences
The Hangman game requires some input sentences/words which the player will be guessing. I copied some English proverbs base to text file and removed commas and other punctuation marks. Proverbs are separated with newline sign. The program reads from such prepared file line by line and chooses randomly one line as a secret line to guess. The implementation of choosing the secret line is presented and discussed below.
UPDATE: The implementation of get_random_line which is presented below is not optimal. Refer to Get random line improved for the improved solution.
2.1 Random number
To use the random feature we need to include rand crate by adding a dependency in Cargo.toml file:
The crate must be also linked (line 1) and the use declaration must be added (line 3). Now the program is able to choose the random number. The
gen_range method returns the random number from the given range. Here the range corresponds to the number of lines read from the
2.2 Reading from file
Reading the file requires the use declarations from lines 3–6. In the 17 line, two things are happening: the file input.txt is opened and the result of this operation is processed by try! macro. Then the opened file is passed to the BufReader object, which provides the methods for the line by line reading. The try! macro is a part of error handling in Rust. It does the job for the method call it wraps. Let’s take a closer look at it.
2.3 Error handling
One of the Rust main characteristics is safety, so it’s not a surprise that the error handling in Rust is quite a wide topic. In this short code snippet we already get in touch with
Result<Type> and two different ways of handling it: try! macro and unwrap/expect.
File::open method returns
Result<File> instead of just
File. When the given path does not exist it will return Result Error, otherwise it will return the Result Ok wrapping the opened file. To access the file you must do something with this Result. You can process it with match, and provide the path for both possible Results Ok and Err, like presented in Rust by Example:
Unfortunately, this solution makes your code bigger.
The try! macro handles the situation nicely, without increasing your code size. Here you can see the code with and without the usage of try!. With try! it’s much nicer, don’t you think?
expect and unwrap
File::open is not the only one call here that return the Result type. We handle the Results also in the 12 line and 23 line with methods
expect. These both methods are basically the same. You can use them for uwrapping the object from the result when you feel brave enough to ignore the Result value. The difference between
expect is that the latter one provides custom message in case of error. This approach isn’t the safest and can make your program panic! or abort. We will discuss it more in the next section.
2.4 Iterating over the lines
BufReader::lines() method returns the iterator over all the lines from this buffer. The resulted line is wrapped by Result – it’s of the type
io::Result<String>. In line 23 we just ignore the Result value by using the
unwrap method. It returns only the String object and the value of Result is forgotten. You can wonder why we don’t do the proper error handling e.g. with try! macro. It’s because I didn’t found the scenario where the Result Error is returned. Even in examples, the result value is ignored. Usually calling unwrap isn’t the best solution, but it’s not always evil.
Ownership is hard at the beginning
The important observation comes with lines 24 and 25. The loaded line is printed (24) and then appended to the vector (25). It works. But if you swap the order of line 24 and 25 (first append to vector and then print), the code won’t compile. Check it! You will get an error:
error: use of moved value: l.
I admit, I have seen such errors many times. It’s very hard to get used to this moved values. The reason for this error is so called ownership. In short words, the value becomes moved (and thus unusable) when it is passed to function directly or assigned (bound) to another object. This is quite complicated and wide topic. In many cases, you can avoid the moved values by passing them by reference.
Check also: Why Rust’s ownership/borrowing is hard.
In line 32, the random secret line is returned, wrapped by the Result Ok. That’s all that was needed for the first part of Hangman. The lines are read from the file and one line is chosen and returned.
2.5 Get random line improved
The proposed implementation of
get_random_line requires reading all sentences from the file and storing them in vector, only to access the one random line after that. It’s not very optimal and may cause problems if the sentence base is huge. It turned out that rand crate contains method which is more suitable for our needs: rand::sample. This function randomly chooses any amount of objects accessible through iterator. The improved implementation below.
3. Read user guess
Let’s start the interaction with user. His job is typing a letter repeatedly until he discovers the whole secret word.
The user input is read by function
read_guess. The whole line is stored in guess variable (I didn’t find a way to read only one char). Then the guess is trimmed, which removes any whitespaces from the line (in case the user typed the whitespaces before his guess) and then the first char is taken from resulted string. The nth() method returns the
Option<char>, because the nth value may not exist. If you know C++ the Option can remind you of boost::optional.
The basic validation is done in validate_user_guess method, which returns true if the first non-white sign was an alphabetical letter and false in all other cases. In line 21 we unwrap the resulted user_guess (we know it’s valid letter since the validate_user_guess returned true) and convert the letter to lowercase (the whole part
.to_lowercase().next().unwrap() could be skipped if you want to distinguish the case).
4. Prepare structure for game data
Let’s create a structure that keeps data defining the game state. The fields of GameData are self-explanatory, but if you have doubts refer to comments.
Also an enum is introduced for deeper validation of user input. We already have checked if user typed a letter, but this letter can be either already discovered (and this choice should be skipped), or missed (and we need to decrease the lives number), or guessed. The UserInputStatus will help us to decide on what to do with the letter.
Display the secret word
We need to print the secret word with guessed letters discovered and others hidden. For this purpose we create a function
format_masked_string, that returns a string with all undiscovered letters replaced with sign
_. It also separates the letters with spaces for nicer look. The input argument is the string to be masked, and the mask is just a string containing all discovered letters.
Putting it all together
Now we have all necessary mechanics. We only need to put it all together and enjoy the hangman game. Match expression located in lines 25-62 takes care of the proper reaction to the letter typed by the player. Match is simple switch equivalent.
The game is already playable, but the user interface is poor. Let’s make it more user friendly.
Printing the hangman
Of course we need to print the hangman. I think the code will explain himself ;).
Use some colors
For a little bit nicer output we can add color to our game. I’ve done it with the use of ansi_term crate. Now the messages to user are red, green or yellow depending on their character. This crate is very easy to use. You can save the colorful message to plain String object after calling
Clear the screen
This is the feature that I’m afraid will work only on Linux. Every time the game status is changed I redraw the hangman and all. I think it looks better if the old hangman is removed, so I clear the screen with the solution from here: davidbegin/clear-terminal.
Game is ready!
Here is the full, final source code: hangman/src/main.rs.