Reconnection

D&D-Inspired Turn-Based Combat Meets Stunning Visuals

Project Overview

Reconnection is a D&D-inspired turn-based fighting game built in Unreal Engine, combining tactical combat with beautiful visuals and dynamic narrative choices. As Lead Engineer, I architected the entire combat system in C++, implementing dice-roll mechanics, stat-based calculations, and AI decision-making that brings tabletop RPG combat to life.

Technical Highlights

D&D Combat System

Automated dice rolls, attack/defense calculations, and turn management

Turn-Based Mechanics

UTurnManager auto-discovers fighters, sorts by initiative, and drives the full round/turn loop via multicast delegates

Enemy AI System

Utility-scored AI weighs attack, heal, block, and buff actions each turn using health ratios, line-of-sight, and weapon type affinity

C++ Architecture

Clean, extensible parent classes with virtual functions

Buff System

Dynamic stat modifications for strategic depth

Dialogue Choices

Player-driven narrative with branching conversations

Code Showcase

Blueprint Integration & Designer Tools

The C++ AFighter and UEnemy classes are designed for Blueprint extensibility, allowing designers to create unique enemy behaviors, player abilities, and combat encounters without touching code. Key Blueprint-implementable functions include:

🤖 Enemy AI Behaviors

  • ChooseAction() - Implement custom AI decision trees
  • Conditional logic - Enemy evaluates player health, buffs, and position
  • Action selection - Choose between Attack, Heal, Block, or special abilities
  • Difficulty scaling - Adjust AI aggression and strategy per encounter

⚔️ Combat Actions

  • Attack() - Custom attack animations and VFX triggers
  • Heal() - Healing effects with particle systems
  • Block() - Defensive stance animations
  • Die() - Death sequences, loot drops, victory conditions

🎯 Event Delegates

  • OnStartTurn - Trigger UI updates, camera effects
  • OnEndTurn - Queue next fighter in initiative order
  • OnHitAttack - Play hit reactions, damage numbers
  • OnHitMiss - Miss animations, combat feedback

💬 Dialogue Integration

  • Dynamic choices - Player dialogue affects combat stats
  • Narrative branching - Choices influence enemy behavior
  • Combat triggers - Dialogue can start/end encounters
  • Character relationships - Track player decisions for story outcomes

AFighter Class - Combat System Foundation

The AFighter parent class implements all core combat mechanics including turn management, dice-roll calculations (d20 system), damage/healing, and buff systems. Designed with Blueprint integration for designer flexibility while maintaining C++ performance.

AFighter. h - Header Definition

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "AFighter.generated.h"

UCLASS()
class RECONNECTION_API AFighter : public AActor
{
    GENERATED_BODY()
    
public:	
    // Sets default values for this actor's properties
    AFighter();

    //Boolean to check if it's this fighter's turn
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Stored Variables")
    bool bIsTurn;

    //Initiative score for turn order
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Stored Variables")
    int32 InitiativeScore;

    //Function to start this fighter's turn
    UFUNCTION(BlueprintCallable, Category="Stored Functions")
    void StartTurn();

    //Function to end this fighter's turn	
    UFUNCTION(BlueprintCallable, Category="Stored Functions")
    void EndTurn();

    UFUNCTION(BlueprintCallable, Category = "Stored Functions")
    void SendDamage(float Damage, const FString& Type, AFighter *Target);

    UFUNCTION(BlueprintCallable, Category = "Stored Functions")
    virtual void ReceiveDamage(float Damage, const FString& Type);

protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

};
                        

Turn Management & Combat Flow

Implements initiative-based turn order system with clear turn start/end demarcation. The turn management system allows for complex action queuing and supports both player and AI-controlled fighters.

AFighter.cpp - Turn System Implementation

// Fill out your copyright notice in the Description page of Project Settings. 

#include "AFighter.h"

void AFighter::BeginPlay()
{
    Super::BeginPlay();
}

// Sets default values
AFighter::AFighter()
{
    // Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it. 
    PrimaryActorTick. bCanEverTick = false;

    bIsTurn = false;
    InitiativeScore = 0;
}

void AFighter::StartTurn()
{
    bIsTurn = true;
    UE_LOG(LogTemp, Warning, TEXT("Turn started"));
}

void AFighter::EndTurn()
{
    bIsTurn = false;
    UE_LOG(LogTemp, Warning, TEXT("Turn ended"));
}

void AFighter:: SendDamage(float Damage, const FString& Type, AFighter *Target)
{
    UE_LOG(LogTemp, Warning, TEXT("Sending %f %s damage to target"), Damage, *Type);
}

void AFighter::ReceiveDamage(float Damage, const FString& Type)
{
    UE_LOG(LogTemp, Warning, TEXT("Received %f %s damage from source"), Damage, *Type);
}
                        

D&D-Style Dice Roll System

From the original UFighter implementation: Authentic d20 attack rolls with modifiers, defense calculations, and random damage ranges. This creates unpredictable, strategic combat that feels like tabletop D&D.

UFighter.cpp - Attack & Defense Mechanics

// D20 Attack Roll System
int UFighter::RollToHit()
{
    int Roll = FMath::RandRange(1, 20);  // d20 roll
    return Roll + BaseAttack + AttackBuff;
}

float UFighter::GetDefense()
{
    return BaseDefense + DefenseBuff;
}

void UFighter:: Attack(UFighter *Target)
{
    // Roll to hit vs target defense (classic D&D mechanic)
    if (RollToHit() >= Target->GetDefense())
    {
        SendDamage(RollDamage(), Target);
        OnHitAttack. Broadcast(Target);
    }
    else
    {
        OnHitMiss.Broadcast(Target);
    }
    EndTurn();
}

float UFighter::RollDamage()
{
    // Random damage range with buffs
    float Damage = FMath::RandRange(MinDamage, MaxDamage) + DamageBuff;
    return Damage;
}

void UFighter:: ReceiveDamage_Implementation(float Damage)
{
    // Apply damage with reduction/armor
    CurrentHealth = FMath::Clamp(CurrentHealth - (Damage - DamageReduction), 0.0f, MaxHealth);
    if (CurrentHealth <= 0)
    {
        Die();
        UE_LOG(LogTemp, Warning, TEXT("Fighter has been defeated"));
    }
}
                        

Strategic Action System

Multiple combat actions beyond basic attacks: healing, blocking, and buff management. Each action has strategic tradeoffs, encouraging thoughtful decision-making during combat.

UFighter.cpp - Heal, Block, & Buff Systems

// Healing Action
void UFighter::Heal_Implementation()
{
    CurrentHealth = FMath::Clamp(CurrentHealth + BaseHeal, 0.0f, MaxHealth);
    EndTurn();
}

// Blocking/Defensive Action
void UFighter::Block_Implementation()
{
    DamageReduction = BaseBlock + BlockBuff;
    EndTurn();
}

// Dynamic Buff System
void UFighter::AddBuff(float BuffAmount, const FString& stat)
{
    if (stat == "Attack")
    {
        AttackBuff += BuffAmount;
    }
    else if (stat == "Damage")
    {
        DamageBuff += BuffAmount;
    }
    else if (stat == "Defense")
    {
        DefenseBuff += BuffAmount;
    }
    else if (stat == "Block")
    {
        BlockBuff += BuffAmount;
    }
    else if (stat == "Heal")
    {
        HealBuff += BuffAmount;
    }
}

void UFighter:: RemoveBuff(float BuffAmount, const FString& stat)
{
    if (stat == "Attack")
        AttackBuff -= BuffAmount;
    else if (stat == "Damage")
        DamageBuff -= BuffAmount;
    else if (stat == "Defense")
        DefenseBuff -= BuffAmount;
    else if (stat == "Block")
        BlockBuff -= BuffAmount;
    else if (stat == "Heal")
        HealBuff -= BuffAmount;
}
                        

UEnemy - Fighter Enemy Architecture

UEnemy inherits from UFighter and adds a full utility-based AI decision system. Enemies track allies and opponents separately, evaluate weighted utility scores for every possible action, and automatically choose the highest-value action each turn. Weapon type awareness (Melee, Ranged, Magic) further biases the AI's attack preference, and all utility weights are EditAnywhere so designers can tune behavior per enemy type without touching code.

UEnemy.h - Header & Weapon Type Enum

UENUM(BlueprintType)
enum class EWeaponType : uint8
{
    Melee  UMETA(DisplayName = "Melee"),
    Ranged UMETA(DisplayName = "Ranged"),
    Magic  UMETA(DisplayName = "Magic")
};

UCLASS(ClassGroup = (Fighters), meta = (BlueprintSpawnableComponent))
class RECONNECTION_API UEnemy : public UFighter
{
    GENERATED_BODY()

public:
    virtual void StartTurn() override;

    // Blueprint-implementable AI entry point
    UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Enemy|Combat")
    void ChooseAction();
    virtual void ChooseAction_Implementation();

    // Roster management
    UFUNCTION(BlueprintCallable, Category = "Enemy|Setup")
    void InitializeEnemy(const TArray<UFighter*>& AllFighters);
    UFUNCTION(BlueprintCallable, Category = "Enemy|Setup")
    void UpdateAlliesAndEnemies(const TArray<UFighter*>& AllFighters);
    UFUNCTION(BlueprintCallable, Category = "Enemy|Setup")
    void OnFighterListChanged(UFighter* ChangedFighter);

    virtual void ReceiveDamage(float Damage) override;

    // --- Utility scorers ---
    UFUNCTION(BlueprintCallable, Category = "Enemy|Utility")
    float GetAttackUtility();
    UFUNCTION(BlueprintCallable, Category = "Enemy|Utility")
    float GetMeleeUtility();
    UFUNCTION(BlueprintCallable, Category = "Enemy|Utility")
    float GetRangedUtility();
    UFUNCTION(BlueprintCallable, Category = "Enemy|Utility")
    float GetMagicUtility();
    UFUNCTION(BlueprintCallable, Category = "Enemy|Utility")
    float GetSelfHealUtility();
    UFUNCTION(BlueprintCallable, Category = "Enemy|Utility")
    float GetAllyHealUtility();
    UFUNCTION(BlueprintCallable, Category = "Enemy|Utility")
    float GetBlockUtility();
    UFUNCTION(BlueprintCallable, Category = "Enemy|Utility")
    float GetBuffUtility();

    // Tunable weights exposed to designers
    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Enemy|Utility|Weight")
    float AttackUtilityWeight;
    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Enemy|Utility|Weight")
    bool bHasMelee;
    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Enemy|Utility|Weight")
    bool bHasRanged;
    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Enemy|Utility|Weight")
    bool bHasMagic;
    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Enemy|Utility|Weight")
    float HealUtilityWeight;
    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Enemy|Utility|Weight")
    bool bHasSelfHeal;
    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Enemy|Utility|Weight")
    bool bHasAllyHeal;
    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Enemy|Utility|Weight")
    float BuffUtilityWeight;
    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Enemy|Utility|Weight")
    bool bHasBuff;
    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Enemy|Utility|Weight")
    float BlockUtilityWeight;
    UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Enemy|Utility|Weight")
    bool bHasBlock;

    UFUNCTION(BlueprintCallable, Category = "Enemy|StorageAccess")
    UFighter* GetClosestEnemy();
    UFUNCTION(BlueprintCallable, Category = "Enemy|StorageAccess")
    UFighter* GetLowestAlly();

private:
    TArray<UFighter*> Allies;
    TArray<UFighter*> Enemies;
    float LastDamageReceived;
    int ClosestEnemyIndex;
    int LowestAllyIndex;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Enemy|Weapon",
              meta = (AllowPrivateAccess = "true"))
    EWeaponType CurrentWeaponType = EWeaponType::Melee;
};
                        
UEnemy.cpp - Ally & Enemy Roster Tracking

void UEnemy::InitializeEnemy(const TArray<UFighter*>& AllFighters)
{
    Allies.Empty();
    for (UFighter* Fighter : AllFighters)
    {
        if (Fighter && Fighter->GetOwner() != GetOwner())
        {
            if (Fighter->GetOwner()->FindComponentByClass<UEnemy>())
                Allies.Add(Fighter);   // Same faction
            else
                Enemies.Add(Fighter);  // Opposing faction
        }
    }
}

void UEnemy::UpdateAlliesAndEnemies(const TArray<UFighter*>& AllFighters)
{
    Allies.Empty();
    Enemies.Empty();
    for (UFighter* Fighter : AllFighters)
    {
        if (Fighter && Fighter->GetOwner() != GetOwner())
        {
            if (Fighter->GetOwner()->FindComponentByClass<UEnemy>())
                Allies.Add(Fighter);
            else
                Enemies.Add(Fighter);
        }
    }
}

// Called by UTurnManager delegates whenever a fighter joins or dies
void UEnemy::OnFighterListChanged(UFighter* /*ChangedFighter*/)
{
    UWorld* World = GetWorld();
    if (!World) return;
    for (TObjectIterator<UTurnManager> It; It; ++It)
    {
        if (It->GetWorld() == World)
        {
            UpdateAlliesAndEnemies(It->Fighters);
            break;
        }
    }
}

void UEnemy::ReceiveDamage(float Damage)
{
    Super::ReceiveDamage(Damage);
    LastDamageReceived = Damage;  // Stored for block utility scoring
}
                        
UEnemy.cpp - Utility AI Scoring System

// Attack utility: drives aggression based on target's remaining health.
// Uses -ln(healthRatio) so near-dead targets score exponentially higher.
float UEnemy::GetAttackUtility()
{
    float CurrUtility = 0;
    ClosestEnemyIndex = 0;
    if (Enemies.Num() <= 0) return CurrUtility;

    float ClosestDistance = Enemies[0]->GetOwner()->GetHorizontalDistanceTo(this->GetOwner());
    bool bHasLineOfSight = false;

    int i = 0;
    for (UFighter* CurrFighter : Enemies)
    {
        float Dist = CurrFighter->GetOwner()->GetHorizontalDistanceTo(this->GetOwner());
        if (Dist < ClosestDistance) { ClosestEnemyIndex = i; ClosestDistance = Dist; }
        if (CheckSightToTarget(CurrFighter)) bHasLineOfSight = true;
        ++i;
    }

    if ((ClosestDistance > MovementLeft && !bHasRanged) || !bHasLineOfSight)
        return CurrUtility;

    float HealthRatio = FMath::Clamp(
        Enemies[ClosestEnemyIndex]->CurrentHealth / Enemies[ClosestEnemyIndex]->MaxHealth,
        0.01f, 1.0f);
    CurrUtility += -FMath::Loge(HealthRatio);
    return CurrUtility * AttackUtilityWeight;
}

// Self-heal: exponential urgency; forced (FLT_MAX) at <=10% health.
float UEnemy::GetSelfHealUtility()
{
    if (!bHasSelfHeal) return 0;
    float HealthRatio = FMath::Clamp(CurrentHealth / MaxHealth, 0.01f, 1.0f);
    if (HealthRatio <= 0.1f)
        return FLT_MAX;  // Force heal when critically low
    return FMath::Exp(-HealthRatio * 5.0f) * HealUtilityWeight;
}

// Ally heal: finds the most wounded visible ally and scores by their need.
float UEnemy::GetAllyHealUtility()
{
    if (!bHasAllyHeal) return 0;
    float LowestHealthRatio = FLT_MAX;
    LowestAllyIndex = 0;
    int i = 0;
    for (auto Ally : Allies)
    {
        if (CheckSightToTarget(Ally))
        {
            float Ratio = Ally->CurrentHealth / Ally->MaxHealth;
            if (Ratio < LowestHealthRatio) { LowestAllyIndex = i; LowestHealthRatio = Ratio; }
        }
        ++i;
    }
    return FMath::Exp(-LowestHealthRatio * 5.0f) * HealUtilityWeight;
}

// Block: scales with recent incoming damage so reactive enemies defend wisely.
float UEnemy::GetBlockUtility()
{
    if (!bHasBlock) return 0;
    float DamageRatio = FMath::Clamp(LastDamageReceived / MaxHealth, 0.0f, 1.0f);
    return (FMath::Exp(DamageRatio * BlockUtilityWeight) - 1.0f) * BlockUtilityWeight;
}

// Buff: rewards buffing when no active buffs exist, diminishes per existing buff.
float UEnemy::GetBuffUtility()
{
    if (!bHasBuff) return 0;
    float CurrUtility = (BuffTracker.Num() == 0) ? 1.0f : 0.5f / BuffTracker.Num();
    return CurrUtility * BuffUtilityWeight;
}

// Weapon-type affinity: preferred weapon scores 1.0, off-type scores lower.
float UEnemy::GetMeleeUtility()  { return bHasMelee  ? (CurrentWeaponType == EWeaponType::Melee  ? 1.0f : 0.3f) : 0; }
float UEnemy::GetRangedUtility() { return bHasRanged ? (CurrentWeaponType == EWeaponType::Ranged ? 1.0f : 0.5f) : 0; }
float UEnemy::GetMagicUtility()  { return bHasMagic  ? (CurrentWeaponType == EWeaponType::Magic  ? 1.0f : 0.6f) : 0; }
                        

Comprehensive Stat System

Tracks 15+ combat statistics including initiative, health, damage ranges, attack bonuses, defense values, and buff modifiers. Provides a complete stat query system for UI and game logic.

UFighter.cpp - Stat Management

TArray<float> UFighter::GetAllStats()
{
    TArray<float> Stats;
    Stats.Add(InitiativeScore);
    Stats.Add(MaxHealth);
    Stats.Add(CurrentHealth);
    Stats.Add(MinDamage);
    Stats.Add(MaxDamage);
    Stats.Add(DamageBuff);
    Stats.Add(BaseAttack);
    Stats.Add(AttackBuff);
    Stats.Add(BaseDefense);
    Stats.Add(DefenseBuff);
    Stats.Add(BaseBlock);
    Stats.Add(BlockBuff);
    Stats.Add(BaseHeal);
    Stats.Add(HealBuff);
    Stats.Add(DamageReduction);
    return Stats;
}

// Example stat usage in combat
void UFighter::SendDamage_Implementation(float Damage, UFighter* Target)
{
    Target->ReceiveDamage(Damage);
}

void UFighter::Die_Implementation()
{
    // Handle death logic here in Blueprint
    // Can trigger animations, VFX, game over states, etc.
}
                        

UTurnManager - Combat Orchestration

UTurnManager is an ActorComponent that owns the entire combat loop. On BeginPlay it scans every actor in the world for UFighter components, sorts them by initiative, binds to their death and end-turn delegates, and waits for a StartCombat() call. Five Blueprint-assignable multicast delegates let UI, cameras, and audio respond to every state change without any hard coupling.

UTurnManager.h - Delegates & Public Interface

// Multicast delegates - bind anything in Blueprint with no code changes
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnTurnChanged,   UFighter*, CurrentFighter);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnRoundStarted);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnCombatEnded);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnFighterDeath,  UFighter*, DeadFighter);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnFighterJoined, UFighter*, NewFighter);

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class RECONNECTION_API UTurnManager : public UActorComponent
{
    GENERATED_BODY()
public:
    UTurnManager();

    UPROPERTY(BlueprintReadOnly,  Category = "Turn Manager") TArray<UFighter*> Fighters;
    UPROPERTY(BlueprintReadOnly,  Category = "Turn Manager") int32 CurrentTurnIndex;
    UPROPERTY(BlueprintReadOnly,  Category = "Turn Manager") int32 CurrentRound;
    UPROPERTY(BlueprintReadWrite, Category = "Turn Manager") bool  bCombatActive;
    UPROPERTY(BlueprintReadOnly,  Category = "Turn Manager") int32 EnemiesLeft = 0;
    UPROPERTY(BlueprintReadWrite, Category = "Turn Manager") FName NextLevel;

    // Events - assign UI, audio, cameras entirely from Blueprint
    UPROPERTY(BlueprintAssignable, Category = "Turn Manager|Events") FOnTurnChanged   OnTurnChanged;
    UPROPERTY(BlueprintAssignable, Category = "Turn Manager|Events") FOnRoundStarted  OnRoundStarted;
    UPROPERTY(BlueprintAssignable, Category = "Turn Manager|Events") FOnCombatEnded   OnCombatEnded;
    UPROPERTY(BlueprintAssignable, Category = "Turn Manager|Events") FOnFighterDeath  OnFighterDeath;
    UPROPERTY(BlueprintAssignable, Category = "Turn Manager|Events") FOnFighterJoined OnFighterJoined;

    UFUNCTION(BlueprintCallable, Category = "Turn Manager") void InitializeCombat();
    UFUNCTION(BlueprintCallable, Category = "Turn Manager") void StartCombat();
    UFUNCTION(BlueprintCallable, Category = "Turn Manager") void NextTurn();
    UFUNCTION(BlueprintCallable, Category = "Turn Manager") UFighter* GetCurrentFighter();
    UFUNCTION(BlueprintCallable, Category = "Turn Manager") void SortFightersByInitiative();
    UFUNCTION(BlueprintCallable, Category = "Turn Manager") void RemoveFighter(UFighter* Fighter);
    UFUNCTION(BlueprintCallable, Category = "Turn Manager") void EndCombat();

private:
    UFUNCTION() void HandleFighterDeath(UFighter* DeadFighter);
    UFUNCTION() void HandleFighterEndTurn(UFighter* Fighter);
};
                        
UTurnManager.cpp - InitializeCombat & StartCombat

void UTurnManager::BeginPlay()
{
    Super::BeginPlay();
    InitializeCombat();
}

void UTurnManager::InitializeCombat()
{
    Fighters.Empty();

    // Iterate every actor and collect those with a UFighter component
    TArray<AActor*> AllActors;
    UGameplayStatics::GetAllActorsOfClass(GetWorld(), AActor::StaticClass(), AllActors);

    for (AActor* Actor : AllActors)
    {
        if (!Actor) continue;
        UFighter* FighterComp = Actor->FindComponentByClass<UFighter>();
        if (!FighterComp) continue;

        Fighters.Add(FighterComp);

        // Bind death & turn-end events for automatic flow control
        FighterComp->OnDeath.AddDynamic(this, &UTurnManager::HandleFighterDeath);
        FighterComp->OnEndTurn.AddDynamic(this, &UTurnManager::HandleFighterEndTurn);

        // If the actor also has an UEnemy component, wire up roster-update delegates
        UEnemy* EnemyComp = Actor->FindComponentByClass<UEnemy>();
        if (EnemyComp)
        {
            EnemyComp->InitializeEnemy(Fighters);
            OnFighterJoined.AddDynamic(EnemyComp, &UEnemy::OnFighterListChanged);
            OnFighterDeath.AddDynamic(EnemyComp,  &UEnemy::OnFighterListChanged);
            ++EnemiesLeft;
        }
        OnFighterJoined.Broadcast(FighterComp);
    }

    SortFightersByInitiative();
    // StartCombat() is called separately so Blueprint can delay the start
}

void UTurnManager::StartCombat()
{
    if (Fighters.Num() == 0) { return; }

    bCombatActive = true;
    CurrentTurnIndex = 0;
    CurrentRound = 1;
    OnRoundStarted.Broadcast();

    // Reset all turns, then give the first fighter their turn
    for (UFighter* Fighter : Fighters)
        if (Fighter && Fighter->bIsTurn) Fighter->bIsTurn = false;

    UFighter* FirstFighter = GetCurrentFighter();
    if (FirstFighter)
    {
        FirstFighter->StartTurn();
        OnTurnChanged.Broadcast(FirstFighter);
    }
}
                        
UTurnManager.cpp - NextTurn, Round Tracking & Initiative Sort

void UTurnManager::NextTurn()
{
    if (!bCombatActive || Fighters.Num() == 0) return;

    CurrentTurnIndex++;

    // Wrap index → new round
    if (CurrentTurnIndex >= Fighters.Num())
    {
        CurrentTurnIndex = 0;
        CurrentRound++;
        OnRoundStarted.Broadcast();
    }

    // Clear any lingering active turns
    for (UFighter* Fighter : Fighters)
        if (Fighter && Fighter->bIsTurn) Fighter->bIsTurn = false;

    UFighter* CurrentFighter = GetCurrentFighter();
    if (CurrentFighter)
    {
        CurrentFighter->StartTurn();
        OnTurnChanged.Broadcast(CurrentFighter);
    }
}

UFighter* UTurnManager::GetCurrentFighter()
{
    return Fighters.IsValidIndex(CurrentTurnIndex) ? Fighters[CurrentTurnIndex] : nullptr;
}

// Descending sort - highest initiative goes first (classic D&D tiebreaker by score)
void UTurnManager::SortFightersByInitiative()
{
    Fighters.Sort([](const UFighter& A, const UFighter& B)
    {
        return A.InitiativeScore > B.InitiativeScore;
    });
}

// Called by HandleFighterEndTurn delegate
void UTurnManager::HandleFighterEndTurn(UFighter* Fighter)
{
    if (bCombatActive) NextTurn();
}
                        
UTurnManager.cpp - Fighter Removal, Victory & Level Transition

void UTurnManager::RemoveFighter(UFighter* Fighter)
{
    if (!Fighter) return;

    int32 RemovedIndex = Fighters.Find(Fighter);
    if (RemovedIndex == INDEX_NONE) return;

    Fighters.Remove(Fighter);

    // Keep CurrentTurnIndex valid after removal
    if (RemovedIndex < CurrentTurnIndex)
        CurrentTurnIndex--;
    else if (RemovedIndex == CurrentTurnIndex && CurrentTurnIndex >= Fighters.Num())
        CurrentTurnIndex = 0;

    if (Fighters.Num() <= 1) EndCombat();
}

void UTurnManager::EndCombat()
{
    bCombatActive = false;
    OnCombatEnded.Broadcast();
}

// Bound to each fighter's OnDeath delegate during InitializeCombat()
void UTurnManager::HandleFighterDeath(UFighter* DeadFighter)
{
    OnFighterDeath.Broadcast(DeadFighter);
    RemoveFighter(DeadFighter);

    // If it was an enemy, decrement counter and load the next level on victory
    if (DeadFighter->GetOwner()->FindComponentByClass<UEnemy>())
    {
        --EnemiesLeft;
        if (EnemiesLeft <= 0)
        {
            UGameplayStatics::OpenLevel(GetWorld(), FName(NextLevel));
        }
    }
}
                        

Blueprint Showcases

Unreal Engine Blueprint graphs built on top of the C++ foundation, handling AI decision-making, movement validation, and input processing.

Team Credits

🎬
Jadyn Englett Producer & Level Design
🖌️
Waldemar Morales Character Art, UI Art, Art Lead
🎮
Wesley Yates Front-End Blueprints, Level Design, and Tech Lead
🎨
Olin Britt-Tores Character Art & 3D Environment
📖
Matthew Polfer Narrative & Level Design
📷
David Diaz Camera & Post-Processing
Daniel Roa VFX & Animations

Developer Reflection

Building Reconnection taught me the importance of designing C++ systems that empower designers. By creating a robust combat foundation with Blueprint extensibility, our team could iterate rapidly on enemy behaviors and game balance while maintaining performant, clean code. Translating the unpredictability and strategy of tabletop D&D into automated combat was an incredibly rewarding challenge!