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

Popular posts from this blog

Firmware Testing Strategies: Hardware-in-the-Loop, Simulation, and CI/CD Pipelines | Embedded Systems Hub

IoT Protocols Compared: MQTT, CoAP, HTTP/2, WebSocket - Performance, Overhead, and Use Cases | Embedded Systems Hub

Embedded Security: Secure Boot, ARM TrustZone, and OTA Update Integrity | Embedded Systems Hub