Programming a Guessing Game

Best way to learn programming languages, and coding in general, is by building projects. This will be our first.

Learning Outcomes

  • Learn about let and match.
  • Methods in Rust.
  • Associated functions.
  • External crates.

Idea

The program generates a random integer between 1-100, prompts the player to enter a guess, and then indicates if too low or too high or correct.

Setting up a New Project

Like we did in Learning Rust set up a new project using Cargo. To confirm everything’s working nicely, try cargo run and you should get the usual hello world message.

Processing a guess

First thing I need to do is to prompt the user for input so we can parse it and check if they guessed correctly. Steps:

  1. Prompt for guess using the println! macro.
  2. Take in the answer using standard input (stdin).
  3. Print it back at them.
use std::io;  
fn main() {  
    println!("Enter a number guess between 1  and 100.");  
    let mut guess_value = String::new();  
    io::stdin().read_line(&mut guess_value).expect("Failed to read stdin line input");  
    println!("You guessed {guess_value}");  
}

Standard I/O

We import libraries using use to bring it into scope. All I/O operations are done using the std::io this is read as the io library from the std library. Rust refers to this as “bringing into scope” not hard to understand why. Some data types exist in the Standard library but not always included automatically. the set that is included in every program is called the Prelude.

Storing variables

We use the let keyword to define a variable. For example: let count = 2; . The previous example had the mut keyword as well, this indicates that this variable is Mutable and its value can be changed. ==In Rust, variables are immutable by default, more on this in Learning Rust - Ch.3 (Variables, Data types, Control Flow and Loops)==.

The :: Path Operator

Is sometimes referred to as the “path separator” and is used denote association for crates and modules.

We then instantiate a new empty string using String::new() this syntax signals that the new() function is associated with the String type. Many data types have new functions.

Back to IO

Now that our string has been made, let’s read the standard input. Using the io library’s stdin() function which returns a std::io::Stdin type which handles standard input for us, then to read the line using using the read_line method which we’re also passing &mut guess as the argument to store (it appends to the string NOT overwrites) the user input in the empty string we created. // This is all basic stuff so far but Rust's syntax is interesting...

The Reference Operator &

This operator allows multiple parts of the code base to “borrow” the data without the need to copy it multiple times. It’s one of Rust’s major advantages that contribute to memory safety. References are immutable by default just like variables, so to mutate what they reference we add the mut keyword. See Learning Rust - Ch.4 (Ownership, Strings, and Slices) for more.

Printing values

To print variable values with println! we can use one of two methods:

  • Placeholders: This is the idiomatic way to do this in Rust. E.g:
println!("Printing values using placeholders, value: {}", value_here)`

The curly braces serve as placeholders, and followed by a comma-separated list of expressions or variables to print in each placeholder, with their respective orders.

  • String Interpolation: println!("This is another way to do it, {value_here});

Failure and error handling and Enums

You’ve likely noticed the .expect("Failure message") at the end of the line that reads the standard input. The read_line function returns a Result type, which is an Enumeration (Enum). RustEnum are types that can have one of multiple possible states or values, each state is a called a RustVariant.

The reason we expect a failure is in case that Result is an error. Results can either be Ok or Err, and are handy for indicating if something succeeds or fails.

  • The Ok variant indicates the operation was successful, and it contains the successfully generated value.
  • The Err variant means the operation failed, and it contains information about how or why the operation failed. // This is common among FunctionalProgramming languages.

Result types have methods, if the read_line returns an Err then the expect method will crash the program and display the message passed to it, but if it returns an Ok then the expect will simply return the value to use, and that’s how we get the number the user inputs.

Generating random numbers

Rust doesn’t have a random number generator but the rand crate does, so we’ll use that. First we need to include this crate in our dependencies, add the following to Cargo.toml.

[dependencies]
rand = "0.8.5" # the latest longterm version here

If you’re using an IDE like RustRover or even official plugins in VS Code, then this will likely be done automatically.

Semantic Versioning

“Cargo understands Semantic Versioning (sometimes called SemVer), which is a standard for writing version numbers. The specifier 0.8.5 is actually shorthand for ^0.8.5, which means any version that is at least 0.8.5 but below 0.9.0.”

Now back to the numbers let’s generate one using the rand crate’s methods.

use rand::Rng;  
use std::cmp::Ordering;  
use std::io;  
  
fn main() {  
    println!("Enter a number guess between 1  and 100.");  
    let mut guess_value = String::new();  
    let rand_number = rand::rng().random_range(1..=100);  
  
    io::stdin()  
        .read_line(&mut guess_value)  
        .expect("Failed to read stdin line input");  
    
    println!("You guessed {guess_value}");  
}

The rng() function returns a random number generator and then invoke its random_range(start..=end), the form start..=end and is inclusive on the lower and upper bounds, so we need to specify 1..=100 to request a number between 1 and 100.

Comparing values

We can compare if two values are equal or not easily, but for this game we’d be better off using a MatchExpression and the Ordering crate.

use rand::Rng;  
use std::cmp::Ordering;  
use std::io;  
  
fn main() {  
    println!("Enter a number guess between 1  and 100.");  
    let mut guess_value = String::new();  
    let rand_number = rand::rng().random_range(1..=100);  
  
    io::stdin()  
        .read_line(&mut guess_value)  
        .expect("Failed to read stdin line input");  
    match guess_value.cmp(&rand_number) {  
        Ordering::Less => println!("Too low."),  
        Ordering::Greater => println!("Too high."),  
        Ordering::Equal => println!("You Win!\nThe number was {}", rand_number),  
    }    println!("You guessed {guess_value}");  
}

We’ve encountered match expressions before in languages such as Ocaml and Elixir, so I won’t go over them more.

Here's what the docs say about match:

“A match expression is made up of arms. An arm consists of a pattern to match against, and the code that should be run if the value given to match fits that arm’s pattern. Rust takes the value given to match and looks through each arm’s pattern in turn. Patterns and the match construct are powerful Rust features: they let you express a variety of situations your code might encounter and they make sure you handle them all.”

Type mismatch

Rust has a strong static type system but also supports type inference. But that doesn’t solve the issue when comparing an i32 (32 bit integer) with a string.

Example:

fn main() {  
   println!("Enter a number guess between 1  and 100.");  
   let mut guess_value = String::new();  
   let rand_number = rand::rng().random_range(1..=100);  
 
   io::stdin()  
       .read_line(&mut guess_value)  
       .expect("Failed to read stdin line input");  
 
   let guess_value: u32 = guess_value.trim().parse().expect("Please type a number!");  
   match guess_value.cmp(&rand_number) {  
       Ordering::Less => println!("Too Low!\nThe number was {}", rand_number),  
       Ordering::Greater => println!("Too High!\nThe number was {}", rand_number),  
       Ordering::Equal => println!("You Win!\nThe number was {}", rand_number),  
   }
}

The guess_value is shadowed with the new value parsed from the string. More on this in Learning Rust - Ch.3 (Variables, Data types, Control Flow and Loops). The parse method on strings converts a string to another type, in this case string to i32 integer, and we ensure the user entered a number by chaining an expect in case the the parse method returns an Err result due to the user entering anything but a number.

Looping

Rust’s loop keyword creates an infinite loop. To break out of it, preferably on some condition, use the break keyword.

Error handling

Instead of simply crashing when receiving an Err, we can handle both scenarios of a possible result.

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

The match expression allows us to handle both scenarios and act accordingly.

Summary

This chapter and mini project went over the following Rust concepts:

  • Variable definition using let.
  • Standard I/O using std::io library.
  • MatchExpression using the match keyword.
  • Functions
  • External crates, how to import them with use, and update the cargo.toml file.