Gameplay

Swarm AI System

A horde AI in C++ on GAS, State Tree, and EQS: enemy packs merge, travel as one body, and answer an alert together.

  • C++
  • State Tree
  • EQS
  • AI Perception
  • Gameplay Ability System
  • Multiplayer
  • Subsystems
  • Game Features
  • Data Driven

Feature context

Project context
Obversus: Umbra
Status
In development
Engine
Unreal Engine 5.8

Responsibilities

  • Designed and implemented the whole swarm in C++: the director subsystem, the State Tree behavior, and the EQS queries.
  • Built the pack layer: merging groups, shared wander goals, caller election, and the group alert that rallies far packs.
  • Built the perception setup and the GAS abilities behind the call, melee, and death.
  • Made every behavior number a data asset field, so a designer tunes the horde without code.

Overview

The swarm is the game’s horde enemy. Alone a unit wanders. Near others it forms a pack: the pack travels as one body, spots as one, and raises the alarm as one. When a member sees you, one unit stops and calls, the call spreads outward as a growing wave, and every pack the wave reaches comes running. Attack from a spot they cannot reach and the whole pack flees to cover instead of stacking up under you.

Everything here is tunable without code: pack sizes, perception ranges, alert distances, speeds, cooldowns, and melee numbers all live in one data asset per enemy variant.

Two Brains, One Hivemind

The system is split in two, and the split is the whole design:

  • A State Tree on each unit decides what this one body does right now: wander, chase, attack, call, flee.
  • A director subsystem at world level acts as the hivemind: it decides what each pack does as a whole, who is in it, where it wanders, who calls, when it flees.

Why: pack behavior is a shared decision. Twenty State Trees cannot agree on one wander goal or elect one caller, because each tree only knows its own unit. Putting the shared state in one place removes every unit to unit negotiation. The trees stay small, the director never animates or moves anything, and each half can grow without touching the other: new pack behaviors are director code, new unit behaviors are tree states. That separation of concerns is what let the horde scale from one wandering unit to coordinated packs without a rewrite.

The director

The director is the hivemind: one world subsystem holding an array of packs, thinking for all of them. A pack is a plain struct: its members, one shared wander goal, and the alert state.

struct FSwarmGroup
{
	TArray<TObjectPtr<ASwarmAIController>> Members;

	// The single point every member walks to - one goal so the pack travels together.
	FVector WanderGoal;

	// Last hostile any pack member spotted - shared so the whole pack joins combat.
	TWeakObjectPtr<AActor> GroupAlertTarget;

	// The member chosen to call while the pack rallies.
	TWeakObjectPtr<ASwarmAIController> CallerController;
	// ...
};

Controllers register themselves on possession. A periodic clustering pass merges packs whose bodies drift close, up to a size cap. Merge only, no splitting: a horde that grows should stay a horde, and split logic would fight the merge logic every pass.

One goal, many slots

A shared goal alone makes the pack pile onto one point. So goal picking runs in two EQS stages:

  1. Goal query scatters candidates around the pack centroid and picks the next shared goal. A minimum travel distance guarantees every pick is a real move; a generous path cost ceiling kills only absurd routes.
  2. Spread query runs once per new goal and assigns each member its own navigable slot near it, so the pack arrives as a loose body instead of a queue.

The pack does not wait for every straggler. When a quorum of members reaches the goal, the pack settles, pauses, and picks the next one. One stuck unit cannot freeze twenty.

The State Tree side

Each unit runs a State Tree of custom C++ tasks, evaluators, and conditions. Wander, chase, attack, search, flee, and the alert states are states; transitions are conditions that mostly ask the director one question.

Why State Tree and not a Behavior Tree

This behavior is state shaped, not priority shaped. A unit is in exactly one mode at a time, and what matters is the explicit transition between modes: wander becomes chase on sight, chase becomes flee when the director says so. A Behavior Tree models “which task wins right now” through decorators and aborts, and the mode a unit is in becomes something you infer from which branch happens to be running. In a State Tree the mode is the state, and every legal transition is written down and readable in the asset.

Data flow decided the rest. A Behavior Tree passes data through blackboard keys: untyped, looked up by name, wrong at runtime instead of at bind time. State Tree bindings are typed properties checked when the tree is built, which is the contract the next section is about. Evaluators also give the tree a built in place for shared per tick data, where a Behavior Tree needs services scattered across branches.

It is also the direction Unreal itself is moving: State Tree is the actively developed successor, and this project follows the engine rather than its legacy path.

How data moves through the tree

State Tree nodes do not reach into each other. Each node declares an instance data struct, and the categories on its properties are the contract: Context is filled by the schema (the AI controller), Input is bound in the editor to another node’s Output, Parameter is set per placement.

struct FStateTreeTask_ChaseTargetInstanceData
{
	// The AI controller (Context, schema-filled).
	UPROPERTY(EditAnywhere, Category = Context)
	TObjectPtr<AAIController> AIController;

	// The actor to chase - bound from the Perception evaluator's SightTarget output.
	UPROPERTY(EditAnywhere, Category = Input)
	TObjectPtr<AActor> SightTarget;

	// EQS query that finds the best reachable approach position near the target.
	UPROPERTY(EditAnywhere, Category = Parameter)
	TObjectPtr<UEnvQuery> ChaseQuery;
};

At runtime a node reads its own storage through the execution context and writes its outputs there; the binding system carries them to whoever bound them:

void FStateTreeEvaluator_SwarmGroup::Tick(FStateTreeExecutionContext& Context, const float DeltaTime) const
{
	FInstanceDataType& InstanceData = Context.GetInstanceData(*this);
	// ...
	// Published as Output: the chase and wander states read these through bindings.
	InstanceData.GroupWanderGoal = AssignedPoint;
	InstanceData.GroupCentroid = Centroid;
}

Why bindings instead of a blackboard: the contract is typed and visible. The editor shows exactly which output feeds which input, a rename breaks loudly at compile or bind time instead of failing silently at runtime, and a node cannot depend on data nobody publishes.

Three evaluators feed the whole tree. Perception distills the AI senses into a small decision set: current sight target, last known location, heard noise. Group asks the director for this unit’s assigned slot and pack centroid. Config republishes the unit’s data asset values as bindable outputs, so a designer binds “melee range” in the tree without any node knowing where numbers live.

Conditions ask the director

Transitions into pack states are one question wide. The whole flee transition:

bool FStateTreeCondition_ShouldFlee::TestCondition(FStateTreeExecutionContext& Context) const
{
	const FInstanceDataType& InstanceData = Context.GetInstanceData(*this);
	// ...
	const bool bFleeing = Director && Director->ShouldFlee(Controller);
	return bFleeing == InstanceData.bWantFleeing;
}

The same shape covers “am I the caller”, “was I flagged to answer”, and “did my pack hear a group alert”. The director flags, the tree consumes. Flags are non consuming reads plus an explicit consume call from the state that acts on them, so a flag set this frame cannot be lost if the tree transitions a frame late.

The group alert

When a member first sees a hostile, the director elects a caller: the earliest spotter, distance as tie break. The caller holds position and calls; everyone else gets flagged to answer, staggered closest first, then joins the chase. A hit on the caller interrupts the call, so silencing it is a real counter.

While the caller calls, an alert wave grows from the call point over a set window. Any far pack the wave reaches drops its wander and runs toward the call area. It carries no target with it: the arriving pack’s own senses discover the threat. Handing the target over instead would let a pack track a player it has never seen, which reads as cheating to the player.

A cooldown follows each cycle. Inside it, a fresh sighting skips the call and goes straight to chase, so the horde does not alarm in a loop while being kited.

The call, the melee, and death all run as Gameplay Ability System abilities on the unit. The call audio replicates as a gameplay cue, so every client hears it without bespoke RPCs, and melee damage flows through the same server side damage execution the players use. One damage pipeline, no AI special cases.

Flee

If the pack’s target stays unreachable past a debounce delay, a shooter on a roof for example, the whole pack flees instead of crowding the wall below. An EQS query picks the flee anchor: far from the threat, hidden from it, and reachable. Hidden is scored, not filtered, so when nothing is fully out of sight the pack still takes the farthest reachable point instead of standing still. The debounce exists because reachability flickers; one bad frame must not route a whole horde.

One data asset

Every number above lives in one config data asset per unit variant, and the State Tree asset itself is one of its fields. A sample of what a designer can turn without code:

Group Knobs
Pack Max pack size, merge distance, personal spacing, cohesion pull
Wander Goal pick radius, arrival quorum fraction, pause at goal, spread radius
Perception Sight radius and cone, lose sight radius, hearing range, memory time
Alert Wave start and max radius, call duration, answer stagger, cooldown
Combat Melee range, damage, swing cadence, unreachable give up time
Flee Trigger debounce, minimum distance from threat, flee spread

Movement speeds are a tag to speed map, so a state changes speed by setting a tag, and the server owned walk speed replicates through normal character movement:

// Walk speed (cm/s) per movement tag. No tag entry = speed unchanged.
TMap<FGameplayTag, float> MovementSpeedProfiles;

A new variant with different speed, sight range, or flee threshold is a new data asset and zero code. The State Tree also publishes its coarse state and a perception snapshot onto the pawn each tick, so the animation Blueprint and debug tools read one struct instead of touching AI internals.

The result is a production oriented architecture: deterministic pack decisions in one authoritative place, typed data flow through the trees, designer facing tuning with no programmer in the loop, and behaviors that were added mid development, flee and the group alert among them, without destabilizing what already worked.