Yes! You have read it right; we are going to write our first game using just the basic knowledge covered in the last 4 articles. So let's begin.
β οΈ Remember!
You can find all the code snippets for this series in its accompanying repo
If you don't want to install Rust locally, you can play with all the code of this series in the official Rust Playground that can be found on its official page. But for this one, you need acargo
installation on your machine (or a VM).
β οΈβ οΈ The articles in this series are loosely following the contents of "The Rust Programming Language, 2nd Edition" by Steve Klabnik and Carol Nichols in a way that reflects my understanding from a Python developer's perspective.
β I try to publish a new article every week (maybe more if the Rust gods π are generous π) so stay tuned π. I'll be posting "new articles updates" on my LinkedIn and Twitter.
Table of Contents:
- The game description and requirements
- Create a new project
- Processing user input
- Using external crates
- Generate a secret number
- Guess and secret number comparison
- Run the game continuously
- Handling invalid input and properly quit the game
- Finish up and release the game
The game description and requirements:
"Guess the Number" is a simple guessing game where the game generates a secret number
and the user tries to guess
it.
The game should tell the user if his/her guess is too big
or too small
and the user wins and the game quit if he/she guesses the secret number.
User input should be checked for invalid non-numeric values and warn the user about that.
The number of the users tries should be tracked and shown when the user wins.
The user can quit the game by typing quit
.
The game should run continuously until either the user wins or quits.
Create a new project:
Like we've learned before, type the following to create a new guess_the_number
project and change directory (cd) into it:
cargo new guess_the_number
cd guess_the_number
I'd like you to have a look at cargo.toml
file that was generated for you with the new project:
# cargo.toml
[package]
name = "guess_the_number"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
For now, let's just say that cargo.toml
serves as a place to describe your app and to list its dependencies (none for now). Think of it like the Python's requirements.txt
that's used with pip
like this pip install -r requiremts.txt
but with more details.
And like always, cargo
has generated a "Hello, World" main function for us to start with.
Processing user input:
Our first order of business is to get what the user enters from stdin. Type the following:
use std::io;
fn main() {
println!("Welcome to: GUESS THE NUMBER game!");
println!("Please input your guess ...");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Unable to parse input!");
println!("Your guess is {guess}")
}
This is the first time in the series that we "import libraries" from the app. We do that with the use
keyword. Here we imported the io
library from the standard library called std
.
use std::io;
Next, we need some place to hold the user's guess. Therefore, we have defined the guess
variable:
let mut guess = String::new();
The mut
keyword is not new but the String::new()
is. Because we don't know what the user will write so guess
can't be a string literal (remember? String literals are hardcoded in the code and known at compile time). We instead use the String
type and it associate new
function which creates an "empty" place in memory that can have unknown size.
β οΈ more on String type in the next article π
After that, we use the io
library that we've imported and call its stdin()
function that returns a "handle" to the StdIn that has a read_line
function that reads the StdIn. What's interesting is what we pass to the read_line
function, we pass a "mutable reference" to the guess
variable so the function can change its value (mutable) but doesn't take ownership over it (reference).
π¦ New Rust Terminology: "ownership" is basically what makes Rust memory safety. I'll discuss it in detail in the next article but for now know that variables can't switch scopes back and forth in Rust by default. Here is the "main" scope and "read_line" function scope.
The last new part in this piece of code is the .expect("Unable to parse input!")
. This is here because read_line
doesn't return a value, it returns a Result
enum (enumeration) which in this case has two variants (think of variants as values), Ok
and Err
. By default, if Result
is Ok
, the value that the user has entered is returned. Else if Result
is Err
, the program "panics" showing the defined error message.
Go ahead and type cargo run
and validate that what you enter is printed out.
Using external crates:
Now we need a "random" secret number for the user to guess. This time we will need and external "crate" called rand
.
π¦ New Rust Terminology: "crate" is Python's packages equivalent for Rust. Visit crates.io to see all Rust's available crates.
To use crates in your apps, you list them in the cargo.toml
file we talked about earlier.
[package]
name = "guess_the_number"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rand = "0.8.5"
We will discuss Rust dependencies in a lot more detail in later articles. For now, know that you use cargo.toml
to list your dependencies. Run your app now, this time you will see the following "crates" being downloaded and installed:
cargo
knew your dependencies for cargo.toml
and if you run your app again, you will notice that nothing is downloaded or installed again. Another thing worth noting is the cargo.lock
file which resides in the project's root directory. cargo
create this file to "lock" dependencies version to ensure consistent builds. If cargo
finds cargo.lock
, then it's used for dependencies listing instead of cargo.toml
.
Generate a secret number:
Now we use the rand
crate that we listed in cargo.toml
like we did with the io
library earlier. Our code becomes this:
use rand::Rng;
use std::io;
fn main() {
println!("Welcome to: GUESS THE NUMBER game!");
// Generate secret number
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is {secret_number}");
println!("Please input your guess ...");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Unable to parse input!");
println!("Your guess is {guess}");
}
The details of how the rand
crate and its components work aren't important right now. All you need to know is it's used to generate a random number from 1 to 100 (inclusive) as denoted by the "=" before the high end in the range expression 1..=100
.
Run the code and you will see a different secret number each time.
Guess and secret number comparison:
We have the user's guess
and the secret_number
now, we need to compare them. In order to do that, we will import yet another standard library component called Ordering
which is an enum
with variants Less
, Greater
and Equal
and we will get a glimpse at Rust's "pattern matching" using the match
keyword.
But in Rust in order to compare variables, they must be of the same type. Till now in our code, guess
is of String
type and secret_number
is of i32
implicitly (default integer type) therefore any comparison between the two will result in the app panicing. In order to remedy that, we will have to make some changes with how we parse the guess
variable:
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Welcome to: GUESS THE NUMBER game!");
// Generate secret number
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is {secret_number}");
println!("Please input your guess ...");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Unable to parse input!");
// Shadowing
let guess: u32 = guess
.trim()
.parse()
.expect("Please input a number between 1 and 100");
println!("Your guess is {guess}");
// Pattern matching
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small"),
Ordering::Greater => println!("Too big"),
Ordering::Equal => println!("You win!"),
}
}
You will notice that we re-defined guess
again. But this time as a u32
type. This is called "Shadowing" in Rust.
π¦ New Rust Terminology: "shadowing" is when you redefine variables inside the same scope in Rust. You can change the variable's type and mutability using shadowing. The old value is destroyed thought!
Now guess
is u32
and secret_number
is infered by the Rust compiler as u32
too and we can compare them (this is allowed in Rust). We used the match
keyword to do pattern matching as we pass a reference to secret_number
(Rememeber the read_line
function and ownership?) to the cmp
function associate on u32
type then the rest is self-explanatory π.
Run the code and try to get the three prints in the match
block.
Run the game continuously:
As you may have noticed, the app quits at any entered value and the game doesn't continue requesting the user's guess if he/she has given the wrong guess. To fix that, we will use loop
and "break" the loop if the user has guessed right. Also, it's now a good time to track the user's tries count:
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Welcome to: GUESS THE NUMBER game!");
// Generate secret number
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is {secret_number}");
let mut tries = 0;
loop {
println!("Please input your guess ...");
let mut guess = String::new();
tries += 1;
io::stdin()
.read_line(&mut guess)
.expect("Unable to parse input!");
// Shadowing
let guess: u32 = guess
.trim()
.parse()
.expect("Please input a number between 1 and 100");
println!("Your guess is {guess}");
// Pattern matching
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small"),
Ordering::Greater => println!("Too big"),
Ordering::Equal => {
println!("You win! Took you {tries} tries to guess the secret number!");
break;
}
}
}
}
Run the code now and validate that the app quits if the user has guessed right and the number of tries is shown.
Handling invalid input and properly quit the game:
The game is now almost complete. We just have to do some user experience enhancements like handling invalid user input (non-numeric) and introduce a quitting mechanism if the user typed quit
.
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Welcome to: GUESS THE NUMBER game!");
// Generate secret number
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is {secret_number}");
let mut tries = 0;
loop {
println!("Please input your guess ...");
let mut guess = String::new();
tries += 1;
io::stdin()
.read_line(&mut guess)
.expect("Unable to parse input!");
// If the user input "quit", the game quits.
if guess.trim().to_lowercase() == "quit" {
break;
}
// Shadowing
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Please input a number between 1 and 100");
continue;
}
};
println!("Your guess is {guess}");
// Pattern matching
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small"),
Ordering::Greater => println!("Too big"),
Ordering::Equal => {
println!("You win! Took you {tries} tries to guess the secret number!");
break;
}
}
}
}
For the quit
part, we are using a simple if
to check on guess
after reading the stdin line. And we've modified the shadowing section of guess
to use pattern matching too. If the Result
enum returns Ok
, return the number. Else if it returns, Err
, print a warning message and continue the loop.
Finish up and release the game:
To finish up, we will just delete the line where the secret number is printed so the complete code will be:
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Welcome to: GUESS THE NUMBER game!");
// Generate secret number
let secret_number = rand::thread_rng().gen_range(1..=100);
let mut tries = 0;
loop {
println!("Please input your guess ...");
let mut guess = String::new();
tries += 1;
io::stdin()
.read_line(&mut guess)
.expect("Unable to parse input!");
// If the user input "quit", the game quits.
if guess.trim().to_lowercase() == "quit" {
break;
}
// Shadowing
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Please input a number between 1 and 100");
continue;
}
};
println!("Your guess is {guess}");
// Pattern matching
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small"),
Ordering::Greater => println!("Too big"),
Ordering::Equal => {
println!("You win! Took you {tries} tries to guess the secret number!");
break;
}
}
}
}
Up until now, we type cargo build
to build our app. This builds the app relatively quickly but forgoes some optimizations that would make the binary run faster. We can see the generated binary in target/debug
.
For release builds, we should type cargo build --release
. You will notice that the build stage takes longer than usual (the optimizations we talked about) and now we will have the release binary in target/release
.
β οΈ The
--release
flag works also withcargo run
Go ahead and type the following:
cargo build --release
Then run the game directly without cargo
as follows:
# Linux
./target/release/guess_the_number
And the game will run smoothly π. Try to guess the number from the first try!
The next article, I'll start to discuss Rust specific features and characteristics namely "ownership". See you then π
Top comments (0)