Enemy AI¶
Summary: Project Eternal uses Unreal's Behavior Tree system with GAS integration for enemy AI.
AEternalAIControllermanages perception, state machines, and ability activation. Combat personality is fully data-driven viaFCombatBehaviorConfig, which pushes all tuning values to blackboard keys. The system supports both melee and ranged archetypes through the same task library. Boss enemies use health-threshold phase transitions that swap abilities and combat configs dynamically.
Table of Contents¶
- Why This Architecture
- AI Architecture
- State Machine
- Idle Behavior
- Behavior Tree Integration
- Perception System
- Boss Phase System
- Combat Behaviors
- Pack Coordination
- Data-Driven Configuration
- Source References
- Related Systems
- Recent Changes
Why This Architecture¶
Design Goals¶
The AI system is built around four principles:
- Behavior Tree Foundation - All decisions flow through composable, debuggable trees
- GAS Integration - Enemies use the same ability system as players for consistency
- Data-Driven - Enemy types configured through data assets, not code
- State-Based - Discrete states with clear transitions for predictable behavior
Architecture Trade-offs¶
| Choice | Benefit | Trade-off |
|---|---|---|
| Behavior Trees | Visual debugging, designer-friendly | Less flexible than code |
| State Machine | Predictable, easy to reason about | More boilerplate |
| GAS for combat | Consistent with player systems | GAS learning curve |
| Data Assets | No code changes for new enemies | More asset management |
AI Architecture¶
+------------------+
| AEternalEnemy | (Character)
+------------------+
|
+-- UAbilitySystemComponent
+-- UEternalAttributeSet
+-- UEnemyCombatComponent
+-- ULootComponent
|
v
+----------------------+
| AEternalAIController | (Controller)
+----------------------+
|
+-- UBehaviorTreeComponent
| |
| +-- Behavior Tree Asset (from DataAsset)
|
+-- UPatrolComponent
| |
| +-- Server-only idle state (wander vs. authored spline)
|
+-- UBlackboardComponent
| |
| +-- Runtime AI state + FCombatBehaviorConfig values
|
+-- UAIPerceptionComponent
|
+-- UAISenseConfig_Sight
+-- UAISenseConfig_Hearing
Component Ownership¶
| Component | Owner | Why |
|---|---|---|
| AbilitySystemComponent | Character | Tied to pawn lifecycle |
| CombatComponent | Character | Physical combat state |
| BehaviorTreeComponent | Controller | Persists if pawn dies/respawns |
| PerceptionComponent | Controller | Sensing is controller responsibility |
| PatrolComponent | Controller | Server-only idle state, survives pawn lifecycle |
State Machine¶
Enemy States¶
| State | Description | Transitions To |
|---|---|---|
Idle |
No target — runs idle behavior (wander / authored path / stationary) | Engaged |
Engaged |
Player detected, in combat | Attacking, Idle |
Attacking |
Executing ability/attack | Engaged, HitReact |
HitReact |
Playing hit reaction | Engaged, Attacking |
PhaseTransition |
Boss phase change | Engaged |
Dead |
Enemy has died | - |
State Flow¶
+--------+
+---------->| Idle |<--------------+
| +---+----+ |
| | |
| (player detected) |
| v |
| +---------+ |
| | Engaged | |
| +----+----+ |
| | |
| (ability activates) (lose sight 5s)
| v |
| +-----------+ |
+---------| Attacking |--------------+
+-----------+
|
(montage ends)
|
+-----+-----+
| Engaged |
+-----------+
In the Idle state the AI runs its data-driven idle behavior (NavMesh wander,
authored-spline patrol, or stationary hold) until a target is perceived.
Blackboard Keys¶
State & Targeting:
| Key | Type | Updated By | Used By |
|---|---|---|---|
hasSeenPlayer |
Bool | Perception | BT conditions |
Target |
Object | Perception | BT tasks |
EnemyState |
Enum | State machine | BT decorators |
dead |
Bool | Death handler | BT abort |
CurrentPhase |
Int | Boss phase system | BT selectors |
DistanceToPlayer |
Float | BTService_UpdateTargetDistance |
Decorators, tasks |
Combat Config (pushed from FCombatBehaviorConfig):
| Key | Type | Purpose |
|---|---|---|
MovementMode |
Enum (EAIMovementMode) | Free or Stationary — gates all movement branches |
MaxAttackRange |
Float | Maximum attack distance |
MinAttackRange |
Float | Minimum attack distance (ranged enemies) |
AttackProbability |
Float | Base probability at max range |
AttackCooldownMin |
Float | Min seconds between attacks |
AttackCooldownMax |
Float | Max seconds between attacks |
AttackCooldownEnd |
Float | World time when cooldown expires |
StrafeRadius |
Float | Orbit radius around target |
StrafeAngle |
Float | Degrees per strafe segment |
MinStrafeDuration |
Float | Minimum strafe cycle time |
MaxStrafeDuration |
Float | Maximum strafe cycle time |
SpiralInwardSpeed |
Float | Units/sec toward orbit target |
ApproachRange |
Float | Max distance for approach behavior |
EngagementRange |
Float | Distance beyond which enemy chases |
PreferredRange |
Float | Preferred engagement distance (0 = melee) |
MinEngagementRange |
Float | Retreat trigger distance |
RetreatDistance |
Float | Target distance after retreat |
Idle Behavior¶
Why a Unified Idle Model¶
The old approach used a standalone APatrolRoute actor that each enemy had to be
manually wired to — an orphaned system that was easy to forget and didn't scale to
spawned mobs. It was replaced by a unified, data-driven idle model: every enemy
declares what it does with no combat target directly on its FEnemyData, and a single
server-only component on the AI controller abstracts the different idle strategies behind
one interface. Idle is treated as a distinct state from combat — it does not vary by
combat phase, so it lives in FIdleBehaviorConfig on the enemy data rather than nested
inside FCombatBehaviorConfig.
Idle Modes (EIdleBehavior)¶
| Mode | Behavior | Use Case |
|---|---|---|
Stationary |
Hold position, no idle movement | Turrets, statues, scripted bosses |
Wander |
NavMesh wander around the spawn point within WanderRadius |
Most mobs (default) |
AuthoredPath |
Follow the spawner's authored USplineComponent (looping or ping-pong) |
Hand-placed patrols |
AuthoredPath falls back to Wander if the spawner has no usable spline (e.g. the spawner's
bUseAuthoredPath is false). Each idle waypoint is followed by a randomized pause in the
[IdlePauseMin .. IdlePauseMax] range.
UPatrolComponent¶
A server-only UPatrolComponent lives on AEternalAIController and holds per-enemy idle
state. It hides the wander-vs-spline distinction behind a single goal-stepping API, so the
Behavior Tree never needs to branch on idle mode.
| Method | Purpose |
|---|---|
InitializeForWander() |
Configure for NavMesh wander around a fixed center + radius |
InitializeForSpline() |
Configure to follow an authored spline from a start index, looping or ping-pong |
GetNextGoal() |
Produce the next idle goal in world space (cached until Advance()) |
Advance() |
Step internal state after a goal is reached (wander re-roll / spline index step) |
GetPauseRange() |
Min/max seconds for the BT to randomize the wait between goals |
IsActive() |
True when mode is not Stationary |
Spawner Spline & Per-Spawn Distribution¶
AEnemySpawner optionally owns the PatrolSpline (USplineComponent) used by AuthoredPath
enemies. At spawn time the spawner configures each enemy's UPatrolComponent and resolves a
per-spawn start index along the spline via ESplineDistributionMode:
| Distribution | Placement |
|---|---|
StartAtPoint0 |
All enemies start at spline point 0 |
EvenlyDistributed |
Spread across spline points by spawn index (default) |
NearestSplinePoint |
Snap each enemy to the nearest point from its spawn location (best for reinforcements) |
When the spawner has no spline (or bUseAuthoredPath is false), AuthoredPath enemies fall
back to Wander around the spawner.
Idle Data Flow¶
AEnemySpawner::SpawnEnemies (server)
|
+-- For each spawned enemy:
| read FEnemyData.IdleBehavior (FIdleBehaviorConfig)
| |
| +-- Mode == AuthoredPath && spawner has spline
| | -> ResolveSplineSpawnIndex() -> PatrolComponent.InitializeForSpline()
| |
| +-- else (Wander, or AuthoredPath fallback)
| -> PatrolComponent.InitializeForWander(spawn point, WanderRadius)
|
v
BT_Idle sub-tree (gated by BTDecorator_HasIdleBehavior)
|
+-- BTTask_PatrolStep: PatrolComponent.GetNextGoal() -> MoveTo -> Advance()
+-- Wait (randomized within GetPauseRange())
Behavior Tree Integration¶
The brain is externally gated.
UEnemySignificanceSubsystemhibernates distant enemies by pausing theBrainComponentunder the lock reason"EnemyHibernation"and disabling the AI controller's tick. A hibernated enemy runs no behavior tree at all — before debugging "the tree isn't ticking", confirm the enemy is not hibernated. See Enemy Performance.
Custom BT Tasks¶
Combat Tasks:
| Task | Purpose | Key BB Keys |
|---|---|---|
BTTask_ActivateAbility |
Activate GAS ability by tag, wait for completion | EnemyState |
BTTask_SetAttackCooldown |
Write next attack cooldown timestamp to BB | AttackCooldownMin, Max, End |
BTTask_SetDesiredGait |
Set ALS gait (Walking, Running, Sprinting) | None |
BTTask_SetDesiredRotationMode |
Set ALS rotation mode (ViewDirection, Aiming) | None |
BTTask_SetGuardState |
Raise and hold the guard stance (stays InProgress); tears down on abort or ability end |
None |
Movement Tasks:
| Task | Purpose | Key BB Keys |
|---|---|---|
BTTask_MoveAlongStrafePoints |
Orbital strafe with spiral mechanics | StrafeRadius, PreferredRange, SpiralInwardSpeed |
BTTask_ApproachTarget |
Two-mode approach (closing + pressure) | MaxAttackRange, PreferredRange, ApproachRange |
BTTask_ChaseTarget |
Persistent chase until within range | PreferredRange |
BTTask_RetreatFromTarget |
Move away from target to safe distance | RetreatDistance, PreferredRange |
BTTask_PatrolStep |
Ask UPatrolComponent for next idle goal, walk there, then Advance() (has stale-request guard) |
None |
Boss Tasks:
| Task | Purpose | Key BB Keys |
|---|---|---|
BTTask_ApplyPhaseConfig |
Push phase-specific FCombatBehaviorConfig to BB | CurrentPhase |
Task Design Principles¶
All movement tasks follow these conventions:
- Speed is external - Tasks do NOT control movement speed. Use
BTTask_SetDesiredGaitbefore any movement task to set Walking/Running/Sprinting via ALS gait tags. - BB with fallbacks - Tasks read tuning values from blackboard keys with per-node default fallbacks. This allows data asset overrides while keeping sensible defaults.
- Instance per node - Movement tasks use
bCreateNodeInstance = trueto maintain per-instance state. - Nav mesh projection - All movement goals are projected onto the nav mesh before pathfinding.
Authoring Rule: Bind Before Activate¶
In any ability driven by a BT task, bind the montage task's delegates BEFORE calling
ReadyForActivation(). This is a repeatable trap, not a one-off bug:
ReadyForActivation()
|
+-- Activate() runs SYNCHRONOUSLY
|
+-- montage fails to play --> OnCancelled broadcast, right here
|
+-- delegate not bound yet? --> nobody hears it
|
+-- ability never ends
+-- BTTask_ActivateAbility waits on OnAbilityEnded forever
+-- the enemy stands still, permanently
A late bind misses the broadcast entirely, and because the BT attack task returns InProgress and only
finishes on OnAbilityEnded, the enemy hangs rather than failing over. The enemy ability bases bind
OnCancelled/OnCompleted before activation for exactly this reason — follow the pattern in any new one.
Other Task Robustness Rules¶
- Attack-budget denial pushes a cooldown. When
BTTask_ActivateAbilityis denied its budget slot at the commit point, it writes a short fallback delay intoAttackCooldownEnd. Leaving the timestamp in the past would make Approach re-enter and reactive-exit every single tick against a still-full budget; the delay gives the budget holders time to swing before this enemy re-contests the slot. Bosses and elites bypass the budget entirely. BTTask_ChaseTargetlogs path-request failures rather than failing the task, and retries on the next repath interval — streamed-tile navmesh legitimately lags behind a moving target. The log line is what distinguishes "navmesh briefly missing" from an enemy genuinely frozen mid-chase.
Task Execution Flow¶
BTTask_ActivateAbility::ExecuteTask
|
+-- Get AIController
+-- Get Pawn
+-- Get AbilitySystemComponent
|
+-- Create TagContainer with AbilityTag
+-- TryActivateAbilitiesByTag(TagContainer)
| |
| +-- Success? --> Set EnemyState, bind OnAbilityEnded, return InProgress
| +-- Failure? --> return Failed
|
+-- OnAbilityEnded (filtered by tag match)
|
+-- Unbind delegate, finish Succeeded
Custom Decorators¶
| Decorator | Condition | Key BB Keys |
|---|---|---|
HasIdleBehavior |
AI controller has an active (non-Stationary) UPatrolComponent — gates the idle sub-tree |
None |
IsInAttackRange |
Distance <= fixed AttackRange | Target |
CanAttack |
Distance + cooldown + budget affordability + probability gate | MaxAttackRange, MinAttackRange, AttackCooldownEnd, AttackProbability |
DistanceCheck |
Target within min/max distance band | Optional BB overrides for min/max |
MovementAllowed |
MovementMode != Stationary | MovementMode |
CanAttack: Distance-Scaled Probability¶
BTDecorator_CanAttack is the primary attack gate with three sequential checks:
- Max range - Distance > AttackRange → fail
- Min range - Distance < MinAttackRange → fail (ranged enemies can't shoot point-blank)
- Cooldown - WorldTime < CooldownEnd → fail
- Budget -
CanAffordon the shared per-target attack budget → fail (advisory only; see Pack Coordination) - Probability - Distance-scaled roll prevents idle standing at close range
The budget check sits before the probability roll on purpose: a denial that consumed a roll would make the attack rate depend on how crowded the target is, which is a different mechanic than the one being tuned.
Probability scaling:
At max range: BaseProbability (e.g. 0.4)
At point-blank: ~1.0
Formula: Effective = Base + (1 - Base) × (1 - DistanceRatio)
DistanceRatio: Normalized within [MinAttackRange .. MaxAttackRange]
Custom Services¶
| Service | Purpose | Interval | Key BB Keys Written |
|---|---|---|---|
BTService_UpdateTargetDistance |
Cache distance to target | 0.1s | DistanceToPlayer |
Task Result Handling¶
| Result | Meaning | Tree Behavior |
|---|---|---|
Succeeded |
Task completed | Continue sequence |
Failed |
Task failed | Try next option |
InProgress |
Waiting (async) | Tick until done |
Aborted |
Externally cancelled | Clean up |
Perception System¶
Configuration¶
| Sense | Parameter | Value |
|---|---|---|
| Sight | SightRadius |
2000 |
| Sight | LoseSightRadius |
3000 |
| Sight | PeripheralVisionAngle |
90 (widens to 359 on detection) |
| Hearing | HearingRange |
1500 |
Senses are externally gated too.
UEnemySignificanceSubsystemdisables both the Sight and Hearing senses on hibernate. This is safe rather than a detection hole: the subsystem's wake distance exceeds every sense range, so the distance pass always wakes an enemy before it could have perceived anything. A hibernated enemy also hasStopMovement()called on it. See Enemy Performance.
Perception Flow¶
Player enters sight/hearing radius
|
v
+-------------------+
| OnTargetPerception|
| Updated |
+-------------------+
|
v
+-------------------+
| Actor has "Player"|----NO----> Ignore
| tag? |
+-------------------+
| YES
v
+-------------------+
| WasSuccessfully |----NO----> HandleLoseSight()
| Sensed? | (5 second delay)
+-------------------+
| YES
v
+-------------------+
| HandlePlayerDetected()
| - Clear lose sight timer
| - Set hasSeenPlayer = true
| - Set Target = Actor
| - SetEnemyState(Engaged)
+-------------------+
Lose Sight Behavior¶
When line of sight is broken:
1. Start 5-second grace period timer
2. If player seen again, cancel timer
3. If timer expires:
- Set hasSeenPlayer = false
- Clear Target
- Return to Idle state (resumes idle behavior)
Boss Phase System¶
Phase Architecture¶
ABossAIController
|
+-- Extends AEternalAIController
|
+-- TArray<FPhaseData> BossPhases
| |
| +-- PhaseHealthThreshold (e.g., 0.5 = 50%)
| +-- AbilitiesToGrant
| +-- PhaseTransitionAbility
|
+-- OnDamageTaken --> CheckBossPhase()
Phase Data Structure¶
| Field | Type | Purpose |
|---|---|---|
PhaseID |
FString | Debug identifier |
PhaseHealthThreshold |
float | Trigger at this health % |
AbilitiesToGrant |
TArray |
Phase-specific abilities |
PhaseTransitionAbility |
TSubclassOf | Transition animation/invulnerability |
bInitialized |
bool | Prevent re-triggering |
Phase Transition Flow¶
OnDamageTaken
|
v
Calculate health percentage
|
v
CheckBossPhase(healthPercent)
|
v
+------------------------+
| For each phase > current|
| if health <= threshold|
| InitializePhase(i) |
+------------------------+
|
v
+------------------------+
| InitializePhase: |
| 1. Store old phase |
| 2. Set current phase |
| 3. Update blackboard |
| 4. Remove old abilities|
| 5. Grant new abilities|
| 6. Trigger transition|
| 7. Broadcast delegate|
+------------------------+
Ability Management¶
During phase transition:
1. Remove all abilities granted in previous phase
2. Grant new phase abilities via GiveAbility()
3. Store ability handles for cleanup in next transition
4. Re-push FCombatBehaviorConfig for the new phase via BTTask_ApplyPhaseConfig
Combat Behaviors¶
Melee vs Ranged¶
The same BT tasks serve both melee and ranged enemies. The distinction is data-driven via FCombatBehaviorConfig:
| Behavior | Melee (PreferredRange = 0) | Ranged (PreferredRange > 0) |
|---|---|---|
| Strafe orbit | Spirals inward toward AttackRange | Maintains orbit at PreferredRange |
| Approach goal | Overshoot past target | Stop at PreferredRange from target |
| Chase exit | Within AcceptanceRadius | Within PreferredRange |
| Retreat | Not used (MinEngagementRange = 0) | Triggered when too close |
| Min attack range | 0 (can attack point-blank) | Non-zero (can't shoot point-blank) |
Hit Reaction System¶
The UEnemyCombatComponent prevents stun-locking with a stagger threshold:
ShouldHitReact()
|
v
Increment ConsecutiveHits
|
v
Reset 3-second timer
|
v
+----------------------------+
| ConsecutiveHits >= Threshold?|
+----------------------------+
|
+-- YES --> Don't react, enter Attacking state
| Reset counter
|
+-- NO --> Play hit reaction
| Parameter | Value | Purpose |
|---|---|---|
StaggerThreshold |
Random 1-3 | Hits before ignoring reactions |
ResetTimer |
3 seconds | Window for consecutive hits |
Strafe Behavior¶
BTTask_MoveAlongStrafePoints moves the enemy in an orbital pattern around the target:
- Orbit radius: Starts at current distance, drifts toward target orbit at
SpiralInwardSpeedunits/sec - Target orbit:
PreferredRangefor ranged enemies,AttackRange × 0.9for melee - Bidirectional drift: Orbit radius adjusts in either direction (outward for ranged enemies that are too close, inward for melee)
- Reactive exit: Succeeds early when within attack range band and off cooldown
- Engagement gate: Fails immediately if target beyond
EngagementRange
Both of those gates measure live distance, not the blackboard value. BTService_UpdateTargetDistance refreshes
DistanceToPlayer on a 0.1s tick, which is fine for a decorator that only has to be approximately right, but a task
that decides this frame whether to exit or bail was acting on a distance up to a tick old — long enough for a
strafing enemy to circle in and out of the band without ever noticing. The service still exists for the decorators;
tasks that gate their own lifetime compute the distance themselves.
Approach Behavior¶
BTTask_ApproachTarget has two dynamic modes that swap based on distance and cooldown:
Closing Mode - Aggressive direct movement:
- Activated when outside attack range or cooldown expired
- Melee: overshoots past target to prevent pathfinding deceleration
- Ranged: approaches to PreferredRange distance from target
- Repaths every 0.3s to track moving targets
Pressure Mode - Lateral threat during cooldown: - Activated when within attack range but on cooldown - Small randomized sidesteps (50-130° angles around target) - Optional forward bias (25% chance to creep closer) - Simulates "sizing up" behavior
Reactive exit when within [MinAttackRange .. MaxAttackRange] and off cooldown.
Guard Stance¶
Enemies with FEnemyBlockConfig.bHasGuardStance hold a visible guard that blocks through the player's own mitigation
path. The mechanics (guard pool, break, punish window) are documented in
Block System — what belongs here is the tree grammar.
[bHasGuardStance] Sequence
|
+-- BTTask_SetDesiredGait (walk)
|
+-- SimpleParallel
main: BTTask_ApproachTarget <-- owns the branch lifetime
background: BTTask_SetGuardState <-- holds InProgress as a stance
Sits as a sibling above plain approach. The main task must be ApproachTarget. SetGuardState-as-main deadlocks
the enemy: CanAttack has no observer aborts, so the branch never yields and the enemy never attacks. ApproachTarget
owns the lifetime because it has a reactive exit. BTTask_SetGuardState stays InProgress for as long as the stance
is held; both AbortTask (the tree commits to an attack) and the ability's own end (guard break) tear it down.
The stance's timing — when it may re-raise after dropping — deliberately lives in the ability rather than in tree structure, so every drop path is covered rather than only BT branch transitions.
Chase Behavior¶
BTTask_ChaseTarget is a simple persistent chase:
- Moves toward target until within PreferredRange (or AcceptanceRadius fallback for melee)
- Repaths every 0.25s
- No time limit — purely distance-based completion
- Used as fallback when target is beyond approach/strafe range
Retreat Behavior¶
BTTask_RetreatFromTarget moves the enemy away from the target:
- Calculates retreat direction: away from target + random lateral offset (up to 45°)
- Projects goal onto nav mesh
- Succeeds when distance >= safe distance (RetreatDistance, falls back to PreferredRange)
- Fails after MaxRetreatDuration (3s default)
- Only relevant for ranged enemies (gated by DistanceCheck on MinEngagementRange)
Typical Melee BT Structure¶
BT_Enemy_Melee (Selector)
[Service: UpdateTargetDistance]
|
+-- ATTACK [CanAttack]
| Sequence: SetGait[Running] -> ActivateAbility -> SetAttackCooldown
|
+-- STRAFE [DistanceCheck: Max=EngagementRange]
| Sequence: SetGait[Walking] -> MoveAlongStrafePoints
|
+-- APPROACH [DistanceCheck: Max=ApproachRange]
| Sequence: SetGait[Running] -> ApproachTarget
|
+-- CHASE [Fallback]
Sequence: SetGait[Sprinting] -> ChaseTarget
Typical Ranged BT Structure¶
BT_Enemy_Ranged (Selector)
[Service: UpdateTargetDistance]
|
+-- RETREAT [MovementAllowed] [DistanceCheck: Max=<MinEngagementRange BB key>]
| Sequence: SetGait[Running] -> RetreatFromTarget
|
+-- ATTACK [CanAttack: with MinAttackRange]
| Sequence: SetRotationMode[Aiming] -> ActivateAbility -> SetAttackCooldown
|
+-- STRAFE [MovementAllowed] [DistanceCheck: Max=EngagementRange]
| Sequence: SetGait[Walking] -> MoveAlongStrafePoints
|
+-- APPROACH [MovementAllowed] [DistanceCheck: Max=ApproachRange]
| Sequence: SetGait[Running] -> ApproachTarget
|
+-- CHASE [MovementAllowed]
| Sequence: SetGait[Sprinting] -> ChaseTarget
|
+-- HOLD POSITION [Fallback — always succeeds]
Wait (loops indefinitely when all movement is gated)
When MovementMode = Stationary, all MovementAllowed decorators fail. The enemy only runs ATTACK when CanAttack passes, otherwise falls through to HOLD POSITION.
Pack Coordination¶
Two rules turn a group of independent agents into a pack: a shared attack budget decides how many of them may swing at a target at once, and a faction gate stops them shredding each other while they do it. Both are server-side and neither adds a coordinator actor.
Attack Budget¶
UCombatEngagementSubsystem already owned the per-target engagement index (who is fighting whom, with lifecycle
cleanup). The budget is more per-target accounting on the same keys, so it lives there rather than in a new
coordinator.
BTDecorator_CanAttack BTTask_ActivateAbility
distance -> cooldown |
-> CanAfford() (advisory) +-- TryAcquireAttack() (atomic, at the commit point)
-> probability roll | |
| +-- denied -> task fails, enemy keeps strafing
|
+-- ability ends -> ReleaseAttack()
(plus self-expiry and death backstops)
The reservation is atomic at activation, not at the decorator. CanAfford is a cheap pre-filter placed before
the probability roll so denials don't consume rolls — but several enemies can pass one free slot before any of them
reaches its attack frames. A live PIE pack produced three simultaneous claims against a budget of two with the
decorator gate alone. Both entry points share one private policy so they cannot drift.
Budget alone doesn't fix simultaneity — spacing does. A saturated pack phase-locks: identical cooldowns finish together and refill freed slots as one synchronized wave. A short per-target attack-start gap staggers the stream into something readable.
Leaked claims must never deadlock a pack. Claims self-expire on a timeout longer than any attack montage, are pruned on every affordability check, and are all released on death or despawn. Weak claimant/target keys mean destroyed actors self-clean.
Per-enemy knobs: AttackWeight (a heavy swing crowds out more of the pack than a light one) and
bIgnoresAttackBudget (bosses and elites attack on their own cadence).
Faction Gate¶
Enemy attacks used to damage other enemies, which at ARPG pack sizes reads as noise rather than as skill expression.
Every damage entry path — melee OnActorHit, AOE application, projectile impact — now asks
UCombatFactionStatics::ShouldBlockFriendlyFire before anything happens, so a blocked ally hit produces nothing:
no damage, no hit-react, no cue, no poise.
| Decision | Rationale |
|---|---|
Factions are a class check behind one seam (GetFaction: player-controlled vs AEternalEnemy vs Neutral) |
There is no team/faction data model in the codebase, and inventing one for two honest sides would be speculation. When factions become data-driven, only GetFaction changes |
| The gate sits at the earliest choke point | An ally hit should produce no effects at all, not mitigated ones |
Friendly fire is opt-in per ability (bCanHitAllies on UDamageAbility) |
Ally damage must be designed — telegraphed heavies, death explosions — not accidental |
| Hazards are ungated, PvP untouched | Ground hazards are faction-agnostic by design |
Data-Driven Configuration¶
Enemy Data Asset¶
| Field | Type | Purpose |
|---|---|---|
EnemyName |
FString | Display name |
EnemyBlueprint |
TSubclassOf | Character class |
BehaviorTree |
UBehaviorTree* | AI behavior |
StartupAbilities |
TArray |
Initial abilities |
PrimaryAttributes |
TSubclassOf |
Base stats |
SecondaryAttributes |
TSubclassOf |
Derived stats |
CombatBehaviorConfigs |
TArray |
Per-phase combat tuning |
IdleBehavior |
FIdleBehaviorConfig | What the enemy does with no target (distinct from combat phases) |
FCombatBehaviorConfig¶
Defines combat personality per enemy (or per boss phase). All fields are pushed to blackboard keys by ApplyCombatBehaviorConfig().
Movement Mode:
| Field | Default | Purpose |
|---|---|---|
MovementMode |
Free | Free = full movement, Stationary = hold position (no strafe/approach/chase/retreat) |
Strafe:
| Field | Default | Purpose |
|---|---|---|
StrafeRadius |
500 | Orbit radius around target |
StrafeAngle |
40 | Degrees per strafe segment |
MinStrafeDuration |
1.5s | Minimum strafe cycle time |
MaxStrafeDuration |
4.0s | Maximum strafe cycle time |
SpiralInwardSpeed |
60 | Units/sec toward target orbit radius |
Attack:
| Field | Default | Purpose |
|---|---|---|
PrimaryAttackTag |
(none) | Ability tag BTTask_ActivateAbility fires — lets one generic BT serve all enemies via tag swap (no tree changes) |
AttackRange |
250 | Maximum attack distance |
MinAttackRange |
0 | Minimum attack distance (ranged only) |
AttackProbability |
0.4 | Base probability at max range |
AttackCooldownMin |
1.0s | Minimum seconds between attacks |
AttackCooldownMax |
2.5s | Maximum seconds between attacks |
AttackWeight |
1 | How much of a shared target's attack budget one swing consumes (light 1, heavy 2) — heavier attackers crowd out more of the pack |
bIgnoresAttackBudget |
false | Bosses and elites attack on their own cadence, outside the shared budget |
Guard stance tuning lives on a sibling struct, FEnemyBlockConfig (FEnemyData.BlockConfig), not in the combat
behavior config — the tree only reads bHasGuardStance and PostHitGuardRaiseChance from it. Full field table in
Block System.
Ranged:
| Field | Default | Purpose |
|---|---|---|
PreferredRange |
0 | Preferred engagement distance (0 = melee behavior) |
MinEngagementRange |
0 | Distance below which enemy retreats (0 = no retreat) |
RetreatDistance |
0 | Target distance after retreat (0 = use PreferredRange) |
Movement:
| Field | Default | Purpose |
|---|---|---|
ApproachRange |
500 | Max distance for approach behavior |
bAllowPressureMove |
true | Souls-style sidesteps while waiting out cooldown in range; false = press straight in (simple melee) |
EngagementRange |
800 | Distance beyond which enemy chases instead of strafing |
Example: Melee Cultist Config¶
AttackRange: 250 StrafeRadius: 500
AttackProbability: 0.4 SpiralInwardSpeed: 60
AttackCooldownMin: 1.0 EngagementRange: 800
AttackCooldownMax: 2.5 ApproachRange: 500
Example: Ranged Cultist Config¶
AttackRange: 1800 PreferredRange: 1200
MinAttackRange: 300 MinEngagementRange: 400
AttackProbability: 0.5 RetreatDistance: 1200
AttackCooldownMin: 2.0 StrafeRadius: 1200
AttackCooldownMax: 4.0 SpiralInwardSpeed: 30
Example: Stationary Ranged (Tower Archer)¶
MovementMode: Stationary
AttackRange: 2500 AttackProbability: 0.6
MinAttackRange: 200 AttackCooldownMin: 1.5
AttackCooldownMax: 3.0
Stationary enemies ignore all movement config (strafe, approach, chase, retreat). They hold position and attack when a target enters their attack range.
FIdleBehaviorConfig¶
Authored on FEnemyData (sibling of the per-phase combat configs). Drives the Idle state.
| Field | Default | Purpose |
|---|---|---|
Mode |
Wander | Stationary / Wander / AuthoredPath (see Idle Behavior) |
WanderRadius |
600 | NavMesh wander radius around the spawn point (Wander only) |
IdlePauseMin |
1.5s | Min pause at each idle waypoint |
IdlePauseMax |
3.5s | Max pause at each idle waypoint |
bLoopingPath |
true | Authored spline loops vs. ping-pongs (AuthoredPath only) |
Distribution |
EvenlyDistributed | Where spawned enemies start on the spline (AuthoredPath only) |
Boss Data Asset¶
Extends enemy data with:
| Field | Type | Purpose |
|---|---|---|
BossPhases |
TArray |
Phase configurations |
Each phase includes its own FCombatBehaviorConfig, allowing bosses to change combat personality mid-fight (e.g., becoming more aggressive in later phases).
Initialization Flow¶
AEternalEnemy::PossessedBy
|
+-- HasAuthority()? --> NO: return
|
+-- Cache AIController
|
+-- AIController->Initialize(EnemyDataAsset)
| |
| +-- Run BehaviorTree
| +-- Setup Blackboard
| +-- ApplyCombatBehaviorConfig(Phase 0)
|
+-- InitAbilityActorInfo()
| |
| +-- Apply PrimaryAttributes
| +-- Apply SecondaryAttributes
| +-- Grant StartupAbilities
|
+-- ApplyBalanceScaling() (if BalanceConfig set)
|
+-- UBalanceSubsystem->CalculateScaledStats()
+-- Override Health, Armor, Poise, BaseDamage
+-- InitMovementSpeed()
+-- ApplyPoiseRecovery() / ApplyPoiseTuning()
+-- ConfigureBlock(FEnemyBlockConfig) (guard-stance enemies)
Two of those inits exist because the attribute they set was never written at all and the whole pipeline hanging
off it was therefore inert: MovementSpeed sat at 0, so every speed effect on an enemy (including the guard slow)
did nothing and the movement-component multiplier could stay undefined; PoiseRecoveryRate sat at 0, so enemy poise
was a one-way meter. Both were invisible for as long as nothing looked. Guard capacity is likewise resolved live
from MaxHealth rather than cached at ConfigureBlock, because the guard config is pushed before balanced stats
land.
AEternalEnemy.ThreatTier replicates. Tier used to live only in the server-side balance config, so clients could
not tell an elite from a trash mob — which meant no client-side feature could gate on tier at all (the enemy stance
bar was the first that needed to).
Balance System Integration¶
Enemy stats can be dynamically scaled using UBalanceSubsystem. When FEnemyBalanceConfig is set on the enemy:
- Area Level determines base scaling (dungeon depth, world zone)
- Threat Tier applies encounter multipliers (Normal, Elite, Champion, Boss)
- Archetype modifies stats for combat role (Balanced, Brute, Skirmisher)
Formula: FinalStat = Base × (1 + Growth × Level^Exp) × TierMult × ArchMult
Config files in Config/Balance/ drive all multipliers with hot-reload support.
Source References¶
AI Controller¶
AEternalAIController-Public/AI/Controllers/EternalAIController.hApplyCombatBehaviorConfig()-Private/AI/Controllers/EternalAIController.cpp
Boss System¶
ABossAIController-Public/AI/Controllers/BossAIController.h
Behavior Tree Tasks¶
BTTask_ActivateAbility-Public/AI/Tasks/BTTask_ActivateAbility.hBTTask_PatrolStep-Public/AI/Tasks/BTTask_PatrolStep.hBTTask_MoveAlongStrafePoints-Public/AI/Tasks/BTTask_MoveAlongStrafePoints.hBTTask_ApproachTarget-Public/AI/Tasks/BTTask_ApproachTarget.hBTTask_ChaseTarget-Public/AI/Tasks/BTTask_ChaseTarget.hBTTask_RetreatFromTarget-Public/AI/Tasks/BTTask_RetreatFromTarget.hBTTask_SetAttackCooldown-Public/AI/Tasks/BTTask_SetAttackCooldown.hBTTask_SetDesiredGait-Public/AI/Tasks/BTTask_SetDesiredGait.hBTTask_SetDesiredRotationMode-Public/AI/Tasks/BTTask_SetDesiredRotationMode.hBTTask_ApplyPhaseConfig-Public/AI/Tasks/BTTask_ApplyPhaseConfig.hBTTask_SetGuardState-Public/AI/Tasks/BTTask_SetGuardState.h
Pack Coordination¶
UCombatEngagementSubsystem(engagement index + attack budget) -Public/Combat/Subsystems/CombatEngagementSubsystem.hUCombatFactionStatics(GetFaction/AreAllies/ShouldBlockFriendlyFire) -Public/Combat/CombatFactionStatics.hbCanHitAllies(per-ability friendly-fire opt-in) -Public/Abilities/DamageAbility.h
Guard Stance¶
UEnemyBlockAbility-Public/Abilities/EnemyBlockAbility.h- Guard pool +
NotifyGuardDropped/CanRaiseGuard-Public/Combat/Components/EnemyCombatComponent.h FEnemyBlockConfig-Public/AI/Data/BaseEnemyDataAsset.h
Decorators¶
BTDecorator_CanAttack-Public/AI/Decorators/BTDecorator_CanAttack.hBTDecorator_DistanceCheck-Public/AI/Decorators/BTDecorator_DistanceCheck.hBTDecorator_IsInAttackRange-Public/AI/Decorators/BTDecorator_IsInAttackRange.hBTDecorator_MovementAllowed-Public/AI/Decorators/BTDecorator_MovementAllowed.hBTDecorator_HasIdleBehavior-Public/AI/Decorators/BTDecorator_HasIdleBehavior.h
Services¶
BTService_UpdateTargetDistance-Public/AI/Services/BTService_UpdateTargetDistance.h
Combat Component¶
UEnemyCombatComponent-Public/Components/EnemyCombatComponent.h
Data Assets¶
UBaseEnemyDataAsset-Public/AI/Data/BaseEnemyDataAsset.hUEnemyDataAsset-Public/AI/Data/EnemyDataAsset.hUBossDataAsset-Public/AI/Data/BossDataAsset.h
Idle / Patrol System¶
UPatrolComponent-Public/AI/Components/PatrolComponent.hFIdleBehaviorConfig,EIdleBehavior,ESplineDistributionMode-Public/AI/IdleBehaviorTypes.hAEnemySpawner-Public/AI/Spawning/EnemySpawner.h(ownsPatrolSpline, resolves per-spawn distribution)
Related Systems¶
- Combat System - Combat component integration
- Ability Classes - Enemy ability hierarchy
- GAS Overview - Ability activation
- Spawning System - Enemy spawning
- Enemy Performance - URO, significance hibernation, density policy
- Replication Overview - AI state replication
- Game Framework - UBalanceSubsystem
Recent Changes¶
| Date | Change | Reason |
|---|---|---|
| 2026-08-06 | Documented the external significance gate and BT authoring traps | Behavior tree and both perception senses are gated by UEnemySignificanceSubsystem (brain paused under the EnemyHibernation lock, controller tick and sight/hearing off) — "the tree isn't ticking" is now a documented first check. Added the bind-before-ReadyForActivation rule (a montage that fails to play broadcasts OnCancelled synchronously inside Activate(); a late bind hangs the BT attack task forever), plus attack-budget denial pushing a fallback cooldown and BTTask_ChaseTarget logging path-request failures instead of failing. |
| 2026-07-24 | Combat-AI pass (FSH-416 / FSH-335 / FSH-431): strafe gates read live distance instead of the 0.1s blackboard value; weighted per-target attack budget on UCombatEngagementSubsystem with atomic reservation at activation; class-based friendly-fire gate (UCombatFactionStatics, bCanHitAllies); enemy guard stance V1 with BTTask_SetGuardState; MovementSpeed initialized so speed effects work at all. |
Packs coordinate and read as menacing rather than polite; enemies stop shredding each other; one shield archetype gains a real defensive layer. |
| 2026-07-27 | Guard economy made honest: guard pool charged in received damage, regen held by any damage, break pays a stagger and returns half of capacity, break cooldown split from the ordinary recommit delay. ThreatTier now replicates. |
The guard break was unreachable by construction; tier was invisible to clients, blocking every client-side tier gate. |
| 2026-05-22 | Replaced orphan APatrolRoute with unified data-driven idle model (FIdleBehaviorConfig, EIdleBehavior, UPatrolComponent, spawner spline + ESplineDistributionMode, BTTask_PatrolStep, BTDecorator_HasIdleBehavior); added data-driven PrimaryAttackTag and bAllowPressureMove | Scalable per-enemy idle behavior (wander / authored patrol / stationary); generic BT serves all enemies |
| 2026-03 | Added EAIMovementMode + MovementAllowed decorator | Stationary ranged enemies (tower archers) |
| 2026-02 | Added ranged combat behaviors | Retreat, preferred range, min attack range for bow enemies |
| 2026-02 | Documented FCombatBehaviorConfig and full BT task library | Previously undocumented |
| 2026-02 | Added Balance System integration | Enemy stat scaling via UBalanceSubsystem |
| - | Initial documentation | Document AI architecture |
Future Considerations¶
| Enhancement | Benefit | Complexity |
|---|---|---|
| EQS for Positioning | Strategic cover/flank selection | Medium |
| Data-driven factions | Replaces the class check in GetFaction with real team data; needed for more than two sides |
Medium |
| Threat Table | Multi-player aggro management | Medium |
| Guard duty cycle | Unguarded strafe segments as a decision, raising how often the guard actually meets a hit — the prerequisite for tuning guard capacity upward | Medium |
| Guard-pool presentation | The pool is a private float today: a pool that drains and one that never drains look identical from behind the camera | Low |
Group attack coordination is no longer future work — see Pack Coordination.