Game AI Systems: Behavior Trees, GOAP & Utility AI | Game Development Hub
Introduction
Game AI drives character behavior, enemy tactics, and world simulation. This guide covers three production-proven architectures: Behavior Trees (industry standard for authoring), GOAP (planning-based flexibility), and Utility AI (scoring-based decision making) — plus integration with animation and perception systems.
Behavior Trees
Behavior Trees (BTs) are hierarchical, reactive planners. They compose simple nodes into complex behaviors through control flow (sequences, selectors) and decorators.
Core Node Types
Type
Children
Returns
Behavior
Sequence
Multiple
Success if ALL succeed
Run children in order, stop on failure
Selector
Multiple
Success if ANY succeeds
Run children in order, stop on success
Parallel
Multiple
Configurable
Run all children simultaneously
Decorator
One
Modified
Modify child result (invert, repeat, until)
Action (Leaf)
None
Success/Failure/Running
Execute behavior (move, attack, wait)
Condition (Leaf)
None
Success/Failure
Query world state (canSeePlayer, healthLow)
Node Status & Tick Flow
enum class Status { Success, Failure, Running };
struct BTNode {
virtual Status tick(Blackboard& bb, float dt) = 0;
virtual void abort(Blackboard& bb) {} // For Running nodes
};
// Sequence: fail fast
Status Sequence::tick(Blackboard& bb, float dt) {
for (auto& child : children) {
Status s = child->tick(bb, dt);
if (s == Status::Failure) return Status::Failure;
if (s == Status::Running) return Status::Running;
}
return Status::Success;
}
// Selector: succeed fast
Status Selector::tick(Blackboard& bb, float dt) {
for (auto& child : children) {
Status s = child->tick(bb, dt);
if (s == Status::Success) return Status::Success;
if (s == Status::Running) return Status::Running;
}
return Status::Failure;
}
Decorators & Services
Decorators wrap a single child to modify execution:
// Inverter: Success ↔ Failure
Status Inverter::tick(Blackboard& bb, float dt) {
Status s = child->tick(bb, dt);
return (s == Status::Success) ? Status::Failure :
(s == Status::Failure) ? Status::Success : Status::Running;
}
// Repeater: Run N times or until failure
Status Repeater::tick(Blackboard& bb, float dt) {
if (count >= maxCount) return Status::Success;
Status s = child->tick(bb, dt);
if (s != Status::Running) count++;
return s;
}
// Cooldown: Prevent spam
Status Cooldown::tick(Blackboard& bb, float dt) {
if (timer > 0) { timer -= dt; return Status::Failure; }
Status s = child->tick(bb, dt);
if (s == Status::Success) timer = cooldownDuration;
return s;
}
// Service: Runs in parallel with tree (e.g., perception updates)
struct Service {
virtual void update(Blackboard& bb, float dt) = 0;
float interval;
float timer;
};
Blackboard Architecture
Shared key-value store for inter-node communication:
struct Blackboard {
unordered_map
data;
template
void set(string key, T value) { data[key] = value; }
template
T get(string key, T default = {}) {
auto it = data.find(key); return it != end ? any_cast
(it->second) : default;
}
};
// Usage in nodes
Status MoveToTarget::tick(Blackboard& bb, float dt) {
EntityId target = bb.get
("TargetEntity");
float3 targetPos = world.getPosition(target);
float3 myPos = world.getPosition(bb.get
("Self"));
if (distance(targetPos, myPos) < 1.0f) return Status::Success;
moveTowards(myPos, targetPos, dt);
return Status::Running;
}
Event-Driven BTs (Optimization)
Instead of ticking full tree every frame, subscribe to blackboard changes:
// Only re-evaluate affected subtrees
bb.onChange("TargetEntity", [&](Variant oldVal, Variant newVal) {
markSubtreeDirty(root->findSubtreeUsing("TargetEntity"));
});
// Tick only dirty nodes
void tickDirty(Blackboard& bb, float dt) {
for (auto* node : dirtyNodes) node->tick(bb, dt);
dirtyNodes.clear();
}
GOAP (Goal-Oriented Action Planning)
GOAP uses A* planning over world state. Agents have goals (world state targets) and actions (state transitions with preconditions/effects). Planner finds action sequence to satisfy goal.
World State Representation
// World state as key-value pairs (can use bitmask for bools)
using WorldState = unordered_map
;
struct Action {
string name;
float cost;
WorldState preconditions; // Required state
WorldState effects; // State changes
bool canExecute(const WorldState& state) const {
for (auto& [key, val] : preconditions) {
if (state[key] != val) return false;
}
return true;
}
WorldState apply(const WorldState& state) const {
WorldState next = state;
for (auto& [key, val] : effects) next[key] = val;
return next;
}
};
// Example actions
Action attack = {
"Attack", 1.0f,
{{"HasWeapon", true}, {"InRange", true}, {"EnemyVisible", true}},
{{"EnemyDead", true}, {"HasWeapon", true}}
};
Action moveToEnemy = {
"MoveToEnemy", 2.0f,
{{"EnemyVisible", true}},
{{"InRange", true}}
};
Action findWeapon = {
"FindWeapon", 5.0f,
{},
{{"HasWeapon", true}}
};
A* Planner
struct PlanNode {
WorldState state;
vector
actions; // Plan so far
float gCost; // Cost so far
float hCost; // Heuristic to goal
float fCost() const { return gCost + hCost; }
};
vector
plan(WorldState start, WorldState goal, vector
& actions) {
priority_queue
, CompareF> open;
unordered_set
closed;
open.push(new PlanNode{start, {}, 0, heuristic(start, goal)});
while (!open.empty()) {
PlanNode* current = open.top(); open.pop();
if (satisfiesGoal(current->state, goal)) {
return current->actions;
}
if (closed.contains(hash(current->state))) continue;
closed.insert(hash(current->state));
for (Action* action : actions) {
if (action->canExecute(current->state)) {
WorldState nextState = action->apply(current->state);
float g = current->gCost + action->cost;
float h = heuristic(nextState, goal);
PlanNode* next = new PlanNode{nextState,
current->actions + vector{action}, g, h};
open.push(next);
}
}
}
return {}; // No plan found
}
// Heuristic: count unsatisfied goal conditions
float heuristic(const WorldState& state, const WorldState& goal) {
float h = 0;
for (auto& [key, val] : goal) {
if (state[key] != val) h += 1.0f; // Could weight by action cost
}
return h;
}
GOAP vs Behavior Trees
Aspect
Behavior Trees
GOAP
Authoring
Visual editors, designer-friendly
Code/data-driven, programmer-friendly
Reactivity
Immediate (tick-based)
Replan on world change
Flexibility
Fixed structure
Emergent plans
Debugging
Visual trace
Plan inspection
Performance
O(tree size) per tick
O(planning) on demand
Best For
Scripted behaviors, bosses
Open-ended agents, squads
Utility AI
Utility AI scores all available actions based on current context, picks highest score. No explicit state machine — decisions emerge from scoring curves.
Scoring Functions
struct Consideration {
string name;
function
score; // Returns 0.0 - 1.0
float weight;
};
struct Action {
string name;
vector
considerations;
function
execute;
float evaluate(Blackboard& bb) const {
float total = 0;
for (auto& c : considerations) {
total += c.score(bb) * c.weight;
}
return total / sumWeights(); // Normalized
}
};
// Common scoring curves
float linear(float x, float min, float max) {
return clamp((x - min) / (max - min), 0.0f, 1.0f);
}
float exponential(float x, float k) { // k > 1 = steep, 0 < k < 1 = shallow
return pow(clamp(x, 0.0f, 1.0f), k);
}
float logistic(float x, float k, float x0) { // Sigmoid
return 1.0f / (1.0f + exp(-k * (x - x0)));
}
// Example: Attack action scoring
Action attackAction = {
"Attack",
{
{"DistanceToTarget", [](Blackboard& bb) {
float dist = bb.get
("DistanceToTarget");
return 1.0f - linear(dist, 0.0f, 10.0f); // Higher score when closer
}, 0.4f},
{"HealthPercent", [](Blackboard& bb) {
return bb.get
("HealthPercent"); // Higher health = more aggressive
}, 0.3f},
{"AmmoCount", [](Blackboard& bb) {
return linear(bb.get
("AmmoCount"), 0, 30);
}, 0.2f},
{"EnemyThreat", [](Blackboard& bb) {
return bb.get
("EnemyThreatLevel");
}, 0.1f}
},
[](Blackboard& bb) { /* execute attack */ }
};
Decision Making
Action* selectBestAction(Blackboard& bb, vector
& actions) {
Action* best = nullptr;
float bestScore = -1.0f;
for (Action* a : actions) {
float score = a->evaluate(bb);
if (score > bestScore) {
bestScore = score;
best = a;
}
}
return best;
}
// Add noise for variety (prevents oscillation)
float addNoise(float score, float noiseAmount = 0.1f) {
return score + randomFloat(-noiseAmount, noiseAmount);
}
// Hysteresis: stick with current action unless alternative is significantly better
Action* selectWithHysteresis(Blackboard& bb, vector
& actions, Action* current) {
Action* best = selectBestAction(bb, actions);
if (current && best != current) {
float currentScore = current->evaluate(bb);
float bestScore = best->evaluate(bb);
if (bestScore < currentScore * 1.1f) return current; // 10% threshold
}
return best;
}
Animation & Perception Integration
AI drives animation state machines; perception feeds blackboard.
Animation State Machine
struct AnimationController {
enum State { Idle, Walk, Run, Attack, Hit, Death };
State currentState = Idle;
float blendTime = 0.2f;
void requestState(State newState) {
if (canTransition(currentState, newState)) {
crossfade(currentState, newState, blendTime);
currentState = newState;
}
}
bool canTransition(State from, State to) {
// Define valid transitions
static const map
, bool> valid = {
{{Idle, Walk}, true}, {{Walk, Idle}, true},
{{Walk, Run}, true}, {{Run, Walk}, true},
{{Idle, Attack}, true}, {{Walk, Attack}, true},
{{Run, Attack}, true}, {{Attack, Idle}, true},
{{Any, Hit}, true}, {{Any, Death}, true}
};
return valid.contains({from, to});
}
};
// AI tick drives animation
void aiTick(Blackboard& bb, AnimationController& anim, float dt) {
Action* action = selectBestAction(bb, availableActions);
action->execute(bb);
// Map action to animation
if (action->name == "MoveToTarget") anim.requestState(AnimationController::Walk);
else if (action->name == "Attack") anim.requestState(AnimationController::Attack);
else anim.requestState(AnimationController::Idle);
}
Perception System
struct PerceptionSystem {
struct Stimulus {
EntityId source;
StimulusType type; // Visual, Audio, Damage
float intensity;
float3 position;
float timestamp;
};
vector
stimuli;
void update(EntityId observer, float dt) {
// Visual: cone check + raycast
for (EntityId target : potentialTargets) {
if (inViewCone(observer, target) &&
raycast(observer, target, LOS_LAYER)) {
float dist = distance(pos(observer), pos(target));
float intensity = 1.0f / (1.0f + dist * 0.1f);
stimuli.push_back({target, Visual, intensity, pos(target), time});
}
}
// Audio: radius check
for (SoundEvent& s : soundEvents) {
float dist = distance(pos(observer), s.position);
if (dist < s.radius) {
stimuli.push_back({s.source, Audio, s.loudness, s.position, time});
}
}
// Update blackboard
bb.set("NearestVisualStimulus", findNearest(stimuli, Visual));
bb.set("ThreatLevel", computeThreat(stimuli));
}
}
Hybrid Architectures
Production systems often combine approaches:
BT + Utility
: BT for high-level structure, Utility for leaf action selection
GOAP + BT
: GOAP for strategic planning, BT for tactical execution
HTN (Hierarchical Task Networks)
: Decompose goals into subtasks — used in F.E.A.R., Killzone
Example: BT with Utility Leaves
// Selector chooses between combat behaviors
Selector combatSelector {
// Each child uses Utility AI to pick specific action
UtilityActionNode combatActions { "Attack", "Flank", "TakeCover", "Reload", "Retreat" }
UtilityActionNode patrolActions { "Patrol", "Investigate", "Idle" }
}
// UtilityActionNode evaluates all actions, runs best one
Status UtilityActionNode::tick(Blackboard& bb, float dt) {
Action* best = selectBestAction(bb, actions);
if (best) {
return best->execute(bb, dt); // Returns Running until complete
}
return Status::Failure;
}
Conclusion
Behavior Trees remain the industry standard for authorable, debuggable AI. GOAP excels at open-ended, planning-heavy agents. Utility AI provides smooth, context-sensitive decision making without rigid state machines. Modern engines (Unreal's GameplayAbilitySystem, Unity's DOTS AI, Godot's BT plugin) combine these approaches. Choose based on team workflow: designers → BTs, programmers → GOAP/Utility, hybrid → best of both worlds.
Comments
Post a Comment