Building a Guessing Game in Rust: A Beginner's Journey

AviralAviral
8 min read

Yo, what’s good? Let’s dive into Rust with a dope guessing game project that’s perfect for beginners. This isn’t just about coding a game—it’s about grokking Rust’s core concepts like variables, loops, error handling, external crates, ownership, borrowing, and pattern matching. By the end, you’ll have a working game and a solid grasp of Rust’s fundamentals. Let’s get to it, bro!

What’s the Guessing Game?

The guessing game is a classic:

  • The program picks a random number between 1 and 100.

  • You guess a number, and it tells you if it’s too high, too low, or correct.

  • Hit the right number, and it yells “You win!” and stops.

We’ll build this step-by-step, exploring Rust’s let, match, loops, crates, and more advanced concepts like ownership and borrowing. Here’s the final code we’re aiming for:

use rand;
use std::io;
use std::cmp::Ordering;
use rand::Rng;

fn main() {
    println!("Welcome to Guessing Game");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Please enter your guess.");

        let mut guess = String::new();
        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue
        };

        println!("you guessed: {}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too Small"),
            Ordering::Equal => {
                println!("You win");
                break;
            },
            Ordering::Greater => println!("Too Big")
        }
    }
}

Setting Up the Project

Kick things off with Cargo, Rust’s build tool. Open your terminal in your projects directory and run:

$ cargo new guessing_game --bin
$ cd guessing_game

The --bin flag creates an executable project. You’ll get a Cargo.toml file for configuration and a src/main.rs file with:

fn main() {
    println!("Hello, world!");
}

Run it with cargo run to confirm it works:

$ cargo run
   Compiling guessing_game v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 1.50 secs
     Running `target/debug/guessing_game`
Hello, world!

Sweet, let’s transform this into our game.

Step 1: Capturing User Input

First, we need to grab the user’s guess and display it. Replace src/main.rs with:

use std::io;

fn main() {
    println!("Welcome to Guessing Game");
    println!("Please enter your guess.");

    let mut guess = String::new();
    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("you guessed: {}", guess);
}

Breaking It Down

  • Importing std::io: use std::io; pulls in the input/output library for reading user input.

  • Prompting: println!("Welcome to Guessing Game"); and println!("Please enter your guess."); set the vibe with a welcome message and prompt.

  • Variables and Mutability: let mut guess = String::new(); creates a mutable variable guess as an empty String. Rust variables are immutable by default, so mut lets us modify it. String::new() is an associated function that creates a new, empty string.

  • Reading Input: io::stdin().read_line(&mut guess) reads a line from the keyboard into guess. The &mut guess is a mutable reference, letting read_line update the string. .expect("Failed to read line") crashes if reading fails, with an error message.

  • Printing the Guess: println!("you guessed: {}", guess); uses {} as a placeholder to show the input.

Run it:

Welcome to Guessing Game
Please enter your guess.
6
you guessed: 6

Concept: Ownership and Borrowing

Rust’s ownership system is a game-changer. Every value has a single owner, and when the owner goes out of scope, the value is dropped (memory is freed). In let mut guess = String::new();, guess owns the String. When we pass &mut guess to read_line, we’re borrowing it as a mutable reference, allowing read_line to modify it without taking ownership. This avoids copying the string, which is efficient and safe. Rust’s rules ensure references don’t outlive the data they point to, preventing bugs like dangling pointers.

Step 2: Generating a Random Number

We need a random secret number. Rust’s standard library doesn’t do random numbers, but the rand crate does. Add it to Cargo.toml:

[dependencies]
rand = "0.8.5"

Use version 0.8.5 for modern Rust compatibility. Cargo’s Semantic Versioning means it’ll grab >=0.8.5 but <0.9.0. Run cargo build:

$ cargo build
   Updating crates.io index
   Downloading crates ...
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 1.50 secs

Cargo creates a Cargo.lock file to lock dependency versions for consistent builds. Update src/main.rs:

use rand;
use std::io;
use rand::Rng;

fn main() {
    println!("Welcome to Guessing Game");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {}", secret_number);

    println!("Please enter your guess.");

    let mut guess = String::new();
    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("you guessed: {}", guess);
}

What’s New?

  • Using rand: use rand; and use rand::Rng; import the rand crate and its Rng trait for random number methods.

  • Random Number: rand::thread_rng().gen_range(1..=100) generates a random number from 1 to 100 (inclusive). thread_rng provides a thread-local random number generator, seeded by the OS.

  • Debug Output: println!("The secret number is: {}", secret_number); shows the number for testing (we’ll remove it later).

Run it a few times with cargo run to see different random numbers.

Concept: Crates and Modules

The rand crate is a library crate, unlike our binary crate (the executable). Crates are Rust’s way of packaging code, and use statements bring modules into scope. rand::Rng is a trait (like an interface) defining random number methods. Rust’s module system organizes code, and :: navigates it (e.g., rand::thread_rng). You can explore rand’s docs with cargo doc --open.

Step 3: Comparing the Guess to the Secret Number

Let’s compare the guess to the secret number. Update src/main.rs:

use rand;
use std::io;
use std::cmp::Ordering;
use rand::Rng;

fn main() {
    println!("Welcome to Guessing Game");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {}", secret_number);

    println!("Please enter your guess.");

    let mut guess = String::new();
    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = match guess.trim().parse() {
        Ok(num) => num,
        Err(_) => continue
    };

    println!("you guessed: {}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too Small"),
        Ordering::Equal => {
            println!("You win");
            break;
        },
        Ordering::Greater => println!("Too Big")
    }
}

Key Changes

  • Importing Ordering: use std::cmp::Ordering; brings in the Ordering enum (Less, Equal, Greater) for comparisons.

  • Parsing Input: let guess: u32 = match guess.trim().parse() { ... }; converts the String input to a u32. trim() removes whitespace (like newlines), and parse() tries to make a number. The match handles errors: Ok(num) returns the number, Err(_) loops back.

  • Comparing: guess.cmp(&secret_number) returns an Ordering variant. The match expression prints “Too Small”, “Too Big”, or “You win” and breaks on a correct guess.

Concept: Pattern Matching with match

The match expression is Rust’s superpower for pattern matching. It’s like a switch statement on steroids, ensuring you handle all possible cases (exhaustive matching). In match guess.cmp(&secret_number), we match against Ordering variants, running different code for each. Similarly, match guess.trim().parse() handles Result variants (Ok or Err). Rust enforces that all variants are covered, preventing bugs.

Why Parse?

secret_number is a u32, but guess is a String. Rust’s type system demands matching types for cmp. Without parse, you’d get:

error[E0308]: mismatched types
   --> src/main.rs: expected `std::string::String`, found `u32`

The match with continue ensures invalid inputs don’t crash the game.

Concept: Error Handling with Result

The parse() method returns a Result enum: Ok(value) for success, Err(error) for failure. We use match to handle both cases gracefully, unlike .expect(), which crashes on errors. Result is Rust’s way of encoding errors explicitly, making code robust. Similarly, read_line returns a Result, but we use .expect() for simplicity here.

Run it:

Welcome to Guessing Game
The secret number is: 58
Please enter your guess.
76
you guessed: 76
Too Big

Step 4: Allowing Multiple Guesses

Your code already has a loop for multiple guesses. The loop keyword creates an infinite loop, prompting for guesses until break (on a correct guess) or continue (on invalid input) changes the flow. Test it:

Welcome to Guessing Game
The secret number is: 61
Please enter your guess.
10
you guessed: 10
Too Small
Please enter your guess.
foo
Please enter your guess.
61
you guessed: 61
You win

Concept: Control Flow with Loops

Rust’s loop is a simple way to create an infinite loop, perfect for games. break exits the loop, and continue skips to the next iteration. Rust also has while and for loops, but loop is ideal here for its clarity. Control flow in Rust is explicit, making it easy to reason about program behavior.

Step 5: Final Touches

Remove the println!("The secret number is: {}", secret_number); to keep the game fair. Here’s the final code:

use rand;
use std::io;
use std::cmp::Ordering;
use rand::Rng;

fn main() {
    println!("Welcome to Guessing Game");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Please enter your guess.");

        let mut guess = String::new();
        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue
        };

        println!("you guessed: {}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too Small"),
            Ordering::Equal => {
                println!("You win");
                break;
            },
            Ordering::Greater => println!("Too Big")
        }
    }
}

Step 6: Bonus Concept - Exploring Option

While our game uses Result for error handling, Rust’s Option enum is another key concept. Option represents a value that might or might not exist: Some(value) or None. Imagine extending the game to read the range from user input. You might parse a string into an Option<u32>:

let range: Option<u32> = "100".parse().ok();
match range {
    Some(num) => println!("Valid range: {}", num),
    None => println!("Invalid range"),
}

Unlike Result, which handles errors, Option handles absence. You could use Option to make the game’s range configurable, falling back to 100 if the input is invalid.

Conclusion

Boom! You’ve built a slick guessing game and learned a ton of Rust:

  • Project Setup: Using Cargo for projects and dependencies.

  • Variables: let, mut, and String.

  • Input/Output: std::io for user interaction.

  • Crates: Adding rand for random numbers.

  • Pattern Matching: match for Ordering and Result.

  • Error Handling: Using Result to handle parsing errors.

  • Ownership and Borrowing: Safe memory management with references.

  • Control Flow: Loops with loop, break, and continue.

  • Option: A peek at handling optional values.

Rust’s ownership, type safety, and explicit error handling make it a beast for reliable code.

0
Subscribe to my newsletter

Read articles from Aviral directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Aviral
Aviral