Finite State Machines
A* tells your guard how to reach the player. It doesn’t tell the guard whether to bother. An entity that only knows how to move isn’t thinking — it’s executing. A Finite State Machine is the decision layer: a formal description of what an entity is doing right now, what it might do instead, and exactly what must be true for it to switch.
Every guard in every stealth game runs on something like this. So does every enemy AI in a platformer, every shop NPC that goes idle when you walk away, every boss that enters its second phase at half health. FSMs are ubiquitous in games because they map directly to how designers describe behaviour: if the player is near, chase; if the player escapes, give up and return home.
What You’ll Master:
- States, transitions, and the conditions that trigger them
- Separating what to do (actions) from when to switch (transitions)
- The classic patrol → alert → chase → return guard pattern
- Where FSMs start to buckle under complexity
Prerequisites:
- A* (the guard needs a pathfinder once it decides to chase — the FSM decides when, A* decides how)
The guard colour tells you the current state — blue for patrol, yellow for alert, red for chase, purple for returning. Watch how the transitions fire as the red player square crosses the detection ring.
What Is a State?
A state is a named mode of behaviour. While in that state, the entity does one thing consistently: patrol, chase, idle. It doesn’t try to do everything at once.
The states themselves are just string constants (or an enum):
const STATE = {
PATROL: 'PATROL',
ALERT: 'ALERT',
CHASE: 'CHASE',
RETURN: 'RETURN',
};
The entity object holds its current state as a single field:
const guard = {
x: 255, y: 45,
speed: 1.0,
state: STATE.PATROL, // the only field that drives all decision-making
// ...
};
At any given moment, guard.state is the single source of truth for what the guard is doing.
Transitions and Actions Are Separate
The most important structural decision in an FSM is keeping transitions and actions in separate passes. Mix them and the logic becomes difficult to reason about; separate them and both become easy to read.
Transition pass — check conditions, switch states, do nothing else:
function updateGuardTransitions(guard, player) {
const d = distance(guard, player);
switch (guard.state) {
case STATE.PATROL:
if (d < guard.DETECT_RANGE) guard.state = STATE.ALERT;
break;
case STATE.ALERT:
if (d < guard.DETECT_RANGE * 0.8) guard.state = STATE.CHASE;
else if (d > guard.DETECT_RANGE * 1.3) guard.state = STATE.RETURN;
break;
case STATE.CHASE:
if (d > guard.LOSE_RANGE) guard.state = STATE.RETURN;
break;
case STATE.RETURN:
if (d < guard.DETECT_RANGE) guard.state = STATE.ALERT;
break;
}
}
Action pass — execute the current state’s behaviour, do not check conditions:
function updateGuardActions(guard, player) {
switch (guard.state) {
case STATE.PATROL:
moveAlongPatrolRoute(guard);
break;
case STATE.ALERT:
// stand still — just face the player
faceDirection(guard, player.x, player.y);
break;
case STATE.CHASE:
moveToward(guard, player.x, player.y, guard.speed * 1.8);
break;
case STATE.RETURN:
if (moveToward(guard, guard.homeX, guard.homeY, guard.speed)) {
guard.state = STATE.PATROL; // arrived — resume patrol
}
break;
}
}
Call them in order each frame:
app.ticker.add(() => {
updateGuardTransitions(guard, player);
updateGuardActions(guard, player);
});
Entry and Exit Callbacks
Some states need setup work on entry (play an animation, reset a timer) or cleanup on exit (cancel a sound, clear a target). A minimal way to handle this is to track the previous state and fire callbacks when it changes:
function setGuardState(guard, newState) {
if (guard.state === newState) return;
// Exit callback
switch (guard.state) {
case STATE.CHASE:
guard.chaseTarget = null;
playAnimation(guard, 'idle');
break;
}
guard.state = newState;
// Entry callback
switch (newState) {
case STATE.ALERT:
playSound('guard_alert');
guard.alertTimer = 60; // frames to stay in ALERT before giving up
break;
case STATE.CHASE:
playAnimation(guard, 'run');
break;
}
}
Calling setGuardState instead of assigning guard.state directly ensures you never miss a transition.
A Complete Guard FSM
Putting it together with a patrol route and detection radius:
const guard = {
x: 255, y: 45,
speed: 1.0,
state: STATE.PATROL,
patrolPoints: [
{ x: 255, y: 45 },
{ x: 45, y: 45 },
{ x: 45, y: 195 },
{ x: 255, y: 195 },
],
patrolIndex: 0,
homeX: 255,
homeY: 45,
DETECT_RANGE: 80, // pixels — enter ALERT
LOSE_RANGE: 120, // pixels — abandon chase
};
function tickGuard(guard, player) {
const d = distance(guard.x, guard.y, player.x, player.y);
// --- Transitions ---
switch (guard.state) {
case STATE.PATROL:
if (d < guard.DETECT_RANGE)
setGuardState(guard, STATE.ALERT);
break;
case STATE.ALERT:
if (d < guard.DETECT_RANGE * 0.8)
setGuardState(guard, STATE.CHASE);
else if (d > guard.DETECT_RANGE * 1.3)
setGuardState(guard, STATE.RETURN);
break;
case STATE.CHASE:
if (d > guard.LOSE_RANGE)
setGuardState(guard, STATE.RETURN);
break;
case STATE.RETURN:
if (d < guard.DETECT_RANGE)
setGuardState(guard, STATE.ALERT);
break;
}
// --- Actions ---
switch (guard.state) {
case STATE.PATROL: {
const target = guard.patrolPoints[guard.patrolIndex];
if (moveToward(guard, target.x, target.y, guard.speed)) {
guard.patrolIndex = (guard.patrolIndex + 1) % guard.patrolPoints.length;
}
break;
}
case STATE.ALERT:
faceDirection(guard, player.x, player.y);
break;
case STATE.CHASE:
moveToward(guard, player.x, player.y, guard.speed * 1.8);
break;
case STATE.RETURN:
if (moveToward(guard, guard.homeX, guard.homeY, guard.speed)) {
setGuardState(guard, STATE.PATROL);
}
break;
}
}
In a real game, moveToward would call your A* pathfinder and follow the returned path rather than moving in a straight line. The FSM decides whether to chase; A* decides the route.
Where FSMs Break Down
For a guard with four states, you have at most 12 possible transitions (each state to each other). Add a fifth state — say, SEARCH (guard investigates the last known player position) — and you potentially add four more transitions. A sixth state adds five more.
The transition count grows roughly as O(n²) with the number of states. A complex enemy with ten states could have 90 possible transitions to reason about. Checking which ones are actually implemented becomes harder than writing the AI itself.
This is the state explosion problem, and it’s why FSMs work well for simple entities but become unwieldy for anything with nuanced behaviour. The solution is a more composable structure — one where you build complex behaviour from small, reusable pieces rather than an ever-growing transition table.
Next up: Behaviour Trees — the composable alternative that scales without state explosion.