7.10Project: the number guessing game
No new features this lesson. Instead, the course's first real project: a complete program, built the way programs actually get built, in small passes that each compile and run. The habit comes from chapter 3 (lesson 3.2: the smaller the step, the smaller the search when something breaks), and this lesson practices it rather than preaching it.
The spec: the program picks a secret number from 1 to 100. You guess. It answers "too high," "too low," or "correct," and keeps asking until you get it, then reports how many guesses you took.
Everything required is on hand: reading input (1.6), parsing (5.6), match (7.3), loops and break (7.4, 7.6), and random numbers (last lesson). What's new is only the assembly.
Pass 1: read a guess, echo it back
Start a fresh project (cargo new guessing_game), and begin with the loop-free core: ask, read, parse, repeat back. This is lesson 5.6's idiom and nothing else:
use std::io;
fn main() {
println!("I'm thinking of a number from 1 to 100.");
println!("Your guess:");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("failed to read line");
let guess: u32 = input.trim().parse().expect("please type a number");
println!("You guessed: {guess}");
}I'm thinking of a number from 1 to 100.
Your guess:
50
You guessed: 50
Compile, run, type a number. Working? Pass 2. (This will feel slow for a 12-line program. The payoff comes when a pass doesn't work and the suspect list is four lines long.)
Pass 2: add the secret
cargo add rand, then create the secret and, for now, print it:
use std::io;
fn main() {
let secret: u32 = rand::random_range(1..=100);
println!("(debug: the secret is {secret})");
println!("I'm thinking of a number from 1 to 100.");
println!("Your guess:");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("failed to read line");
let guess: u32 = input.trim().parse().expect("please type a number");
println!("You guessed: {guess}");
}(debug: the secret is 61)
I'm thinking of a number from 1 to 100.
Your guess:
50
You guessed: 50
Printing the answer looks like cheating; it's actually lesson 3.4 on the job. In the next pass we have to verify that comparisons work, and you can't check "too low" is correct without knowing what the truth was. The debug line is scaffolding; it comes down in the last pass.
Note the annotation on secret. rand::random_range is happy to produce many integer types, so we tell it which one (lesson 4.9: inference needs evidence). Choosing u32 to match guess means the comparison in pass 3 just works.
Pass 3: compare
Now the program earns its keep. We could chain if guess < secret / else if guess > secret / else, but "which of exactly three cases is it?" is match's home turf, and Rust's standard library leans into that. Every integer has a .cmp() method that compares it to another and returns one of three values, named Ordering::Less, Ordering::Greater, and Ordering::Equal:
use std::cmp::Ordering;
use std::io;
fn main() {
let secret: u32 = rand::random_range(1..=100);
println!("(debug: the secret is {secret})");
println!("I'm thinking of a number from 1 to 100.");
println!("Your guess:");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("failed to read line");
let guess: u32 = input.trim().parse().expect("please type a number");
match guess.cmp(&secret) {
Ordering::Less => println!("Too low!"),
Ordering::Greater => println!("Too high!"),
Ordering::Equal => println!("Correct!"),
}
}(debug: the secret is 34)
I'm thinking of a number from 1 to 100.
Your guess:
50
Too high!
Two spellings in there are on credit, and here are the receipts. Ordering is an enum, a type whose every possible value is one of a short, named list; chapter 11 is where enums and match reveal they were made for each other, and this match is your preview. Note what the compiler is doing for you already: three arms, no _, and it compiles, because the compiler knows those three cases are all of them. (Delete an arm and you'll get lesson 7.3's E0004, listing exactly which Ordering you forgot.) And the & in cmp(&secret): that one character is most of chapter 9; until then, .cmp() wants its argument written with the &, and that's the recipe.
Run pass 3 a few times, guessing above, below, and (thanks to the debug line) exactly at the secret, until you've seen all three arms fire.
Pass 4: loop until won
Wrap the ask-read-compare core in a loop, break on Equal, count the attempts, and retire the debug line:
use std::cmp::Ordering;
use std::io;
fn main() {
let secret: u32 = rand::random_range(1..=100);
let mut guesses = 0;
println!("I'm thinking of a number from 1 to 100.");
loop {
println!("Your guess:");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("failed to read line");
let guess: u32 = input.trim().parse().expect("please type a number");
guesses += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Too low!"),
Ordering::Greater => println!("Too high!"),
Ordering::Equal => {
println!("Correct! You got it in {guesses} guesses.");
break;
}
}
}
}
A match arm's right side can be a block when it has several things to do; the Equal arm prints and breaks. Here's one session of the finished game:
I'm thinking of a number from 1 to 100.
Your guess:
50
Too high!
Your guess:
25
Too low!
Your guess:
37
Too low!
Your guess:
43
Too high!
Your guess:
40
Too high!
Your guess:
38
Correct! You got it in 6 guesses.
That's a complete, honest program: state, a loop, input, branching, and an ending it chooses for itself. Sixteen lessons ago you printed "Hello, world!".
The crash you should go cause
Lesson 3.6 told you to ask of every program: what's the worst its caller could hand it? Our caller is a human with a keyboard. Run the game and answer its first prompt with banana:
thread 'main' (2306317) panicked at src/main.rs:15:47:
please type a number: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Our old friend from lesson 5.6: expect panicking on an unparseable guess, wearing the message we wrote. For a learning project, a crash with a clear message is acceptable; for a real one, ending the program because someone fat-fingered a guess is obviously wrong. The polite fix (catch the failure, scold gently, loop back for another try) needs Result, and it's one of the first things chapter 12 will do. The game is this course's running project, and upgrading it is how you'll feel each new tool's worth.
The strategy you already know
One last debt to settle. When you play this game, what's the best strategy? Guess 50. If too high, the answer lives in 1 to 49; guess 25. Each guess splits the remaining range in half, exactly the halving strategy lesson 3.3 taught you for cornering bugs, and that lesson promised you'd meet it here, where you get to be the computer executing it. The algorithm's real name is binary search, and its power compounds: 100 numbers fall in at most 7 guesses, because doubling 1 seven times passes 100 (2⁷ = 128). A million numbers? 20 guesses. The session above used it, sloppily, and still won in 6.
Quiz time
Question #1
Be the house: change the game so the player gets at most 7 guesses, and loses (with the secret revealed) when they run out. Binary search says 7 is exactly fair for 1 to 100; nobody playing well can lose.
Show solution
Replace the loop section of pass 4 with:
loop {
if guesses == 7 {
println!("Out of guesses! The number was {secret}.");
break;
}
println!("Your guess:");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("failed to read line");
let guess: u32 = input.trim().parse().expect("please type a number");
guesses += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Too low!"),
Ordering::Greater => println!("Too high!"),
Ordering::Equal => {
println!("Correct! You got it in {guesses} guesses.");
break;
}
}
}
The limit check sits at the top of the loop, where it reads as a rule of the game, and the existing counter does the bookkeeping. (If you instead wrote for _ in 0..7 with a "did they win?" flag after, it works too; compare it to this version and lesson 7.6's opinion of flag variables, and pick your side.)
Question #2
Add play-again: when a game ends (won or lost), ask Play again? (y/n) and start a fresh game (new secret, reset count) on y. Hint: the whole game becomes the body of an outer loop, and lesson 7.3 mentioned that match works on strings.
Show solution
use std::cmp::Ordering;
use std::io;
fn main() {
loop {
let secret: u32 = rand::random_range(1..=100);
let mut guesses = 0;
println!("I'm thinking of a number from 1 to 100.");
loop {
println!("Your guess:");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("failed to read line");
let guess: u32 = input.trim().parse().expect("please type a number");
guesses += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Too low!"),
Ordering::Greater => println!("Too high!"),
Ordering::Equal => {
println!("Correct! You got it in {guesses} guesses.");
break;
}
}
}
println!("Play again? (y/n)");
let mut answer = String::new();
io::stdin().read_line(&mut answer).expect("failed to read line");
match answer.trim() {
"y" => println!("New game!"),
_ => break,
}
}
println!("Thanks for playing!");
}
The secret and the counter move inside the outer loop so each game gets fresh ones. The inner break ends a game; the outer break ends the program; no labels needed, since each break targets the loop it's written in, but add them ('program: and 'game:) if you find the two-loop nesting hard to scan. The _ arm makes anything but exact y mean "no," which is the forgiving default. (If you did question #1 first, the out-of-guesses arm slots in the same way.)
That's chapter 7's toolbox, used in anger. The summary and chapter quiz are next, and then chapter 8, where this course has been heading since page one.