7 Min. read

The Bug Squad: Gameplay Ability System Setup

I’ve been waiting for a chance to play around with the Gameplay Ability System (GAS) that ships with Unreal for quite some time. The official documentation defines GAS as:

[…] a highly flexible framework for building the types of abilities and attributes that you might find in an RPG or MOBA title. You can build actions or passive abilities for the characters in your games […]

The system packs a lot of features — perhaps even too many! 😄 — but its major selling point in my opinion is the fact that it’s fully networked, with support for client-side prediction and reconciliation. Anyones who’s ever written multiplayer code knows how difficult that can be to get right. Solutions are usually project-specific, whereas GAS tries to accomplish the same in a project-agnostic manner.

GAS is also battle-tested, having being used in most of the recent games by Epic, including Fortnite. I’m planning to use GAS to drive most of the gameplay in The Bug Squad.

Getting started

If it’s true that software is only as good as its documentation, then one would be up for a disappointment with GAS: official documentation is lacking at best. Luckily, there are several learning resources maintained by the community:

  • Using the Gameplay Ability System: Unreal Fest Europe 2019 talk that provides a good high level intro to the system.
  • GAS Documentation: the definitive guide to GAS. It explains all the concepts, and then some.
  • GAS Shooter: companion project to the above. Implements a shooter game using GAS.
  • Let’s make an MMO in UE4: long video series where GAS is used to create a MMO.
  • thegames.dev: a blog that offers advanced tips and tricks related to GAS.
  • GAS Content: a list of additional resources.
  • Lyra: UE5 example project by Epic. Perhaps too complex for what it’s accomplishing, but a good learning resource nonetheless.
  • ActionRPG: older GAS example project released by Epic. Unfortunately it doesn’t support networking.

The system is complex and will take a lot of time to learn, but over the weekend I managed to at least get the basics going.

Basic setup

Setting up GAS requires a lot of boilerplate. According to GAS Documentation there are at least four classes to override straight away:

  • UAbilitySystemComponent: the entry point to all GAS functionality. Abilities need to be registered with this component in order to be triggered. It manages (and replicate) all the building blocks of the system, including: Gameplay Effects, Gameplay Tags, Gameplay Cues, Attributes, etc.
  • UGameplayAbility: base class for all abilities.
  • UAbilitySystemGlobals: singleton-like manager with several hooks into the system.
  • UGameplayCueManager: another singleton-like manager specific to Gameplay Cues.

Managers need to be configured in DefaultGame.ini:

[/Script/GameplayAbilities.AbilitySystemGlobals]
AbilitySystemGlobalsClassName=/Script/BugSquad.BSAbilitySystemGlobals
GlobalGameplayCueManagerClass=/Script/BugSquad.BSGameplayCueManager

There are several utility functions and classes mentioned in the docs that I foresee will be worth adding to the project as well.

Attributes

Attributes define numeric properties such as health, mana and so on. They live inside a UAttributeSet object that needs to be instantiated alongside the main UAbilitySystemComponent. Defining attributes involves a lot of code, partially reduced by using macros:

/** Utility macro that wraps all attributes macros into one. */
#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
	GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
	GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
	GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
	GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)
 
UCLASS()
class UBSPlayerAttributeSet : public UAttributeSet
{
	GENERATED_BODY()
 
public:
 
	UPROPERTY(BlueprintReadOnly, Category = "Attributes", ReplicatedUsing = OnRep_Health)
	FGameplayAttributeData Health;
 
	UPROPERTY(BlueprintReadOnly, Category = "Attributes", ReplicatedUsing = OnRep_MaxHealth)
	FGameplayAttributeData MaxHealth;
 
    // [...]
 
	ATTRIBUTE_ACCESSORS(UBSPlayerAttributeSet, Health);
	ATTRIBUTE_ACCESSORS(UBSPlayerAttributeSet, MaxHealth);
 
protected:
 
	UFUNCTION()
	void OnRep_Health(const FGameplayAttributeData& OldValue);
 
	UFUNCTION()
	void OnRep_MaxHealth(const FGameplayAttributeData& OldValue);
 
	// UObject interface
public:
	void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
void UBSPlayerAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
 
	DOREPLIFETIME_CONDITION_NOTIFY(UBSPlayerAttributeSet, Health, COND_None, REPNOTIFY_Always);
	DOREPLIFETIME_CONDITION_NOTIFY(UBSPlayerAttributeSet, MaxHealth, COND_None, REPNOTIFY_Always);
}
 
void UBSPlayerAttributeSet::OnRep_Health(const FGameplayAttributeData& OldValue)
{
	GAMEPLAYATTRIBUTE_REPNOTIFY(UBSPlayerAttributeSet, Health, OldValue);
}
 
void UBSPlayerAttributeSet::OnRep_MaxHealth(const FGameplayAttributeData& OldValue)
{
	GAMEPLAYATTRIBUTE_REPNOTIFY(UBSPlayerAttributeSet, MaxHealth, OldValue);
}

Abilities can change attributes (including predictively on the local owning client!) using Gameplay Effects.

Ability System Component initialization

As explained in the GAS Documentation, the UAbilitySystemComponent can be added to either the player state or the character depending on the project’s needs. The player state approach is better if players respawn frequently and requires considerable additional setup.

Luckily, if a player dies in The Bug Squad, then the mission’s over and the player is taken back to the main menu — so I was able to set up the component and attributes in the character. This is the code that initializes the system:

void ABSMech::Restart()
{
	Super::Restart();
 
	// Ability system initialization
	if (AbilitySystemComponent == nullptr)
	{
		UE_LOG(LogBSCharacter, Error, TEXT("No ability system component in %s"), *GetName());
		return;
	}
 
	AbilitySystemComponent->InitAbilityActorInfo(this, this);
 
	if (HasAuthority())
	{
		// Apply initial (passive) gameplay effects
		if (PlayerAttributes != nullptr)
		{
			FGameplayEffectContextHandle EffectContext = AbilitySystemComponent->MakeEffectContext();
			EffectContext.AddSourceObject(this);
 
			for (const TSubclassOf<UGameplayEffect> GameplayEffect : StartupEffects)
			{
				FGameplayEffectSpecHandle NewHandle = AbilitySystemComponent->MakeOutgoingSpec(GameplayEffect,
					PlayerAttributes->GetLevel(), EffectContext);
				if (NewHandle.IsValid())
				{
					AbilitySystemComponent->ApplyGameplayEffectSpecToTarget(*NewHandle.Data.Get(), AbilitySystemComponent);
				}
			}
		}
 
		// Grant default abilities
		// TODO: this is temporary, abilities should be granted based on mech class and unlock progress
		for (const FBSPlayerAbility& Ability : Abilities)
		{
			GrantAbility(Ability, nullptr);
		}
	}
}

I’m using a passive gameplay effect to initialize attributes based on what’s considered best practice in the docs.

Binding abilities to input

GAS supports binding abilities to input events, but unfortunately it doesn’t work out-of-the-box with the Enhanced Input Plugin based setup I’m using for The Bug Squad. I’ve worked around the problem by reusing what Epic did in Lyra.

The current plan is for each mech in the game to have 3 active abilities that can be triggered by players. Each ability has its own input tag, i.e. Input.Ability.Active1, Input.Ability.Active2, etc. I’m planning to add a dash ability as well, which is managed via its own dedicated tag Input.Ability.Dash.

A screenshot of my custom input config asset

Ability input is bound in the mech class similarly to move/look input:

void ABSMech::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);
 
    // ...
 
	// Bind ability input
	for (const TPair<FGameplayTag, TObjectPtr<UInputAction>>& Pair : InputConfig->AbilityInputActions)
	{
		if (Pair.Key.IsValid() && Pair.Value != nullptr)
		{
			EnhancedInputComponent->BindAction(Pair.Value, ETriggerEvent::Triggered, this, &ThisClass::OnAbilityInputPressed, Pair.Key);
			EnhancedInputComponent->BindAction(Pair.Value, ETriggerEvent::Completed, this, &ThisClass::OnAbilityInputReleased, Pair.Key);
		}
	}
}
 
void ABSMech::OnAbilityInputPressed(const FGameplayTag InputTag)
{
	if (AbilitySystemComponent != nullptr)
	{
		AbilitySystemComponent->AbilityInputTagPressed(InputTag);
	}
}
 
void ABSMech::OnAbilityInputReleased(const FGameplayTag InputTag)
{
	if (AbilitySystemComponent != nullptr)
	{
		AbilitySystemComponent->AbilityInputTagReleased(InputTag);
	}
}

Input is passed on to the ability system component where it’s processed:

void UBSAbilitySystemComponent::AbilityInputTagPressed(const FGameplayTag& InputTag)
{
	if (InputTag.IsValid())
	{
		for (const FGameplayAbilitySpec& AbilitySpec : ActivatableAbilities.Items)
		{
			if (AbilitySpec.Ability && AbilitySpec.DynamicAbilityTags.HasTagExact(InputTag))
			{
				InputPressedSpecHandles.AddUnique(AbilitySpec.Handle);
			}
		}
	}
}
 
void UBSAbilitySystemComponent::AbilityInputTagReleased(const FGameplayTag& InputTag)
{
	if (InputTag.IsValid())
	{
		for (const FGameplayAbilitySpec& AbilitySpec : ActivatableAbilities.Items)
		{
			if (AbilitySpec.Ability && AbilitySpec.DynamicAbilityTags.HasTagExact(InputTag))
			{
				InputReleasedSpecHandles.AddUnique(AbilitySpec.Handle);
			}
		}
	}
}
 
void UBSAbilitySystemComponent::ClearAbilityInput()
{
	InputPressedSpecHandles.Reset();
	InputReleasedSpecHandles.Reset();
}
 
void UBSAbilitySystemComponent::ProcessAbilityInput(const float DeltaTime, const bool bGamePaused)
{
	ABILITYLIST_SCOPE_LOCK();
 
	static TArray<FGameplayAbilitySpecHandle, TInlineAllocator<4>> AbilitiesToActivate;
	AbilitiesToActivate.Reset();
 
	// Process all abilities that had their input pressed this frame
	for (const FGameplayAbilitySpecHandle& SpecHandle : InputPressedSpecHandles)
	{
		if (FGameplayAbilitySpec* AbilitySpec = FindAbilitySpecFromHandle(SpecHandle))
		{
			if (AbilitySpec->IsActive())
			{
				AbilitySpecInputPressed(*AbilitySpec);
			}
			else
			{
				AbilitiesToActivate.Add(AbilitySpec->Handle);
			}
		}
	}
 
	// Try to activate all the abilities that are from presses and holds
	// We do it all at once so that held inputs don't activate the ability
	// and then also send a input event to the ability because of the press
	for (const FGameplayAbilitySpecHandle& AbilitySpecHandle : AbilitiesToActivate)
	{
		TryActivateAbility(AbilitySpecHandle);
	}
 
	// Process all abilities that had their input released this frame
	for (const FGameplayAbilitySpecHandle& SpecHandle : InputReleasedSpecHandles)
	{
		if (FGameplayAbilitySpec* AbilitySpec = FindAbilitySpecFromHandle(SpecHandle))
		{
			if (AbilitySpec->IsActive())
			{
				AbilitySpecInputReleased(*AbilitySpec);
			}
		}
	}
 
	// Clear the cached ability handles
	ClearAbilityInput();
}
 
void UBSAbilitySystemComponent::AbilitySpecInputPressed(FGameplayAbilitySpec& Spec)
{
	Super::AbilitySpecInputPressed(Spec);
 
	// We don't support UGameplayAbility::bReplicateInputDirectly
	// Use replicated events instead so that the WaitInputPress ability task works
	if (Spec.IsActive())
	{
		// Invoke the InputPressed event. This is not replicated here
		// If someone is listening, they may replicate the InputPressed event to the server
		InvokeReplicatedEvent(EAbilityGenericReplicatedEvent::InputPressed, Spec.Handle,
			Spec.ActivationInfo.GetActivationPredictionKey());
	}
}
 
void UBSAbilitySystemComponent::AbilitySpecInputReleased(FGameplayAbilitySpec& Spec)
{
	Super::AbilitySpecInputReleased(Spec);
 
	// We don't support UGameplayAbility::bReplicateInputDirectly
	// Use replicated events instead so that the WaitInputRelease ability task works
	if (Spec.IsActive())
	{
		// Invoke the InputReleased event. This is not replicated here
		// If someone is listening, they may replicate the InputReleased event to the server
		InvokeReplicatedEvent(EAbilityGenericReplicatedEvent::InputReleased, Spec.Handle,
			Spec.ActivationInfo.GetActivationPredictionKey());
	}
}

In order to test things I’ve set up a minimal ability that is activated by pressing a button and prints some debug messages to the screen:

A screenshot of a simple ability used to test input functionality

And here’s the final result in all its glory!

A test ability in action, with debug messages printed on the screen when key is pressed and released

Next up: doing something useful with all of this, starting with a dash ability.