Gameplay Ability System (GAS)
While GAS requires considerable boilerplate code, once set up it becomes quite convenient to work with. My base GAS setup is built on four GAS-specific classes (using "Thor" as the project prefix, following standard naming conventions):
- ThorAbilityInputID
- ThorAbilitySystemComponent
- ThorAttributeSet
- ThorGameplayAbility
These four classes can be surprisingly minimal. ThorAbilityInputID is simply an enumeration. ThorGameplayAbility, the base class for all gameplay abilities, implements only a single property: the aforementioned ability input ID. ThorAbilitySystemComponent doesn't require any implementation to get started. GAS also relies on Gameplay Tags, which prove useful for all kinds of implementations beyond GAS itself.
UENUM(BlueprintType)
enum class EThorAbilityInputID : uint8
{
None = 0,
Confirm,
Cancel,
Roll,
LightAttack,
MediumAttack,
HeavyAttack,
Block,
UseItem
};
UCLASS()
class THUNDERSTORM_API UThorGameplayAbility : public UGameplayAbility
{
GENERATED_BODY()
public:
UThorGameplayAbility::UThorGameplayAbility();
UPROPERTY(EditDefaultsOnly, Category = "Input")
EThorAbilityInputID AbilityInputID = EThorAbilityInputID::None;
};
Attribute Set
GAS provides macros for setting up attributes. For my souls-like game, I use just four: Health, MaxHealth, Stamina, MaxStamina. One aspect that requires attention is proper clamping of these attributes. This happens in two places: PreAttributeChange clamps the value before it's queried, ensuring valid reads. PostGameplayEffectExecute clamps the actual base value after a gameplay effect is applied.
void UThorAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME_CONDITION_NOTIFY(UThorAttributeSet, Health, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UThorAttributeSet, MaxHealth, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UThorAttributeSet, Stamina, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UThorAttributeSet, MaxStamina, COND_None, REPNOTIFY_Always);
}
void UThorAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
Super::PreAttributeChange(Attribute, NewValue);
if (Attribute == GetHealthAttribute())
{
NewValue = FMath::Clamp(NewValue, 0.f, GetMaxHealth());
}
else if (Attribute == GetStaminaAttribute())
{
NewValue = FMath::Clamp(NewValue, 0.f, GetMaxStamina());
}
}
void UThorAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
Super::PostGameplayEffectExecute(Data);
if (Data.EvaluatedData.Attribute == GetHealthAttribute())
{
SetHealth(FMath::Clamp(GetHealth(), 0.f, GetMaxHealth()));
}
else if (Data.EvaluatedData.Attribute == GetStaminaAttribute())
{
SetStamina(FMath::Clamp(GetStamina(), 0.f, GetMaxStamina()));
}
}
void UThorAttributeSet::OnRep_Health(const FGameplayAttributeData& OldHealth)
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UThorAttributeSet, Health, OldHealth);
}
void UThorAttributeSet::OnRep_MaxHealth(const FGameplayAttributeData& OldMaxHealth)
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UThorAttributeSet, MaxHealth, OldMaxHealth);
}
void UThorAttributeSet::OnRep_Stamina(const FGameplayAttributeData& OldStamina)
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UThorAttributeSet, Stamina, OldStamina);
}
void UThorAttributeSet::OnRep_MaxStamina(const FGameplayAttributeData& OldMaxStamina)
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UThorAttributeSet, MaxStamina, OldMaxStamina);
}
Base Character implementing GAS Interface
ThorCharacter, the base class for all my character classes, implements the GAS interface. ThorPlayerCharacter, which inherits from ThorCharacter, sets up the input bindings for GAS using ThorAbilityInputID.
One of GAS's key advantages is that it's built with multiplayer in mind. For a proper multiplayer setup, we need to separate client-side and server-side initialization. There are two options: initialize in the player controller class (ThorPlayerController) or in the character class (ThorCharacter). I chose the character class approach, which involves less cross-referencing. Server-side initialization happens in PossessedBy, while client-side initialization occurs in OnRep_PlayerState. Both need to initialize ability actor info, but only the server should grant default abilities and effects. We also bind callback functions to GAS delegates in both functions.
For my souls-like game's health and stamina attributes, GAS allows binding to attribute changes—useful for displaying health and stamina in the UI. I set up custom OnHealthChange and OnStaminaChange delegates on ThorCharacter and tie them to their respective GAS delegates.
void AThorCharacter::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
// Server-side initialization
if (AbilitySystemComponent)
{
AbilitySystemComponent->InitAbilityActorInfo(this, this);
InitializeAttributes();
GrantDefaultAbilities();
ApplyDefaultEffects();
BindToAttributeChanges();
BroadcastInitialValues();
}
}
void AThorCharacter::OnRep_PlayerState()
{
Super::OnRep_PlayerState();
// Client-side initialization
if (AbilitySystemComponent)
{
AbilitySystemComponent->InitAbilityActorInfo(this, this);
BindToAttributeChanges();
BroadcastInitialValues();
}
}
void AThorCharacter::GrantDefaultAbilities()
{
if (!AbilitySystemComponent || !HasAuthority()) return;
for (const TSubclassOf<UThorGameplayAbility>& AbilityClass : DefaultAbilities)
{
if (!AbilityClass) continue;
const UThorGameplayAbility* AbilityCDO = AbilityClass.GetDefaultObject();
FGameplayAbilitySpec Spec(
AbilityClass,
1,
static_cast<int32>(AbilityCDO->AbilityInputID),
this
);
AbilitySystemComponent->GiveAbility(Spec);
}
}
void AThorCharacter::BindToAttributeChanges()
{
if (AbilitySystemComponent)
{
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(UThorAttributeSet::GetHealthAttribute()).AddUObject(this, &AThorCharacter::HandleHealthChanged);
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(UThorAttributeSet::GetStaminaAttribute()).AddUObject(this, &AThorCharacter::HandleStaminaChanged);
}
}
Player Character processing GAS Input ID
ThorPlayerCharacter is primarily responsible for input setup. For GAS, there are two approaches to handling input: TryActivateAbility and AbilityLocalInputPressed / AbilityLocalInputReleased. I opted for TryActivateAbility. During development, I found that AbilityLocalInputPressed doesn't recognize retriggered inputs, which prevented animation canceling from working. TryActivateAbility, on the other hand, handles this correctly.
void AThorPlayerCharacter::OnAbilityInputPressed(EThorAbilityInputID InputID)
{
if (AbilitySystemComponent)
{
FGameplayAbilitySpec* Spec = AbilitySystemComponent->FindAbilitySpecFromInputID(
static_cast<int32>(InputID)
);
if (Spec)
{
AbilitySystemComponent->TryActivateAbility(Spec->Handle);
}
}
}
void AThorPlayerCharacter::OnAbilityInputReleased(EThorAbilityInputID InputID)
{
if (AbilitySystemComponent)
{
AbilitySystemComponent->AbilityLocalInputReleased(static_cast<int32>(InputID));
}
}
void AThorPlayerCharacter::Input_Roll_Pressed(const FInputActionValue& Value)
{
OnAbilityInputPressed(EThorAbilityInputID::Roll);
}
void AThorPlayerCharacter::Input_LightAttack_Pressed(const FInputActionValue& Value)
{
OnAbilityInputPressed(EThorAbilityInputID::LightAttack);
}
void AThorPlayerCharacter::Input_MediumAttack_Pressed(const FInputActionValue& Value)
{
OnAbilityInputPressed(EThorAbilityInputID::MediumAttack);
}
void AThorPlayerCharacter::Input_HeavyAttack_Pressed(const FInputActionValue& Value)
{
OnAbilityInputPressed(EThorAbilityInputID::HeavyAttack);
}
Gameplay Ability - Cancellable
One aspect GAS doesn't handle out of the box is animation canceling, so I developed a custom solution built on top of GAS. What is animation canceling? To prevent ability spamming, we could simply block incoming inputs during an ability's lifetime—that's straightforward. However, for fluid combat, we want to lift input blocking during a cancel window, typically at the start of an animation's recovery phase.
An animation notify state opens and closes this window by assigning and removing a custom tag. The gameplay ability's CanActivateAbility function (which I extend) then checks whether we're performing a blocked ability and, if so, whether the cancel tag is present.
UThorGameplayAbility_Cancellable::UThorGameplayAbility_Cancellable()
{
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
bRetriggerInstancedAbility = true;
}
bool UThorGameplayAbility_Cancellable::CanActivateAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayTagContainer* SourceTags,
const FGameplayTagContainer* TargetTags,
FGameplayTagContainer* OptionalRelevantTags) const
{
UAbilitySystemComponent* ASC = ActorInfo->AbilitySystemComponent.Get();
if (!ASC) return false;
bool bAttackActive = ASC->HasMatchingGameplayTag(ThorGameplayTags::Ability_Attack);
if (bAttackActive)
{
if (!RequiredCancelTag.IsValid() || !ASC->HasMatchingGameplayTag(RequiredCancelTag))
{
return false;
}
}
return Super::CanActivateAbility(Handle, ActorInfo, SourceTags, TargetTags, OptionalRelevantTags);
}
Gameplay Ability - Attack
Building on the cancellable ability, I implement the attack ability. The attack ability itself is fairly straightforward, but I also added another signature feature of souls-like games: the "canned" combo sequence. If the player times the cancel windows correctly, they chain through a combo attack sequence. My implementation checks the animation montage—if it contains different sections, it's automatically recognized as a combo sequence.
UThorGameplayAbility_Attack::UThorGameplayAbility_Attack()
{
ActivationOwnedTags.AddTag(ThorGameplayTags::Ability_Attack);
RequiredCancelTag = ThorGameplayTags::State_CancelWindow_Attack;
}
void UThorGameplayAbility_Attack::ActivateAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData)
{
Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
if (!K2_CommitAbility())
{
K2_EndAbility();
return;
}
if (HasAuthorityOrPredictionKey(ActorInfo, &ActivationInfo))
{
FName CurrentSection = GetCurrentSection();
UAbilityTask_PlayMontageAndWait* MontageTask = UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy(
this,
NAME_None,
AttackMontage,
MontagePlayRate,
CurrentSection
);
MontageTask->OnCompleted.AddDynamic(this, &ThisClass::OnMontageCompleted);
MontageTask->OnBlendOut.AddDynamic(this, &ThisClass::OnMontageBlendOut);
MontageTask->OnCancelled.AddDynamic(this, &ThisClass::OnMontageCancelled);
MontageTask->OnInterrupted.AddDynamic(this, &ThisClass::OnMontageInterrupted);
MontageTask->ReadyForActivation();
AdvanceCombo();
}
}
void UThorGameplayAbility_Attack::OnMontageCompleted()
{
ResetCombo();
K2_EndAbility();
}
void UThorGameplayAbility_Attack::OnMontageBlendOut()
{
ResetCombo();
K2_EndAbility();
}
void UThorGameplayAbility_Attack::OnMontageCancelled()
{
K2_EndAbility();
}
void UThorGameplayAbility_Attack::OnMontageInterrupted()
{
ResetCombo();
K2_EndAbility();
}
FName UThorGameplayAbility_Attack::GetCurrentSection() const
{
if (!AttackMontage) return NAME_None;
const TArray<FCompositeSection>& Sections = AttackMontage->CompositeSections;
if (Sections.Num() == 0) return NAME_None;
int32 Index = CurrentComboIndex % Sections.Num();
return Sections[Index].SectionName;
}
void UThorGameplayAbility_Attack::AdvanceCombo()
{
if (!AttackMontage) return;
int32 NumSections = AttackMontage->CompositeSections.Num();
if (NumSections == 0) return;
CurrentComboIndex++;
if (CurrentComboIndex >= NumSections)
{
CurrentComboIndex = 0;
}
}
void UThorGameplayAbility_Attack::ResetCombo()
{
CurrentComboIndex = 0;
}
World Interaction System
To interact with the world—and that includes almost everything, from kindling a light and opening chests to picking up objects or talking to NPCs—I have a single, all-purpose interaction interface that each interactable object can implement as needed. The interaction interface is complemented by an interaction info widget, and an interaction input key that allows players to initiate actions, such as starting a conversation with an NPC.
The environment scanner continuously detects nearby interactable objects. When it finds one, the info widget appears and populates its slots with data provided by the object: the appropriate icon, name, description, and action prompt. The performance depends heavily on how the environment scanner is implemented. My implementation differs slightly from Tom Looman's approach. I use an overlap sphere limited to a custom interaction channel, then apply a dot product filter to identify the object directly in front of the player while also factoring in distance.
// Spatial partitioning with overlap sphere, then filtering by dot product
void UAsgardInteractionComponent::UpdateBestInteractable()
{
// Get all interactable objects within radius
TArray<FOverlapResult> Overlaps;
FVector OwnerLocation = Owner->GetActorLocation();
GetWorld()->OverlapMultiByChannel(
Overlaps,
OwnerLocation,
FQuat::Identity,
ECC_Interactable,
FCollisionShape::MakeSphere(InteractionRadius),
QueryParams
);
// Filter by dot product to find object in front of player
AActor* NewBestActor = nullptr;
float BestScore = -1.0f;
FVector CameraForward = CameraRotation.Vector();
for (const FOverlapResult& Overlap : Overlaps)
{
AActor* Actor = Overlap.GetActor();
FVector ToActor = (Actor->GetActorLocation() - CameraLocation).GetSafeNormal();
float DotProduct = FVector::DotProduct(CameraForward, ToActor);
if (DotProduct > DotProductThreshold)
{
// Weight by distance - closer objects preferred
float Distance = FVector::Dist(OwnerLocation, Actor->GetActorLocation());
float DistanceWeight = 1.0f - (Distance / InteractionRadius);
float Score = DotProduct * FMath::Lerp(0.5f, 1.0f, DistanceWeight);
if (Score > BestScore)
{
BestScore = Score;
NewBestActor = Actor;
}
}
}
// Update current interactable and trigger widget updates
if (CurrentInteractable != NewBestActor)
{
if (CurrentInteractable)
{
HideInteractionWidget();
IAsgardInteractable::Execute_OnInteractionFocusLost(CurrentInteractable);
}
CurrentInteractable = NewBestActor;
if (CurrentInteractable)
{
IAsgardInteractable::Execute_OnInteractionFocusGained(CurrentInteractable);
ShowInteractionWidget();
}
}
}
Credits & Assets
- Professional Game Development in C++ and Unreal Engine (Course) - Tom Looman, courses.tomlooman.com
- Viking Girl (Character Model) - Dary Palasky
- Frank Slash Pack (Animation Pack) - Frank Climax
- Medieval Props (3D Assets) - Yarrawah Interactive
- Mythical Attack Trail (VFX) - CodePhase Games
- Flat Icons Collection (UI Assets) - OneLittleFeather
- VFX Backgrounds (VFX) - FX Cat UA