11.3 Decision-Making (Behavior Trees, FSM, GOAP, Utility)

Phase 11 · Game AI · Study time: 25–45 h

How agents choose what to do — finite state machines, behavior trees (the industry default), goal-oriented planning, and utility scoring.

This chapter is about how a game AI agent decides what to do. Earlier chapters showed you how to move an object smoothly and how to organize gameplay code with components, events, and the State pattern. None of that answers the actual question a designer asks about an enemy: "why did it just do that?" This chapter covers four ways to answer that question, from simplest to most flexible: Finite State Machines (FSM), Behavior Trees (BT), Goal-Oriented Action Planning (GOAP), and Utility AI. You will write real C# for the first two, understand the concept and see a worked example for the last two, and finish knowing which tool to reach for on a real project.

1. How a Game AI Agent Decides What To Do

Every game AI, no matter how it is built, runs the same three-step loop every frame: look at the world (perception), decide what to do about it (decision-making), then actually do it (action). You already know how to write the "action" part — moving a Transform, playing an animation, firing a projectile. This chapter is entirely about the middle step: the part of the code that picks which action runs.

+------------+ +--------------+ +------------+ | Perception | ---> | Decision- | ---> | Action | | can I see | | making | | move, aim, | | the player?| | (FSM / BT / | | attack, | | how is my | | GOAP / | | play anim | | health? | | Utility AI) | | | +------------+ +--------------+ +------------+ ^ | +------------------------------------------+ the action changes the world, so next frame's perception reads something new

A "decision-making system" is just the piece of code that turns perception into a choice of action. The four systems in this chapter are four different ways to write that piece of code. They are not different AI "brands" competing for the same job — they solve the decision problem with different trade-offs, and real games often mix them:

2. Finite State Machines: States and Transitions

A finite state machine (FSM) is a system that is always in exactly one named state out of a fixed, known set, with explicit rules — called transitions — for switching from one state to another. You already met this shape as the "State pattern" when organizing gameplay code; here we use the exact same idea specifically to decide enemy behavior.

A classic enemy has three states: Patrol (walk a fixed route), Chase (run toward the player), and Attack (deal damage in melee range). The rules for switching between them:

Patrol --(sees player)-----------> Chase Chase --(target in attack range)-> Attack Chase --(loses sight of player)--> Patrol Attack --(target out of range)----> Chase

Only four transitions connect three states, and every one of them is easy to justify out loud: "if it can see the player, it should chase; if it's close enough, it should attack." That clarity is the whole appeal of an FSM — you can point at any line of behavior and explain exactly when it applies.

3. Implementing an FSM in C#

A clean FSM implementation gives each state its own small class instead of one giant method full of if statements. Each state needs to know what to do when it starts (Enter) and what to do every frame while active (Tick):


public interface IEnemyState
{
    void Enter(EnemyAI enemy);
    void Tick(EnemyAI enemy);
}

Each concrete state implements that interface and decides, inside Tick, whether it is time to switch to a different state:


using UnityEngine;

public class PatrolState : IEnemyState
{
    public void Enter(EnemyAI enemy)
    {
        Debug.Log("Enter Patrol");
    }

    public void Tick(EnemyAI enemy)
    {
        enemy.MoveAlongPatrolRoute();

        if (enemy.CanSeePlayer())
        {
            enemy.ChangeState(new ChaseState());
        }
    }
}

public class ChaseState : IEnemyState
{
    public void Enter(EnemyAI enemy)
    {
        Debug.Log("Enter Chase");
    }

    public void Tick(EnemyAI enemy)
    {
        enemy.MoveTowardPlayer();

        if (enemy.DistanceToPlayer() < enemy.attackRange)
        {
            enemy.ChangeState(new AttackState());
        }
        else if (!enemy.CanSeePlayer())
        {
            enemy.ChangeState(new PatrolState());
        }
    }
}

public class AttackState : IEnemyState
{
    public void Enter(EnemyAI enemy)
    {
        Debug.Log("Enter Attack");
    }

    public void Tick(EnemyAI enemy)
    {
        enemy.AttackPlayer();

        if (enemy.DistanceToPlayer() >= enemy.attackRange)
        {
            enemy.ChangeState(new ChaseState());
        }
    }
}

The EnemyAI component just holds the current state and hands control to it every frame:


using UnityEngine;

public class EnemyAI : MonoBehaviour
{
    public float attackRange = 2f;
    private IEnemyState currentState;

    void Start()
    {
        ChangeState(new PatrolState());
    }

    void Update()
    {
        currentState.Tick(this);
    }

    public void ChangeState(IEnemyState newState)
    {
        currentState = newState;
        currentState.Enter(this);
    }

    public bool CanSeePlayer() { /* raycast toward the player */ return false; }
    public float DistanceToPlayer() { /* Vector3.Distance to the player */ return 999f; }
    public void MoveAlongPatrolRoute() { /* walk to the next waypoint */ }
    public void MoveTowardPlayer() { /* walk toward the player */ }
    public void AttackPlayer() { /* deal damage on a cooldown */ }
}

Expected output: when the enemy spawns, the console prints Enter Patrol. Once CanSeePlayer() returns true, it prints Enter Chase, and once DistanceToPlayer() drops below attackRange, it prints Enter Attack. A new state object is created on every ChangeState call, so any per-state field (a timer, a target position) always starts fresh.

Common mistake Caching a single shared instance of each state (one ChaseState object reused by every enemy) to "save allocations." If a state ever stores per-use data — a timer, a remembered position — that data now leaks between different enemies or different visits to the same state, because they are all secretly sharing one object. Creating a small new state object per transition, as above, is cheap and avoids this bug entirely.

4. Why Big FSMs Turn Into Spaghetti

Three states and four transitions are easy to reason about. Real enemies eventually need more: Flee when health is low, Search when the player was seen but is now hidden, Stunned after taking a heavy hit, Dead. Six states do not need six transitions — they need up to 6 × 5 = 30 possible directed transitions, because in principle any state might need to interrupt any other:

Patrol --> Chase Chase --> Attack Chase --> Search Patrol --> Stunned Attack --> Flee Search --> Chase Chase --> Flee Attack --> Stunned Search --> Patrol Flee --> Stunned Flee --> Search Stunned --> Patrol Attack --> Search Flee --> Patrol Stunned --> Chase ... and any state must also reach Dead at any time. 6 states, up to 30 possible transitions. Every new state added must be checked against every existing state: "can I reach state X from here? should a heavy hit interrupt Chase? Attack? Flee?"

The pain is not just counting arrows — it is that every state class ends up needing to know about every other state that might interrupt it:


// FleeState now has to know about every other state that
// might need to interrupt fleeing -- coupling grows with
// every new feature anyone adds to the game.
public class FleeState : IEnemyState
{
    public void Tick(EnemyAI enemy)
    {
        if (enemy.Health <= 0)
        {
            enemy.ChangeState(new DeadState());
        }
        else if (enemy.IsCornered())
        {
            enemy.ChangeState(new StunnedState());
        }
        else if (enemy.Health > enemy.fleeThreshold)
        {
            enemy.ChangeState(new PatrolState());
        }
        else if (enemy.CanSeePlayer() && enemy.HasBackup())
        {
            enemy.ChangeState(new ChaseState());
        }
        // ...one more line for every future state, forever.
    }
}

This is the well-known problem with FSMs at scale: the number of transitions to think about grows roughly with the square of the number of states, every state class gets tightly coupled to many others, and the same guard conditions (is the enemy dead? stunned? out of ammo?) get copy-pasted into every single state. This tangled, hard-to-follow growth is exactly the spaghetti code problem from gameplay architecture, now inside your AI. It is not a sign you wrote a bad FSM — it is a structural limit of the FSM shape itself, and it is the reason the next tool exists.

5. Behavior Trees: Nodes and the Tick

A behavior tree (BT) is a tree of small nodes, evaluated top to bottom, left to right, once per frame — that single evaluation pass is called a tick. Every node returns one of three results when ticked:


public enum NodeStatus
{
    Success,
    Failure,
    Running
}

public interface IBTNode
{
    NodeStatus Tick();
}

There are two families of nodes. Composite nodes have children and combine their results; leaf nodes do the actual checking or acting and have no children. The two composite nodes you need to know first:

Sequence (checklist / AND) Selector (priority list / OR) stops at the first FAILURE stops at the first SUCCESS Sequence Selector +-- A +-- A +-- B +-- B +-- C +-- C A=Success, B=Success, C=Success A=Failure, B=Success -> Sequence returns Success -> Selector stops at B, A=Success, B=Failure returns Success (C never ticked) -> Sequence stops at B, A=Failure, B=Failure, C=Failure returns Failure (C never ticked) -> Selector returns Failure

Notice both composites can short-circuit: a Sequence never ticks children after the first failure, and a Selector never ticks children after the first success. This "stop early" behavior is what lets a tree express priority — the leftmost branch of a Selector is tried first, and only if it fails does the tree even look at the next option.

6. Building a Behavior Tree Framework in C#

The composite nodes: Sequence and Selector


public class Sequence : IBTNode
{
    private readonly IBTNode[] children;

    public Sequence(params IBTNode[] children)
    {
        this.children = children;
    }

    public NodeStatus Tick()
    {
        foreach (var child in children)
        {
            NodeStatus status = child.Tick();
            if (status != NodeStatus.Success)
            {
                return status; // Failure or Running: stop here
            }
        }
        return NodeStatus.Success; // every child succeeded
    }
}

public class Selector : IBTNode
{
    private readonly IBTNode[] children;

    public Selector(params IBTNode[] children)
    {
        this.children = children;
    }

    public NodeStatus Tick()
    {
        foreach (var child in children)
        {
            NodeStatus status = child.Tick();
            if (status != NodeStatus.Failure)
            {
                return status; // Success or Running: stop here
            }
        }
        return NodeStatus.Failure; // every child failed
    }
}

The leaf nodes: Condition and Action

A Condition node checks something about the world and returns Success or Failure immediately — it is never Running. An Action node actually does something, and can return Running while the action is still in progress:


using System;

public class ConditionNode : IBTNode
{
    private readonly Func<bool> condition;

    public ConditionNode(Func<bool> condition)
    {
        this.condition = condition;
    }

    public NodeStatus Tick()
    {
        return condition() ? NodeStatus.Success : NodeStatus.Failure;
    }
}

public class ActionNode : IBTNode
{
    private readonly Func<NodeStatus> action;

    public ActionNode(Func<NodeStatus> action)
    {
        this.action = action;
    }

    public NodeStatus Tick()
    {
        return action();
    }
}

That is the whole framework: two composites, two leaf types, one shared Tick() contract. Every behavior tree you will ever build, no matter how large, is assembled from just these four building blocks.

Tip Production games rarely hand-write this scaffolding. Unreal Engine ships a built-in Behavior Tree editor, and Unity has both an official com.unity.behavior package and popular third-party tools (Behavior Designer, NodeCanvas) with a visual node graph. Learning the plain C# version first is what makes those visual tools make sense — they are drawing exactly the tree you are about to build in code.

7. A Full Behavior Tree: Patrol, Chase, Attack

Here is the same enemy from Section 3 — Patrol, Chase, Attack — rebuilt as a behavior tree instead of an FSM, so you can compare the two directly:

Selector (root) +-- Sequence | +-- Condition: CanSeePlayer? | +-- Selector | +-- Sequence | | +-- Condition: InAttackRange? | | +-- Action: Attack | +-- Action: Chase +-- Action: Patrol Read top to bottom, left branch first: try "see player, then (attack or chase)" before ever falling through to plain Patrol.

Read the tree the same way you read the Selector semantics from Section 5: try the leftmost branch first, and only fall through to the next one if it fails.


using UnityEngine;

public class EnemyAI : MonoBehaviour
{
    public float attackRange = 2f;
    private IBTNode root;

    void Start()
    {
        root = new Selector(
            new Sequence(
                new ConditionNode(CanSeePlayer),
                new Selector(
                    new Sequence(
                        new ConditionNode(InAttackRange),
                        new ActionNode(AttackPlayer)
                    ),
                    new ActionNode(ChasePlayer)
                )
            ),
            new ActionNode(Patrol)
        );
    }

    void Update()
    {
        root.Tick();
    }

    bool CanSeePlayer() { /* raycast toward the player */ return false; }
    bool InAttackRange() { /* distance check against attackRange */ return false; }

    NodeStatus AttackPlayer()
    {
        Debug.Log("Attack!");
        return NodeStatus.Running; // the attack animation is still playing
    }

    NodeStatus ChasePlayer()
    {
        Debug.Log("Chasing...");
        return NodeStatus.Running; // still closing the distance
    }

    NodeStatus Patrol()
    {
        Debug.Log("Patrolling...");
        return NodeStatus.Running; // walking to the next waypoint
    }
}

Expected output, traced by hand for three situations:

Compare this to the FSM in Section 3: same three behaviors, same rules, but here nothing is hardcoded as "state X transitions to state Y." The priority order and the fallback logic live entirely in the shape of the tree.

8. Why Behavior Trees Scale Better Than FSMs

Go back to the Flee/Search/Stunned/Dead problem from Section 4 and picture it as a tree instead. Adding "flee when health is low" means adding one new Sequence branch near the top of the Selector:


root = new Selector(
    new Sequence(new ConditionNode(IsCritical), new ActionNode(Flee)),   // new
    new Sequence(new ConditionNode(CanSeePlayer), /* chase/attack subtree */ null),
    new ActionNode(Patrol)
);

Nothing inside the chase/attack subtree changed. PatrolState, ChaseState, and AttackState did not need a single new line added to check for "is the enemy fleeing right now" — the tree structure itself guarantees the Flee branch is checked first, and if it fails (health is fine), everything below it runs exactly as before. This is the core reason behavior trees scale better than FSMs:

Tip Behavior trees are not free of downsides — a poorly organized tree with hundreds of nodes can still be hard to follow, and debugging usually means watching the tree run live in a visual debugger rather than reading code. But the failure mode is "this one big tree is hard to read," not "editing this file broke three unrelated behaviors," which is the sharper problem FSMs run into.

9. GOAP: Goal-Oriented Action Planning

FSMs and behavior trees both require a person to hand-author every path the agent can take. GOAP (Goal-Oriented Action Planning) takes a different approach: instead of authoring paths, you describe individual actions the agent can take, and a planner searches, at run time, for a sequence of those actions that reaches a goal. This is the technique the soldiers in the 2005 shooter F.E.A.R. made famous — instead of a hand-scripted "take cover, then reload, then shoot" routine, the game just told them the goal ("kill the target") and let each soldier work out its own plan from whatever actions were available and useful in the moment.

Actions: preconditions and effects

Each action has a precondition (what must already be true in the world for this action to be allowed) and an effect (what becomes true in the world once the action finishes), plus a cost the planner will try to minimize:


using System.Collections.Generic;

public class GoapAction
{
    public string Name;
    public float Cost = 1f;
    public Dictionary<string, bool> Preconditions = new Dictionary<string, bool>();
    public Dictionary<string, bool> Effects = new Dictionary<string, bool>();
}

GoapAction moveToCover = new GoapAction
{
    Name = "MoveToCover",
    Cost = 1f,
    // no preconditions -- the agent can always try to move to cover
    Effects = { ["InCover"] = true }
};

GoapAction reload = new GoapAction
{
    Name = "Reload",
    Cost = 1f,
    Preconditions = { ["AmmoInReserve"] = true },
    Effects = { ["HasAmmo"] = true }
};

GoapAction attackFromCover = new GoapAction
{
    Name = "AttackFromCover",
    Cost = 2f,
    Preconditions = { ["InCover"] = true, ["HasAmmo"] = true },
    Effects = { ["EnemyDead"] = true }
};

The planner: searching for a plan

Given a starting world state and a goal world state, the planner searches backward: "which action's effects would satisfy the goal? what does that action need as a precondition? keep chaining backward until every remaining requirement is already true right now." This is the same kind of graph search as pathfinding, except the "map" is made of world states instead of physical locations, and the "roads" between them are actions:

Goal: EnemyDead = true Start: HasAmmo=false, InCover=false, AmmoInReserve=true, EnemyDead=false [HasAmmo=false, InCover=false] | MoveToCover (cost 1) v [HasAmmo=false, InCover=true] | Reload (cost 1) v [HasAmmo=true, InCover=true] | AttackFromCover (cost 2) v [EnemyDead=true] -- goal reached, total plan cost = 4 The planner also tries other orderings (e.g. Reload before MoveToCover) and keeps whichever valid path costs least.

// Conceptual pseudocode for how the plan gets used once found.
// Real planners run an A*-style search with a priority queue --
// this sketch shows the idea, not a production implementation.
WorldState goal = new WorldState { EnemyDead = true };
WorldState current = agent.GetCurrentWorldState();

List<GoapAction> plan = Planner.FindPlan(current, goal, availableActions);

foreach (GoapAction step in plan)
{
    Debug.Log("Plan step: " + step.Name);
}
// Prints, for the example above:
//   Plan step: MoveToCover
//   Plan step: Reload
//   Plan step: AttackFromCover

The payoff is adaptability: if the world changes mid-plan (the cover gets destroyed, ammo runs out somewhere else), the agent simply re-plans from its new current state — nobody had to hand-author a rule for that specific situation, because the planner searches fresh every time it is asked.

Common mistake Adding many actions with loosely-tuned costs and trusting the planner to "just look smart." The search only optimizes for lowest total cost — if a far-away health pickup happens to have a lower cost number than a nearby one, the agent will walk right past the near one, and it will look like a bug even though the planner did exactly what it was told. GOAP needs careful cost tuning, and its plans are harder to predict and debug than a hand-authored tree, which is why it shows up more in high-budget, systemic games than in small projects.

10. Utility AI: Scoring Every Option

Utility AI skips both trees and planning. Every possible action gets a numeric score (also called its utility) computed from the current situation, using a small scoring function per action, and the agent simply runs whichever action scored highest this frame. It is the right tool when the "correct" choice genuinely depends on blending several continuous factors at once, rather than a clean yes/no rule.


public interface IUtilityAction
{
    string Name { get; }
    float Score(EnemyAI enemy);
    void Execute(EnemyAI enemy);
}

public class FleeAction : IUtilityAction
{
    public string Name => "Flee";

    public float Score(EnemyAI enemy)
    {
        // Full health never wants to flee; near-death wants it badly.
        float missingHealthFraction = 1f - (enemy.Health / enemy.MaxHealth);
        return missingHealthFraction * 10f; // ranges 0 .. 10
    }

    public void Execute(EnemyAI enemy) { enemy.RunAwayFromPlayer(); }
}

public class AttackAction : IUtilityAction
{
    public string Name => "Attack";

    public float Score(EnemyAI enemy)
    {
        if (enemy.Ammo <= 0) return 0f; // can't attack with no ammo

        float closeness = Mathf.Clamp01(1f - enemy.DistanceToPlayer() / enemy.MaxAttackDistance);
        return closeness * 6f; // ranges 0 .. 6, higher when the player is close
    }

    public void Execute(EnemyAI enemy) { enemy.AttackPlayer(); }
}

public class PatrolAction : IUtilityAction
{
    public string Name => "Patrol";

    public float Score(EnemyAI enemy) => 1f; // a low, constant fallback

    public void Execute(EnemyAI enemy) { enemy.MoveAlongPatrolRoute(); }
}

using System.Collections.Generic;
using UnityEngine;

public class UtilityAI : MonoBehaviour
{
    public EnemyAI enemy;
    private List<IUtilityAction> actions;

    void Awake()
    {
        actions = new List<IUtilityAction>
        {
            new FleeAction(),
            new AttackAction(),
            new PatrolAction()
        };
    }

    void Update()
    {
        IUtilityAction best = null;
        float bestScore = float.MinValue;

        foreach (var action in actions)
        {
            float score = action.Score(enemy);
            if (score > bestScore)
            {
                bestScore = score;
                best = action;
            }
        }

        best.Execute(enemy);
    }
}

Worked trace: say Health = 20, MaxHealth = 100, Ammo = 3, DistanceToPlayer = 4, MaxAttackDistance = 10:

Flee : (1 - 20/100) * 10 = 0.8 * 10 = 8.0 Attack : ammo > 0, closeness = clamp01(1 - 4/10) = 0.6, 0.6 * 6 = 3.6 Patrol : constant = 1.0 Scores this frame: Flee [########################] 8.0 <- highest, wins Attack [#############] 3.6 Patrol [####] 1.0

Flee wins, even though the player is well within attack range and the enemy still has ammo, because health is critically low. This is exactly the kind of "it depends on several things at once" nuance an FSM would need an extra explicit rule for, and a behavior tree would need an extra Condition node for — Utility AI gets it for free, because the score functions already blend every factor together.

Tip Keep every action's score on a comparable scale (here, roughly 0..10) so no single action unfairly dominates just because its formula happens to produce bigger raw numbers. Most utility AI systems normalize every scoring factor to a 0..1 range and then apply a weight per factor, so tuning means adjusting weights, not rewriting formulas.

11. Choosing Among FSM, BT, GOAP, and Utility AI

None of these four tools is strictly "better" — each fits a different shape of decision problem:

A practical rule of thumb: start with the simplest tool that honestly fits — often an FSM for a small prototype — and move to a behavior tree the moment you feel yourself fighting the transition-table problem from Section 4. Reach for GOAP or Utility AI only once you have a concrete decision that the simpler tools genuinely cannot express cleanly, not because they sound more advanced.

12. Combining Techniques: A Realistic Hybrid

Shipped games rarely use exactly one of these in isolation. A common, practical combination is: use Utility AI (or a lightweight GOAP goal-picker) once, at a coarse grain, to choose a high-level goal, then hand that goal to a behavior tree that knows how to execute it step by step:

UtilityAI.Update() scores high-level goals this frame: ConsiderAttack() score 3 ConsiderDefend() score 7 <- picked ConsiderFlee() score 2 Picked goal: Defend | v BehaviorTree for the "Defend" goal runs as usual: Selector Sequence( EnemyInMeleeRange?, BlockAction ) Sequence( HasShieldCharge?, RaiseShieldAction ) RetreatToCoverAction

Utility AI is good at the coarse, nuanced "what should my priority be" question; a behavior tree is good at reliably executing the concrete steps of whatever priority won. Neither tool has to do the other's job. The same pattern works with GOAP standing in for the top layer: GOAP decides the sequence of high-level actions, and each individual action can itself be implemented as a small behavior tree. There is no rule that says a project must pick exactly one of these four systems — pick per problem, and combine them where it genuinely simplifies the code.

13. Glossary

14. Exercises

Exercise 1 — Add a Flee State Using the FSM from Section 3, add a fourth state, FleeState. Rule: if enemy.Health <= 20, the enemy should switch to FleeState from any state and run away; once enemy.Health > 50, it should return to PatrolState. Write the FleeState class, and write the one extra check you would need to add inside each of PatrolState, ChaseState, and AttackState's Tick methods to make the "flee from any state" rule actually work.
Show answer

public class FleeState : IEnemyState
{
    public void Enter(EnemyAI enemy)
    {
        Debug.Log("Enter Flee");
    }

    public void Tick(EnemyAI enemy)
    {
        enemy.RunAwayFromPlayer();

        if (enemy.Health > 50)
        {
            enemy.ChangeState(new PatrolState());
        }
    }
}

// And inside EACH of PatrolState.Tick, ChaseState.Tick, AttackState.Tick,
// as the very first check:
if (enemy.Health <= 20)
{
    enemy.ChangeState(new FleeState());
    return;
}

Notice you had to open and edit three existing, previously-working classes just to add one new state. This is exactly the spaghetti growth described in Section 4 — a fourth state should not require touching the first three, but with a plain FSM it does. Exercise 2 shows the same feature added to a behavior tree, where the existing branches are not touched at all.

Exercise 2 — Build a Guard Behavior Tree Using the Sequence, Selector, ConditionNode, and ActionNode classes from Section 6, build a behavior tree for a guard with this priority: (1) if HeardNoise() is true, run Investigate; otherwise (2) if CanSeePlayer() is true, run Chase; otherwise (3) run Patrol.
Show answer

root = new Selector(
    new Sequence(
        new ConditionNode(HeardNoise),
        new ActionNode(Investigate)
    ),
    new Sequence(
        new ConditionNode(CanSeePlayer),
        new ActionNode(Chase)
    ),
    new ActionNode(Patrol)
);

bool HeardNoise() { /* check a recent noise event */ return false; }
bool CanSeePlayer() { /* raycast toward the player */ return false; }

NodeStatus Investigate()
{
    Debug.Log("Investigating noise...");
    return NodeStatus.Running;
}

NodeStatus Chase()
{
    Debug.Log("Chasing...");
    return NodeStatus.Running;
}

NodeStatus Patrol()
{
    Debug.Log("Patrolling...");
    return NodeStatus.Running;
}

The root Selector tries the "heard a noise" Sequence first, since investigating a noise should outrank both chasing and patrolling. If that Sequence fails (no noise heard), the Selector falls through to the "see the player" Sequence, and only if that also fails does it fall all the way through to Patrol. Adding this whole "investigate" behavior required zero changes to how chasing or patrolling work.

Exercise 3 — Score It By Hand Using the scoring formulas from Section 10, compute the Flee, Attack, and Patrol scores by hand for an enemy with Health = 60, MaxHealth = 100, Ammo = 0, DistanceToPlayer = 3, MaxAttackDistance = 10. Which action wins? Then explain in one sentence why this result might mean the FleeAction formula needs re-tuning.
Show answer

Flee   : (1 - 60/100) * 10 = 0.4 * 10 = 4.0
Attack : Ammo <= 0, so Score() returns 0 immediately, regardless of distance
Patrol : constant                                                    = 1.0

Winner: Flee, with a score of 4.0

Flee wins even though the enemy is at 60% health, which does not feel "critically low" to a player watching the fight — it just happens to score higher than Patrol's fixed 1.0 because Attack is completely locked out at zero ammo. This is a sign the FleeAction formula is too aggressive in the middle of its range: a designer would likely lower the multiplier, or change it from a straight line to a curve that stays near zero until health drops below some clearer danger threshold (say 30%), so fleeing only wins when it actually looks justified.

← Back to all chapters