Skip to content

Enemy AI

Summary: Project Eternal uses Unreal's Behavior Tree system with GAS integration for enemy AI. AEternalAIController manages perception, state machines, and ability activation. Combat personality is fully data-driven via FCombatBehaviorConfig, 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

Design Goals

The AI system is built around four principles:

  1. Behavior Tree Foundation - All decisions flow through composable, debuggable trees
  2. GAS Integration - Enemies use the same ability system as players for consistency
  3. Data-Driven - Enemy types configured through data assets, not code
  4. 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. UEnemySignificanceSubsystem hibernates distant enemies by pausing the BrainComponent under 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:

  1. Speed is external - Tasks do NOT control movement speed. Use BTTask_SetDesiredGait before any movement task to set Walking/Running/Sprinting via ALS gait tags.
  2. BB with fallbacks - Tasks read tuning values from blackboard keys with per-node default fallbacks. This allows data asset overrides while keeping sensible defaults.
  3. Instance per node - Movement tasks use bCreateNodeInstance = true to maintain per-instance state.
  4. 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_ActivateAbility is denied its budget slot at the commit point, it writes a short fallback delay into AttackCooldownEnd. 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_ChaseTarget logs 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:

  1. Max range - Distance > AttackRange → fail
  2. Min range - Distance < MinAttackRange → fail (ranged enemies can't shoot point-blank)
  3. Cooldown - WorldTime < CooldownEnd → fail
  4. Budget - CanAfford on the shared per-target attack budget → fail (advisory only; see Pack Coordination)
  5. 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. UEnemySignificanceSubsystem disables 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 has StopMovement() 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 SpiralInwardSpeed units/sec
  • Target orbit: PreferredRange for ranged enemies, AttackRange × 0.9 for 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:

  1. Area Level determines base scaling (dungeon depth, world zone)
  2. Threat Tier applies encounter multipliers (Normal, Elite, Champion, Boss)
  3. 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.h
  • ApplyCombatBehaviorConfig() - Private/AI/Controllers/EternalAIController.cpp

Boss System

  • ABossAIController - Public/AI/Controllers/BossAIController.h

Behavior Tree Tasks

  • BTTask_ActivateAbility - Public/AI/Tasks/BTTask_ActivateAbility.h
  • BTTask_PatrolStep - Public/AI/Tasks/BTTask_PatrolStep.h
  • BTTask_MoveAlongStrafePoints - Public/AI/Tasks/BTTask_MoveAlongStrafePoints.h
  • BTTask_ApproachTarget - Public/AI/Tasks/BTTask_ApproachTarget.h
  • BTTask_ChaseTarget - Public/AI/Tasks/BTTask_ChaseTarget.h
  • BTTask_RetreatFromTarget - Public/AI/Tasks/BTTask_RetreatFromTarget.h
  • BTTask_SetAttackCooldown - Public/AI/Tasks/BTTask_SetAttackCooldown.h
  • BTTask_SetDesiredGait - Public/AI/Tasks/BTTask_SetDesiredGait.h
  • BTTask_SetDesiredRotationMode - Public/AI/Tasks/BTTask_SetDesiredRotationMode.h
  • BTTask_ApplyPhaseConfig - Public/AI/Tasks/BTTask_ApplyPhaseConfig.h
  • BTTask_SetGuardState - Public/AI/Tasks/BTTask_SetGuardState.h

Pack Coordination

  • UCombatEngagementSubsystem (engagement index + attack budget) - Public/Combat/Subsystems/CombatEngagementSubsystem.h
  • UCombatFactionStatics (GetFaction / AreAllies / ShouldBlockFriendlyFire) - Public/Combat/CombatFactionStatics.h
  • bCanHitAllies (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.h
  • BTDecorator_DistanceCheck - Public/AI/Decorators/BTDecorator_DistanceCheck.h
  • BTDecorator_IsInAttackRange - Public/AI/Decorators/BTDecorator_IsInAttackRange.h
  • BTDecorator_MovementAllowed - Public/AI/Decorators/BTDecorator_MovementAllowed.h
  • BTDecorator_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.h
  • UEnemyDataAsset - Public/AI/Data/EnemyDataAsset.h
  • UBossDataAsset - Public/AI/Data/BossDataAsset.h

Idle / Patrol System

  • UPatrolComponent - Public/AI/Components/PatrolComponent.h
  • FIdleBehaviorConfig, EIdleBehavior, ESplineDistributionMode - Public/AI/IdleBehaviorTypes.h
  • AEnemySpawner - Public/AI/Spawning/EnemySpawner.h (owns PatrolSpline, resolves per-spawn distribution)


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.