DEV Community

Cover image for Learning Rust πŸ¦€: 05- Your first Rust game: "Guess The Number"
Fady GA 😎
Fady GA 😎

Posted on • Edited on

Learning Rust πŸ¦€: 05- Your first Rust game: "Guess The Number"

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 a cargo 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:

"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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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}")
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Next, we need some place to hold the user's guess. Therefore, we have defined the guess variable:

let mut guess = String::new();
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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:

dependencies
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}");
}
Enter fullscreen mode Exit fullscreen mode

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!"),
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 with cargo run

Go ahead and type the following:

cargo build --release
Enter fullscreen mode Exit fullscreen mode

Then run the game directly without cargo as follows:

# Linux
./target/release/guess_the_number
Enter fullscreen mode Exit fullscreen mode

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)