ontainer.h"
#include "FCombatSkillData.generated.h"
USTRUCT(BlueprintType)
struct FCombatSkillData
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, Category = "Combat")
FGameplayTag SkillCategoryTag;
UPROPERTY(EditAnywhere, Category = "Combat")
float BaseDamageMultiplier = 1.0f;
UPROPERTY(EditAnywhere, Category = "Combat")
FGameplayTagContainer AppliedTags;
UPROPERTY(EditAnywhere, Category = "Combat")
float CooldownDuration = 0.0f;
UPROPERTY(EditAnywhere, Category = "Combat")
bool bIsInstant = true;
};
The context builder takes this raw data, injects runtime state (caster attributes, target modifiers), and produces a frozen execution context. This prevents mid-execution state mutations from causing unpredictable behavior.
### Step 2: Implement the Execution Core
The execution core handles the lifecycle of an ability. It inherits from `UGameplayAbility` and manages activation, cost payment, cooldown application, and event broadcasting. By keeping this logic in C++, you gain compile-time validation, easier profiling, and deterministic networking behavior.
```cpp
// UAbilityExecutorBase.h
#pragma once
#include "CoreMinimal.h"
#include "Abilities/GameplayAbility.h"
#include "UAbilityExecutorBase.generated.h"
UCLASS(Abstract)
class UAbilityExecutorBase : public UGameplayAbility
{
GENERATED_BODY()
public:
virtual void ActivateAbility(const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData) override;
virtual void EndAbility(const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
bool bReplicateEndAbility, bool bWasCancelled) override;
protected:
virtual void PreExecute(FCombatExecutionContext& OutContext) PURE_VIRTUAL(UAbilityExecutorBase::PreExecute, );
virtual void Execute(FCombatExecutionContext& Context) PURE_VIRTUAL(UAbilityExecutorBase::Execute, );
virtual void PostExecute(FCombatExecutionContext& Context) PURE_VIRTUAL(UAbilityExecutorBase::PostExecute, );
UFUNCTION(BlueprintCallable, Category = "Combat")
void BroadcastSkillEvent(FGameplayTag EventTag, const FGameplayEventData& Payload);
};
The lifecycle is deliberately split into PreExecute, Execute, and PostExecute. This separation allows the system to validate targets, apply costs, run the core logic, and then trigger presentation cues (animations, camera shakes, VFX) in a predictable order. Blueprint subclasses only override the presentation hooks, keeping C++ in control of simulation integrity.
Step 3: Build the Status Effect Framework
Status effects are implemented as independent objects that respond to tag lifecycle events. Instead of polling for active debuffs, the system listens for tag additions and removals, triggering turn-based ticks or expiration logic automatically.
// UTagEffectHandlerBase.h
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "UTagEffectHandlerBase.generated.h"
UCLASS(Abstract, Blueprintable)
class UTagEffectHandlerBase : public UObject
{
GENERATED_BODY()
public:
virtual void OnEffectApplied(AActor* Target, const FGameplayTag& EffectTag);
virtual void OnTurnTick(AActor* Target, float DeltaTime);
virtual void OnEffectRemoved(AActor* Target, const FGameplayTag& EffectTag);
protected:
UPROPERTY(Transient)
TWeakObjectPtr<AActor> CachedTarget;
UPROPERTY(EditDefaultsOnly, Category = "Effect")
float Duration = 0.0f;
UPROPERTY(EditDefaultsOnly, Category = "Effect")
bool bPersistsThroughTurns = false;
};
Each effect object registers itself with a central manager when its associated tag is applied. The manager routes turn ticks to active effects, handles duration countdowns, and cleans up expired handlers. This pattern eliminates the need for monolithic status effect classes and allows designers to compose complex interactions by stacking tags.
Step 4: Wire the Turn Manager & Event Bus
Turn-based combat requires deterministic ordering. A central TurnScheduler queries all combatants, sorts them by initiative attributes, and broadcasts OnTurnStart / OnTurnEnd events. Abilities listen to these events via GameplayEvents, ensuring that turn resolution remains decoupled from individual skill logic.
The architecture decision to use C++ for the core and Blueprints for presentation is deliberate. C++ provides type safety, performance, and easier debugging for simulation logic. Blueprints excel at sequencing animations, camera movements, and particle systems. By enforcing a strict boundary, you prevent designers from accidentally modifying damage calculations while allowing programmers to iterate on networking without breaking cinematic flows.
Pitfall Guide
1. Tag Explosion
Explanation: Creating overly granular tags (e.g., Status.Debuff.Fire.Small, Status.Debuff.Fire.Medium) leads to maintenance nightmares and inefficient memory usage.
Fix: Use hierarchical naming conventions (Status.Debuff.Fire) and store magnitude/duration in data assets or attributes. Query parent tags and filter by numeric values at runtime.
2. Mixing Presentation & Logic in Abilities
Explanation: Embedding animation montages or camera logic directly into C++ ability classes creates tight coupling and makes networking unpredictable.
Fix: Keep C++ abilities strictly simulation-focused. Broadcast FGameplayEvent tags when phases complete, and let Blueprint subclasses or UI/Animation systems listen and trigger presentation.
3. Ignoring Replication in Turn-Based Systems
Explanation: Assuming turn order and tag applications are automatically synchronized across clients leads to desyncs, especially when abilities modify attributes mid-turn.
Fix: Mark abilities with NetExecutionPolicy = ServerOnly or ClientPredicted depending on design. Use AbilitySystemComponent::SetReplicationPolicy() and ensure all tag applications go through GameplayEffect replication rather than direct tag manipulation.
4. Memory Leaks in Effect Objects
Explanation: Dynamically spawning effect handlers without proper lifecycle management causes heap fragmentation and crashes when actors are destroyed mid-combat.
Fix: Use TWeakObjectPtr for target references. Implement explicit OnEffectRemoved cleanup, and pool effect objects if spawning frequency exceeds 50 per second.
5. Overcomplicating the Context Builder
Explanation: Adding gameplay logic (damage calculation, tag validation) inside the context builder violates single-responsibility principles and makes testing difficult.
Fix: Treat the builder as a pure data assembler. It should only resolve dynamic values, merge base data with runtime modifiers, and return a frozen struct. All validation belongs in the executor.
6. Synchronous Tag Queries in Tick
Explanation: Calling HasMatchingGameplayTag() or GetActiveGameplayTags() every frame during turn resolution causes unnecessary overhead and cache misses.
Fix: Cache tag counts using AbilitySystemComponent::GetTagCount(). Subscribe to OnActiveGameplayTagAdded and OnActiveGameplayTagRemoved delegates to update local state only when changes occur.
7. Hardcoding Turn Order Logic
Explanation: Calculating initiative inside individual ability classes creates inconsistent sorting and makes global modifiers (e.g., "slow all enemies") impossible to implement cleanly.
Fix: Centralize turn resolution in a dedicated scheduler. Expose a modifier interface that abilities can use to temporarily adjust initiative values, then re-sort the queue before the next phase.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-player turn-based RPG | Server-authoritative GAS + local prediction | Simplifies networking, reduces client-side validation overhead | Low (single code path) |
| Multiplayer competitive combat | Client-predicted abilities + server reconciliation | Maintains responsiveness while preventing cheating | Medium (requires rollback/prediction systems) |
| Rapid prototyping | Blueprint-heavy abilities with minimal GAS | Faster iteration, lower initial complexity | High long-term (refactoring required for scale) |
| Large team (5+ programmers) | Strict C++ core + data-driven Blueprints | Prevents merge conflicts, enforces architecture boundaries | Low (improves velocity after setup) |
| Mobile/low-end hardware | Tag-count caching + pooled effect objects | Reduces GC pressure and CPU overhead during turn resolution | Medium (requires profiling & optimization) |
Configuration Template
GameplayTags.ini
[/Script/GameplayTags.GameplayTagsSettings]
+GameplayTagList=(Tag="Ability.Category.Physical",DevComment="Melee and ranged physical attacks")
+GameplayTagList=(Tag="Ability.Category.Magical",DevComment="Spell-based abilities")
+GameplayTagList=(Tag="Status.Debuff.Bleed",DevComment="Damage over time from physical wounds")
+GameplayTagList=(Tag="Status.Debuff.Chill",DevComment="Reduces movement speed and initiative")
+GameplayTagList=(Tag="Event.Skill.Activated",DevComment="Broadcast when any ability begins execution")
+GameplayTagList=(Tag="Event.Turn.Start",DevComment="Signals new turn phase for all combatants")
C++ Ability Base Header
// UCombatAbilityBase.h
#pragma once
#include "CoreMinimal.h"
#include "Abilities/GameplayAbility.h"
#include "UCombatAbilityBase.generated.h"
UCLASS(Abstract)
class UCombatAbilityBase : public UGameplayAbility
{
GENERATED_BODY()
public:
virtual void ActivateAbility(...) override;
virtual void EndAbility(...) override;
protected:
UPROPERTY(EditDefaultsOnly, Category = "Combat")
FGameplayTag AbilityCategory;
UPROPERTY(EditDefaultsOnly, Category = "Combat")
float BaseCooldown = 0.0f;
virtual void ResolveTargets(const FGameplayAbilityActorInfo* ActorInfo, TArray<AActor*>& OutTargets);
virtual void ApplyCosts(const FGameplayAbilityActorInfo* ActorInfo);
virtual void BroadcastPhase(FGameplayTag PhaseTag);
};
Quick Start Guide
- Attach GAS Components: Add
AbilitySystemComponent and a custom AttributeSet to your player and enemy classes. Enable replication if multiplayer is planned.
- Create a Skill Data Asset: Derive from
UDataAsset, add properties for damage, tags, and cooldowns. Populate a test asset in the Content Browser.
- Implement the Executor: Create a C++ class inheriting from
UAbilityExecutorBase. Override Execute() to read the data asset, apply tags via GameplayEffect, and broadcast a completion event.
- Wire a Blueprint Subclass: Create a Blueprint child of your executor. In the
OnActivate event, play an animation montage and trigger a camera shake. Keep all damage/tag logic in C++.
- Test Turn Resolution: Place two combatants in a test level. Use the turn scheduler to sort by initiative, trigger
OnTurnStart, and verify that tag applications persist correctly across phases.
This architecture transforms combat development from a fragile, script-heavy process into a predictable, data-driven pipeline. By enforcing clear boundaries between simulation and presentation, leveraging GAS's native tag system, and centralizing turn resolution, teams can scale content rapidly while maintaining performance and debuggability.