1 The Problem
We want a tiny game: the computer thinks of a number between 1 and 100, and the player keeps guessing. After each guess the computer says “too high” or “too low” until the player gets it right. Simple — but it teaches the three things every program does: ask, decide, and repeat.
2 How to Think About It
Before writing any code, picture the game as a loop with a decision inside it. You do not need to know Rust yet — you just need to know the shape of what happens:
3 The Build — explained part by part
Here is the complete game, split into a testable play_game function and a tiny main that wires it to real stdin/stdout. Read each part’s plain-English note below it.
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
use std::io::{self, BufRead, Write};
/// Rust's standard library deliberately ships no random number generator —
/// that job belongs to the `rand` crate in real projects. `RandomState`,
/// normally used to seed HashMap's hasher against denial-of-service attacks,
/// is reseeded from the OS's random source every time `RandomState::new()`
/// runs, so hashing nothing with a fresh one gives an unpredictable u64 with
/// zero external dependencies — good enough for a guessing game.
fn random_u64() -> u64 {
RandomState::new().build_hasher().finish()
}
/// Picks a whole number in `[min, max]` (inclusive on both ends).
fn random_range(min: u32, max: u32) -> u32 {
min + (random_u64() % (max - min + 1) as u64) as u32
}
/// Plays one full game against a fixed `secret`, reading guesses from
/// `input` and writing prompts and feedback to `output`. Kept separate from
/// `main` so it can be tested without touching real stdin/stdout.
fn play_game<R: BufRead, W: Write>(secret: u32, input: &mut R, output: &mut W) -> u32 {
let mut guesses = 0u32;
loop {
write!(output, "Guess a number between 1 and 100: ").ok();
output.flush().ok();
let mut line = String::new();
if input.read_line(&mut line).unwrap_or(0) == 0 {
writeln!(output, "No more input — giving up.").ok();
return guesses;
}
let guess: u32 = match line.trim().parse() {
Ok(n) => n,
Err(_) => {
writeln!(output, "That's not a whole number — try again.").ok();
continue;
}
};
guesses += 1;
if guess < secret {
writeln!(output, "Too low.").ok();
} else if guess > secret {
writeln!(output, "Too high.").ok();
} else {
writeln!(output, "Correct! You got it in {guesses} guesses.").ok();
return guesses;
}
}
}
fn main() {
let secret = random_range(1, 100);
let stdin = io::stdin();
let mut input = stdin.lock();
let mut output = io::stdout();
play_game(secret, &mut input, &mut output);
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn finds_the_secret_in_one_guess() {
let mut input = Cursor::new(b"42\n".to_vec());
let mut output = Vec::new();
let guesses = play_game(42, &mut input, &mut output);
assert_eq!(guesses, 1);
assert!(String::from_utf8(output).unwrap().contains("Correct!"));
}
#[test]
fn narrows_down_with_too_low_and_too_high() {
let mut input = Cursor::new(b"10\n90\n50\n".to_vec());
let mut output = Vec::new();
let guesses = play_game(50, &mut input, &mut output);
assert_eq!(guesses, 3);
let text = String::from_utf8(output).unwrap();
assert!(text.contains("Too low."));
assert!(text.contains("Too high."));
}
#[test]
fn ignores_input_that_is_not_a_number() {
let mut input = Cursor::new(b"banana\n7\n".to_vec());
let mut output = Vec::new();
let guesses = play_game(7, &mut input, &mut output);
assert_eq!(guesses, 1);
assert!(String::from_utf8(output).unwrap().contains("not a whole number"));
}
#[test]
fn random_range_stays_within_bounds() {
for _ in 0..500 {
let n = random_range(1, 100);
assert!((1..=100).contains(&n));
}
}
}
rustup) is installed.rand crate in real projects (cargo add rand). This build environment cannot reach crates.io, so this project borrows a well-known trick: RandomState, the type HashMap normally uses to seed its hasher against denial-of-service attacks, reseeds itself from the OS’s random source every time you call RandomState::new() — hashing nothing with a fresh one gives an unpredictable number with zero external dependencies. Good enough for a guessing game; a real project should still reach for rand.fn play_game<R: BufRead, W: Write>(...) — the whole game logic takes its input and output as generic parameters instead of calling
stdin()/stdout() directly. That is what lets the tests below feed it fake input and capture its output without touching a real terminal.loop { ... } — Rust’s dedicated infinite-loop keyword, used here instead of
while true because it signals intent directly: this loop is deliberately unbounded until a return inside it ends the function.line.trim().parse() —
read_line keeps the trailing newline, so trim() removes it first; .parse() then tries to turn the text into a u32, returning a Result that the match right after it handles explicitly — there is no exception to accidentally ignore.if guess < secret / > secret / else — the decision, identical in shape to every other language’s version of this game.
random_range(1, 100) inside the loop instead of once before it — the secret would change every guess and the game could never be won.play_game starts looping.if guess = secret by mistake instead of ==..parse() without checking the Result, or reaching for .unwrap() — a non-numeric guess would crash the whole program instead of printing a friendly message.Result explicitly, as play_game does, and continue the loop on a parse error.4 Test & Prove Each Part
How do we know the game works without playing it by hand? We write small tests — each one checks one rule and proves it. Rust’s built-in #[cfg(test)] module and cargo test are the standard way to do this, no extra install required.
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn finds_the_secret_in_one_guess() {
let mut input = Cursor::new(b"42\n".to_vec());
let mut output = Vec::new();
let guesses = play_game(42, &mut input, &mut output);
assert_eq!(guesses, 1);
assert!(String::from_utf8(output).unwrap().contains("Correct!"));
}
#[test]
fn narrows_down_with_too_low_and_too_high() {
let mut input = Cursor::new(b"10\n90\n50\n".to_vec());
let mut output = Vec::new();
let guesses = play_game(50, &mut input, &mut output);
assert_eq!(guesses, 3);
let text = String::from_utf8(output).unwrap();
assert!(text.contains("Too low."));
assert!(text.contains("Too high."));
}
#[test]
fn ignores_input_that_is_not_a_number() {
let mut input = Cursor::new(b"banana\n7\n".to_vec());
let mut output = Vec::new();
let guesses = play_game(7, &mut input, &mut output);
assert_eq!(guesses, 1);
assert!(String::from_utf8(output).unwrap().contains("not a whole number"));
}
#[test]
fn random_range_stays_within_bounds() {
for _ in 0..500 {
let n = random_range(1, 100);
assert!((1..=100).contains(&n));
}
}
}
Run them with cargo test. Each test is an ordinary function marked #[test] inside a mod tests block in the same file — Rust keeps unit tests right beside the code they test rather than in a separate file. A std::io::Cursor stands in for real stdin, which is exactly what the generic play_game<R, W> signature was designed to make possible.
5 The Interface
Even a tiny program has an interface — the way a person interacts with it. Here is its contract, documented plainly, the same way a professional would describe any tool.
What it expects
A number like 42, typed and then Enter pressed.What it returns
"Too low."
"Too high."
"Correct! You got it in N guesses."6 Run It & Automate It
Save the code as src/main.rs inside a Cargo project's src/ folder and run it with cargo run — Cargo compiles and executes in one step while you are experimenting, then cargo build --release gives you an optimized binary once you are done.
cargo runThen type a number, press Enter, and follow the “too high / too low” hints until you win.
A CI tool like Jenkins runs cargo test automatically whenever the code changes — every line below has a plain explanation.
Guess a number between 1 and 100: 50
Too high.
Guess a number between 1 and 100: 25
Too low.
Guess a number between 1 and 100: 37
Too high.
Guess a number between 1 and 100: 31
Correct! You got it in 4 guesses.u32 and got something else, like a &str straight from input. Convert explicitly with .parse() and handle the Result.guess to secret and not to a copy that never updates — and that the loop actually returns or breaks on a correct guess.rustup and open a new terminal.// Jenkinsfile — runs the tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Rust') {
steps {
sh 'rustc --version' // confirm Rust is installed
sh 'cargo build' // compile, downloading any crates
}
}
stage('Run the tests') {
steps {
sh 'cargo clippy -- -D warnings' // catch obvious mistakes before running
sh 'cargo test' // run every test, show each result
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Limit the attempts. Give the player only 7 guesses. (Teaches: counting down, and an early
returnon failure.) - Add difficulty levels. Let the player choose a range like 1–1000. (Teaches: passing parameters into
random_range.) - Use the real
randcrate. If you have network access, replacerandom_rangewithrand::rng().random_range(1..=100). (Teaches: adding a dependency withcargo add.)
Result-based input parsing with no exceptions to forget, and a genuinely useful pattern — writing core logic against generic Read/Write parameters so it can be tested without touching a real terminal. You also saw why Rust keeps randomness out of the standard library. Related: Control Flow, The Result Type.