Beginner Projects

Number Guessing Game

A number guessing game manages a stateful interactive loop where a player attempts to guess a secret target number within a bounded range (min..=max), receiving directional feedback (TooLow, TooHigh, Correct) while narrowing the valid search bounds and tracking attempt history.

Game State Machine & Feedback Loop:

  [ Initialize `GameSession { secret, low: 1, high: 100, max_attempts: 7 }` ]
                                    |
                                    v
             +---> [ Player Submits Guess Input (`&str`) ]
             |                      |
             |      Parse `u32` & Validate Range (`low..=high`)
             |                      |
             |      `guess.cmp(&secret)` (`Ordering`)
             |         ├── `Less`    ---> Update `low = low.max(guess + 1)`  (`TooLow`)
             |         ├── `Greater` ---> Update `high = high.min(guess - 1)`(`TooHigh`)
             |         └── `Equal`   ---> Return `TurnOutcome::Won`!
             +--- (Repeat while `attempts < max_attempts`)

Separating Pure Game Engine State from Terminal I/O

Designing the game around a pure GameSession struct (separate from stdin().read_line()) makes every transition—out-of-range guesses, remaining attempt limits, dynamic hint narrowing, and win/loss conditions—100% deterministic and unit-testable:

Component Type Responsibility
GuessError Enum Reports non-numeric input (InvalidNumber) or guesses outside [min..=max] (OutOfBounds) without consuming an attempt!
TurnOutcome Enum Reports TooLow, TooHigh, Won { attempts }, or Lost { secret }.
GameSession Struct Tracks secret, dynamic narrowed hint window [hint_low..=hint_high], max_attempts, and history: Vec<u32>.

Complete Runnable Implementation

use std::cmp::Ordering;

#[derive(Debug, PartialEq, Eq)]
pub enum GuessError {
    EmptyInput,
    InvalidNumber(String),
    OutOfBounds { guess: u32, min: u32, max: u32 },
}

#[derive(Debug, PartialEq, Eq)]
pub enum TurnOutcome {
    TooLow {
        remaining_attempts: usize,
        hint_range: (u32, u32),
    },
    TooHigh {
        remaining_attempts: usize,
        hint_range: (u32, u32),
    },
    Won {
        attempts_used: usize,
    },
    Lost {
        secret: u32,
    },
}

pub struct GameSession {
    secret: u32,
    min: u32,
    max: u32,
    hint_low: u32,
    hint_high: u32,
    max_attempts: usize,
    history: Vec<u32>,
    finished: bool,
}

impl GameSession {
    pub fn new(min: u32, max: u32, secret: u32, max_attempts: usize) -> Self {
        assert!(min <= secret && secret <= max, "secret must be within min..=max");
        assert!(max_attempts > 0, "max_attempts must be > 0");
        Self {
            secret,
            min,
            max,
            hint_low: min,
            hint_high: max,
            max_attempts,
            history: Vec::with_capacity(max_attempts),
            finished: false,
        }
    }

    pub fn submit_str(&mut self, raw: &str) -> Result<TurnOutcome, GuessError> {
        let trimmed = raw.trim();
        if trimmed.is_empty() {
            return Err(GuessError::EmptyInput);
        }
        let guess = trimmed
            .parse::<u32>()
            .map_err(|_| GuessError::InvalidNumber(trimmed.to_string()))?;

        if !(self.min..=self.max).contains(&guess) {
            return Err(GuessError::OutOfBounds {
                guess,
                min: self.min,
                max: self.max,
            });
        }

        self.history.push(guess);
        let attempts_used = self.history.len();
        let remaining = self.max_attempts.saturating_sub(attempts_used);

        match guess.cmp(&self.secret) {
            Ordering::Equal => {
                self.finished = true;
                Ok(TurnOutcome::Won { attempts_used })
            }
            Ordering::Less => {
                if remaining == 0 {
                    self.finished = true;
                    Ok(TurnOutcome::Lost { secret: self.secret })
                } else {
                    self.hint_low = self.hint_low.max(guess + 1);
                    Ok(TurnOutcome::TooLow {
                        remaining_attempts: remaining,
                        hint_range: (self.hint_low, self.hint_high),
                    })
                }
            }
            Ordering::Greater => {
                if remaining == 0 {
                    self.finished = true;
                    Ok(TurnOutcome::Lost { secret: self.secret })
                } else {
                    self.hint_high = self.hint_high.min(guess - 1);
                    Ok(TurnOutcome::TooHigh {
                        remaining_attempts: remaining,
                        hint_range: (self.hint_low, self.hint_high),
                    })
                }
            }
        }
    }

    pub fn history(&self) -> &[u32] {
        &self.history
    }
}

fn main() {
    let mut game = GameSession::new(1, 100, 42, 5);

    // 1. Invalid or out-of-range inputs return Err without wasting an attempt:
    assert_eq!(
        game.submit_str("abc"),
        Err(GuessError::InvalidNumber("abc".to_string()))
    );
    assert_eq!(
        game.submit_str("150"),
        Err(GuessError::OutOfBounds {
            guess: 150,
            min: 1,
            max: 100
        })
    );
    assert_eq!(game.history().len(), 0);

    // 2. Binary-search style gameplay narrowing hint_range:
    let r1 = game.submit_str("50").unwrap();
    println!("Guess 50 -> {r1:?}");

    let r2 = game.submit_str("25").unwrap();
    println!("Guess 25 -> {r2:?}");

    let r3 = game.submit_str("42").unwrap();
    println!("Guess 42 -> {r3:?}");
    println!("Guess history: {:?}", game.history());
}
Guess 50 -> TooHigh { remaining_attempts: 4, hint_range: (1, 49) }
Guess 25 -> TooLow { remaining_attempts: 3, hint_range: (26, 49) }
Guess 42 -> Won { attempts_used: 3 }
Guess history: [50, 25, 42]