Earning your Wings - Building Space Invaders. Rust Beginner Series


Welcome to Episode 5 in the Rust Beginner series. Episode 4 left the series with a demonstration of a simple method to use Rust to solve an optimisation problem in Finance: a brute-force grid search that walks every feasible blend of momentum, mean reversion and buy and hold financial strategies, and keeps whichever one maximises the Sharpe ratio, and closes by admitting that brute force is not how anyone solves that problem at real scale. The solver that fixes it is coming, but not in this episode. Here we step away for a moment from the world of Finance and take a detour into the world of Gaming. Another practical goal to truly earn our wings is to introduce a terminal UI Rust library called Ratatui and build something actually playable: Space Invaders, rendered in your terminal, invaders from the classic arcade game marching sideways and dropping a row every time they touch an edge, bullets you can fire as fast as you can press, a score, a win condition and a loss condition. In the next episode, we promise to return to apply graphing functions to the financial application, and when we do, the drawing machinery introduced here will be exercised to put graphs on the screen. Along the way this episode picks up the two ideas that make the difference between code that runs and code you can trust: automated tests, and Rust’s ownership and borrowing rules explained in plain English rather than in compiler diagnostics.

From Finance to Space Invaders

The long awaited book this series is based on is now on Amazon.

There is a reason a game is the right vehicle for this episode. The Sharpe ratio calculated in previous episodes is a straight line: data in, number out, program ends. A game is a loop that never stops, holding state that changes on its own, being read by one piece of code and written by another, several times a second. That shape is where Rust’s ownership rules stop being a theoretical talking point and start being the thing that stops you shipping a bug. This episodes starts by considering what ownership and borrowing in Rust means to our application. This is the single most important topic in Rust. Getting this right will determine whether or not you get your Rust wings. The other important topic covered is testing. Here it is shown that the habit of testing pays for itself immediately, because “did the invaders drop a row correctly” is not a question you want to answer by squinting at a terminal thirty times in a row.

The program we build is a single Cargo package, roughly 250 lines of Rust across two files, src/game.rs and src/main.rs. Every code listing in this post is taken from that real, compiling, tested program. Where a listing is trimmed to keep the post readable, the post says so rather than quietly presenting a simplified version that would not actually build. If you have been following along the Rust Beginner Series, brace yourself, this is a long one, and another practical application episode. This article promises to stretch your knowledge of Rust so far to its limit, but at the same time also promises you that you will earn your wings if you do come out at the other side.

You can find the previous articles in this series here:

Here is what Episode 5 covers

  • Ownership and borrowing in plain English: the R, W and O permissions every binding carries
  • Moves, immutable borrows, mutable borrows, and the many readers XOR one writer rule
  • How the borrow checker rules out use after free and double free before your program ever runs
  • Slices, and why a function that only reads a run of values should never take ownership of it
  • The MVU pattern, Model, View and Update as three functions with three separate jobs
  • A complete Space Invaders model: a marching invader formation, a stream of in-flight bullets, collisions, and a Playing, Won or Lost state machine
  • Ratatui: init, restore, the draw loop, and how Layout and Constraint carve up a terminal
  • crossterm::event::poll draining the event queue, and the difference between a tick and an event
  • Unit tests: #[cfg(test)], #[test], assert!, assert_eq! and assert_ne!, and what a good test for a game actually asserts

Ownership and borrowing, in plain English

The programming language question that separates Rust from other programming languages is - who frees this memory, and when? C and C++ say “you do, and good luck”: this method is fast, no overhead, and the source of the largest single category of security vulnerabilities in the industry. Java, Python, Go and C# say “a garbage collector does, whenever it feels like it”: safe, and paid for with background CPU and unpredictable pauses. Rust takes a third path and answers the question at compile time. The compiler works out where every value’s life ends, inserts the cleanup itself, and refuses to compile any program in which a value could be used after that point or freed twice. There is no collector and no runtime cost. The mechanism is called ownership.

The clearest way to hold ownership in your head is to think of every binding as carrying up to three permissions. R (Read) means you may look at the value. W (Write) means you may change it. O (Own) means you are responsible for it: when your ownership ends, because the binding goes out of scope, the value is cleaned up and any memory it holds is released, automatically, with no code from you.

Before reaching for the game, it is worth watching these three letters play out somewhere smaller, because the shape you are about to meet in App::new is exactly the same shape, just wearing different names [2]. Here is a tiny, ordinary piece of Rust with nothing to do with invaders at all:

fn total(scores: &Vec<i32>) -> i32 {
    scores.iter().sum()
}

fn add_score(scores: &mut Vec<i32>, new_score: i32) {
    scores.push(new_score);
}

struct ScoreBoard {
    scores: Vec<i32>,
}

fn main() {
    let mut scores = vec![10, 20, 30]; // let mut: R, W and O
    add_score(&mut scores, 40);        // &mut: R and W, lent out temporarily
    let points = total(&scores);       // &: R only, lent out temporarily
    println!("Total: {points}");

    let board = ScoreBoard { scores }; // move: O transfers into board
    // `scores` is finished here; using it again would not compile.
    println!("Board holds {} scores", board.scores.len());
}

Walk it a line at a time and every permission from the paragraph above has already made an appearance. let mut scores = vec![10, 20, 30] gives the local binding all three letters, R, W and O, which is why the very next line is allowed to change it at all. add_score(&mut scores, 40) hands out a mutable borrow: for the length of that call, add_score’s parameter holds R and W, and main’s own scores binding keeps O but is stripped of R and W, which is exactly why add_score is allowed to push onto a vector it does not own. Ownership never transfers through a borrow, only through a move, so main is still the vector’s owner throughout, and the borrow ends the moment the call returns, giving scores all three permissions back before the next line runs. let points = total(&scores) hands out an immutable borrow instead: total’s parameter gets R only, main keeps R, W and O throughout, and total could no more push onto that vector than it could delete main itself. Finally, let board = ScoreBoard { scores } is a move: the field-init shorthand quietly transfers ownership of the vector’s heap buffer into board, the local scores binding loses all three permissions in that instant, and the comment on the next line is not decoration, it is the compiler’s actual verdict if you try to read scores again afterwards.

Hold onto that shape, because nothing about it changes size once the vector holds fifteen invaders instead of three integers and the struct is called App instead of ScoreBoard. Watch the same four moves happen again, this time in the code you are actually going to run.

A let binding has R and O. A let mut binding has R, W and O, and that is the entire difference between the two: mut is the W permission, spelled out. This is why let mut invaders = Vec::with_capacity(...) in App::new needs the mut and let ctx = ..., the market context in Episode 3’s backtester, did not: one of them gets pushed to, and the other only gets read.

Once you have the three letters, every borrow-checker error in Rust becomes the same error: some operation needed a permission that the binding did not have at that moment. app.player_col -= 1 needs W on app, which is exactly why update takes &mut App and view does not. The compiler is not being fussy about references; it is checking a permission, and the reference type is how you told it which permissions to hand out.

A move is what happens when ownership transfers. After a move, the original binding loses all three permissions at once. It still exists as a name, but it holds nothing, and using it is a compile error rather than a runtime surprise. Two moves happen in the game code you have already read. The first is in App::new, where the local invaders vector moves into the App struct literal: the vector’s heap buffer is not copied, ownership of it simply transfers, and the local name is finished. The second is at the top of update, whose signature takes event: Event by value, so main hands over its event and cannot use it again afterwards, which is fine because main has no further use for it. Scalar types like i32 and usize behave differently, because they implement Copy: app.player_col can be read into a local as many times as you like, since duplicating an integer is trivially cheap and Rust does it implicitly rather than moving.

An immutable borrow, written &T, hands out the R permission without the O. The owner keeps R and O, and temporarily loses W for as long as the borrow is live. This is view(app: &App, frame: &mut Frame): view can read every field of the game and cannot write to any of them, and main keeps ownership of the App throughout. A mutable borrow, &mut T, is the opposite trade: the reference gets R and W, and the owner temporarily loses both, but keeps O. Ownership is never transferred by a borrow; the original binding remains the owner throughout, and regains R and W the moment the borrow ends. This is update(app: &mut App, ...) and tick(app: &mut App), and it is why *app = App::new() is allowed to replace the entire game state through the reference: the borrow’s W covers the whole value, so writing a fresh App through the reference overwrites the old one in place, with no transfer of ownership involved.

The rule that all of this collapses into is a single line, and it is worth memorising in exactly this form: at any given moment you may have many immutable borrows, or exactly one mutable borrow, and never both. Not “you should not”. You cannot: the program does not compile. The exclusive-or is the whole point. Many readers are harmless because nobody is changing anything underneath anybody. One writer is safe because nobody else is looking. One writer plus any reader at all is the configuration in which a value can change while someone is mid-way through relying on it, and that configuration is simply unreliable at runtime and therefore unavailable at compile time in safe Rust.

In the Space Inverders game program, we see that main’s loop is this rule in action, and the reason it compiles is timing. terminal.draw(|frame| view(&app, frame)) takes an immutable borrow of app. update(&mut app, event) takes a mutable one. Those two borrows would be illegal together, and they are never together: the draw call completes, its borrow ends, and only then does update begin. The borrows are sequential rather than simultaneous, so the rule is satisfied without anybody having to think about it.

The other half of the rule is that borrows end at their last use, not at the closing brace of the block they were created in. This is called Non-Lexical Lifetimes, and phase one of tick depends on it completely: app.bullets.iter_mut() creates a mutable borrow into the vector that is last used by the for loop’s body, and so the very next line, app.bullets.retain(...), is free to borrow the same vector again, because the earlier borrow is already over. Had the compiler insisted that borrows last to the end of their enclosing block, that natural way of writing the bullet’s movement would have been rejected outright.

Two families of bug disappear as a direct consequence, and both are worth naming, because the pilot episode of this series listed them among the failure modes the industry has been repeating for fifty years.

Use after free is reading memory that has already been released. Phase three of tick is a small monument to it. Consider what would happen if that phase held a borrow of the invaders while mutating them: alive_cols exists precisely so that it does not. The read pass collects the living columns into an owned Vec<usize>, the borrow of app.invaders that produced them ends there, and only then do the iter_mut() loops take a mutable borrow to do the moving. Try to keep the read borrow alive across the write and the compiler refuses, because a Vec that is written to may reallocate its buffer and move its contents elsewhere, leaving any reference into the old buffer pointing at memory the allocator has already reclaimed. In C that reference is a dangling pointer, silently readable, silently wrong. In Rust it is a compile error, at no runtime cost whatsoever.

Double free is releasing the same memory twice, corrupting the allocator in ways that often surface much later and somewhere else entirely. Moves exist to prevent it. Because ownership is exclusive, there is never a moment at which two bindings both believe they are responsible for the same heap buffer, so there is never a moment at which two cleanups could run for it. The *app = App::new() line in update is the interesting case: the old App, including its invader vector’s buffer, is dropped exactly once at that assignment, and the new one takes its place. Nothing in that line names the old value, nothing could name it afterwards, and no free call appears anywhere in the program.

A slice is a borrowed view of a contiguous run of values: &[usize] is “a reference to some usize values laid out end to end”, regardless of whether they actually live in a Vec, in a fixed-size array, or in part of either [3]. Episode 1 introduced &[T] as a non-owning, read-only view over a contiguous run of values; the game shows why it matters. alive_cols.iter().min() works on a Vec<usize> because a Vec will hand out a slice view of its buffer on request, so every method that a slice offers is available on a vector too, without conversion and without copying. That is why describe_history(returns: &[f64]) in Episode 3 could be called with &history where history was a Vec<f64>, and it is why the same function would work unchanged on a fixed array or on a sub-range like &history[1..3]. Layout::split returns its rectangles as a run you index exactly like a slice, which is why areas[0], areas[1] and areas[2] in view need no unwrapping. And the practical rule that falls out of all this is the one Episode 3 handed down, now with a reason attached: when a function only needs to read a sequence, take &[T] rather than Vec<T>. It costs no allocation, it leaves ownership with the caller, and it makes the function usable with more kinds of input than it would otherwise accept. Take the owned Vec<T> only when the function genuinely needs to keep the data or grow it.

Earning your Wings

Space Invaders is, at its heart, a single question asked five times a second: has anything that matters changed since the last time we looked? The object of the game is the arcade original: a formation of invaders marches sideways across the top of the board, drops a row and reverses the instant it touches an edge, and keeps doing that, implacably, until either every invader is dead or one of them reaches the row the player’s ship stands on. The player commands exactly one thing, a ship confined to the bottom row, and has exactly two verbs available to it: move left or right along that row, and fire a bullet straight up from wherever the ship currently sits. Held down, the fire key produces a stream of bullets rather than one shot at a time, a deliberate departure from the arcade original that this series will justify properly once the Model is on the table. Every bullet that connects with a living invader kills it and adds one to the score; every bullet that reaches the top of the board without hitting anything simply vanishes. The game is won the moment the last invader dies, and lost the moment any invader reaches the player’s row, and there is no third outcome: at every instant the game is in exactly one of Playing, Won or Lost, and everything else this post builds exists to keep that one fact true. Here’s what the finished game looks like.

That description is a set of rules, but a running program is a loop, and it helps to see the whole shape of that loop before reading the code that implements it, phase by phase, in the order those phases actually run [9]:

Read that diagram against the four phases named inside the tick partition and you are reading the same order the real tick function runs in, later in this post: bullets move first, collisions are resolved second, the formation marches third, and the win or loss check runs fourth, once everything else has already moved for this step. The outer loop is main, and it is drawn as three plain steps for a reason: drain every waiting key event before drawing anything, draw exactly one frame of whatever the state currently is, and only then, if enough wall-clock time has passed, advance the simulation by one tick. Nothing in that outer shape mentions invaders, bullets or scores at all, which is the whole point: the loop is generic machinery, and everything specific to Space Invaders lives inside the tick partition and in the data the loop carries around, which is exactly what the rest of this episode is about to build.

With the shape of the whole program mapped out, here is what it actually takes to build it.

Everything in this episode is built on exactly two crates from crates.io, added the same way colored, the crate that coloured Episode 3’s terminal output, was added. Here is the whole manifest for the game:

[package]
name = "ep05_space_invaders"
version = "0.1.0"
edition = "2021"

[dependencies]
ratatui = "0.29"
crossterm = "0.28"

ratatui is the user interface library: it owns widgets, layout and rendering, and it is what turns a grid of characters into something that looks deliberate rather than accidental. crossterm is the layer underneath it: cross-platform terminal control and, crucially for this game, keyboard input. The two are separate crates on purpose. Ratatui draws; crossterm tells you what the user did. The version numbers are pinned to major-minor rather than an exact patch, so cargo build is free to pick up bug fixes within the same compatible release. Note that there is no third dependency here for timing or for game loops: the tick clock this game needs comes from std::time, which ships with the language.

Model, View, Update

The pattern that holds an interactive program together in Ratatui Terminal User Interface (TUI) is called MVU, Model, View and Update, the same idea popularised by the Elm language as The Elm Architecture [7]. It splits an interactive program into three responsibilities and refuses to let them mix. The Model is the single source of truth: one value holding everything the program needs to remember between frames. The View is a function from model to picture: it reads the model and renders it, and never mutates anything, which means the same model always produces the same picture. The Update is a function from model plus event to a changed model, and it never renders.

Here are the three signatures from the game, with their bodies elided so the shape is visible on its own:

pub fn update(app: &mut App, event: Event) -> bool { /* ... */ }
pub fn tick(app: &mut App) { /* ... */ }
pub fn view(app: &App, frame: &mut Frame) { /* ... */ }

Read those three lines as a contract, because that is exactly what they are. view takes &App, an immutable borrow, so it is structurally incapable of changing the game: if the screen shows something wrong, the bug is either in view or in the state it was handed, and nowhere else. update and tick take &mut App, an exclusive mutable borrow, so they may change any field they like, but they have no access to the terminal at all and therefore cannot draw anything. update returns a bool, which is the whole of its conversation with main: true means keep playing, false means the user asked to quit. Notice too that update takes its Event by value rather than by reference, because an event is a small, self-contained fact that the caller has no further use for once it has been handed over.

The payoff is diagnostic rather than aesthetic. When something is displayed incorrectly you read view and nothing else. When something changes incorrectly you read update, or in this game tick, and nothing else. You are never in the position, familiar from any codebase where rendering and state are tangled together, of having to hold both halves in your head simultaneously to work out which one lied to you.

The second payoff is testability, and it is the reason this game has a test suite at all. Because update and tick are ordinary functions over ordinary data, with no terminal anywhere in their signatures, a test can construct an App, feed it a synthesised key press, and assert on the resulting state, with no terminal, no rendering and no human in the loop. A design where the key handling lived inside the drawing code could not be tested that way at any price.

The clearest way to see what view actually is, stripped of every game-specific detail, is to build the smallest Ratatui program that can hold one MVU triple: a counter. Here is the whole thing, including its own tiny Model, View and Update:

use crossterm::event::{self, Event, KeyCode};
use ratatui::{widgets::Paragraph, Frame};

fn view(count: i32, frame: &mut Frame) {
    let text = format!("Counter: {count}\n\nUp/Down to change, q to quit");
    frame.render_widget(Paragraph::new(text), frame.area());
}

fn main() {
    let mut terminal = ratatui::init();
    let mut count = 0;

    loop {
        terminal
            .draw(|frame| view(count, frame))
            .expect("failed to draw frame");

        if let Event::Key(key) = event::read().expect("failed to read event") {
            match key.code {
                KeyCode::Up => count += 1,
                KeyCode::Down => count -= 1,
                KeyCode::Char('q') => break,
                _ => {}
            }
        }
    }

    ratatui::restore();
}

Every piece of that program is worth naming, because every one of them reappears, unchanged in kind, in the game’s own view [4]. count is the entire Model, one i32 instead of the game’s seven-field App, but the same idea: a single value that holds everything the program needs to remember. view takes it by value here rather than by &i32, because an i32 is Copy and there is nothing to borrow that costs anything to duplicate, but the shape is identical to view(app: &App, frame: &mut Frame): read the Model, touch nothing, produce a picture. Frame is the same handle in both programs, the draw surface for exactly one rendered snapshot, valid only for the duration of the closure passed to terminal.draw. frame.area() is the simplest possible use of the rectangle a Frame offers: rather than slicing it with a Layout, as the game does to get three separate bands, the counter hands its one Paragraph the entire available rectangle directly, because a program with exactly one thing to show has no carving up to do [5]. Paragraph::new(text) is the same widget the game uses three times over, for its header, its board and its status line, and frame.render_widget(widget, rect) is the same call that places every one of them. The loop in main is even simpler than the game’s: no tick, no Duration, just draw, block on event::read() until a key arrives, and either change count or break. There is nothing to animate here, so there is nothing that needs to run on a clock.

What the counter leaves out is exactly what the game had to add once its Model grew past one field into a whole board. Line them up and the additions are precisely the components this post has already introduced:

  • Layout and Constraint: the counter needed no layout at all, one widget, one rectangle; the game’s view calls Layout::vertical([...]).split(frame.area()) to carve the terminal into a controls band, a board band and a status band before it draws anything into any of them.
  • Block::bordered().title(...): the counter’s Paragraph floats with no border; every one of the game’s three Paragraphs is wrapped in a Block so the player can see where the controls end and the board begins.
  • Multiple Paragraph widgets: the counter draws one; the game draws three, one per band, each built and thrown away fresh on every frame exactly as the counter’s single one is.
  • A hand-built character grid: the counter has nothing to grid, one number is one string; the game paints a Vec<Vec<char>> twenty cells wide and sixteen tall, flattens it into one multi-line string, and hands that whole string to a single Paragraph, a technique the counter never needed because it never had 320 cells to fill.
  • A non-blocking poll loop instead of a blocking read: the counter is turn-based in the truest sense, it blocks on event::read() and does nothing until a key arrives; the game cannot afford to block at all, because invaders keep marching whether the player presses anything or not, which is exactly why its main drains events with poll(Duration::ZERO) instead.

The Model: what a Space Invaders game has to remember

Everything else in the program is src/game.rs. It opens with the imports and the board’s fixed dimensions:

use crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::{
    layout::{Constraint, Layout},
    widgets::{Block, Paragraph},
    Frame,
};

/// Board width, in terminal columns.
pub const WIDTH: usize = 20;
/// Board height, in terminal rows. The bottom row is the player's row.
pub const HEIGHT: usize = 16;
/// The row the player ship lives on, and
/// the row that ends the game if an
/// invader reaches it.
pub const PLAYER_ROW: usize = HEIGHT - 1;

const INVADER_ROWS: usize = 3;
const INVADER_COLS: usize = 5;

Three of these constants are pub because main.rs and the tests need them, and two are private because only the code inside this module ever builds a formation. PLAYER_ROW is computed from HEIGHT rather than written as 15, which means the board can be resized by editing one line and nothing goes quietly out of step. All five are usize, the counting and indexing type from Episode 1, because every one of them ends up indexing into a grid. The /// comments are doc comments rather than ordinary // comments: they attach documentation to the item beneath them, and cargo doc turns them into browsable HTML.

Next come the three types that describe what is on the board:

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Invader {
    pub row: usize,
    pub col: usize,
    pub alive: bool,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bullet {
    pub row: usize,
    pub col: usize,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GameState {
    Playing,
    Won,
    Lost,
}

An Invader is a position and a flag. Notice what it is not: dead invaders are not removed from the collection, they are marked alive: false and left in place. That single decision ripples through the rest of the program, and mostly in a good way, since it means the formation’s shape and every invader’s index stay stable for the whole game, which makes the tests far easier to write. It also creates precisely one trap, which a regression test at the end of this post exists to guard. A Bullet is a position with no flag at all, because a bullet is either in flight or gone, and “gone” means it has been removed from the Vec that holds the bullets, not that a field has been toggled. GameState is a three-variant enum with no data attached: the game is in progress, won, or lost. A two-player game’s end state typically needs to carry who won; here there is only one player, so the variant carries nothing. The #[derive(Debug, Clone, Copy, PartialEq)] line on all three is doing real work rather than being decoration, as Episode 2 discussed: Copy lets a Bullet be read out of the Vec by value whenever a test wants a copy, PartialEq lets the tests compare states with assert_eq!, Clone lets a test snapshot the whole formation, and Debug is what prints them when an assertion fails.

The Model itself is one struct and one constructor:

pub struct App {
    pub player_col: usize,
    pub invaders: Vec<Invader>,
    /// Horizontal direction the invader formation is currently marching in:
    /// `1` for right, `-1` for left.
    pub direction: i32,
    pub bullets: Vec<Bullet>,
    pub score: u32,
    pub tick_count: u64,
    pub state: GameState,
}

impl App {
    pub fn new() -> Self {
        let mut invaders = Vec::with_capacity(INVADER_ROWS * INVADER_COLS);
        for r in 0..INVADER_ROWS {
            for c in 0..INVADER_COLS {
                invaders.push(Invader {
                    row: 1 + r,
                    col: 3 + c * 3,
                    alive: true,
                });
            }
        }

        App {
            player_col: WIDTH / 2,
            invaders,
            direction: 1,
            bullets: Vec::new(),
            score: 0,
            tick_count: 0,
            state: GameState::Playing,
        }
    }
}

Seven fields, and between them they are the entire game. player_col is the ship’s column, and there is no player_row because the ship never leaves the bottom row. invaders is a Vec<Invader> rather than a fixed array, which is the growable collection from Episode 2, chosen here because the formation size is a decision made in App::new rather than baked into the type. direction is an i32 holding 1 or -1, so reversing the march is the single expression -app.direction; making it a signed integer rather than a two-variant enum is a deliberate trade of a little type safety for a lot of arithmetic convenience. bullets is a Vec<Bullet>, the collection that lets the player fire as fast as they can press, and we will come back to that in a moment. score, tick_count and state round it out.

App::new is the associated function convention from Episode 2, where Strategy::new(...) built each portfolio strategy, and it does two things. First it builds the formation: Vec::with_capacity allocates room for all fifteen invaders up front, so the pushes inside the loop never trigger a reallocation, and the nested for loops lay out three rows of five, starting one row down from the top with 1 + r, and spaced three columns apart with 3 + c * 3. Then it builds the App itself, moving that freshly built invaders vector into the struct. That line is worth pausing on: invaders, on its own inside the struct literal is field init shorthand, meaning “the field named invaders gets the value of the local variable of the same name”, and what happens there is a move, not a copy. The vector’s heap buffer is not duplicated; ownership of it transfers from the local binding into the struct, and after that line the local invaders no longer exists as a usable value.

Three rules define this game, and all three are encoded in the model rather than in the code that manipulates it.

The formation marches as a block. Every living invader moves one column in the current direction on each tick. When the formation reaches an edge, the whole thing drops one row and reverses. Because they move as a block, the state needs only one direction for the entire formation rather than one per invader, and the edge test is a question about the formation’s extremes, not about any individual.

A stream of fire, not one shot at a time. The classic arcade game limits the player to one shot on screen, so a new shot cannot be fired until the previous one has hit something or left the top of the board. This game deliberately does the opposite: holding the fire button produces one shot per key press, a stream of bullets. Modelling that as Vec<Bullet> rather than Option<Bullet> makes the rule structural rather than merely enforced, and the choice of collection is the gameplay rule. An Option<Bullet> can hold at most one value, so there is nowhere in the model for a second bullet to exist and no amount of key mashing can produce one; a Vec<Bullet> holds as many as the player produces, so the stream is equally unbreakable in the other direction. Decide which rule you want, and the type writes it down for you. This is the same move as choosing Option<T> over a nullable value in Episode 2, applied to a gameplay rule.

The game ends two ways. Every invader dead means Won; any living invader reaching PLAYER_ROW means Lost. Both are checked once per tick, at the end, after everything else has moved, so there is exactly one place in the program where the game can end. GameState then acts as a gate: while it is Playing the player may move and fire and the clock advances, and once it is Won or Lost movement, firing and the clock all stop, leaving only restart and quit live. A state machine with three states and one gate is small enough to hold in your head completely, which is precisely why it is worth keeping it that small.

Update: turning key presses into state changes

update is the only function that ever sees a key press. crossterm::event::Event is an enum of everything that can happen at a terminal, a key press, a mouse move, a resize, so working out which one arrived means matching on it. Here it is in full:

pub fn update(app: &mut App, event: Event) -> bool {
    let Event::Key(key) = event else {
        return true;
    };
    // Accept auto-repeat events as well as genuine presses. On Windows
    // crossterm reports a held key as a stream of `Repeat` events, and the
    // game wants them: a held arrow key moves the ship once per repeat, and
    // a held fire key keeps firing, just like the initial press. Only the
    // release is discarded, so one physical press can't also act as a press
    // when the key comes back up.
    if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
        return true;
    }

    match key.code {
        KeyCode::Char('q') => return false,
        KeyCode::Char('r') => {
            *app = App::new();
            return true;
        }
        _ => {}
    }

    if app.state != GameState::Playing {
        return true;
    }

    match key.code {
        KeyCode::Left => {
            if app.player_col > 0 {
                app.player_col -= 1;
            }
        }
        KeyCode::Right => {
            if app.player_col < WIDTH - 1 {
                app.player_col += 1;
            }
        }
        KeyCode::Char(' ') | KeyCode::Enter => {
            app.bullets.push(Bullet {
                row: PLAYER_ROW.saturating_sub(1),
                col: app.player_col,
            });
        }
        _ => {}
    }

    true
}

The function is a series of filters, each one narrowing what is left to consider. let Event::Key(key) = event else { return true; }; is a let-else statement: try to destructure event as a key event, and if that fails, run the else block, which must leave the function. It is the “must match or exit” companion to the if let you met in Episode 2, and it keeps the happy path unindented instead of burying the whole function inside a match arm. Mouse events and terminal resize events fall out here, returning true because “I did not understand that” is not a reason to quit. The matches! check on the next line decides which key kinds to believe. A genuine press and an auto-repeat are both accepted, and a release is not. That distinction matters because of what Windows reports: while a key is held, crossterm emits a stream of Repeat events rather than more Press events, so a gate that only admitted Press would move the ship once and then go deaf until the key comes back up, making a held arrow key feel dead. Accepting both means a held key keeps moving and a held fire button keeps firing, and discarding the release means one physical press can never act as a second press when the key is let go. This gate is a policy, and the policy is written to match the events the platform actually emits.

The first match handles the two keys that work regardless of what is happening: q returns false, which is the signal main interprets as “break out of the loop”, and r restarts. That restart line, *app = App::new(), deserves attention. app is a &mut App, so *app is the App on the other side of that reference, and assigning to it replaces the entire game state in place: a whole fresh App, built from scratch, written through the borrow. The old App is dropped at that moment, including its invader vector’s heap buffer, with no explicit cleanup written anywhere. The gate comes next: if the game is not Playing, we return early, so a finished game ignores the arrow keys and the fire button and waits for r or q.

The second match is the actual gameplay. KeyCode::Left and KeyCode::Right move the ship one column, each guarded so it cannot leave the board. The left guard is not merely tidy, it is load bearing: player_col is a usize, an unsigned type, so 0 - 1 does not produce -1, it panics in a debug build and wraps to a colossal number in a release build. The if app.player_col > 0 check is what stops that from ever being attempted. Firing is KeyCode::Char(' ') | KeyCode::Enter, one arm matching two patterns through the | alternative, and because shots live in a Vec, every press pushes a new one: app.bullets.push(Bullet { ... }) spawns a shot one row above the ship at the ship’s current column. There is no is_none() guard, so a held fire button produces one shot per key press, the stream of fire the Vec model exists to allow. The saturating_sub(1) rather than - 1 is there for the same unsigned-arithmetic reason as above, even though PLAYER_ROW is comfortably greater than zero here. Finally the function returns true, because everything that is not q means carry on.

Tick: the game clock in one function

tick is where the game actually happens. It is the longest function in the program, and it runs five times a second whether anyone is watching or not:

/// Advances the game clock by one step: the bullet moves, the invader
/// formation marches (or drops and reverses at an edge), collisions are
/// resolved, and the win/loss state is updated. Called on a timer, not on
/// keypress, which is what makes the invaders move on their own.
pub fn tick(app: &mut App) {
    // Gate: a finished game does not advance.
    if app.state != GameState::Playing {
        return;
    }
    app.tick_count += 1;

    // Phase 1: advance every in-flight bullet; each disappears once it
    // flies off the top of the board.
    for bullet in app.bullets.iter_mut() {
        if bullet.row > 0 {
            bullet.row -= 1;
        }
    }
    app.bullets.retain(|b| b.row > 0);

    // Phase 2: resolve bullet/invader collisions.
    app.bullets.retain(|bullet| {
        if let Some(invader) = app
            .invaders
            .iter_mut()
            .find(|i| i.alive && i.row == bullet.row && i.col == bullet.col)
        {
            invader.alive = false;
            app.score += 1;
            false
        } else {
            true
        }
    });

    // Phase 3: march the invader formation as a block, moving sideways
    // until an edge is reached, then dropping a row and reversing
    // direction.
    let alive_cols: Vec<usize> = app
        .invaders
        .iter()
        .filter(|i| i.alive)
        .map(|i| i.col)
        .collect();
    if let (Some(&min_col), Some(&max_col)) = (alive_cols.iter().min(), alive_cols.iter().max()) {
        let hit_right = app.direction == 1 && max_col >= WIDTH - 1;
        let hit_left = app.direction == -1 && min_col == 0;
        if hit_right || hit_left {
            app.direction = -app.direction;
            for invader in app.invaders.iter_mut().filter(|i| i.alive) {
                invader.row += 1;
            }
        } else {
            for invader in app.invaders.iter_mut().filter(|i| i.alive) {
                if app.direction == 1 {
                    invader.col += 1;
                } else {
                    invader.col -= 1;
                }
            }
        }
    }

    // Phase 4: decide whether the game has been won or lost.
    if app
        .invaders
        .iter()
        .any(|i| i.alive && i.row >= PLAYER_ROW)
    {
        app.state = GameState::Lost;
    } else if app.invaders.iter().all(|i| !i.alive) {
        app.state = GameState::Won;
    }
}

The function is a gate and four phases, labelled in the comments above exactly as they are named below, and the order of the phases is a design decision rather than an accident. Each section that follows repeats its own slice of that listing so it can sit right next to its explanation, rather than making you scroll back up to the full function every time.

The gate
// Gate: a finished game does not advance.
if app.state != GameState::Playing {
    return;
}
app.tick_count += 1;

If the game is over, the clock does not advance at all, which is what freezes the final frame on screen. Otherwise tick_count increments, giving the rest of the program a monotonic count of how many steps have been simulated.

Phase 1: move the bullets
for bullet in app.bullets.iter_mut() {
    if bullet.row > 0 {
        bullet.row -= 1;
    }
}
app.bullets.retain(|b| b.row > 0);

app.bullets.iter_mut() hands out a mutable reference to each bullet in the vector in turn, so the for loop can decrement each one’s row, because row zero is the top of the screen and the bullet flies upwards. Then the first retain call removes the bullets that have reached row zero, which is what “flew off the top” means: a shot that has left the board is no longer in the model, with no None to assign and no flag to toggle.

Phase 2: resolve collisions
app.bullets.retain(|bullet| {
    if let Some(invader) = app
        .invaders
        .iter_mut()
        .find(|i| i.alive && i.row == bullet.row && i.col == bullet.col)
    {
        invader.alive = false;
        app.score += 1;
        false
    } else {
        true
    }
});

This second retain call is where the closure idiom earns its keep. retain walks the vector, calls the closure on each element, and keeps the element only if the closure returns true; return false and the element is removed. Here the closure does the collision work itself: iter_mut().find(...) walks the formation looking for the first living invader whose row and column both match the bullet’s position, and find hands back a mutable reference to it if there is one. When a match is found, three things happen together inside the closure: the invader dies, the score goes up, and the closure returns false, which removes the bullet from the vector. That last part is how a shot that hits something ends: it is consumed by the collision, gone from the model, and the player may fire again on the next key press. A bullet that hits nothing returns true and stays in flight for another tick. The two retain calls are separate and sequential, which is why they compile: the first borrows app.bullets mutably and ends, and the second borrows it again, while inside the second closure app.invaders is borrowed mutably in the same breath as app.score is written.

Phase 3: march the formation
let alive_cols: Vec<usize> = app
    .invaders
    .iter()
    .filter(|i| i.alive)
    .map(|i| i.col)
    .collect();
if let (Some(&min_col), Some(&max_col)) = (alive_cols.iter().min(), alive_cols.iter().max()) {
    let hit_right = app.direction == 1 && max_col >= WIDTH - 1;
    let hit_left = app.direction == -1 && min_col == 0;
    if hit_right || hit_left {
        app.direction = -app.direction;
        for invader in app.invaders.iter_mut().filter(|i| i.alive) {
            invader.row += 1;
        }
    } else {
        for invader in app.invaders.iter_mut().filter(|i| i.alive) {
            if app.direction == 1 {
                invader.col += 1;
            } else {
                invader.col -= 1;
            }
        }
    }
}

This phase is deliberately split into a read pass and a write pass. The read pass collects the columns of every living invader into alive_cols. The write pass asks whether the formation has reached an edge, by comparing the direction of travel against the largest or smallest of those columns, and then either drops every living invader one row and reverses direction, or shifts every living invader one column sideways. Both loops filter on i.alive, and that filter is not cosmetic: a dead invader sitting at column zero would underflow its usize column the first time the formation marched left, panicking the game, even though the living invaders were nowhere near the edge. That exact bug happened, and there is a regression test for it later in this post.

Encoding the march correctly needs three things. First, the formation’s extremes: alive_cols.iter().min() and .max() give the leftmost and rightmost living column. Both return an Option, because a formation with no living invaders has no minimum and no maximum, and if let (Some(&min_col), Some(&max_col)) = ... destructures both options from a tuple in one pattern, skipping the whole block when the formation is empty, exactly the tick on which the player has just killed the last invader, so treating “no invaders” as “nothing to march” is what stops that tick from doing anything strange. Second, direction-aware edge tests: hit_right is only true when the formation is marching right and its rightmost member has reached the last column; hit_left is the mirror image. The direction has to be part of the test, because a formation that has just dropped and reversed at the right wall is still sitting at the right wall on the next tick, and without the direction check it would reverse again immediately and jitter in place forever. Third, dead invaders are skipped in the movement itself, not just in the edge test, the subtle one. Edge detection looks only at living invaders, so the formation’s apparent extremes shrink as the player clears its outer columns. If the movement loops then shifted every invader including the dead ones, a dead invader already resting at column zero would be pushed to column negative one, and since columns are usize that is an arithmetic overflow panic rather than a wrong number on screen. Filtering both loops on i.alive keeps the invisible dead exactly where they died and lets the living formation march on without them.

Phase 4: check for a win or a loss
if app
    .invaders
    .iter()
    .any(|i| i.alive && i.row >= PLAYER_ROW)
{
    app.state = GameState::Lost;
} else if app.invaders.iter().all(|i| !i.alive) {
    app.state = GameState::Won;
}

The order of these two checks matters. Losing is tested first, with .any(...) asking whether any living invader has reached the player’s row, and winning is tested second, with .all(...) asking whether every invader is dead. Testing loss first means that if the final invader lands on the player’s row on the same tick as it would otherwise have been the last one alive, the player does not get a win they did not earn.

Collision detection in a game this size needs no clever geometry, because everything lives on integer grid coordinates: two things collide when their row and column are equal. What it does need is a decision about when the comparison happens, and that decision is entirely about the order of the phases within a tick. Here the bullet moves first, collisions are resolved second, and the formation marches third, so a bullet is always compared against the invaders’ positions before the invaders take their step. Change that order and the game changes: resolve collisions before moving the bullet and every shot takes an extra tick to land; march the formation before checking collisions and invaders can march sideways into a bullet’s cell, which sounds like a bonus until you notice the bullet can equally march out of the way and the results stop being predictable. Fixing the order also makes the behaviour testable, which is why the test that fires a shot and asserts a kill can place an invader at a known position and assert that exactly one tick kills it. The wider lesson generalises past this game: in any simulation that advances in discrete steps, “what moves before what” is part of the specification, not an implementation detail. Write it down, in a comment if nowhere else, because someone reorganising the function later will otherwise change the game’s rules while believing they are only tidying up.

View: turning state into a picture

view is the only function that touches the terminal, and it is forbidden from touching the game:

pub fn view(app: &App, frame: &mut Frame) {
    let areas = Layout::vertical([
        Constraint::Length(3),
        Constraint::Length(HEIGHT as u16 + 2),
        Constraint::Fill(1),
    ])
    .split(frame.area());

    let header_text = format!(
        "Space Invaders | Score: {} | \u{2190}\u{2192}: move | Space/Enter: fire | r: restart | q: quit",
        app.score
    );
    let header = Paragraph::new(header_text).block(Block::bordered().title("Controls"));
    frame.render_widget(header, areas[0]);

    let mut grid = vec![vec!['.'; WIDTH]; HEIGHT];
    for invader in &app.invaders {
        if invader.alive && invader.row < HEIGHT && invader.col < WIDTH {
            grid[invader.row][invader.col] = 'W';
        }
    }
    for bullet in &app.bullets {
        if bullet.row < HEIGHT && bullet.col < WIDTH {
            grid[bullet.row][bullet.col] = '|';
        }
    }
    if app.player_col < WIDTH {
        grid[PLAYER_ROW][app.player_col] = 'A';
    }

    let board_text = grid
        .iter()
        .map(|row| row.iter().collect::<String>())
        .collect::<Vec<_>>()
        .join("\n");
    let board = Paragraph::new(board_text).block(Block::bordered().title("Board"));
    frame.render_widget(board, areas[1]);

    let status_text = match app.state {
        GameState::Playing => format!("Score: {} | Playing", app.score),
        GameState::Won => format!("Score: {} | You win! Press r to restart.", app.score),
        GameState::Lost => format!("Score: {} | Game over. Press r to restart.", app.score),
    };
    let status = Paragraph::new(status_text).block(Block::bordered().title("Status"));
    frame.render_widget(status, areas[2]);
}

Ratatui is best understood as a layout engine for a character grid [4]. You do not tell it “print this at row 12, column 40”; you tell it how to slice the available rectangle, hand it a widget for each slice, and let it work out which characters to send to the terminal. Layout::vertical says the pieces stack top to bottom, Layout::horizontal would stack them left to right instead, and you supply one Constraint per piece [5]. The constraints above are a small vocabulary of intentions rather than measurements: Constraint::Length(3) demands exactly three rows for the controls band, one line of text plus a border above and below; Constraint::Length(HEIGHT as u16 + 2) demands the board’s sixteen rows plus its own two border rows; Constraint::Fill(1) claims whatever is left for the status band. Related constraints in the same family, Constraint::Min, Constraint::Max and Constraint::Percentage, cover the cases where you want a floor, a ceiling, or a proportion instead. Ratatui resolves the whole set together: fixed lengths are honoured first, then whatever remains is divided among the flexible constraints. .split(frame.area()) applies that recipe to the entire terminal area and hands back the three resulting rectangles, in the same order as the constraints, indexed like a slice, areas[0], areas[1] and areas[2]. Nothing here is a hard-coded position: resize the terminal and the same three constraints produce three different rectangles, with no code change and no arithmetic on your part. Layouts also nest freely: split vertically into row bands, then split each band horizontally into cells, which is the general technique for building a real grid of separately styled cells; this board takes a different route, for reasons that come up in a moment.

Frame is the handle to one rendered snapshot of the terminal, handed to view for the duration of a single draw and not kept afterwards. frame.area() gives the full rectangle available, and frame.render_widget(widget, rect) places a widget into a rectangle within it.

The header is a format! string carrying the live score and the key bindings, wrapped in a Paragraph inside a Block::bordered().title("Controls"). Block and Paragraph are widgets, values that know how to draw themselves into a rectangle they are given; Paragraph renders text, Block draws a border with an optional title. What matters here is how they are configured: by method chaining, where each configuring call returns the widget itself, so a whole widget is one expression, with no temporary variables and no mutable binding, as in Paragraph::new(text).block(Block::bordered().title("Controls")). That is the builder pattern from Episode 2, exactly as StrategyBuilder::new(...).with_return(...).with_return(...) assembled a strategy one call at a time. The only difference is who wrote the builder: there it was you, here it is Ratatui. Those \u{2190} and \u{2192} escapes are the left and right arrow characters written by codepoint rather than pasted literally, which keeps the source file plain ASCII and avoids any question about how the file is encoded.

The board is drawn by a different technique. A grid could be built the same way, nesting a Layout cell per square, which is right for a handful of large cells that each need their own border and highlight. This board is 20 by 16, which is 320 cells, and nesting 320 layouts to render one character each would be both slow and pointless. Instead view builds a character grid, vec![vec!['.'; WIDTH]; HEIGHT], paints the living invaders as W, every bullet as |, and the ship as A, then flattens the whole thing into one multi-line string and hands that to a single Paragraph. The flattening is the chain at the end: .iter() walks the rows, .map(|row| row.iter().collect::<String>()) turns each row of char values into a String, and .join("\n") glues the rows together with newlines. The bounds checks, invader.row < HEIGHT and friends, are belt and braces: nothing in tick should ever put an invader off the board, but a rendering function that panics on unexpected state is a much worse failure than one that quietly declines to draw it. The bullet loop is a plain for bullet in &app.bullets, an immutable borrow of the vector for the duration of the loop, which is all view may do because view takes &App and therefore cannot touch a mutable borrow even if it wanted to.

The status band is a match on app.state, producing one of three strings, which is the whole of the Won and Lost user experience: the board freezes, because tick has stopped advancing, and the status line tells you what happened and which key restarts. And the important structural fact about this entire function, 45 lines of it, is in its first line: it takes &App. It cannot kill an invader, cannot move the ship, cannot end the game. It reads state and produces pixels, nothing else.

Ticks and events: the loop this game needed

A turn-based game can get away with a loop that draws, then blocks forever waiting for a key press, because nothing happens until a human does something. Space Invaders is not like that: the invaders march on their own, a bullet climbs the screen on its own, and the game has a clock that must keep running even when the player’s hands are nowhere near the keyboard. Here is the whole of src/main.rs:

mod game;
use game::{tick, update, view, App};
use std::time::{Duration, Instant};

/// Target interval between autonomous game-clock advances. Invaders (and the
/// bullet) move on this wall-clock cadence, independent of how often key
/// events arrive: holding a key down can make `poll` return an event faster
/// than this interval (via OS key-repeat), so ticking is driven by elapsed
/// time rather than by "poll timed out", which would otherwise let a held
/// key starve invader movement entirely.
const TICK_RATE: Duration = Duration::from_millis(200);

fn main() {
    let mut terminal = ratatui::init();
    let mut app = App::new();
    let mut last_tick = Instant::now();

    loop {
        // Drain every pending key event before rendering, so a held key
        // (OS key-repeat) moves the player at full speed instead of one
        // cell per redraw cycle.
        while crossterm::event::poll(Duration::ZERO).expect("failed to poll for event") {
            let event = crossterm::event::read().expect("failed to read event");
            if !update(&mut app, event) {
                ratatui::restore();
                return;
            }
        }

        terminal
            .draw(|frame| view(&app, frame))
            .expect("failed to draw frame");

        // The tick runs on elapsed wall-clock time, independent of input, so
        // a run of key events can't starve the invaders of movement.
        if last_tick.elapsed() >= TICK_RATE {
            tick(&mut app);
            last_tick = Instant::now();
        }
    }
}

ratatui::init() is the first thing main does. A normal terminal prints lines that scroll away forever, and buffers your keystrokes until you press Enter, no use for a game. init() fixes both in one call: it switches the terminal into raw mode, so a key press is delivered the instant it happens, with no Enter required and no echoing of the character, and it switches to the alternate screen buffer, a separate blank canvas the program owns completely, leaving the user’s scroll history untouched underneath. It hands back the terminal handle main keeps for the program’s lifetime. ratatui::restore(), at the other end, undoes all of that: raw mode off, alternate screen abandoned, normal terminal back. This matters more than it looks: if the program exits without calling it, the user is left in a terminal that no longer echoes what they type and no longer moves the cursor where they expect, with no recovery short of closing the window or typing reset blind. Always call it, on every exit path.

main is deliberately thin, and it owns exactly three things: the terminal handle, the application state, and a stopwatch. mod game; pulls in src/game.rs as a module, exactly the file-per-module arrangement Episode 3 used to split its code into main.rs and portfolio.rs, and the use line brings the four public items we need into scope. Then the loop does three things in order, every single pass. First it drains the input queue, the while crossterm::event::poll(Duration::ZERO) loop, which is what keeps the keys feeling live and gets its own paragraph below. Second it draws the current state. Third, if the tick is due, it advances the game clock and restarts the stopwatch.

The comments in the source record two real bugs that this arrangement fixes, and both are about holding a key down. The first is the drain loop. The obvious design is to read one event per pass and redraw, then loop: poll returns true, read returns one event, the ship moves one cell, the screen redraws, and the next poll finds the next event in the queue. That is one cell per full redraw cycle, and a held arrow key with operating-system key repeat queues a whole stream of events, so on a slow terminal flush the ship visibly lags the key. The while around poll(Duration::ZERO) fixes it: poll with a zero timeout returns instantly, true if anything is queued, false if not, so the loop drains every waiting event into update before the single redraw, and a held key moves the player at full speed.

The second is the tick. The naive way to run the game clock is to tick whenever poll reports that nothing arrived, treating “no input” as “time passed”. That works right up until the player holds down an arrow key, at which point the operating system’s key repeat delivers events faster than the tick interval, poll never comes back empty, and the invaders freeze in place for as long as the key is held. Driving the tick from elapsed wall-clock time instead of from the absence of input makes the two completely independent, which is what the game actually wants: the invaders keep marching whether you are moving or not. The if last_tick.elapsed() >= TICK_RATE check is the whole of that decision, and tick is the only call in the whole loop that advances the simulation clock.

crossterm::event::read() blocks: it does not return until an input event exists, and if the user never touches the keyboard it never returns at all, which is fine for a turn-based game and useless for anything with a clock. crossterm::event::poll(timeout) is the non-committal version: it waits at most timeout and returns a bool telling you whether an event is now available to read [6]. true means a subsequent read() will return immediately with that event; false means the timeout expired with nothing to show. Passing a zero duration turns it into a pure “is there anything waiting?” check that returns instantly, which is exactly how this game uses it, at the head of the drain loop.

That distinction gives a game loop two entirely separate sources of change, and it is worth naming them clearly. An event is something the player did: a key press, arriving whenever they happen to press it. A tick is something time did: a fixed, regular advance of the simulation, arriving whether the player acts or not. In this game every key press is an event handled by update, and every 200 milliseconds is a tick handled by tick. Bullet movement, invader marching, collisions and the win or loss check all live in tick, because none of them should depend on how fast anyone is typing. Player movement and firing live in update, because they should.

The tick rate is a gameplay decision disguised as a constant. Duration::from_millis(200) means five simulation steps a second, so the invaders shuffle across at a pace a human can react to. Reduce it and the game gets harder with no other change; there is no speed setting anywhere else in the code. Instant::now() and Instant::elapsed are the standard library’s monotonic clock, which is to say a clock that only ever moves forwards and is unaffected by the system clock being adjusted underneath you [8], which is exactly what you want for measuring intervals rather than telling the time.

Testing the rules, not the pixels

None of the game logic above needs a terminal to be checked, and that is not luck, it is the MVU contract paying out. update and tick are ordinary functions over ordinary data, so a test can build a game, put it in an exact state, fire a synthetic key press at it, and assert on what came out.

Testing in Rust needs no external framework, no test runner to install and no configuration file [1]. It needs two attributes and three macros. #[cfg(test)] on a module is conditional compilation: the module is compiled when you run cargo test and skipped entirely otherwise, so your tests cost precisely nothing in the shipped binary no matter how many of them you write. By convention the module is named tests, lives at the bottom of the file it tests, and starts with use super::*; to pull in everything from the parent module, which is how the test module below gets App, update, tick, WIDTH and PLAYER_ROW without naming them one at a time. #[test] marks a single function as a test. It takes no arguments, returns nothing, and passes if it finishes without panicking. cargo test finds every such function in the project, runs them, and prints one line each.

The three assertion macros are how a test panics deliberately. assert!(expr) fails when expr is false, and takes an optional message after the expression, which is worth supplying whenever the bare failure would not make the cause obvious. assert_eq!(left, right) fails when the two are unequal and prints both values, which is almost always what you want, since knowing that a score was wrong is much less useful than knowing it was 0 when you expected 1. assert_ne!(left, right) is the inverse, failing when the two are equal, and it earns its keep when you care that something changed without caring what it changed to. assert_eq! and assert_ne! need the values to be comparable with == and printable with {:?}, which is exactly why Invader, Bullet and GameState all derive PartialEq and Debug.

Two options are worth knowing from day one. cargo test test_fire runs only the tests whose names contain test_fire, which is what you want while working on one function. cargo test -- --nocapture shows println! output from inside tests, which is suppressed by default; the bare -- separates Cargo’s own flags from the flags handed to the test binary.

The test module opens with a helper, and then the simplest tests in the file. It is quoted here in pieces, so the closing brace of the module is not shown until the last excerpt has gone by, and the indentation of the later excerpts is the giveaway that they are all still inside the same mod tests block:

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::{KeyEvent, KeyModifiers};

    fn press(code: KeyCode) -> Event {
        Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
    }

    fn press_repeat(code: KeyCode) -> Event {
        Event::Key(KeyEvent::new_with_kind(
            code,
            KeyModifiers::NONE,
            KeyEventKind::Repeat,
        ))
    }

    #[test]
    fn test_player_movement_clamps_at_left_edge() {
        let mut app = App::new();
        app.player_col = 0;
        update(&mut app, press(KeyCode::Left));
        assert_eq!(app.player_col, 0);
    }

    #[test]
    fn test_player_movement_clamps_at_right_edge() {
        let mut app = App::new();
        app.player_col = WIDTH - 1;
        update(&mut app, press(KeyCode::Right));
        assert_eq!(app.player_col, WIDTH - 1);
    }

    #[test]
    fn test_repeat_events_keep_holding_a_key_moving() {
        // Regression test: `update` used to discard every event that was not
        // a fresh `Press`, and on Windows a held key arrives as a stream of
        // `Repeat` events, so holding an arrow key did nothing after the
        // first cell. Auto-repeats must move the ship like presses.
        let mut app = App::new();
        let start = app.player_col;
        update(&mut app, press_repeat(KeyCode::Right));
        assert_eq!(app.player_col, start + 1);
        update(&mut app, press_repeat(KeyCode::Right));
        assert_eq!(app.player_col, start + 2);
    }

    #[test]
    fn test_release_events_are_ignored() {
        let mut app = App::new();
        let before = app.player_col;
        update(&mut app, press(KeyCode::Right));
        let after = app.player_col;
        assert_eq!(after, before + 1);
        // The release must not act as another press.
        update(
            &mut app,
            Event::Key(KeyEvent::new_with_kind(
                KeyCode::Right,
                KeyModifiers::NONE,
                KeyEventKind::Release,
            )),
        );
        assert_eq!(app.player_col, after);
    }

press is the small piece of scaffolding that makes everything else readable: it wraps a KeyCode into the full Event that update expects, with no modifier keys held and with the default key kind, a fresh press. Without it every test would carry three lines of event construction noise. The two tests either side of it check the unsigned-underflow guards from update: park the ship at column zero, press Left, and assert it did not move, then do the mirror image at the right-hand edge. These are the tests that would have caught the underflow panic described earlier, and they take four lines each.

Beside press sits press_repeat, the same helper built with KeyEvent::new_with_kind instead of KeyEvent::new. The new_with_kind constructor is the one that lets a test choose the event’s KeyEventKind explicitly; the plain new used by press always produces Press, which is why the two helpers exist separately, and crossterm offers new_with_kind and not a builder-style with_kind method, which is worth knowing before a compiler error sends you hunting for a method that does not exist. The two tests built on it pin down the gate’s policy. test_repeat_events_keep_holding_a_key_moving feeds two Repeat events at the right arrow and asserts the ship moved two cells, which is the regression test for the bug that gave this game a deaf held key: before the fix, every event that was not a Press was discarded, and on Windows a held key arrives as a stream of Repeat events, so holding an arrow key moved the ship exactly one cell and then nothing. test_release_events_are_ignored presses, moves one cell, then fires a Release event and asserts the ship did not move again, which is the other half of the policy, that one physical press cannot act as a second press when the key comes back up.

The most valuable test in the file is the one that exercises a whole tick, because it checks four separate consequences of a single event:

    #[test]
    fn test_fire_then_tick_kills_invader_and_scores() {
        let mut app = App::new();
        let target_col = 5;
        app.player_col = target_col;
        // Put an invader one row above where the bullet spawns, so the very
        // next tick moves the bullet straight into it. Collision detection
        // runs before the formation's horizontal march within a tick, so
        // this is deterministic regardless of the formation's direction.
        app.invaders[0].row = PLAYER_ROW - 2;
        app.invaders[0].col = target_col;
        app.invaders[0].alive = true;

        assert!(update(&mut app, press(KeyCode::Char(' '))));
        let bullet = app
            .bullets
            .first()
            .expect("bullet should be in flight");
        assert_eq!(bullet.row, PLAYER_ROW - 1);
        assert_eq!(bullet.col, target_col);

        let initial_score = app.score;
        tick(&mut app);

        assert_eq!(app.score, initial_score + 1);
        assert!(!app.invaders[0].alive);
        assert!(app.bullets.is_empty());
        assert_eq!(app.state, GameState::Playing);
    }

The test arranges an exact scenario rather than a plausible one, and that is the whole craft of testing a simulation. The ship is placed at a known column, and one invader is moved to sit two rows above the player’s row, which is one row above where the bullet will spawn. Space is pressed, and the first three assertions check the firing rule alone: update returned true so the game continues, a bullet now exists as the first element of bullets, and it is at exactly the expected row and column. Then one tick runs, and the last four assertions check everything that tick was supposed to do: the score went up by exactly one, that specific invader is dead, the bullet has been consumed, removed from the vector by the collision’s retain, and the game has not accidentally ended. The comment in the source explains why the outcome is deterministic rather than dependent on which way the formation happens to be marching: collision detection runs before the horizontal march within a tick, which is the phase-order decision from earlier, now pinned in place by a test that would fail if anyone reordered it.

The flip side of the same rule is the multi-bullet test, which appears among the remaining tests quoted below: press fire three times and assert that three bullets are now in flight, all spawned at the ship’s column one row above it. Where the fire-then-tick test proves a shot can be consumed, that one proves a shot never waits on another, which is what pins down the Vec model: no guard, no cap, every press a bullet.

The two end-of-game conditions are one test each, and both are refreshingly blunt:

    #[test]
    fn test_killing_every_invader_sets_won() {
        let mut app = App::new();
        for invader in app.invaders.iter_mut() {
            invader.alive = false;
        }
        tick(&mut app);
        assert_eq!(app.state, GameState::Won);
    }

    #[test]
    fn test_invader_reaching_player_row_sets_lost() {
        let mut app = App::new();
        for invader in app.invaders.iter_mut() {
            invader.alive = false;
        }
        app.invaders[0].alive = true;
        app.invaders[0].row = PLAYER_ROW;
        app.invaders[0].col = 0;
        tick(&mut app);
        assert_eq!(app.state, GameState::Lost);
    }

Neither test plays the game to reach its condition, and neither should. The win test kills the entire formation by writing to it directly through iter_mut(), ticks once, and asserts the state. The loss test does the same, then resurrects a single invader on the player’s row before ticking. Both take under ten lines and run in microseconds, where reaching either condition by simulated play would take hundreds of ticks and would be testing the whole game rather than the one rule under examination. Note that for invader in app.invaders.iter_mut() is a mutable borrow of the vector for the duration of the loop, handing out one &mut Invader at a time, which is the many-readers-XOR-one-writer rule allowing exactly one writer to walk the collection.

Finally, the test that exists because of a real bug:

    #[test]
    fn test_march_skips_dead_invaders_and_does_not_underflow() {
        // Regression test: a partially-dead column at the marching edge used
        // to panic. Edge detection only looks at alive invaders' min/max
        // columns, but the shift used to apply to every invader including
        // dead ones, so a dead invader already sitting at column 0 would
        // underflow the very next time the formation marched left, even
        // though the (still alive) rest of the formation hadn't reached the
        // edge yet.
        let mut app = App::new();
        for invader in app.invaders.iter_mut() {
            invader.alive = false;
        }
        app.invaders[0].row = 5;
        app.invaders[0].col = 0;
        app.invaders[0].alive = false;
        app.invaders[1].row = 5;
        app.invaders[1].col = 10;
        app.invaders[1].alive = true;
        app.direction = -1;

        // Must not panic (this is the actual regression check).
        tick(&mut app);

        assert_eq!(app.invaders[1].col, 9, "alive invader should have marched left");
        assert_eq!(app.invaders[0].col, 0, "dead invader must not be touched by movement");
    }

This is what a regression test looks like: a comment explaining the bug in full, a setup that reproduces the exact conditions, and assertions that pin down the fixed behaviour. A dead invader sits at column zero, a living one sits at column ten, and the formation is marching left. Before the fix, tick shifted every invader regardless of whether it was alive, so the dead one at column zero underflowed its usize and the game panicked. The primary check here is simply that the tick call does not panic, which is why the comment says so explicitly, and the two assert_eq! calls then confirm the intended behaviour on both sides: the living invader marched to column nine, and the dead one was not touched. The two messages attached to those assertions are the optional second argument mentioned above, and they turn a failure report from “9 was not 10” into a sentence that names what actually broke.

The remaining tests in the file follow the same pattern and are worth writing yourself rather than reading: that three presses produce three bullets in flight, that tick does nothing at all once the game is over, that r restarts from any state, that q returns false, and that movement is ignored after the game ends. Every one of them is under ten lines, and together they mean the game’s rules can be changed with confidence, because the moment a change breaks one of them cargo test says so in under a second. The full module holds fourteen tests, and the repeat and release tests quoted above are the ones that exist because of a real bug rather than as a formality.

Before reading any further, try this yourself. Take the game as it stands and give the invaders return fire, using only what this episode has covered. Decide first where the new state belongs in the Model: can a bomb reuse the player’s bullets Vec, or does it need its own Vec because several invaders may be firing at once, and what does the choice of Option versus Vec say about the gameplay you want? Then decide which function owns it: firing on a timer belongs in tick alongside the marching, not in update, because nothing the player does should trigger it. Work out what makes a bomb collide with the ship, and which of the two end-of-game checks in phase four it belongs next to. Then, before you run the game once, write the three tests you would need: that a bomb descends one row per tick, that a bomb reaching the player’s row sets Lost, and that the existing win condition still fires when it should. If the tests pass first time, you have learned the real lesson of this episode, which is that a program whose state, rendering and rules live in three separate places can be extended without being feared.

Coming up next

We have been away from Finance for exactly one episode, and it was worth the detour: everything in this post, the draw loop, the layout constraints, the tick clock and the strict separation between state and rendering, is machinery a financial application wants just as badly as a game does. Next we go back and pick up Episode 4 where it stopped, which was mid-sentence on a promise: the brute-force grid search found the right answer by checking thousands of candidate portfolios one at a time, and the next step is to hand the same objective and the same constraints to a genuine optimisation solver that finds the optimum directly. Ratatui comes with us, because the output of that solver is a curve rather than a number, and a curve wants to be drawn: we will apply graphing functions to the financial application, plotting candidate portfolios and the efficient frontier they trace out, in the terminal, using the very same Layout, Constraint and draw-loop machinery you have just used to shoot down invaders.

Get the Book

Computer programming doesn’t have to be so hard, does it? It can be as easy as A, B, C. As 1, 2, 3. As “doh,” “ray,” “mi.” As data, control, and structures.

Don’t worry. My new book, Rust - the good parts! has you covered, and guides you through these concepts step by step. You can learn about data and control structures in one week, function structures by week two, and by the end of week four you will have built a complete working system, building a game from scratch.

References and Further Reading

[1] The Rust Programming Language, “Writing Automated Tests”, https://doc.rust-lang.org/book/ch11-00-testing.html

[2] The Rust Programming Language, “Understanding Ownership”, https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html

[3] The Rust Programming Language, “The Slice Type”, https://doc.rust-lang.org/book/ch04-03-slices.html

[4] Ratatui, “Ratatui: build rich terminal user interfaces”, https://ratatui.rs

[5] Ratatui API documentation, ratatui::layout::Layout, https://docs.rs/ratatui/latest/ratatui/layout/struct.Layout.html

[6] The crossterm crate, crossterm::event::poll, https://docs.rs/crossterm/latest/crossterm/event/fn.poll.html

[7] Evan Czaplicki, “The Elm Architecture”, https://guide.elm-lang.org/architecture/

[8] The Rust Standard Library, std::time::Instant, https://doc.rust-lang.org/std/time/struct.Instant.html

[9] PlantUML, “PlantUML: Open-source tool that uses simple textual descriptions to draw UML diagrams”, https://plantuml.com

[10] Iyalla John Alamina “Rust - the Good Parts, A first Course”, Amazon

Glossary

  • assert!: a testing macro that panics, failing the test, when the expression given to it evaluates to false, and does nothing at all when it evaluates to true. It accepts an optional message after the expression, which is printed on failure and which is worth supplying whenever the expression alone would not tell a reader what actually went wrong; the regression test in this post uses exactly that facility. Reach for it whenever the thing being checked is a condition rather than a comparison of two values, as in assert!(app.bullets.is_empty()), and reach for assert_eq! instead the moment you find yourself writing assert!(a == b), because that form throws away the values on failure.
  • assert_eq!: a testing macro that panics when its two arguments are not equal, printing both of them in the failure message so the report reads “left: 25, right: 0” rather than merely “assertion failed”. It requires the values to be comparable with == and printable in debug form, which in practice means their types must derive or implement PartialEq and Debug; that requirement is precisely why Invader, Bullet and GameState in this game derive both. It is the default choice for almost every assertion about a specific expected outcome, such as a score, a column or a game state.
  • assert_ne!: a testing macro that panics when its two arguments are equal, the mirror image of assert_eq!, and subject to the same PartialEq and Debug requirements. It is the right tool when the point of the test is that something must have changed, or must differ from a known-bad value, without the test needing to commit to what the new value should be. Used sparingly, because a test that pins down the exact expected value is nearly always more informative than one that only insists on a difference.
  • Borrowing: the act of handing out access to a value without handing over ownership of it, written with & for read-only access or &mut for exclusive read-write access. A borrow gives the reference some of the owner’s permissions for a limited period and suspends the corresponding permissions on the owner while it lasts, then gives them back automatically when the borrow ends. Borrowing is how data moves between functions in almost all idiomatic Rust, because it costs nothing at runtime, allocates nothing, and leaves the caller in possession of its own data: view(&app, frame) reads the whole game state without main giving up a thing.
  • #[cfg(test)]: a conditional compilation attribute that tells the compiler to include the item beneath it only when building for tests. Applied to a mod tests block, as in this game’s game.rs, it means the entire test module, including any helper functions and any test-only dependencies, is compiled by cargo test and skipped completely by cargo build. The practical consequence is that tests are free in production: they add nothing to the binary’s size and nothing to its runtime, so there is never a performance argument for writing fewer of them.
  • Collision detection: deciding whether two things in a simulation occupy the same space. On an integer grid, as in this game, the geometry is trivial, two objects collide when their row and column are equal, and the difficulty moves entirely into timing: at what point within a simulation step is the comparison made, and what has already moved by then. In tick the bullet advances first, collisions are resolved second, and the invader formation marches third, which makes every shot’s outcome deterministic and testable; reordering those phases changes the game’s behaviour, which is why the order is documented in the source and pinned by a test.
  • Constraint: Ratatui’s vocabulary for describing how much space a piece of a layout should get. Rather than positions or pixel counts, you state an intention: Constraint::Length(n) for exactly n rows or columns, Constraint::Fill(n) for a share of whatever remains after the fixed demands are satisfied, plus Min, Max and Percentage for floors, ceilings and proportions. Ratatui resolves the whole set of constraints together against the rectangle being divided, honouring the fixed demands first, which is what allows a layout to keep making sense when the user resizes their terminal.
  • crossterm: the cross-platform terminal control crate that sits underneath Ratatui, responsible for everything Ratatui does not do. It provides raw mode, the alternate screen, cursor control, and, most importantly for a game, keyboard and mouse input through its event module: Event, KeyCode, KeyEvent and KeyEventKind all come from crossterm, not from Ratatui. The division of labour is worth remembering when reading documentation: if it is about what appears on screen it is a Ratatui question, and if it is about what the user did or what state the terminal itself is in it is a crossterm question.
  • Double free: releasing the same block of memory twice, which corrupts the memory allocator’s own bookkeeping and typically causes a crash or a security vulnerability far away from the code that caused it, often long afterwards. Rust eliminates it structurally through exclusive ownership: because exactly one binding is ever responsible for a value, there is never a second binding that could run a second cleanup, and because moving a value invalidates the source, you cannot end up with two owners by accident. This is why *app = App::new() in update is safe: the old game state, invader vector and all, is dropped exactly once at that assignment, and nothing can name it afterwards to drop it again.
  • Edge drop and reversal: the classic Space Invaders movement rule, in which the formation marches sideways as a block until it touches a wall, then drops one row closer to the player and reverses direction. Implementing it correctly needs three things, all visible in this game’s tick: the formation’s leftmost and rightmost living columns, so the edge test is about the block rather than any individual invader; a direction-aware test, so a formation that has just reversed at a wall does not immediately reverse again and jitter in place; and a filter that excludes dead invaders from the movement itself, not merely from the edge test, since shifting a dead invader that is already at column zero would underflow an unsigned column and panic.
  • Frame: Ratatui’s handle to a single rendered snapshot of the terminal, passed to the closure given to terminal.draw. Everything drawn during one draw call goes into the same frame and is assembled completely before anything is written to the terminal, which is what makes redrawing the whole screen several times a second flicker-free. frame.area() gives the full rectangle available, and frame.render_widget(widget, rect) places a widget into a rectangle within it; a Frame is valid only for the duration of that one draw call, which is why it is always borrowed as &mut Frame rather than stored.
  • Game state machine: the small, explicit set of states a game can be in, together with the rules for moving between them. Here it is GameState with three variants, Playing, Won and Lost, plus exactly one place in the whole program where a transition happens, at the end of tick. The state then acts as a gate that other functions consult: tick returns immediately unless the game is Playing, which is what freezes the final frame, and update allows movement and firing only while Playing, leaving restart and quit live in every state. Keeping the machine this small is a deliberate choice, because a game whose entire lifecycle fits in three variants and one transition point can be reasoned about completely.
  • Immutable borrow (&T): a reference that grants read access to a value without ownership, sometimes called a shared reference. The reference gets the R permission; the owner keeps R and O and temporarily loses W for as long as the borrow lives. Any number of immutable borrows of the same value may exist at once, which is safe precisely because none of them can change anything, so no reader can be surprised by a value shifting under it. In this game view(app: &App, ...) is the definitive example: view can read every field of the game and is structurally incapable of altering any of them, which is what makes the rendering code trustworthy at a glance.
  • Layout: Ratatui’s rule for dividing one rectangle into several, created with Layout::vertical for a top-to-bottom stack or Layout::horizontal for a left-to-right row, given one Constraint per piece, and applied with .split(rect). It returns the resulting rectangles in the same order as the constraints, indexed exactly like a slice. Layouts nest freely, and nesting is how grids are built: split vertically into row bands, then split each band horizontally into cells. Nothing in a layout is a fixed coordinate, which is why a Ratatui interface adapts to a resized terminal without any arithmetic on the programmer’s part.
  • Mutable borrow (&mut T): a reference granting exclusive read and write access, sometimes called an exclusive reference. The reference receives R and W; the original binding keeps O but is stripped of R and W, not even the ability to read, for as long as the borrow lives, then regains them when it ends. Ownership never transfers through a borrow, only through a move. Exactly one mutable borrow of a value may exist at a time, and it may not coexist with any immutable borrow, which is what guarantees that a value cannot change while something else is relying on it. update(app: &mut App, ...) and tick(app: &mut App) are this game’s mutable borrows, and *app = App::new() shows the extent of the power involved: the entire value on the far side of the reference can be replaced through it.
  • MVU (Model, View, Update): an architectural pattern, also known as The Elm Architecture, that splits an interactive program into three parts with three non-overlapping jobs. The Model is a single value holding all the state, here the App struct. The View is a function that reads the model and renders it, never mutating, here view(&App, &mut Frame). The Update is a function that takes the model and an event and changes the state, never rendering, here update(&mut App, Event) and its timer-driven sibling tick(&mut App). The pattern earns its keep twice over: a display bug can only be in the View and a state bug can only be in the Update, so you never have to search both; and because Update takes plain data and returns plain data, it can be tested without a terminal, which is exactly how this game’s test suite works.
  • Non-Lexical Lifetimes (NLL): the compiler’s rule that a borrow ends at its last use rather than at the closing brace of the block it was created in. It makes Rust’s borrow rules considerably less restrictive than they first appear, because two borrows written a few lines apart in the same block often do not overlap at all in the compiler’s eyes. Phase one of this game’s tick depends on it entirely: app.bullets.iter_mut() creates a mutable borrow that is last used by the for loop’s body, so it is already finished by the time the next line calls app.bullets.retain(...) and borrows the same vector again.
  • Ownership: Rust’s answer to the question of who is responsible for a value and when it gets cleaned up. Every value has exactly one owner at any moment; when the owner goes out of scope the value is dropped and any memory it holds is released, automatically and with no code written by you. All of this is worked out by the compiler before the program runs, which is what lets Rust manage memory with neither manual free calls nor a garbage collector, and therefore with no runtime overhead and no unpredictable pauses. Every borrow-checker error you will ever see is the compiler enforcing one of four consequences of this: one owner per value, automatic cleanup when the owner goes out of scope, many immutable borrows or one mutable borrow but never both, and no reference outliving what it points at. Ownership passes from one binding to another by the move Episode 2 already glossaried, and this post shows two of them: the invader vector moving into the App struct literal in App::new, and the Event moving from main into update.
  • PlantUML: a plain-text language for describing diagrams, including the flowchart used earlier in this post to lay out the game’s main loop and the four phases inside tick. The diagram is written as a sequence of steps and branches in ordinary text rather than drawn by hand, which keeps it easy to edit as the program’s shape changes and easy to review the same way a code diff is reviewed.
  • poll: crossterm’s non-blocking input check, crossterm::event::poll(timeout), which waits at most the given duration and returns a bool reporting whether an input event is now available to read(). It is the counterpart to event::read(), which blocks indefinitely until an event arrives and is therefore unusable in any program that also has a clock to keep. A zero duration turns it into an instant “is anything waiting?” check. This game calls it with a zero duration at the head of a while loop that drains every waiting event before redrawing, which keeps the keys responsive while the invaders still march on the tick’s own schedule.
  • Ratatui: a Rust library for building terminal user interfaces, meaning programs that run in an ordinary terminal but present bordered panels, colour, columns and keyboard-driven interaction rather than a scrolling transcript. It works as a layout engine over the terminal’s character grid: you describe regions with Layout and Constraint, fill them with widgets such as Block and Paragraph, and let the library work out which cells to write. ratatui::init() prepares the terminal, terminal.draw(...) renders one frame, and ratatui::restore() puts the terminal back as it was. Ratatui does not handle input; that is crossterm’s job.
  • Raw mode and the alternate screen: the two terminal modes that ratatui::init() switches on and ratatui::restore() switches off. Raw mode delivers each key press to the program immediately, without waiting for Enter and without echoing the character, which is what makes real-time keyboard control possible. The alternate screen is a separate blank buffer that the program draws into, leaving the user’s normal terminal contents and scroll history untouched and instantly restored on exit. Failing to call restore() before exiting leaves the user in a terminal that no longer echoes their typing, so it should be treated as mandatory on every exit path rather than as a courtesy.
  • R/W/O permissions: a teaching device for reading ownership and borrowing as a permission system rather than as a set of unrelated rules. Every binding carries up to three capabilities: R, the permission to read the value; W, the permission to change it; and O, the permission to own it, meaning the responsibility for cleaning it up when the binding goes out of scope. A let binding has R and O, a let mut binding has R, W and O, and mut is therefore nothing more mysterious than the W permission written down. Borrowing redistributes these temporarily: &T lends R while suspending W on the owner, and &mut T lends R and W to the reference while suspending both on the owner, who keeps O throughout, since a borrow never transfers ownership. The value of the model is diagnostic, because it collapses every borrow-checker error in the language into one sentence, that some operation required a permission the binding did not have at that point, and it tells you immediately which of two questions to ask: did I need write access I never asked for, or is something else still holding a borrow that has not ended yet.
  • Multiple in-flight bullets: the arcade choice of how many of the player’s shots may be on screen at once. The classic arcade game allows only one, and a new shot cannot be fired until the previous one has hit something or left the top of the board; this game instead lets the player fire as fast as they can press, one shot per key press. The two rules are structural rather than merely enforced: an Option<Bullet> can hold at most one shot, so no sequence of key presses can produce a second, while a Vec<Bullet> holds as many as the player produces. update pushes a new bullet on every Space or Enter press with no guard, and tick removes a bullet only when it flies off the top or when it kills an invader, via the two retain calls.
  • Slice, as a parameter type: Episode 1 introduced &[T] as a non-owning, read-only view over a contiguous run of values; what this post adds is why that type belongs in a function signature. A slice is a borrow, so it carries R and not O, which means a function taking &[T] reads the caller’s data in place, allocates nothing, copies nothing, and leaves the caller in full possession of it afterwards. A slice is also agnostic about where the values actually live, so one function signature accepts a Vec<T>, a fixed-size array, or a sub-range of either, which is why alive_cols.iter().min() works on a Vec<usize> in this game’s tick: a vector hands out a slice view of its buffer on request, and every method a slice offers is available on the vector without conversion. The practical rule, and the one worth carrying into every function you write from here on, is to take &[T] whenever the function only needs to read a sequence, and to take an owned Vec<T> only when it genuinely needs to keep the data or grow it. The mutable counterpart, &mut [T], exists for the rarer case of a function that must modify a run of values in place without owning them.
  • #[test]: an attribute marking a function as a test, so that cargo test discovers it, runs it, and reports on it. A test function takes no arguments and returns nothing; it passes by finishing normally and fails by panicking, which is why the assertion macros work by panicking deliberately. Test functions live by convention inside a #[cfg(test)] mod tests block alongside the code they exercise, with use super::*; at the top to bring the parent module’s items into scope. Naming matters more than it looks, because the name is what appears in the output and what you filter on with cargo test <substring>.
  • Tick: one discrete advance of a simulation’s clock, as distinct from an event, which is something the user did. A tick arrives on a schedule whether anyone is at the keyboard or not, and it is what makes autonomous movement possible: in this game every 200 milliseconds a tick moves the bullet, marches the invaders, resolves collisions and checks whether the game has ended. The interval, held in TICK_RATE, is a gameplay setting disguised as a constant, since lowering it makes the game harder with no other change. Ticks are driven from elapsed wall-clock time, measured with Instant, rather than from the absence of input, so that a held-down key with its operating system key repeat cannot starve the invaders of movement.
  • Use after free: reading or writing memory that has already been released, one of the oldest and most exploitable defect classes in systems programming, and one that typically produces plausible-looking wrong data rather than an obvious crash. Rust’s borrow checker rules it out at compile time by refusing to let a reference outlive the data it points at, and in particular by refusing to let a collection be mutated while a reference into it is still live: a Vec that grows may move its buffer, which would leave any existing reference pointing at reclaimed memory. This is exactly why phase three of this game’s tick collects the living invaders’ columns into an owned Vec<usize> first, ending the read borrow, before taking a mutable borrow to move the formation.
  • Widget: any Ratatui value that knows how to render itself into a rectangle, including Paragraph for text, Block for a border with an optional title, and many others such as lists, tables, gauges and charts. Widgets are configured by method chaining, where each call returns the widget so that the whole thing can be built as a single expression, as in Paragraph::new(text).block(Block::bordered().title("Board")). They are drawn with frame.render_widget(widget, rect), and they are cheap, throwaway descriptions of what should appear rather than long-lived objects, which is why this game constructs fresh ones on every single frame.