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
        +-- WeaponMesh / WeaponMeshLeft   (register Trace.Weapon.Right / Trace.Weapon.Left
        |                                  only when the mesh has sockets — dual-wield archetypes)
        |
        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
FarExitDistance Float Approach/Chase hand control back when the target is beyond this, off cooldown and in LOS (0 = off)
AllowPressureMove Bool Sidesteps while waiting out cooldown in range
EnableSlotBias Bool Movement goals biased toward claimed engagement-ring bearing
SlotBiasStrength Float How hard strafe leans slot-ward (0-1)
PressureSectorHalfAngle Float Pressure sidesteps clamped to this sector around the slot bearing
EnableWaitingRing Bool Budget-denied enemies hold at the waiting ring
WaitingRange Float Absolute hold distance while budget-denied
RingTransitionSpeed Float Orbit-radius drift speed between waiting and attack rings

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. Node AbilityTag wins; empty falls back to the config's PrimaryAttackTag. bFaceTargetBeforeActivation snaps yaw to the target first (slot-biased movement leaves the pawn facing its path, so root-motion dashes launched off-line and landed beside the player) 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); far-exit hands control back MaxAttackRange, PreferredRange, ApproachRange, FarExitDistance
BTTask_ChaseTarget Persistent chase until within range; far-exit; optionally holds guard while chasing PreferredRange, MaxAttackRange, FarExitDistance, bHasGuardStance
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.
  • BTTask_ActivateAbility::AbortTask cancels the ability by tag (CancelAbilities on the resolved tag). A BT abort that left the ability running produced orphaned swings — the montage kept playing while the tree had already moved on.
  • Reactive far-exit (FarExitDistance BB key, 0 = off): BTTask_ApproachTarget and BTTask_ChaseTarget finish early when the target is at or beyond the distance, the attack cooldown has expired and the controller has line of sight. Without it a kiting target keeps the movement task latched and higher-priority long-range branches (gap-closer, ranged opener) only get evaluated once per disengage. LOS-gated because those branches all require LOS — exiting while obstructed just re-enters the move next tick, a stall loop.

Task Execution Flow

BTTask_ActivateAbility::ExecuteTask
    |
    +-- Get AIController
    +-- Get Pawn
    +-- Get AbilitySystemComponent
    |
    +-- Resolve tag: node AbilityTag, else config PrimaryAttackTag
    +-- [bFaceTargetBeforeActivation] snap yaw to AttackTarget
    +-- TryActivateAbilitiesByTag(ResolvedTag)
    |       |
    |       +-- 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 + LOS + cooldown + budget affordability + probability gate MaxAttackRange, MinAttackRange, AttackCooldownEnd, AttackProbability
DistanceCheck Target within min/max distance band. MaxDistance = 0 can never pass — it is not "unbounded"; a "farther than X" check needs MinDistance = X, MaxDistance = 99999. BB-override users rely on the 0 semantics as "branch off when key unset" Optional BB overrides for min/max
MovementAllowed MovementMode != Stationary MovementMode

CanAttack: Distance-Scaled Probability

BTDecorator_CanAttack is the primary attack gate with sequential checks:

  1. Max range - Distance > AttackRange → fail
  2. Min range - Distance < MinAttackRange → fail (ranged enemies can't shoot point-blank)
  3. Line of sight - AIController->LineOfSightTo(Target) fails → fail (distance alone let ranged enemies keep firing through walls while the target stayed inside aggro range)
  4. Cooldown - WorldTime < CooldownEnd → fail
  5. Budget - CanAfford on the shared per-target attack budget → fail (advisory only; see Pack Coordination)
  6. Probability - Distance-scaled roll prevents idle standing at close range

bIgnoreRangeGate skips checks 1–2 (keeping LOS, cooldown, budget, probability). Use it on branches whose own DistanceCheck deliberately sits outside attack range — a gap-closer that requires the target to be far — where pairing with the range gate would make the branch unsatisfiable.

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

Invincibility backstop. When a phase has a PhaseTransitionAbility, InitializePhase grants it and adds the loose Effect.Invincible tag; the transition ability's montage tail is what removes the tag. If the ability never activates (BT stall, cancelled grant) nothing else would — a permanently immortal boss — so the controller also arms InvincibilityBackstopHandle: a 10s one-shot timer into ABossAIController::ClearStuckTransitionInvincibility, which strips the tag if it is still present. The delay sits well past any transition montage length, so a healthy transition never sees it fire.

Headless BT Authoring

Boss trees that are too fiddly to hand-author reliably are generated by commandlet:

  • UnrealEditor-Cmd <project> -run=BossBTBuilder [-dumponly] (UBossBTBuilderCommandlet, Source/ProjectEternalEditor/Private/BossBTBuilder/) rebuilds the Cultist Boss trees — BT_Cultist_Boss, BT_CultistBoss_Phase_One, BT_CultistBoss_Phase_Two — from code. It writes the runtime tree and regenerates the editor graph (UBehaviorTree::BTGraph) through the same path the editor uses for legacy graph-less assets, because the BT editor treats the graph as source of truth and would otherwise rebuild the runtime tree from the stale graph on open. -dumponly prints the current trees without writing.
  • Hand edits to those generated trees are lost on the next run — the commandlet discards the old graph and orphaned nodes and rebuilds from scratch. Change the builder, not the asset.
  • -run=BarrageCastTrim (UBarrageCastTrimCommandlet, same folder) is a one-shot montage surgery for the boss orb-barrage cast, kept for reproducibility; not a BT tool.

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
  • Slot bias: Orbit direction picks slot-ward with probability 0.5 + 0.5 × SlotBiasStrength (see Pack Coordination)
  • Waiting ring: RefreshRingRadii re-reads knobs + budget room every tick; leash = max(AttackRange, TargetOrbitRadius) × 1.5
  • Segment backstop: SegmentRepathBackstop (1.5s) advances a stuck segment; non-Aborted move results advance the orbit rather than stalling it

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. A second reactive exit fires when the target is at or beyond FarExitDistance (BB key from FCombatBehaviorConfig::FarExitDistance, 0 = off), off cooldown and in line of sight — see Other Task Robustness Rules.

Both modes are slot-aware when bEnableSlotBias is on: closing blends the goal toward the claimed bearing (full inside 2×AttackRange, fading to 0 at ApproachRange; unbiased is exactly the old goal), pressure sidesteps are clamped to the slot sector (an empty intersection holds instead of moving). While budget-denied with the waiting ring on, the closing goal clamps to WaitingRange with overshoot suppressed.

Combat Facing (ALS rotation mode)

Rotation mode is code-managed off the state machine, not authored per-BT: SetEnemyState(Engaged) sets ViewDirection (body tracks the focus target), non-engaged states set VelocityDirection, and BTTask_ChaseTarget re-asserts VelocityDirection while sprinting, restoring ViewDirection on finish while engaged. Entering Attacking deliberately leaves the BT-authored mode in place — the ranged tree sets Aiming before its attack, and stomping it froze the archer's body at zero velocity.

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 does not need the parallel: BTTask_ChaseTarget raises the guard itself when GuardAbilityTag is set on the node (e.g. Ability.Enemy.Block) and the bHasGuardStance BB key is true — it activates the ability by tag, retries once a second while the stance's recommit window or break cooldown denies it, and cancels it when the task ends.

Chase Behavior

BTTask_ChaseTarget is a simple persistent chase: - Stop radius chain: PreferredRange BB key when > 0 → BB key named by AttackRangeKeyName (default MaxAttackRange) when > 0 → AcceptanceRadius (150). Melee stops at the attack-range edge so the attack decision happens there; sprinting to point-blank first carried the enemy through its outer selection band, so gap-closers authored for that band could never open a fight - Repaths every 0.25s - No time limit — distance-based completion, plus the FarExitDistance reactive exit (far, off cooldown, LOS clear) - Optionally holds a guard ability via GuardAbilityTag (see Guard Stance) - 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

Four mechanisms turn a group of independent agents into a pack, layered from physical to tactical: crowd avoidance keeps bodies from merging, engagement slots spread the pack around the target, the waiting ring holds budget-denied enemies at a prowl distance, and the shared attack budget decides how many may swing at once (with a faction gate stopping them shredding each other while they do it). All are server-side and none adds a coordinator actor — the shared state lives on UCombatEngagementSubsystem, and every spatial mechanism is advisory steering bias, never an exclusive lock.

Crowd Avoidance (Detour Crowd)

Both AI controllers swap UPathFollowingComponent for UCrowdFollowingComponent in their constructors, so pathing enemies steer around packmates instead of shoving through them.

  • Config: Config/DefaultEngine.ini [/Script/AIModule.CrowdManager] — explicit MaxAgents=120 (agents past the cap silently lose avoidance) and bResolveCollisions=False (the CMC owns collision; two resolvers fighting produces jitter).
  • Per-enemy knob: bUseCrowdAvoidance on FCombatBehaviorConfig — turn off for bosses so avoidance never nudges a telegraphed attack off-line.
  • Hibernation: crowd agents otherwise register for component lifetime, so AEternalAIController::SetCrowdSimulationSuspended toggles the agent with the significance hibernation cycle (see Enemy Performance). Wake re-applies the per-enemy knob via RefreshCrowdSimulationState — suspend state and the desired flag merge instead of blindly enabling.
  • Combat commit: a third input, SetCombatCommitAvoidanceSuspended, is raised by enemies mid-attack-string so crowd separation cannot steer a committed swing off-line. ShouldEnableCrowdSimulation() is the composed decision: per-enemy knob AND not hibernated AND not combat-committed (public so tests can pin the composition without a live crowd manager).

Enemy Combos (AI side)

Multi-hit attack strings are an ability archetype, documented with the other ability classes in Ability Classes. What the AI controller contributes is three seams, kept on AEternalAIController so the ability never couples to blackboard key names:

Seam Purpose
GetAttackRange() Attack range of the currently applied combat config (phase-aware)
SetCombatCommitAvoidanceSuspended(bool) Crowd-avoidance suspension while committed (above)
ShouldEnableCrowdSimulation() Composed avoidance decision, testable without a crowd manager

Boss trees author the combo branches explicitly: each BTTask_ActivateAbility carries its own AbilityTag, usually with bFaceTargetBeforeActivation, and gap-closer branches pair DistanceCheck (far band) with CanAttack.bIgnoreRangeGate.

Engagement Slots

A world-anchored ring of bearings per target on UCombatEngagementSubsystem. Each engaged enemy claims one via GetOrClaimEngagementBearing; movement tasks bias their goals toward the claimed bearing so packs fan out and surround instead of stacking on one approach vector.

  • Cost-based assignment, not nearest-free: angular travel vs dispersion (weighted by the SurroundBias cvar) vs archetype preference in the target's facing frame (SlotPreference: brutes front, skirmishers flank, ranged rear — mixed packs read as composed). Claimants solve in current-bearing order, so the result never depends on which pack member queried first. Overflow shares the nearest slot at reduced bias.
  • Re-solve hysteresis: a claim re-solves only when the enemy has drifted >90° from its bearing AND the last solve is ≥1.5s old — a moving target drags stale slots behind it otherwise, but per-tick re-solves would thrash every consumer.
  • Solo exemption: fewer than two slot-eligible enemies engaged with a target returns no-slot. Slots coordinate packs; a lone duelist keeps the unconstrained duel feel (sector-pinned pressure made a solo enemy stand still on cooldown).
  • Exclusions: stationary enemies and budget-ignoring bosses/elites never claim.
  • Consumers: ApproachTarget blends its goal toward the slot bearing (full inside 2×AttackRange → 0 at ApproachRange, overshoot preserved along the biased direction) and clamps pressure sidesteps to a sector around the bearing (PressureSectorHalfAngle); MoveAlongStrafePoints picks the slot-ward orbit direction with probability 0.5 + 0.5 × SlotBiasStrength (half-slot deadzone, shared-slot strength halved).
  • Lifecycle: slots free on ReportDisengaged/ReportEnemyGone, die with the engagement entry, and weak keys self-clean for destroyed actors — same ledger rules as the attack budget.

Cvars: Eternal.AI.Slots.{NumSlots, SurroundBias, TravelWeight, PreferenceWeight, Debug}. Debug draws world spokes per target: green claimed, red shared, gray free.

Waiting Ring

Budget-denied enemies hold and strafe at WaitingRange instead of crowding the attack orbit — one or two press in, the rest prowl visibly. Opt-in per enemy (bEnableWaitingRing), because WaitingRange needs per-enemy tuning against the orbit and strafe envelope.

  • The standing query is HasBudgetRoom, not CanAfford: weight-vs-budget only, const and non-mutating, callable every tick. It deliberately skips the attack start-spacing gap — that gate staggers when swings start; keying standing position on it would pulse the whole pack out to the waiting ring after every swing.
  • GetEffectiveEngagementRadius(AI, OrbitRadius, WaitingRange) resolves the radius a movement task should hold at: the caller's own orbit while there is budget room, WaitingRange while denied. WaitingRange <= OrbitRadius disables the ring. Callers pass their own orbit resolution, so ranged enemies get a correct waiting ring too.
  • Asymmetric transitions in the strafe task: promotion is eager (breaks the current segment and drifts inward at RingTransitionSpeed — a freed budget slot converts to pressure quickly), demotion applies only at segment boundaries (no mid-orbit yo-yo against the budget-denial fallback cooldown). Approach clamps its closing goal to WaitingRange while denied, with overshoot suppressed — the goal is the ring.
  • Validator: UBaseEnemyDataAsset::IsDataValid enforces the ring ordering orbit < WaitingRange <= min(StrafeRadius when nonzero, EngagementRange × 0.9) per phase, skipping inert configs (ring off / bIgnoresAttackBudget / Stationary).

Debugging Pack Behavior

  • Gameplay debugger category "EternalCombat" (dedicated-server-safe): state/target/crowd-sim/move-status text, actual path polyline + goal point, slot claim line + claimed-bearing spoke, and a Ring: ATTACK|WAITING (budget room, waiting radius) line.
  • Eternal.AI.Slots.Debug: world-space slot spokes per target.
  • Eternal.AI.FlowDebug (default 0): LogAIFlow movement audit — every AI move request logged with the requesting BT node and a TOWARD/AWAY classification, plus 0.5s engaged snapshots. This is what found the strafe-orbiting-SelfActor ping-pong; reach for it whenever movement "flows" wrong before touching tuning.

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)
KillCreditCompleteQuestTag FGameplayTag Quest root tag completed for every damage contributor on death (idempotent) — for boss kills that end a quest with no post-kill dialogue to carry the completion
BossBannerEpithet FText Epithet under the name on the boss-defeated banner; when set, every credited killer gets AEternalPlayer::Client_NotifyBossDefeated on this enemy's death. Empty = no banner

Both kill-credit fields are consumed by AEternalEnemy::GrantKillCreditQuestTag(), which walks UCombatComponent::GetDamageContributors() rather than only the killing blow. Quest side: Quest System.

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)
bUseCrowdAvoidance true Detour Crowd separation while pathing — off for bosses so avoidance never nudges a telegraphed attack off-line

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) Fallback ability tag for BTTask_ActivateAbility nodes whose own AbilityTag is empty. Shared trees leave the node tag empty so one generic BT serves many enemies via config; boss trees author an explicit tag per branch, which wins
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)
FarExitDistance 0 Off-cooldown targets beyond this end Approach/Chase early (LOS-gated) so a higher-priority long-range branch gets an evaluation window. 0 = disabled — leave it off for trees with no long-range branch
EngagementRange 800 Distance beyond which enemy chases instead of strafing

Engagement Slots:

Field Default Purpose
bEnableSlotBias true Bias movement toward a claimed bearing on the target's engagement ring (fan out + surround). Off = straight-line approach + coin-flip strafe
SlotBiasStrength 0.7 Strafe picks the slot-ward direction with probability 0.5 + 0.5 × strength — 0 is exactly a coin flip
SlotPreference Any Where in the target's facing frame this archetype wants to stand (Front / Flank / Rear)
PressureSectorHalfAngle 60 Pressure sidesteps clamped to ±this many degrees around the claimed bearing

Waiting Ring (opt-in — WaitingRange needs per-enemy tuning against the orbit and strafe envelope):

Field Default Purpose
bEnableWaitingRing false Budget-denied enemies hold/strafe at WaitingRange instead of crowding the attack ring. Inert with bIgnoresAttackBudget
WaitingRange 400 Absolute hold distance while denied. Validator enforces orbit < WaitingRange <= min(StrafeRadius when nonzero, EngagementRange × 0.9)
RingTransitionSpeed 300 Drift speed between rings — separate from SpiralInwardSpeed so promotion stays brisk while melee tightening stays slow

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
  • UBossBTBuilderCommandlet / UBarrageCastTrimCommandlet - Source/ProjectEternalEditor/Private/BossBTBuilder/

Enemy Character

  • AEternalEnemy (WeaponMesh / WeaponMeshLeft, GrantKillCreditQuestTag) - Public/AI/Enemy/EternalEnemy.h
  • UEnemyComboAbility (caller of the combat-commit / attack-range seams) - Private/Abilities/EnemyAbilities/EnemyComboAbility.cpp

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 + slot ring + waiting ring) - Public/Combat/Subsystems/CombatEngagementSubsystem.h
  • FEngagementSlotResult, EEngagementSlotPreference - Public/Combat/Types/EngagementSlotTypes.h
  • Gameplay debugger category "EternalCombat" - Private/AI/Debug/GameplayDebuggerCategory_EternalCombat.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/Combat/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-20 Docs caught up with the cultist-boss refactor and the AI side of enemy combos: node AbilityTag overrides config PrimaryAttackTag (now fallback only); CanAttack LOS gate + bIgnoreRangeGate; DistanceCheck Max=0 never passes; FarExitDistance reactive exit on Approach/Chase; Chase stop-radius chain (PreferredRange → MaxAttackRange → 150) and guard-while-chasing via GuardAbilityTag; bFaceTargetBeforeActivation; AbortTask cancels the ability by tag; WeaponMeshLeft / Trace.Weapon.Left; boss invincibility backstop; kill-credit quest completion + boss banner; combat-commit crowd-avoidance suspension and the GetAttackRange / ShouldEnableCrowdSimulation seams; headless -run=BossBTBuilder authoring. Boss trees authored per-branch tags and gap-closer bands that the shared-tree assumptions (config-tag-only, range-gated CanAttack, Max=0 as unbounded) silently broke; generated trees were being hand-edited and reverted.
2026-08-11 Pack spacing landed in three layers: Detour crowd avoidance (UCrowdFollowingComponent, MaxAgents=120, CMC owns collision, hibernation-suspended), engagement slot ring on UCombatEngagementSubsystem (cost-based world-anchored bearings, facing-frame archetype preference, solo exemption, re-solve hysteresis), waiting ring (HasBudgetRoom standing query, eager promotion / segment-boundary demotion, opt-in per enemy with a ring-ordering validator). Combat facing made code-managed (ViewDirection while engaged; Attacking leaves BT-authored modes alone). Debug: gameplay debugger category "EternalCombat", Eternal.AI.Slots.Debug, Eternal.AI.FlowDebug movement audit. Packs merged into a blob on one approach vector, budget-denied enemies crowded the attack ring invisibly, and a strafe task orbiting SelfActor (found by the movement audit) ping-ponged ranged enemies.
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.