How NPCs Sense and Decide
Follow a guard through sensing, delayed reactions, and state changes to understand how games choose an NPC's next action.
Cheatsheet for Lesson 4.10
A one-page summary of “How NPCs Sense and Decide” you can keep beside your notes. Plain Markdown.
Lesson 4.10 — How NPCs Sense and Decide: cheatsheet
- Remember: A guard sees a target only if three checks pass: close enough, inside the view cone, and no wall in the way. Cheap checks run first.
- Also useful: Think of waking at a noise: you need a few moments to be sure it is real, but you stop worrying the instant it clearly goes away.
Key terms
- ticks: Many games update the world in fixed steps called ticks.
- dot product: It uses a quick calculation called a dot product, and the lab's constant
VIEW_CONE_COS= 0.5 is the value that stands for 60°. - authoritative: The server is the authoritative copy: its decisions are the ones that count.
Review
- Q: In your own offline toy, a guard has been chasing a player it has seen for five ticks in a row. You write 0 (Patrol) into its state byte between two ticks. What happens on the next tick, and why? A: It chases again, because
deciderebuilds the state andseen_foris 3 or more - Q: What does "AI" usually mean for a game character? A: Code that picks the next action, often using a few quick rules
Recording a guard’s movement tells us when it turned, chased, or stopped. It does not explain why. To reconstruct how the guard chooses its action, follow the information the game gave the guard, the decision it made from that information, and the action that followed.
An NPC is a small program that runs every tick
Section titled “An NPC is a small program that runs every tick”A non-player character (NPC) is any character the game controls rather than a person: a guard, a shopkeeper, an enemy soldier. In games, “AI” means the code that picks what an NPC does next. It is usually far simpler than the AI in the news: a small set of distance, visibility, health, and timing checks, evaluated many times per second.
The portable lab npc_brain_lab.rs
builds one such character, a guard, and this lesson follows it piece by piece.
Nothing in it touches a real game.
npc_brain_lab.rs
Show the codeHide the code
//! NPC brain lab for Lessons 4.12 and 4.13.
//!
//! A toy guard senses, decides, and acts once per tick, the way an NPC does
//! inside a game loop. It shows perception (distance, view cone, and line of
//! sight), a finite-state machine with a deliberate reaction delay, and
//! utility scoring for choosing among actions. Nothing here touches a game.
use std::ops::{Add, Mul, Sub};
/// How far the guard can see.
const VIEW_DISTANCE: f32 = 10.0;
/// Cosine of half the view cone's angle: 0.5 means 60 degrees either side.
const VIEW_CONE_COS: f32 = 0.5;
/// Consecutive ticks the player must be visible before the guard reacts.
const REACTION_TICKS: u32 = 3;
const ATTACK_RANGE: f32 = 1.5;
/// Ticks spent searching the last known position before giving up.
const SEARCH_TICKS: u32 = 4;
const FLEE_HEALTH: u32 = 25;
/// Distance covered in one tick.
const SPEED: f32 = 1.0;
/// Spacing of the samples in the line-of-sight check.
const SIGHT_STEP: f32 = 0.1;
/// Guard decisions per second, so each tick lasts 1000 / 10 = 100 ms.
const TICKS_PER_SECOND: u32 = 10;
#[derive(Clone, Copy, Debug, PartialEq)]
struct Vec2 {
x: f32,
y: f32,
}
impl Vec2 {
const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
fn length(self) -> f32 {
self.x.hypot(self.y)
}
fn dot(self, other: Self) -> f32 {
self.x * other.x + self.y * other.y
}
fn normalized(self) -> Option<Self> {
let length = self.length();
if length > f32::EPSILON {
Some(self * length.recip())
} else {
None
}
}
}
impl Add for Vec2 {
type Output = Self;
fn add(self, other: Self) -> Self {
Self::new(self.x + other.x, self.y + other.y)
}
}
impl Sub for Vec2 {
type Output = Self;
fn sub(self, other: Self) -> Self {
Self::new(self.x - other.x, self.y - other.y)
}
}
impl Mul<f32> for Vec2 {
type Output = Self;
fn mul(self, scale: f32) -> Self {
Self::new(self.x * scale, self.y * scale)
}
}
/// An axis-aligned box of solid level geometry.
#[derive(Clone, Copy, Debug)]
struct Wall {
min: Vec2,
max: Vec2,
}
impl Wall {
fn contains(self, point: Vec2) -> bool {
(self.min.x..=self.max.x).contains(&point.x) && (self.min.y..=self.max.y).contains(&point.y)
}
}
/// A crude ray cast: walk along the segment in small steps and fail if any
/// sample lands inside a wall. Engines trace against real collision
/// geometry, but the question they answer is the same one.
fn line_of_sight(from: Vec2, to: Vec2, walls: &[Wall]) -> bool {
let offset = to - from;
let distance = offset.length();
let Some(direction) = offset.normalized() else {
return true;
};
let mut travelled = 0.0;
while travelled < distance {
let sample = from + direction * travelled;
if walls.iter().any(|wall| wall.contains(sample)) {
return false;
}
travelled += SIGHT_STEP;
}
true
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum GuardState {
Patrol,
Chase,
Attack,
Search { ticks: u32 },
Flee,
}
/// What the guard perceives this tick. The decision reads only this, so it
/// can be tested without any geometry.
#[derive(Clone, Copy, Debug)]
struct Senses {
sees_player: bool,
seen_for: u32,
distance: f32,
health: u32,
}
/// The finite-state machine. Every transition has a stated reason.
fn decide(current: GuardState, senses: Senses) -> GuardState {
if senses.health <= FLEE_HEALTH {
return GuardState::Flee;
}
// The reaction delay: one glimpse is not enough to start a chase.
let reacted = senses.sees_player && senses.seen_for >= REACTION_TICKS;
match current {
GuardState::Flee => GuardState::Flee,
GuardState::Patrol | GuardState::Search { .. } if reacted => GuardState::Chase,
GuardState::Patrol => GuardState::Patrol,
GuardState::Chase | GuardState::Attack if senses.sees_player => {
if senses.distance <= ATTACK_RANGE {
GuardState::Attack
} else {
GuardState::Chase
}
}
GuardState::Chase | GuardState::Attack => GuardState::Search { ticks: 0 },
GuardState::Search { ticks } if ticks >= SEARCH_TICKS => GuardState::Patrol,
GuardState::Search { ticks } => GuardState::Search { ticks: ticks + 1 },
}
}
#[derive(Debug)]
struct Guard {
position: Vec2,
facing: Vec2,
health: u32,
state: GuardState,
/// Consecutive ticks the player has been visible.
seen_for: u32,
/// Where the player was last seen. The guard never reads the player's
/// true position except through its own senses.
last_known: Option<Vec2>,
route: Vec<Vec2>,
next_waypoint: usize,
}
#[derive(Clone, Copy, Debug)]
struct TickReport {
sees_player: bool,
state: GuardState,
}
impl Guard {
fn new(position: Vec2, facing: Vec2, route: Vec<Vec2>) -> Self {
Self {
position,
facing,
health: 100,
state: GuardState::Patrol,
seen_for: 0,
last_known: None,
route,
next_waypoint: 0,
}
}
fn can_see(&self, target: Vec2, walls: &[Wall]) -> bool {
let offset = target - self.position;
if offset.length() > VIEW_DISTANCE {
return false;
}
let Some(direction) = offset.normalized() else {
return true;
};
if direction.dot(self.facing) < VIEW_CONE_COS {
return false;
}
line_of_sight(self.position, target, walls)
}
/// Steps toward `target`, turning to face it. Returns true on arrival.
fn move_toward(&mut self, target: Vec2) -> bool {
let offset = target - self.position;
let Some(direction) = offset.normalized() else {
return true;
};
self.facing = direction;
if offset.length() <= SPEED {
self.position = target;
true
} else {
self.position = self.position + direction * SPEED;
false
}
}
/// Sense, decide, act: one tick of the NPC's part of the game loop.
fn tick(&mut self, player: Vec2, walls: &[Wall]) -> TickReport {
// Sense.
let sees_player = self.can_see(player, walls);
self.seen_for = if sees_player { self.seen_for + 1 } else { 0 };
if sees_player {
self.last_known = Some(player);
}
let senses = Senses {
sees_player,
seen_for: self.seen_for,
distance: (player - self.position).length(),
health: self.health,
};
// Decide.
self.state = decide(self.state, senses);
// Act.
match self.state {
GuardState::Patrol => {
if let Some(&waypoint) = self.route.get(self.next_waypoint)
&& self.move_toward(waypoint)
{
self.next_waypoint = (self.next_waypoint + 1) % self.route.len();
}
}
GuardState::Chase | GuardState::Search { .. } => {
if let Some(spot) = self.last_known {
self.move_toward(spot);
}
}
GuardState::Attack => {}
GuardState::Flee => {
if let Some(spot) = self.last_known {
let away = self.position + (self.position - spot);
self.move_toward(away);
}
}
}
TickReport {
sees_player,
state: self.state,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Choice {
Attack,
TakeCover,
Heal,
}
#[derive(Clone, Copy, Debug)]
struct Situation {
/// Remaining health as a fraction from 0.0 to 1.0.
health: f32,
enemy_visible: bool,
in_cover: bool,
potions: u32,
}
/// How attractive each option is right now, from 0.0 (pointless) upward.
fn score(choice: Choice, situation: Situation) -> f32 {
match choice {
Choice::Attack if situation.enemy_visible => 0.4 + 0.6 * situation.health,
Choice::TakeCover if situation.enemy_visible && !situation.in_cover => {
0.9 - 0.6 * situation.health
}
Choice::Heal if situation.potions > 0 => 1.0 - situation.health,
_ => 0.0,
}
}
/// Utility scoring: score every option and run the best one, or nothing if
/// no option scores above zero.
fn choose(situation: Situation) -> Option<Choice> {
[Choice::Attack, Choice::TakeCover, Choice::Heal]
.into_iter()
.map(|choice| (choice, score(choice, situation)))
.filter(|&(_, value)| value > 0.0)
.max_by(|(_, left), (_, right)| left.total_cmp(right))
.map(|(choice, _)| choice)
}
fn main() {
println!("1. A guard with a reaction delay, a chase, and a search");
let walls = [Wall {
min: Vec2::new(5.5, -1.0),
max: Vec2::new(6.5, 1.0),
}];
let post = Vec2::new(0.0, 0.0);
let mut guard = Guard::new(post, Vec2::new(1.0, 0.0), vec![post]);
for tick in 1..=16 {
// The player stands in the open for seven ticks, then slips behind
// the wall.
let player = if tick <= 7 {
Vec2::new(5.0, 0.0)
} else {
Vec2::new(8.0, 0.0)
};
let report = guard.tick(player, &walls);
let state = format!("{:?}", report.state);
let millis = tick * 1000 / TICKS_PER_SECOND;
println!(
" tick {tick:>2} ({millis:>4} ms) player at ({:>3.1}, {:>3.1}) sees: {:<5} state: {state:<20} guard at ({:>3.1}, {:>3.1})",
player.x, player.y, report.sees_player, guard.position.x, guard.position.y
);
}
println!("\n2. Utility scoring: score every option, run the best one");
let situations = [
(
"healthy, enemy in view",
Situation {
health: 1.0,
enemy_visible: true,
in_cover: false,
potions: 1,
},
),
(
"hurt, enemy in view, exposed",
Situation {
health: 0.3,
enemy_visible: true,
in_cover: false,
potions: 1,
},
),
(
"hurt, enemy in view, in cover",
Situation {
health: 0.3,
enemy_visible: true,
in_cover: true,
potions: 1,
},
),
(
"hurt, in cover, no potions left",
Situation {
health: 0.3,
enemy_visible: true,
in_cover: true,
potions: 0,
},
),
(
"healthy, nobody in view",
Situation {
health: 1.0,
enemy_visible: false,
in_cover: false,
potions: 1,
},
),
];
for (label, situation) in situations {
let scores = [Choice::Attack, Choice::TakeCover, Choice::Heal]
.map(|choice| format!("{choice:?} {:.2}", score(choice, situation)));
println!(
" {label:<32} {} -> {:?}",
scores.join(", "),
choose(situation)
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn senses(sees_player: bool, seen_for: u32, distance: f32) -> Senses {
Senses {
sees_player,
seen_for,
distance,
health: 100,
}
}
fn guard_at_origin_facing_east() -> Guard {
let post = Vec2::new(0.0, 0.0);
Guard::new(post, Vec2::new(1.0, 0.0), vec![post])
}
#[test]
fn one_glimpse_is_not_enough_to_react() {
assert_eq!(
decide(GuardState::Patrol, senses(true, 1, 5.0)),
GuardState::Patrol
);
assert_eq!(
decide(GuardState::Patrol, senses(true, 2, 5.0)),
GuardState::Patrol
);
assert_eq!(
decide(GuardState::Patrol, senses(true, 3, 5.0)),
GuardState::Chase
);
}
#[test]
fn a_chase_turns_into_an_attack_in_range() {
assert_eq!(
decide(GuardState::Chase, senses(true, 9, 5.0)),
GuardState::Chase
);
assert_eq!(
decide(GuardState::Chase, senses(true, 9, 1.0)),
GuardState::Attack
);
}
#[test]
fn a_lost_target_is_searched_for_then_abandoned() {
let mut state = decide(GuardState::Attack, senses(false, 0, 3.0));
assert_eq!(state, GuardState::Search { ticks: 0 });
for _ in 0..SEARCH_TICKS {
state = decide(state, senses(false, 0, 3.0));
}
assert_eq!(
state,
GuardState::Search {
ticks: SEARCH_TICKS
}
);
assert_eq!(decide(state, senses(false, 0, 3.0)), GuardState::Patrol);
}
#[test]
fn low_health_overrides_every_other_state() {
let hurt = Senses {
health: 20,
..senses(true, 9, 1.0)
};
for state in [
GuardState::Patrol,
GuardState::Chase,
GuardState::Attack,
GuardState::Search { ticks: 2 },
] {
assert_eq!(decide(state, hurt), GuardState::Flee);
}
}
#[test]
fn perception_checks_distance_cone_and_walls() {
let guard = guard_at_origin_facing_east();
let wall = [Wall {
min: Vec2::new(2.0, -1.0),
max: Vec2::new(3.0, 1.0),
}];
assert!(guard.can_see(Vec2::new(5.0, 0.0), &[]));
assert!(!guard.can_see(Vec2::new(0.0, 5.0), &[]), "outside the cone");
assert!(!guard.can_see(Vec2::new(15.0, 0.0), &[]), "too far away");
assert!(
!guard.can_see(Vec2::new(5.0, 0.0), &wall),
"wall in the way"
);
assert!(
guard.can_see(Vec2::new(4.0, 3.0), &wall),
"the wall is not between them"
);
}
#[test]
fn the_guard_reacts_after_a_delay_then_closes_in() {
let mut guard = guard_at_origin_facing_east();
let player = Vec2::new(5.0, 0.0);
let states: Vec<GuardState> = (0..7).map(|_| guard.tick(player, &[]).state).collect();
assert_eq!(
states,
[
GuardState::Patrol,
GuardState::Patrol,
GuardState::Chase,
GuardState::Chase,
GuardState::Chase,
GuardState::Chase,
GuardState::Attack,
]
);
assert_eq!(guard.position, Vec2::new(4.0, 0.0));
}
#[test]
fn utility_scoring_picks_the_best_option_or_none() {
let situation = |health, enemy_visible, in_cover, potions| Situation {
health,
enemy_visible,
in_cover,
potions,
};
assert_eq!(choose(situation(1.0, true, false, 1)), Some(Choice::Attack));
assert_eq!(
choose(situation(0.3, true, false, 1)),
Some(Choice::TakeCover)
);
assert_eq!(choose(situation(0.3, true, true, 1)), Some(Choice::Heal));
assert_eq!(choose(situation(0.3, true, true, 0)), Some(Choice::Attack));
assert_eq!(choose(situation(1.0, false, false, 1)), None);
}
}The game loop from Lesson 1.2 reads input, updates the world,
and draws a frame. Many games update the world in fixed steps called ticks.
Every tick advances the simulation by the same slice of time, however fast the
computer draws. The lab runs its guard at 10 ticks per second (the constant
TICKS_PER_SECOND), which makes each tick 1000 ÷ 10 = 100 milliseconds.
An NPC makes one decision per tick, for two reasons:
- Behavior follows simulated time, not the frame rate. A guard that reacts after 3 ticks reacts after 3 × 100 = 300 milliseconds on a fast computer and on a slow one. If it decided once per drawn frame, a machine drawing 144 frames per second would make it react faster than one drawing 30.
- The cost is bounded and the behavior repeats. Fifty guards cost fifty decisions per tick, no matter what the renderer does. The same inputs, tick by tick, give the same decisions, which is what lets the lab test its guard without drawing anything.
Each tick, the guard’s tick method runs three stages in a fixed order: sense,
decide, and act. Sensing also updates what the guard remembers:
The guard never reads the player’s true position directly. It gets the player’s
position only through its senses, and remembers the last place it saw them in
last_known. That separation is what makes hiding behind a wall work.
This simulation needs JavaScript. The lesson text around it works through the same numbers.
Sense: three checks, cheapest first
Section titled “Sense: three checks, cheapest first”The guard sees a target only if it passes three checks, in this order:
- Distance: the target is no more than
VIEW_DISTANCE= 10 units away. - View cone: the target is inside the guard’s view cone, the range of directions it can see: within 60° of the way it faces, on either side, so 60 + 60 = 120° in all. Everything behind the guard is outside it.
- Line of sight: the straight line from the guard to the target is free of solid geometry, so no wall hides the target.
The order matters for speed: the first two checks cost a handful of arithmetic operations each, while the last walks along the line and tests every wall, so it runs only after the cheap checks pass. Each check also gives the player a way to stay unseen: keep your distance, stay behind the guard, or put a wall between you.
The cone check never measures an angle. It uses a quick calculation called a
dot product, and the lab’s constant VIEW_CONE_COS = 0.5 is the value that
stands for 60°. Lesson 11.1 works
through all three checks in the lab’s code and shows why.
Checks in order, then the answer
Three checks, cheapest first
5 ≤ 10 and 0.8 ≥ 0.5 (about 36.9° from the facing), and nothing blocks the line: the guard sees the target.
Decide: a finite-state machine
Section titled “Decide: a finite-state machine”The guard’s behavior is a finite-state machine, the same structure Lesson 4.6 uses for a macro: a fixed set of named states and a condition for each transition between them. The guard has five states:
- Patrol: walk the route. The lab’s route is a single point, the guard’s post at (0, 0), so patrolling means standing there.
- Chase: walk toward where the player was last seen.
- Attack: the player is within
ATTACK_RANGE= 1.5. - Search: the player vanished, so go to the last known spot and wait there.
- Flee: health is
FLEE_HEALTH= 25 or less, so run.
The whole decision is one function of the current state and this tick’s senses:
fn decide(current: GuardState, senses: Senses) -> GuardState { if senses.health <= FLEE_HEALTH { return GuardState::Flee; } // The reaction delay: one glimpse is not enough to start a chase. let reacted = senses.sees_player && senses.seen_for >= REACTION_TICKS; match current { GuardState::Flee => GuardState::Flee, GuardState::Patrol | GuardState::Search { .. } if reacted => GuardState::Chase, GuardState::Patrol => GuardState::Patrol, GuardState::Chase | GuardState::Attack if senses.sees_player => { if senses.distance <= ATTACK_RANGE { GuardState::Attack } else { GuardState::Chase } } GuardState::Chase | GuardState::Attack => GuardState::Search { ticks: 0 }, GuardState::Search { ticks } if ticks >= SEARCH_TICKS => GuardState::Patrol, GuardState::Search { ticks } => GuardState::Search { ticks: ticks + 1 }, }}Drawn out, the transitions are:
When no arrow applies, the state stays the same. Three details of the code decide the shape of that diagram:
- The health check runs before the
match, so it overrides every state. Nothing leads out of Flee in this lab; a fleeing guard flees until the program ends. - The
matcharms are tried from top to bottom. A searching guard that sees the player for 3 ticks goes back to Chase before its counter is even looked at. - Search starts at count 0 on the tick the player is lost and adds 1 each tick.
The guard gives up on the tick after the count has reached
SEARCH_TICKS= 4, so a lost player is searched for five ticks: counts 0, 1, 2, 3, and 4.
Reaction time is counted in ticks
Section titled “Reaction time is counted in ticks”seen_for counts the consecutive ticks in which the guard has seen the player,
and drops back to 0 on any tick it does not. The guard only reacts when the
count reaches REACTION_TICKS = 3, so one glimpse through a gap never starts a
chase.
At 10 ticks per second, the ticks are 100 milliseconds apart. The count reaches 3 two ticks after the first tick that saw the player, 2 × 100 = 200 milliseconds later. The player may also have been visible for up to 100 milliseconds before that first tick sensed them, so the guard reacts 200 to 300 milliseconds after the player steps into view. That is close to a person’s own reaction time of about a quarter of a second, and that is the point: a guard that turns the instant one pixel of you appears feels unfair. It is also the filter that Lesson 4.9 calls debouncing.
The delay only runs one way. Once chasing, a single tick without sight sends the guard to Search. Losing the player is instant; noticing them is not.
One run, tick by tick
Section titled “One run, tick by tick”The lab’s first demo puts the guard at its post, (0, 0), facing east, (1, 0). A wall fills the box from (5.5, −1) to (6.5, 1): every point with x from 5.5 to 6.5 and y from −1 to 1. The player stands in the open at (5, 0) for ticks 1 to 7, then slips behind the wall to (8, 0) for ticks 8 to 16. “Distance” is measured before the guard moves:
| Tick | Sees | seen_for |
Distance | State | Guard after acting |
|---|---|---|---|---|---|
| 1 | yes | 1 | 5 | Patrol |
(0, 0) |
| 2 | yes | 2 | 5 | Patrol |
(0, 0) |
| 3 | yes | 3 | 5 | Chase |
(1, 0) |
| 4 | yes | 4 | 4 | Chase |
(2, 0) |
| 5 | yes | 5 | 3 | Chase |
(3, 0) |
| 6 | yes | 6 | 2 | Chase |
(4, 0) |
| 7 | yes | 7 | 1 | Attack |
(4, 0) |
| 8 | no | 0 | 4 | Search { ticks: 0 } |
(5, 0) |
| 9 | no | 0 | 3 | Search { ticks: 1 } |
(5, 0) |
| 10 | no | 0 | 3 | Search { ticks: 2 } |
(5, 0) |
| 11 | no | 0 | 3 | Search { ticks: 3 } |
(5, 0) |
| 12 | no | 0 | 3 | Search { ticks: 4 } |
(5, 0) |
| 13 | no | 0 | 3 | Patrol |
(4, 0) |
| 14 | no | 0 | 4 | Patrol |
(3, 0) |
| 15 | no | 0 | 5 | Patrol |
(2, 0) |
| 16 | no | 0 | 6 | Patrol |
(1, 0) |
Every row follows from the perception checks and state transitions above:
- Ticks 1 and 2: the player is 5 away, straight ahead, and in the open, so
all three checks pass, but
seen_foris only 1, then 2. - Tick 3:
seen_forreaches 3, so the guard chases, steppingSPEED= 1.0 toward the player. - Ticks 4 to 7: each step closes 1 unit. On tick 7 the distance is 5 − 4 = 1, within 1.5, so the guard attacks and stops moving.
- Tick 8: the line from the guard at (4, 0) to the player’s new spot at
(8, 0) runs along y = 0 and passes through the wall where x is between 5.5
and 6.5, so line of sight fails. The guard switches to Search and walks the
1 unit to
last_known, (5, 0). - Ticks 9 to 12: the guard waits there, facing the wall that hides the player, while the count climbs to 4.
- Tick 13: the count has reached 4, so the guard returns to Patrol, turns west toward its post, and steps to (4, 0).
- Ticks 14 to 16: the player is now straight behind the guard, 180° from its facing and far outside the cone.
Games also use two other ways to decide, often mixed with small state machines: utility scoring runs whichever action scores highest right now, and a behavior tree checks an NPC’s priorities from the top every tick. Lesson 11.1 works through both.
Moving is a separate system
Section titled “Moving is a separate system”The lab’s guard moves with move_toward, which turns to face the target and
steps straight at it, ignoring walls. It avoids the wall in the demo only
because the spot it walks to, (5, 0), happens to be in front of it.
Games split movement off into a navigation system. The decision layer picks where to go. The navigation layer works out how: it plans a route over a grid or a navigation mesh, a set of polygons covering the floor an NPC can walk on, with a search such as A* from Lesson 4.8, then steers the NPC along that route one tick at a time.
An NPC in memory
Section titled “An NPC in memory”A game written in C++ usually stores each NPC as a structure, a block of memory with each field at a fixed offset from its start, and keeps the state machine’s current state as a small number in one of those fields. In a toy layout of the guard, the state is a single byte:
1 is Chase. No other value means anything to the code.Lesson 14.1 lays out the whole structure field by field, as an example of an object layout.
What editing the state byte would do
Section titled “What editing the state byte would do”Take a toy program you wrote yourself, or an offline single-player game running in your lab VM, whose guard uses the five-state implementation above. Changing the state byte between two ticks has very different effects depending on the value:
- Write 4 (Flee) mid-chase: on the next tick
decidesees Flee, and nothing leads out of Flee, so the guard turns and runs from the last place it saw the player until the program restarts. The edit lasts. - Write 0 (Patrol) mid-chase, with the player in view: a chase only starts
after 3 ticks of sight, so
seen_foris already 3 or more, and the next decision turns Patrol straight back into Chase before the act stage runs. The guard never takes a patrol step; the edit is undone within one tick, 100 milliseconds. - Write 0 (Patrol) while the guard is searching: now the senses agree with Patrol, because the player is not in view, so the guard gives up the search early and walks home. The edit lasts.
The pattern behind those results: the state is an output the game recomputes
from its inputs every tick. An edit to the output survives only when the inputs
agree with it, as seen_for did not in the second case. An edit to an input that the decision code reads, such as writing 20 into health, changes behavior for as
long as the input holds, here by sending the guard into Flee on the next tick.
Why online NPCs are out of reach
Section titled “Why online NPCs are out of reach”In an online game, the server runs the NPCs. Their state bytes, timers, and decision code exist in the server’s memory, on a machine you do not control. The server is the authoritative copy: its decisions are the ones that count. Your client receives only what it needs to draw, such as positions, animations, and perhaps a health bar, and Lesson 1.3 describes why changing a client-side copy does not change what the server decides. Editing an NPC’s position or state in client memory changes, at most, what your own screen draws until the next server update overwrites it. Tampering with a live multiplayer service is also outside this book’s scope; everything above belongs in your own toy programs and offline games.
Run the lab
Section titled “Run the lab”From the rust-labs folder:
cargo run --bin npc_brain_labcargo test --bin npc_brain_labThe first command prints the sixteen-tick run from the table above, then five situations scored by the utility system from Lesson 11.1. The tests check each decision case separately, from the reaction delay and the search timeout to low health overriding every state, the three perception checks, and the utility choices.
Then change REACTION_TICKS to 1 and predict, before running the demo, which
tick the chase starts on and where the guard stands when it attacks. Two of the
tests will fail after that change, because they pin the delay at 3 ticks.
Compare the new trace with the old one, then restore the constant to 3
so the tests describe the program again. Lesson 11.1
explains the sensing calculations and behavior-tree and utility-selection algorithms that
this trace treats as black boxes.
Why the edit does not stick
In your own offline toy, a guard has been chasing a player it has seen for five ticks in a row. You write 0 (Patrol) into its state byte between two ticks. What happens on the next tick, and why?
The state is an output that decide rebuilds from the senses every tick. seen_for is still at least REACTION_TICKS = 3, so Patrol turns straight back into Chase before the act stage runs. An edit lasts only when the inputs agree with it, or when it changes an input the rules read, such as health.
Finished Lesson 4.10?
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.
Plain Markdown, saved in this browser.
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.