Skip to content
Lesson 1.5Computer, Game, and Code Foundations

Rust Primer: Logic in Code

Express programming logic in Rust: name state, choose a path, repeat work, group values, borrow safely, and handle missing or failed results.

Lesson 1.5
Read or listenPrint chapter or book
About 55 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 1.5Key ideas, reference and questions

A one-page summary of “Rust Primer: Logic in Code” you can keep beside your notes. Plain Markdown.

Lesson 1.5 — Rust Primer: Logic in Code: cheatsheet

  • Remember: Every loop needs a stop condition: a collection that runs out, a test that turns false, or a break.
  • Also useful: Photocopying a note leaves two usable notes. Moving a String is like handing over the only house key: the old holder can no longer open the door.

Key terms

  • Cargo: Cargo is the tool normally used to build and run Rust projects.
  • cost: Rust can infer that cost is a u32 and can_dash is a bool, because >= produces true or false.
  • u32: Rust can infer that cost is a u32 and can_dash is a bool, because >= produces true or false.
  • can_dash: Rust can infer that cost is a u32 and can_dash is a bool, because >= produces true or false.
  • bool: Rust can infer that cost is a u32 and can_dash is a bool, because >= produces true or false.
  • selection: This is selection: test a condition, then run the matching branch.
  • stamina: So far stamina is a standalone number.
  • struct: A struct names a shape of related fields; Runner groups a name with a stamina amount.
  • String: The name field is a String: owned text stored in Mira's record.
  • T: T names the success type and E the error type.

Review

  • Q: Why does the Rust primer call show_stamina(&mira) but dash(&mut mira, 4)? A: The first only reads Mira, while the second needs temporary permission to change her
  • Q: In Rust's build tool, how does checking a project differ from running it? A: Checking finds errors fast without making a program; running builds it and starts it
Download cheatsheet (.md)

This lesson gives Rust syntax a purpose. A program runs steps in order, chooses between possible steps, repeats a step, and keeps related state together. Rust has familiar ways to express each of those ideas. We will build one small program around a fictional runner named Mira, who has 12 stamina. A dash uses 4 stamina. A second runner named Sol will let us see why the same dash function can update several records.

Programming question Rust expression in this lesson
What happens first, next, and last? Statements in main, function calls, and assignments.
Which path should run? if, match, and if let.
What should repeat, and when should it stop? for, while, loop, and collection iterators.
Which values belong together? struct, collections, and enum.
Who can read or change a value? &, &mut, ownership, and methods.
What if a value is missing or an operation fails? Option, Result, and ?.

The traces below open with a worked example. Change a value or step through a branch to see why the result changes. Typing practice is optional: Practise typing this hides selected names, parameters and calculations, so you can recall them after reading the explanation. Hints and an answer reveal are always available. Copy visible code offers practice with the complete snippet instead.

Cargo is the tool normally used to build and run Rust projects. After installing a Rust toolchain, a terminal can check that Cargo is available:

cargo --version

A version number means the command is ready. If it is not installed, the official Rust installation guide explains the supported setup. You can read this primer now and run it after the Windows lab setup in Lesson 1.7.

Make a project in a folder you control:

cargo new rust_stamina_primer --vcs none
cd rust_stamina_primer

Cargo creates Cargo.toml, which describes the project, and src/main.rs, which holds the starting program. Replace the contents of src/main.rs with the complete program below. The shorter fragments before it each isolate one idea; do not paste all of them into the same file. cargo run compiles and runs it. cargo check checks the code without producing a program you can run. The official Cargo chapter walks through these same commands.

src/main.rs
source you edit
cargo run
compile
running program
prints Mira's stamina

Start with three values, each with a distinct job. The statements run from top to bottom; later statements can use names created earlier:

let mut stamina: u32 = 12;
let cost = 4_u32;
let can_dash = stamina >= cost;

let binds a name. mut permits a later assignment to stamina. The : u32 is an explicit type; the _u32 on 4 gives the literal that type too. Rust can infer that cost is a u32 and can_dash is a bool, because >= produces true or false. A semicolon ends each statement. To update state, assign a new value to the same name:

stamina = stamina - cost; // first calculate 12 - 4, then store 8

The assignment above assumes the runner has enough stamina. To choose whether the dash runs or reports insufficient stamina, use if and else:

if can_dash {
stamina = stamina - cost;
} else {
println!("Not enough stamina");
}

With 12 stamina, the first branch runs and stamina becomes 8. The else branch would run for 2 stamina and a 4-stamina cost. The braces group the statements belonging to each path. This is selection: test a condition, then run the matching branch. Later, match and if let will select by the shape of a result instead of a true/false condition.

A comparison chooses whether stamina changes. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

let mut stamina = start;
let cost = dash_cost;
if stamina >= cost {
    stamina -= cost;
} else {
    println!("Rest first");
}

The branch ends with 8 stamina. The guarded subtraction spent the cost.

Final values: start = 12; dash_cost = 4; stamina = 8; cost = 4; can_dash = true; path = dash.

Read every step of the lesson’s example
  1. Step 1, line 1. The runner starts with 12 stamina. This binding can change because it uses mut.

    start = 12; dash_cost = 4; stamina = 12.

  2. Step 2, line 2. This dash costs 4. Naming the cost does not spend it.

    start = 12; dash_cost = 4; stamina = 12; cost = 4.

  3. Step 3, line 3. 12 >= 4 is true. Only the selected branch runs.

    start = 12; dash_cost = 4; stamina = 12; cost = 4; can_dash = true.

  4. Step 4, line 4. The check passed, so subtract 4 and store 8.

    start = 12; dash_cost = 4; stamina = 8; cost = 4; can_dash = true; path = dash.

  5. Step 5, line 7. The branch ends with 8 stamina. The guarded subtraction spent the cost.

    start = 12; dash_cost = 4; stamina = 8; cost = 4; can_dash = true; path = dash.

Move the stamina below the cost, then step forward. The failed branch keeps the original amount; a comparison chooses the path before any subtraction.

Repetition: call the dash function for several inputs

Section titled “Repetition: call the dash function for several inputs”

A for loop repeats a body for each item in a collection. Here the loop tries three dash costs in order, carrying the current stamina into the next attempt:

let mut stamina = 12_u32;
for cost in [4, 10, 2] {
if stamina >= cost {
stamina -= cost;
}
}
// stamina is 6: use 4, skip 10, use 2

stamina -= cost is short for stamina = stamina - cost. The trace is 12 → 8; 10 needs more stamina than remains, so stamina stays 8; using 2 leaves 6. The code shows repetition without writing the dash check three times.

The next attempt uses the amount left by the previous one. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

let mut stamina = start;
for cost in [4_u32, 10, 2] {
    if stamina >= cost {
        stamina -= cost;
    }
}
// the loop has finished

All three costs have been visited. The final stamina is 6.

Final values: start = 12; stamina = 6; cost = 2; attempt = 3; can_dash = true.

Read every step of the lesson’s example
  1. Step 1, line 1. Begin with 12 stamina. A new pass does not reset this binding.

    start = 12; stamina = 12.

    Memory: attempt 1 = 4; attempt 2 = 10; attempt 3 = 2.

  2. Step 2, line 2. Pass 1 takes cost 4 from the collection.

    start = 12; stamina = 12; cost = 4; attempt = 1.

    Memory: attempt 1 = 4 (current cost); attempt 2 = 10; attempt 3 = 2.

  3. Step 3, line 3. 12 >= 4 is true. Test the amount remaining now.

    start = 12; stamina = 12; cost = 4; attempt = 1; can_dash = true.

    Memory: attempt 1 = 4 (current cost); attempt 2 = 10; attempt 3 = 2.

  4. Step 4, line 4. Spend 4; 8 stamina remains.

    start = 12; stamina = 8; cost = 4; attempt = 1; can_dash = true.

    Memory: attempt 1 = 4 (current cost); attempt 2 = 10; attempt 3 = 2.

  5. Step 5, line 2. Pass 2 takes cost 10 from the collection.

    start = 12; stamina = 8; cost = 10; attempt = 2; can_dash = true.

    Memory: attempt 1 = 4; attempt 2 = 10 (current cost); attempt 3 = 2.

  6. Step 6, line 3. 8 >= 10 is false. Test the amount remaining now.

    start = 12; stamina = 8; cost = 10; attempt = 2; can_dash = false.

    Memory: attempt 1 = 4; attempt 2 = 10 (current cost); attempt 3 = 2.

  7. Step 7, line 5. Skip this subtraction because 10 is more than 8. The loop still visits the next cost.

    start = 12; stamina = 8; cost = 10; attempt = 2; can_dash = false.

    Memory: attempt 1 = 4; attempt 2 = 10 (current cost); attempt 3 = 2.

  8. Step 8, line 2. Pass 3 takes cost 2 from the collection.

    start = 12; stamina = 8; cost = 2; attempt = 3; can_dash = false.

    Memory: attempt 1 = 4; attempt 2 = 10; attempt 3 = 2 (current cost).

  9. Step 9, line 3. 8 >= 2 is true. Test the amount remaining now.

    start = 12; stamina = 8; cost = 2; attempt = 3; can_dash = true.

    Memory: attempt 1 = 4; attempt 2 = 10; attempt 3 = 2 (current cost).

  10. Step 10, line 4. Spend 2; 6 stamina remains.

    start = 12; stamina = 6; cost = 2; attempt = 3; can_dash = true.

    Memory: attempt 1 = 4; attempt 2 = 10; attempt 3 = 2 (current cost).

  11. Step 11, line 7. All three costs have been visited. The final stamina is 6.

    start = 12; stamina = 6; cost = 2; attempt = 3; can_dash = true.

    Memory: attempt 1 = 4; attempt 2 = 10; attempt 3 = 2.

The watched amount belongs to one runner throughout the loop. Change its starting value to 16: all three costs now fit and the final amount is zero.

Use for when there is a collection to visit. Use while when the condition determines whether another pass is needed:

let mut attempts = 0;
while attempts < 3 {
attempts += 1;
println!("Attempt {attempts}");
}
// prints Attempt 1, Attempt 2, Attempt 3

Use loop when the body decides exactly when to stop:

let mut remaining = 2;
loop {
if remaining == 0 {
break;
}
remaining -= 1;
}

The important question is always what makes the repetition stop? The for example ends after its three costs, the while example ends when attempts reaches three, and the loop example reaches break when remaining reaches zero.

So far stamina is a standalone number. A game also needs to know whose stamina it is. A struct names a shape of related fields; Runner groups a name with a stamina amount. A function names a repeatable behavior. Here dash checks stamina before subtracting the cost; show_stamina displays the state:

struct Runner {
name: String,
stamina: u32,
}
fn show_stamina(runner: &Runner) {
println!("{} has {} stamina", runner.name, runner.stamina);
}
fn dash(runner: &mut Runner, cost: u32) -> bool {
if runner.stamina < cost {
return false;
}
runner.stamina = runner.stamina - cost;
return true;
}
fn main() {
let mut mira = Runner {
name: String::from("Mira"),
stamina: 12,
};
show_stamina(&mira);
let dashed = dash(&mut mira, 4);
println!("Dash succeeded: {dashed}");
show_stamina(&mira);
}

Run it with cargo run. You should see:

Mira has 12 stamina
Dash succeeded: true
Mira has 8 stamina

The order of those lines matters. main starts, creates this sample runner, prints the starting value, asks dash to check and spend stamina, and prints the new value. The first and last print calls read the same runner record, before and after one controlled change.

Mira.stamina before12
dash cost4
Mira.stamina after8
dash checks the cost, then changes Mira's stored stamina
The dash call changes Mira's record; show_stamina only reads it.

Who owns the runner, and who may change it?

Section titled “Who owns the runner, and who may change it?”

let mut mira creates a mutable runner value inside main. main owns that value for this example. The name field is a String: owned text stored in Mira’s record. u32 is a non-negative whole-number type, suitable for this simple stamina example.

show_stamina(&mira) lends the function a read-only reference to Mira. The &Runner parameter says it may inspect a runner without taking over ownership. dash(&mut mira, 4) lends a temporary editable reference; &mut Runner says the function may change that runner. When the call returns, main can use Mira again. Neither function needs to copy the whole record just to read or update its fields.

Think of Mira’s record as one notebook. show_stamina may read it; dash needs permission to write in it. The analogy stops at Rust’s borrowing checks: references are checked by the compiler, and Rust will reject conflicting access to the same value during an editable borrow. Lesson 3.1 develops ownership, borrowing, and error handling when we build an external memory tool.

&mira: read
&mut mira: edit
&mira: read again
main owns Mira
show_stamina
dash
show_stamina

Remove mut from let mut mira and run cargo check: the editable &mut mira call is rejected before the program runs. Put mut back to make the intended change explicit. Compiler messages are useful clues about which part of the code’s promise does not match.

Ownership becomes clearer when we compare two assignments. A u32 number is a small Copy value: assigning it to another variable copies the number, so both names remain usable. A String owns growable text; assigning it normally moves ownership to the new name:

let stamina = 12_u32;
let copied_stamina = stamina; // both names can still be used
let name = String::from("Mira");
let moved_name = name; // moved_name now owns the text
// println!("{name}"); // rejected: name no longer owns it
println!("{moved_name} has {copied_stamina} stamina");
A u32 is copied; a String is movedThe two assignments from the example, in order. The number is duplicated; the text on the heap is not.
  1. 1 let copied_stamina = stamina;

    Stack
    12stamina
    12copied_stamina: its own copy

    A u32 is just four bytes, so the assignment copies them. Both names hold 12 and both stay usable.

  2. 2 let moved_name = name;

    Stack
    movedname: cannot be used
    pointer · length 4moved_name
    Heap
    M
    i
    r
    a

    Only the small String value is copied, and name is marked moved. "Mira" stays where it was with one owner, so it is freed exactly once.

The two assignments from the example, in order. The number is duplicated; the text on the heap is not.

This does not make a second independent copy of the text. If both names claimed to own the same growable allocation, each might try to release it later. Rust makes the transfer visible to the compiler instead. A borrow such as &mira is different: it lets a function use the value temporarily while Mira remains owned by main.

This interactive ownership model needs JavaScript. The surrounding lesson still explains every step in text.

The interactive view separates the String value’s small bookkeeping from the text it owns. It previews the stack and heap vocabulary that Lesson 1.8 will explain. The important ownership consequence here is that the old name cannot be used after its owned String moves; the new owner can use it. A copyable u32 behaves differently.

Rust’s symbols are easier to read when each has a job:

In the example Read it as
name: String a field named name whose type is String
-> bool this function returns true or false
runner.stamina the stamina field in this runner
String::from("Mira") call from associated with the String type
&mira / &mut mira lend read access / lend editable access
println! a printing macro; the ! marks macro syntax

The braces in println!("{} has {} stamina", ...) mark slots filled by the values after the quoted text. The {dashed} form uses the local variable by name. In a later collection example, use std::collections::{HashMap, VecDeque}; brings two standard-library names into scope; :: walks through their module path.

Grow one runner into the full two-runner program

Section titled “Grow one runner into the full two-runner program”

To see how one function updates several records, add Sol to the program. Mira starts with 12 stamina and Sol with 7; each has a separate health amount. Replace src/main.rs with this complete version; do not paste a second main underneath the first one:

struct Runner {
name: String,
stamina: u32,
health: u32,
max_health: u32,
}
fn dash(runner: &mut Runner, cost: u32) -> bool {
if runner.stamina < cost {
return false;
}
runner.stamina = runner.stamina - cost;
return true;
}
fn show_runner(runner: &Runner) {
println!(
"{} has {} stamina and {}/{} health",
runner.name, runner.stamina, runner.health, runner.max_health
);
}
fn main() {
let mut runners = [
Runner {
name: String::from("Mira"),
stamina: 12,
health: 70,
max_health: 100,
},
Runner {
name: String::from("Sol"),
stamina: 7,
health: 45,
max_health: 60,
},
];
for runner in &mut runners {
dash(runner, 4);
}
for runner in &runners {
show_runner(runner);
}
}

The first loop lends each existing runner to dash for an edit. That loop finishes before the second loop lends each runner to show_runner for reading. Mira’s stamina becomes 8 and Sol’s becomes 3; their health fields do not change. cargo run should print:

Mira has 8 stamina and 70/100 health
Sol has 3 stamina and 45/60 health

The shape of each runner became larger, but the stamina check did not need a special Mira version and a special Sol version. Add a third record with 2 stamina: the dash fails and that record keeps 2. The function’s bool result could also be used to display whether each dash succeeded.

An impl Runner block attaches a method to the record type. &mut self gives that method editable access to its receiving runner, just as dash receives &mut Runner. A short method can reuse the existing check:

impl Runner {
fn try_dash(&mut self, cost: u32) -> bool {
dash(self, cost)
}
}

In the full program’s first loop, runner.try_dash(4) has the same result as dash(runner, 4): Mira 8 and Sol 3. Rust methods organize behavior; class inheritance is not required. Lesson 3.4 keeps the full direct-check alternative and its original practice. Use that alternative in place of this method, not as a second method with the same name.

Model two possible outcomes with enum and match

Section titled “Model two possible outcomes with enum and match”

The bool from dash says whether the dash succeeded, but it does not say why it failed or how much stamina is left. When an answer can take one of a few distinct shapes, Rust uses an enum. This separate program decides a dash without changing a runner record:

enum DashOutcome {
Dashed { remaining: u32 },
TooTired { missing: u32 },
}
fn decide_dash(stamina: u32, cost: u32) -> DashOutcome {
if stamina >= cost {
DashOutcome::Dashed { remaining: stamina - cost }
} else {
DashOutcome::TooTired { missing: cost - stamina }
}
}
fn main() {
let decision = decide_dash(2, 4);
match decision {
DashOutcome::Dashed { remaining } => println!("Left: {remaining}"),
DashOutcome::TooTired { missing } => println!("Need {missing} more stamina"),
}
}

This prints Need 2 more stamina. The if chooses which outcome to construct. match then selects the arm for that outcome and names its stored value. The last expression in each branch has no semicolon, so it becomes the function’s answer. If you later add a third kind of dash outcome, Rust requires the match to handle it too. This is how the logic of alternatives appears in a type, instead of being hidden in a special number such as -1.

The two-runner program uses a fixed array. A vector can instead grow as new values arrive:

let mut recent_stamina = vec![12, 8];
recent_stamina.push(6);

The vector now holds 12, 8, and 6. A hash map finds a value by key; a queue takes actions in arrival order. Lesson 3.3 gives their complete Rust examples, including a missing map entry or an empty queue.

An iterator repeatedly yields items. For stamina values 8, 3, and 6, a query keeping values at least 4 counts two ready runners. The loop forms above show how such repetition works. The full iterator/loop comparison and original practice follow the collection examples when we examine bounded sequences.

.get(1) returns an Option: Some(value) when that array position exists, or None when it does not. if let runs its block only for the matching shape and names the value inside it:

let dash_costs = [4, 10];
if let Some(cost) = dash_costs.get(1) {
println!("Second dash cost: {cost}");
}

This prints 10. .get(5) would give None and skip the print, while direct out-of-bounds indexing would fail at runtime. A missing answer differs from a valid zero: 4_u32.checked_sub(4) gives Some(0), whereas 2_u32.checked_sub(4) gives None. No negative unsigned value is invented. These calculations return an answer; they do not change a runner’s fields.

Lesson 3.2 keeps the complete remaining-stamina function, its original practice, and the zero-versus-missing trace.

Option says whether a value exists. Result<T, E> says either Ok(value) or Err(reason). Parsing the text "4" as a u32 succeeds; "tired" is not a whole number. The caller handles both possibilities:

match "4".parse::<u32>() {
Ok(cost) => println!("Cost: {cost}"),
Err(error) => println!("Invalid cost: {error}"),
}

::<u32> selects the numeric type. For "4", the success arm prints Cost: 4; for "tired", the error arm runs. Invalid input is not a valid zero cost. T names the success type and E the error type.

? continues with an Ok value or immediately returns an Err to the caller. The function’s return type must support that failure:

fn read_dash_cost(text: &str) -> Result<u32, std::num::ParseIntError> {
let cost = text.parse::<u32>()?;
Ok(cost)
}

The final expression without a semicolon supplies the returned value. Here success is Ok(cost); a failed parse returns before that line. The caller still decides how to handle the failure. Lesson 3.2 keeps the complete two-input caller and original parser practice, then contrasts that parse error with a domain-specific purchase failure.

The book’s memory tools also introduce syntax whose detailed requirements belong beside the tool that needs them. You can still tell what role each plays:

Later syntax Programming idea it expresses Where it becomes concrete
trait MemoryReader { ... } A promise that different readers provide the same behavior. Lesson 3.1
unsafe { ... } Allows operations such as raw-pointer dereferencing; the programmer must ensure the data is valid, aligned and accessible for that operation. Lesson 3.1
extern "system" { ... } A call across the Rust–Windows boundary. Lesson 6.10

You can read a lab function as inputs → operation → Result → caller’s decision even before knowing every Windows API name. The later lesson defines the new boundary when you reach it.

Check insufficient stamina with a small test

Section titled “Check insufficient stamina with a small test”

Below the complete two-runner program, add this test. It creates a runner with only 2 stamina, requests a 4-point dash, and checks both the answer and the unchanged state:

#[test]
fn insufficient_stamina_keeps_state() {
let mut runner = Runner {
name: String::from("Mira"),
stamina: 2,
health: 70,
max_health: 100,
};
let dashed = dash(&mut runner, 4);
assert!(!dashed);
assert_eq!(runner.stamina, 2);
}

#[test] marks a function for Cargo’s test runner. assert! requires the answer to be false here; assert_eq! requires the stamina to remain 2. Run cargo test. If you change dash so it subtracts without checking, this test catches the mistake. The test does not prove every possible dash is correct, but it protects one important boundary. The successful path can be checked with 12 stamina and cost 4. The Cargo Book’s testing guide explains how tests in a project are discovered and run.

In the two-runner program, change dash(runner, 4) to dash(runner, 20). Neither runner has 20 stamina. The function returns false before subtraction, so the final lines show 12 and 7 stamina. The 4-point cost restores the original run.

You can now read the Rust code in the next lessons as a set of explicit promises: a value has a type, a reference says what access a function gets, and a return value says what came back. Hacking Fundamentals applies the same programming concepts to a different game’s observed behavior.

Choose the next Rust idea when you need it

Section titled “Choose the next Rust idea when you need it”

You have now seen names, branches, loops, ownership and fallible results. The following lessons take one group of those ideas further with small worked examples, adjustable traces and optional recall practice:

Use the link that answers your current question. Later memory and tool lessons also point back to the relevant idea when it first appears.

Knowledge check

Read Mira, then change stamina

Why does the Rust primer call show_stamina(&mira) but dash(&mut mira, 4)?

Finished Lesson 1.5?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 1: 10 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.