Targeting System

Documentation Unreal Engine AI Targeting Multi-Player

Multi-target combat support with intelligent target selection, orphan handling, and custom selectors.


Intelligent multi-target support for co-op, versus, and dynamic combat scenarios. AI automatically selects, switches, and redistributes targets.

Selector:
Target 1(2)
Target 2(3)
E1
E2
E3
E4
E5
Target Counts
Target 1: 2
Target 2: 3
Events
Click targets to eliminate. System periodically re-evaluates assignments based on strategy.

When to Use This

  • Co-op games where multiple players fight together
  • Versus modes where AI needs to distribute attention across teams
  • Dynamic encounters where targets spawn/die mid-combat
  • Boss fights where specific AI should ignore player-switching

[!NOTE] Works seamlessly with single-player - zero configuration needed. AI auto-assigns to the first registered target.


Core Concepts

Multi-Target Architecture

Instead of a single "player" reference, the system tracks multiple combat targets:

// Register multiple targets (players, destructibles, objectives)
UAICombatRoleSubsystem* Subsystem = GetWorld()->GetSubsystem<UAICombatRoleSubsystem>();
Subsystem->RegisterCombatTarget(Player1);
Subsystem->RegisterCombatTarget(Player2);

Each target maintains its own independent pool of combatants with separate role limits.

Target Selectors

When an AI needs a target (spawn, death, re-evaluation), it uses a Target Selector:

SelectorBehaviorBest For
FirstTargetSelectorPicks first registered targetSingle-player (default)
ClosestTargetSelectorPicks nearest targetDistance-based threat
BalancedTargetSelectorPicks target with fewest enemiesFair distribution
RandomTargetSelectorRandom distributionUnpredictable encounters

Selectors are data-driven and can be configured per-AI or project-wide.

Lazy Target Acquisition

AI spawning before targets exist? No problem:

1. Enemy spawns → No target available → Waits (unassigned)
2. Player registers → Subsystem notifies unassigned AI
3. Each AI runs its TargetSelector → Gets assigned

This solves the classic "AI spawns before player" race condition.


Quick Setup

1. Register Targets

In your GameMode or Player setup:

void AMyGameMode::HandlePlayerSpawn(APlayerController* PC)
{
    if (APawn* Pawn = PC->GetPawn())
    {
        UAICombatRoleSubsystem* Subsystem = GetWorld()->GetSubsystem<UAICombatRoleSubsystem>();
        
        // Auto-assign waiting AI to this target
        Subsystem->RegisterCombatTarget(Pawn, /*bAutoAssignUnassigned=*/ true);
    }
}

2. Configure Per-AI Selectors (Optional)

In your EnemyAIConfig Data Asset:

// Pick closest player
TargetSelector: UClosestTargetSelector
 
// Bosses ignore global reshuffling
bIgnoreTargetRedistribution: true

3. Project-Wide Default

Project Settings → Plugins → Soulslike Enemy Combat:

Default Target Selector: UBalancedTargetSelector

If no per-AI selector is set, AI uses this. If neither is set, it falls back to UFirstTargetSelector.


Target Destruction Handling

When a target dies or unregisters, the system automatically handles orphaned AI:

1. Target destroyed → Subsystem detects
2. Orphaned AI roles reset to "None"
3. Each AI receives OnCombatTargetLost() callback
4. Subsystem runs TargetSelector for each orphan
5. AI auto-assigns to new targets
6. OnCombatantsOrphaned delegate broadcasts (for custom handling)

Handling Target Loss Manually

Implement IAICombatRoleInterface on your AI Controller:

void AMyAIController::OnCombatTargetLost_Implementation(AActor* LostTarget)
{
    // Play confused animation
    PlayAnimMontage(ConfusedMontage);
    
    // Wait before accepting new target
    FTimerHandle Handle;
    GetWorld()->GetTimerManager().SetTimer(Handle, [this]()
    {
        // TargetSelector will have already assigned a new target
        // Resume normal behavior
    }, 1.5f, false);
}

Target Selector API

Selection Context

Custom selectors receive rich context for informed decisions:

USTRUCT(BlueprintType)
struct FTargetSelectionContext
{
    // AI's current target (for stickiness decisions)
    TWeakObjectPtr<AActor> CurrentTarget;
    
    // How many AI assigned to each target
    TMap<TObjectPtr<AActor>, int32> CombatantCountPerTarget;
    
    // Pre-computed distances from AI to each target
    TMap<TObjectPtr<AActor>, float> DistanceSquaredPerTarget;
    
    // True if called due to target destruction
    bool bIsTargetLossReselection;
};

Creating Custom Selectors

Subclass UTargetSelector for custom logic:

UCLASS(DisplayName = "Threat-Based Target Selector")
class UMyThreatSelector : public UTargetSelector
{
    GENERATED_BODY()
 
public:
    virtual AActor* SelectTarget_Implementation(
        AController* Controller,
        const TArray<AActor*>& AvailableTargets,
        const FTargetSelectionContext& Context) const override
    {
        // Example: Pick the target that has hurt this AI most
        AActor* HighestThreat = nullptr;
        float MaxDamage = 0.f;
        
        for (AActor* Target : AvailableTargets)
        {
            float DamageTaken = GetDamageTakenFrom(Controller, Target);
            if (DamageTaken > MaxDamage)
            {
                MaxDamage = DamageTaken;
                HighestThreat = Target;
            }
        }
        
        // Fall back to balanced if no damage history
        if (!HighestThreat)
        {
            return AvailableTargets[0];
        }
        
        return HighestThreat;
    }
};

Skipping Auto-Reselection

Return nullptr from a selector during target loss to handle assignment manually:

if (Context.bIsTargetLossReselection)
{
    // Don't auto-assign - let OnCombatTargetLost handle it
    return nullptr;
}

Manual Control API

Force Target Assignment

// Assign specific combatant to specific target
Subsystem->AssignCombatantToTarget(Controller, Target, /*bTriggerRoleEvaluation=*/ true);

Trigger Re-evaluation

// Re-run target selection for one AI
Subsystem->ReassignCombatantTarget(Controller);
 
// Re-run for ALL AI (respects bIgnoreTargetRedistribution)
Subsystem->ReevaluateAllTargets();

Balance Distribution

// Force even redistribution across all targets (ignores selectors)
Subsystem->BalanceCombatantsAcrossTargets();

Transfer Combatants

// Player 1 dies, transfer their enemies to Player 2
Subsystem->TransferCombatantsToTarget(Player1, Player2, /*bForceReassignment=*/ true);

Role Locking for Scripted Sequences

Lock AI into specific roles during boss phases or cinematics:

// Force this AI to be an Attacker, locked from evaluation
Subsystem->ForceAssignRole(Controller, SEC.Role.Attacker, /*bLockRole=*/ true);
 
// Later, unlock so normal evaluation can resume
Subsystem->UnlockRole(Controller);
 
// Check lock status
if (Subsystem->IsRoleLocked(Controller))
{
    // This AI won't be affected by role re-evaluation
}

Locked combatants are completely skipped during automatic role evaluation.


Configuration: bIgnoreTargetRedistribution

For bosses or special AI that shouldn't auto-switch targets:

// In EnemyAIConfig:
bIgnoreTargetRedistribution = true;

This AI will:

  • ✅ Get initial target assignment via TargetSelector
  • ✅ React to its own target dying (OnCombatTargetLost)
  • ❌ Ignore ReevaluateAllTargets() calls
  • ❌ Ignore new target registration reshuffles

Perfect for boss encounters in co-op where the boss should focus one player until they're dead.


Delegates

Subscribe to global events for UI, analytics, or custom logic:

// When any combatant's target changes
Subsystem->OnCombatRoleChangedForTarget.AddDynamic(this, &UMyClass::OnRoleChanged);
void UMyClass::OnRoleChanged(AController* Controller, AActor* Target, FGameplayTag NewRole, FGameplayTag OldRole);
 
// When combatants lose their target
Subsystem->OnCombatantsOrphaned.AddDynamic(this, &UMyClass::OnOrphaned);
void UMyClass::OnOrphaned(AActor* LostTarget, const TArray<AController*>& OrphanedCombatants);
 
// When a new target registers
Subsystem->OnCombatTargetRegistered.AddDynamic(this, &UMyClass::OnNewTarget);

Backward Compatibility

Existing single-player setups work with zero changes:

// Old API (deprecated but functional)
Subsystem->SetCombatTarget(Player);  // → calls RegisterCombatTarget(Player, true)
Subsystem->GetCombatTarget();         // → returns first registered target
 
// These log deprecation warnings but compile and run correctly

Query API

// Get all registered targets
TArray<AActor*> Targets = Subsystem->GetAllCombatTargets();
 
// Check if a target is registered
bool bIsTarget = Subsystem->HasCombatTarget(Player);
 
// Get which target an AI is assigned to
AActor* Target = Subsystem->GetCombatantTarget(Controller);
 
// Get all AI assigned to a specific target
TArray<AController*> Enemies = Subsystem->GetCombatantsForTarget(Player);
 
// Get role count for a specific target
int32 AttackerCount = Subsystem->GetRoleCountForTarget(SEC.Role.Attacker, Player);

Integration Points

SystemHow Targeting Uses It
Combat RolesEach target has independent role pools
Action SystemActions can be target-aware
Movement SystemPositioning relative to assigned target