How to Build a Simple Game Loop in Rust

Build a Rust game loop using an infinite loop block with a break condition to handle input and exit gracefully.

The heartbeat of your game

You write a function that moves a character. You call it once. The character moves. Then nothing happens. The program exits. A game needs to keep running, checking for keys, updating positions, drawing frames, over and over, until the player quits. That heartbeat is the game loop. In Rust, you build it with loop, break, and continue.

Concept: the factory floor

Think of a game loop as a factory floor that runs non-stop. Every cycle, the line grabs raw materials (player input), assembles the next state (game logic), and ships the result (rendering). The line keeps cycling until the manager pulls the emergency stop.

In Rust, loop is the conveyor belt. It runs forever by default. break is the emergency stop. It kills the loop and exits the block. continue is a shortcut. It skips the rest of the current cycle and jumps back to the start of the belt.

Rust treats loop as an expression, not just a statement. This means the loop can produce a value. You can use break value to return data directly from the loop, eliminating the need for a separate variable to capture the result. This is a powerful idiom that keeps your code tight.

Minimal example: input, parse, exit

Here is a basic loop that reads input, parses it, and exits on a win condition.

use std::io;

fn main() {
    let secret_number = 42;

    // The loop runs forever until break is hit.
    loop {
        println!("Please input your guess.");
        let mut guess = String::new();
        io::stdin().read_line(&mut guess).expect("Failed to read line");

        // Parse the input. If it fails, skip the rest of this cycle.
        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue, // Jump back to the top immediately.
        };

        println!("You guessed: {guess}");

        if guess == secret_number {
            println!("You win!");
            break; // Exit the loop entirely.
        }
    }
}

Walkthrough

When the code runs, loop starts the first iteration. read_line blocks until the user types something. If the user types "abc", parse returns Err. The match hits Err(_) => continue. The rest of the loop body is skipped. Control jumps to the top. The prompt prints again.

If the user types "42", parse returns Ok(42). The if check passes. break executes. The loop ends. main finishes. The program exits.

If you try to use guess after the match without binding it, the compiler rejects you with E0382 (use of moved value). The match consumes guess during parsing. The let guess: u32 binding creates a new variable in the same scope, shadowing the old one. This is safe and idiomatic.

Convention aside: The community prefers loop over while true. loop makes the intent of "infinite until break" explicit. It also enables break to return a value, which while cannot do. Use loop for game loops.

Use loop for infinite cycles. It signals intent and unlocks break values.

Structuring the loop body

A game loop usually follows a pattern: input, update, render. Even in a CLI tool, separating these phases keeps the code readable.

fn game_loop() {
    let mut state = GameState::new();

    loop {
        // Input phase: grab events.
        let event = poll_event();

        // Update phase: mutate state based on input.
        state.handle_event(event);

        // Render phase: draw the current state.
        render(&state);

        // Exit check: stop if the player quits.
        if state.is_quit() {
            break;
        }
    }
}

This structure separates concerns. Input handling doesn't leak into rendering. State updates happen in one place. The exit check is explicit.

Convention aside: Keep the loop body flat. Avoid deep nesting inside the loop. If you need complex logic, extract it into helper functions. The loop should read like a checklist.

Keep the loop body organized. Separate input, update, and render phases so the logic stays readable.

Realistic example: returning a value

Game loops often need to return a result. Maybe the loop returns the final score, or the reason the game ended. Rust lets you return a value directly from break.

use std::io;

fn play_game() -> u32 {
    let mut score = 0;

    // The loop itself is an expression that returns a value.
    let final_score = loop {
        println!("Score: {score}. Type 'q' to quit, or '1' to score.");
        let mut input = String::new();
        io::stdin().read_line(&mut input).expect("Failed to read");

        match input.trim() {
            "q" => break score, // Break returns the value.
            "1" => {
                score += 10;
            }
            _ => println!("Unknown command."),
        }
    };

    final_score
}

fn main() {
    let result = play_game();
    println!("Game over! Final score: {result}");
}

The loop type is inferred from the break values. Here, break score returns a u32, so the loop evaluates to u32. The let final_score = loop { ... } binding captures that value.

This avoids a pattern like let mut result = None; loop { ... result = Some(val); break; }. The break value approach is safer because the compiler forces you to provide a value on every exit path. You cannot forget to set the result.

Convention aside: Use break value whenever the loop produces a result. It turns the loop from a control flow statement into an expression that produces data. This is idiomatic Rust.

Turn loops into expressions. Use break value to return data directly from the loop body.

Pitfalls and compiler errors

Break value type mismatch

Every break in a loop must return the same type. If you have multiple exit paths, they must agree.

let result: u32 = loop {
    if condition {
        break 1;
    } else {
        break "two"; // E0308: mismatched types.
    }
};

The compiler rejects this with E0308 (mismatched types). The loop expects u32, but one branch returns &str. Fix it by ensuring all break values match the loop's inferred type.

Continue runs drops

continue skips the rest of the loop body, but it does not skip drops. If you have local variables, they are dropped when continue executes.

loop {
    let guard = ResourceGuard::new();
    if error {
        continue; // guard is dropped here.
    }
    use_guard(&guard);
}

This is a safety feature. Resources are cleaned up correctly even when you skip ahead. You don't need to manually drop variables before continue.

Nested loops and labels

break only exits the innermost loop. If you have nested loops, you need labels to break out of the outer one.

'outer: loop {
    loop {
        if should_quit() {
            break 'outer; // Exits the outer loop.
        }
        // Inner loop logic.
    }
}

Labels are identifiers followed by a colon. break 'label jumps to the end of the labeled loop. This is essential for complex control flow.

Label your loops when nesting. break without a label only kills the inner loop, leaving the outer one spinning.

Decision: choosing the right loop

Rust offers three loop constructs. Pick the one that matches your pattern.

Use loop when the exit condition is buried deep inside the body or when the loop needs to return a value via break. Use loop for game loops where the structure is "run until quit" and the quit signal comes from input or an event. Use loop when you want the loop to be an expression that produces data.

Use while condition when the loop should run as long as a boolean predicate holds true, and the check happens naturally at the top. Use while for waiting on a flag that changes inside the loop, like while running { ... }. Use while when the condition is simple and evaluated before each iteration.

Use for item in iterator when you are iterating over a collection or a range. Use for for processing lists, arrays, or fixed counts. Never use loop to iterate a vector; use for. The compiler optimizes for loops aggressively and prevents index-out-of-bounds errors.

Match the loop to the pattern. for for collections, while for predicates, loop for infinite cycles.

Where to go next