A Gameplay Framework with GAS based on Risk of Rain 2

Empowering designers to create complex skills and items with Unreal's GameplayAbilitySystem.

Posted by Vitor Cantão on Sunday, January 29, 2023

Introduction

Crafting RPG-like systems and new skills is always a fun and enjoyable process. It offers creative freedom while also requiring careful game balance consideration. It’s often the meat of the gameplay loop, after all. As the primary inspiration for this project, Risk of Rain 2 does an incredible job of tackling complex synergies and well-thought skill kits, with fast-paced and intuitive gameplay.

To support this process, designers need systems that are flexible and enable frequent iteration, providing an optimal environment for emergent design. This project is a study on this topic and aims to present a gameplay framework with tools to create complex skills, items, and systems. It builds on Unreal’s GameplayAbilitySystem (GAS) and focuses on flexibility, scalability, and extensibility. Some features include the Damage Pipeline, Ability Stacking, Proc chains, and a Melee System.

This article also seeks to expand the conversation about GAS, as there are limited resources, especially on the covered topics. The first chapters are focused on the specifics of the Framework, providing the necessary information for the later chapter to discuss more practical examples.

Note

This article assumes familiarity with Unreal and a basic understanding of the GAS framework.

Characters

Attributes

This section describes some of the character’s attributes cited throughout the article.

Attribute Description
Base Damage Base Damage that scales with Level. Every Damage is a multiplier of this attribute.
Total Damage1 Final Damage output. This attribute is modified by effects and other abilities (such as on-hit items). See Damage Pipeline.
Critical Strike Chance Chance to land a critical strike, doubling the damage dealt (also affects proc-chains).

1.Total Damage is a Meta Attribute. (See GAS Documentation)

Attribute Description
Health/Max Health Scales with level. If it reaches 0 or less, the character dies and activates on-kill effects.
Health Regen The amount of health regenerated every second.
Shield/Max Shield Similar to health, but with special rules. Shields are emptied before health. See the Personal Shield Generator item.
Armor Reduces (or increases if negative) the damage that a character takes.

Characters in Risk of Rain (including monsters) have four active skills slots: Primary, Secondary, Utility, and Special. A skill can have multiple charges, with effects being able to increase and decreasie it. While prototyping various solutions, I find using attributes to track the ability charges the most intuitive approach. Of course, if you don’t have a small, fixed number of skill slots, this approach might be unsustainable. An alternative is to use tags instead. Skill stacks and recharge are covered in detail in the Ability Stack section.

Attribute Description
Primary/Max Primary Number of charges the primary skill has.
Secondary/ Max Secondary Number of charges the secondary skill has.
Utility/Max Utility Number of charges the utility skill has.
Special/Max Special Number of charges the special skill has.

Creating Characters

Every new character derives from the ASCharacterBase class and has an IAbilitySystemInterface. Player controlled characters have their ASC on the PlayerState with a Mixed replication mode, while AI characters have their ASC on the AvatarActor with a Minimal replication mode.

When creating new characters, designers need to fill in a few parameters on the character’s blueprint. The initial stats are granted by an instant GE that overrides the fundamental attributes. To assign skills to each slot a Skill Set (see below) is used. Designers can also add passive skills and assign any permanent tags.

Bandit Character

Skill Set

Designers can easily assign skills to slots by populating a Skill Set. This means that the skills themselves don’t have an inherent concept of slots, but are later assigned to them within a Skill Set.

Skill Set

Bandit’s Skill Set example.

The Skill Set is a simple DataAsset that features the GiveSkills method. Given an ASC, it grants the ability to the correct slot using an InputID.

class AS_API UGameplaySkillSet : public UDataAsset
{
  // ...
	UPROPERTY(EditAnywhere, DisplayName = "Skill", Category = "Primary (M1)")
	TSubclassOf<UGameplaySkill> PrimarySkill;
  // ...
}



void UGameplaySkillSet::GiveAbilities(UAbilitySystemComponent* ASC) const
{
  // ...
  ASC->GiveAbility(FGameplayAbilitySpec(
          PrimarySkill, 
          DefaultLevel, 
          static_cast<int32>(EASAbilityInputID::Primary))
  );

  ASC->GiveAbility(FGameplayAbilitySpec(
          SecondarySkill, 
          DefaultLevel, 
          static_cast<int32>(EASAbilityInputID::Secondary))
  );
  // ...
}

Ability Stack

Ability stacking is a common feature in MOBAs and similar genres. As opposed to firing abilities and waiting for their cooldown, like in traditional RPGs, you can use them right after as long as there are charges/stacks left. Charges are generated after a certain period up to a maximum limit.

Overwatch 2 - Ability Stack

Overwatch 2 has many examples of ability stack. Tracer’s “Blink” ability can have up to 3 charges.

While GAS has support for stacking GameplayEffects, there’s currently no support for Ability stacking. So, instead of using the built-in Cost and Cooldown, I came up with a system that utilizes a GameplayStackRecharger. Designers can set up the max stacks, the recharge duration and the recharge Ability for each GameplaySkill.

Skill Config

After the skill has been granted through a Skill Set, the stack attributes are automatically changed to reflect the skill’s Max Stacks. This is possible using a dynamic GE created at runtime:

void UGameplaySkill::ApplyStackChangeGameplayEffect(
  const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec)
{
	UGameplayEffect* GEStackChange = 
    	NewObject<UGameplayEffect>(GetTransientPackage(), FName(TEXT("StackChange")));
	
	GEStackChange->DurationPolicy = EGameplayEffectDurationType::Instant;

	const int32 Index = GEStackChange->Modifiers.Num();
	GEStackChange->Modifiers.SetNum(Index + 2);

	FGameplayModifierInfo& MaxStackInfo = GEStackChange->Modifiers[Index];
	MaxStackInfo.ModifierMagnitude = FScalableFloat(MaxStacks);
	MaxStackInfo.ModifierOp = EGameplayModOp::Override;

	FGameplayModifierInfo& CurrentStackInfo = GEStackChange->Modifiers[Index + 1];
	CurrentStackInfo.ModifierMagnitude = FScalableFloat(MaxStacks);
	CurrentStackInfo.ModifierOp = EGameplayModOp::Override;

	MaxStackInfo.Attribute = UAbilitiesStackSet::GetMaxAttributeByInputID(Spec.InputID);
	CurrentStackInfo.Attribute = UAbilitiesStackSet::GetCurrentAttributeByInputID(Spec.InputID);

	ASC->ApplyGameplayEffectToSelf(GEStackChange, 1.0f, ASC->MakeEffectContext());
}

Gameplay Stack Recharger

The GameplayStackRecharger (GSR) is a GameplayAbility that is responsible for applying a recharge GE. As a GameplayAbility, it offers the flexibility to create complex logic in order to apply the recharge effect. However, in most cases, skills only require the basic method of recharging, which is to begin recharging as soon as the current stack count is below the maximum. The image below demonstrates the default recharge ability.

Default Blueprint Example

The DefaultStackRecharge ability waits for the stack attribute to change and applies the Recharge Effect if the current stack is below the maximum.

Recharge Gameplay Effect

The RGE is an archetype GE that increases the stack attribute associated with the skill by one. It has both duration and period equal to the desired recharge duration. Additionally, the “Execute periodic effect on application” option is set to false. In other words, the period acts as a delay, just like a cooldown. By setting the GE duration to the same value as the period, it becomes easy to calculate how much time is left for the modifier to apply.

Info

An Archetype Gameplay Effect is a standard blueprint Gameplay Effect created through the Editor that is later modified with C++ in run-time. This is necessary as, in C++, only Instant GameplayEffects can be created at runtime.

// We modify the GE before applying it. 
void UGameplayStackRecharger::ApplyRechargeEffect()
{
	// ...
	RechargeGE->DurationMagnitude = FScalableFloat(StackRechargeDuration);
	RechargeGE->Period = StackRechargeDuration;
	RechargeGE->bExecutePeriodicEffectOnApplication = false;
	// ...
}

Damage

Gameplay Damage Container

Inspired by Epic’s Action RPG Sample FGameplayEffectContainer, Damage Containers are the primary way to do damage using Skills. It saves the effort of manually creating the required GameplayEffectExecutionCalculations and setting the base damage multiplier, while also creating an easy way to pass damage to be applied on projectiles.

Most important, however, is handling the Damage Pipeline and abstracting it from game designers. As explained in more detail in the next subsection, the Damage Pipeline needs a DamageCalculationEffect and a DamageApplicationEffect, which is obtained by either passing both classes as a parameter when creating a DamageContainerSpec or simply using the ones assigned to the Skill.

Damage Container BP Functions

Damage Pipeline

The Damage Pipeline is the sequence of steps necessary to inflict damage. The need for a more complex system, in contrast to how Unreal’s Lyra Sample Project handles damage, comes from the nature of the flexible modifiers in Risk of Rain.

While the BaseDamage attribute is associated with the character and only ever changes when leveling up, the TotalDamage attribute is the final value used to inflict damage and is altered by an arbitrary number of items and/or passive skills. For this reason, unlike Lyra, the modifiers are not hardcoded in the GameplayEffectExecutionCalculation and are instead added in response to the Event.BeforeDamage event, after the DamageCalculationEffect is applied.

This enables designers to modify the TotalDamage attribute before the final calculation, based on the target’s information, such as its position, health percentage, animation state, and so on. See Bandit’s Passive for a practical example. The following is a chart to illustrate how the Damage Pipeline works:

Damage Pipeline

In summary:

  1. The Damage Pipeline begins when a DamageContainer is applied.
  2. The DamageCalculationEffect is applied to the instigator. Its job is to calculate the TotalDamage based on the BaseDamage, compute whether the damage is a critical hit, and fire the BeforeDamage event.
  3. Passive skills and items react to the BeforeDamage event, modifying the TotalDamage.
  4. The DamageExecutionffect is applied to the target. Its job is to modify the target’s shield and health based on the instigator’s TotalDamage and the target’s armor. Also fires the Hit event.
  5. On-hit Passive skills and items react to the On-Hit event.

The Skills section contains lots of examples of how to use the Damage Container, while the Item proccing section demonstrates some on-hit reactions.

Items

Items are collectibles that grant special GameplayEffects to characters. In this project, items are DataAssets with a name, description, icon, and most importantly, a GameplayAbility. Creating new items is as easy as creating an Item Data Asset and filling in its fields.

Ukelele Data Asset

Inventory

For players and monsters to keep track of and receive the effect of items they must possess an InventoryComponent. The inventory system for this project is pretty standard. Every controller has an InventoryComponent that can be interacted with by an Interactable interface. A pickup object is a common example of this.

Imgur

Example of an Item Pickup.

Every item added to the inventory becomes an ItemEntry, which keeps a reference to the data asset, the GameplayAbilitySpec and the stack count. This is important, as we’ll see in the next subsection. Other systems, such as the UI, can subscribe to inventory events containing all the information available by ItemEntries.

Inventory UI

Inventory UI

Item Stacking

If a player acquires a copy of a previously owned item, that item count increases in their inventory. This effect is known as stacking, and most items have one or two variables that increase with stacking. So, instead of adding multiple ItemEntries of the same item, we only need to change the stack field.

Item Example

Item stacking example from RoR2 Wiki. Each stack increases the chance to apply.

Since increasing the item power by stack is essentially leveling it up, I decide to use the built-in ability level in GAS. By default, you can change the level of abilities by either assigning a new level to the GameplayAbilitySpec or by removing the ability and re-granting it at the desired level. To streamline this process, the method SetAbilityLevel is available on the custom ASC class.

void UASAbilitySystemComponent::SetAbilityLevel(FGameplayAbilitySpec& Spec, int32 NewLevel)
{
	if (!HasAuthorityOrPredictionKey(&Spec.ActivationInfo))
	{
		return;
	}
	
	Spec.Level = NewLevel;
	// Dirty specs will be replicated to clients
	MarkAbilitySpecDirty(Spec);
	
	// If we're dealing with a passive ability, cancel it and activate it again with the new level
	const UASGameplayAbility* AbilityDef = Cast<UASGameplayAbility>(Spec.Ability);
	if (AbilityDef && AbilityDef->ActivateAbilityOnGranted)
	{
		CancelAbility(Spec.Ability);
		TryActivateAbility(Spec.Handle, true);
	}
}

Info

A Passive Ability is automatically activated when granted and runs continuously. Read more on GASDoc Passive Abilities.

By retrieving the ability level on an ability blueprint, designers can use it for a wide range of possibilities, like increasing the radius of an elemental explosion, the number of enemies affected by a debuff, or the damage dealt.

Stack Scaling

AtG Missile Mk. 1 total damage scaling.

Proccing

Many items in Risk of Rain have a “Chance to Trigger” that normally occurs whenever a player hits or kills an enemy. This can be easily modeled by reacting to an event and checking if the random probability is successful.

However, as a balance mechanism, each skill has a Proc Coefficient. The Proc coefficient scales an item’s trigger chance. So, for a fast-hitting skill, the proc coefficient will usually be low. As an example, if an item with a 20% trigger chance is activated by a skill with a proc coefficient of 0.5, the actual trigger chance is only 10%.

Fields

Example of an item that triggers on Event.HitEnemy with a 20% trigger chance and a proc coefficient of 1.0.

Chains and Mask

Proc coefficients are also present on items. The reason for this is that items can also proc other items. When one or more items are triggered by one item, this is known as proc chain.

Note that an item can proc other items, which in turn can proc other items. This can lead to an extensively large chain. To combat that, each item can only be activated once by chain. This is known as proc mask.

Big Chain

Proc chain with mask example.

Imgur

Illustration of multiple proc chains with mask.

By using a DamageExecutionffect, items can do damage and trigger events such as Event.HitEnemy and Event.Kill. This means that items can activate other items, making proc chains work out of the box. However, to factor in the proc coefficients, we need a way to pass it as event data. And if we want to mask proc chains, we need to remember all the previous triggered items.

A solution to these problems is to create a custom GameplayEffectContext containing a list of GameplayAbilities. Here is an excellent article on creating and using your own GameplayEffectContext by KaosSpectrum.

/* ASGameplayEffectTypes.h */

// List of all previous triggered Abilities CDOs.
TArray<TWeakObjectPtr<UGameplayAbility>> AbilityCDOChain;

// Some of the relevant functions for proc chain and mask
TArray<UGameplayAbility*> GetAbilityChain() const;

virtual void AddAbilityToChain(UGameplayAbility* Ability);
virtual void AppendAbilityChain(const TArray<UGameplayAbility*> Abilities);

The GameplayAbility ShouldAbilityRespondToEvent function can now be overridden to check for both proc chance and proc mask with the new ability chain. The proc chance is a multiplication of the trigger chance with the last ability proc coefficient. The proc mask check fails if the current ability (item) is already on the ability chain.

Should Ability Respond to Event

Every item that uses proc chains and masks must be responsible to change its own context. Below is the SetContextAbilityChain function. It modifies the current GameplayEffectContext by adding the previous chain and the current ability to the ability chain list. The AppendPreviousChain and AddAbilityToChain are custom blueprint functions that utilize our custom GameplayEffectContext.

Set Chain

Item Abilities Breakdown

This section presents two distinct items, a passive shield and a proc damage missile, to show the general idea behind their abilities.

AtG Missile Mk. 1

“10% chance to fire a missile that deals 300% (+300% per stack) TOTAL damage.”

Demo

The AtG Missile Mk. 1 is an On-hit item that activates on an Event.HitEnemy trigger. The missile deals damage scaled by the total damage dealt, so it does massive damage if part of a big proc chain. The GE, which is a generic DamageExecutionffect with a Set by Caller magnitude, is passed to the missile projectile and applied when the actor hits an enemy.

Blueprint

Personal Shield Generator

“Gain a shield equal to 8% (+8% per stack) of your maximum health. Recharges outside of danger.”

Demo

The Personal Shield Generator is a passive item that grants the character two GameplayEffects. An instant one that overrides the Shield and MaxShield attributes, and an infinite one that slowly increases the Shield attribute over time.

Blueprint

The shield should only recharge when out of danger. This can be achieved by granting the character a “Recently Damaged” tag when hit, and adding an ongoing tag requirement of not having that tag to the shield regen GE.

Imgur

Melee System

There are several melee abilities in Risk of Rain. Its melee system is not designed to be precise animation-wide, but reliable and lenient. For this reason, the melee system presented here (inspired by Epic´s A Guided Tour of Gameplay Abilities) is designed around pre-defined hitboxes that are activated during an animation window.

In order to perform melee attacks, every character needs to implement the IMeleeAbilityInterface and possess a Melee Component. The Melee Component is responsible for knowing which hitboxes are active and performing the collision checks on them. If a check is successful, it sends a GameplayEvent so that Melee Skills can react.

void UMeleeComponent::PerformMelee()
{
	for (FMeleeCollisionData* HitBox : HitBoxes)
	{
		TArray<FHitResult> HitResults;
		SweepMultiMelee(HitBox, HitResults);
		
		for (const FHitResult& HitResult : HitResults)
		{
			AActor* ActorHit = HitResult.GetActor();

			// Use HitActors as a TSet for O(log N) retrieval
			if (!HitActors.Contains(ActorHit))
			{
				HitActors.Add(ActorHit);
				
				SendMeleeHitEvent(HitResult, ActorHit);
			}
		}
	}
}

Melee Component

The Melee Component needs a DataTable containing the available hitboxes and a Hit Tag to send the hit GameplayEvent.

The hitboxes are stored in a DataTable, with each entry containing the Row Name, the Location Offset from the character’s position, and the hitbox sphere Radius.

Hitbox DataTable

As all melee skills derive from GS_MeleeBase, designers only need to specify the animation montage and the damage dealt by the skill. The idea is to play the animation montage and wait for melee hit events, extract the Hit information and apply a Damage Container. See the Serrated Dagger skill for an example.

Base Melee GA

GS_MeleeBase blueprint.

In the animation montage view, designers can represent the hitboxes' active frames using the NotifyStateHitboxWindow. Each NotifyStateHitboxWindow has a list of names as a parameter, so multiple hitboxes can be active at a time.

Melee Animation

Animation

Active hitboxes during Bandit’s Serrated Dagger melee skill.

The NotifyStateHitboxWindow is a NotifyState that calls the melee interface on the owner with the hitbox names. It registers when it begins and unregisters when it ends.

Hitbox Start

Enemies Hit

Skills Breakdown

This section presents a quick breakdown of all skills of the Bandit and Engineer heroes.

Bandit

Passive - Backstab

“All attacks from behind are Critical Strikes.”

The backstab skill is a great example of using the flexibility of the Damage Pipeline. By reacting to the BeforeDamage event, designers can freely modify the EffectContext and character attributes before the damage is actually applied. If just a GameplayEffectExecution was used instead, this behavior would need to be hardcoded.

Imgur

Primary - Burst

“Fire a burst for 5x100% damage. Can hold up to 4 shells.”

Demo

The Burst skill is a simple hitscan ability that fires multiple rounds. What set it apart is its unique Reload mechanic.

Imgur

Instead of using the default GSR, it uses a custom blueprint that listens for stack changes and applies a GE_BurstReload when the stack decreases. However, after firing, there is a delay to start reloading again that depends on the number of bullets left.

Delay Reload Blueprint

The Bandit can’t reload while using other abilities (with the Smoke Bomb being an exception). This behavior is achieved by using Ongoing Tag Requirements.

Fire Delay Tags

Shoot Blueprint

Secondary - Serrated Dagger

“Slash for 360% damage. Critical Strikes also cause hemorrhaging”

Imgur

Hemorrhage: Deal 2000% base damage over 15s. Can stack.”

The Serrated Dagger is a melee skill that makes use of the Melee System. To create it, one has to create a new blueprint with the GS_MeleeBase as a parent and override the parameters appropriately:

Parameters

This is enough for most melee abilities, however, the skill also applies Hemorrhage if the hit is critical. To do this, we can listen to the HitEnemy event before calling the base ActivateAbility, and apply the effect by checking the Custom Context.

Blueprint

The hemorrhage GE uses a simpler version of the DamageExecutionffect that doesn’t consider the target’s armor. It does 2000% base damage over 15s.

Hemorrhage

Utility - Smoke Bomb

“Deal 200% damage, become invisible, then deal 200% damage again.”

Area Damage

The Smoke Bomb is a skill that does area damage, turns the character invisible and increases its speed momentarily. The area damage works just like the previous skills, but a sphere trace is used instead and multiple hits are passed into the Damage Container.

Blueprint

There are three steps involved to become invisible. First, a GE that grants the Invisible tag is applied. The character then unregisters itself from the AIPerceptionSystem. Finally, the skill spawns an invisible dummy character that acts as a decoy, following the Bandit every 2s.

Invisibility

Invisibility: Prevents enemies from targeting you. Pings your location every 2s.”

Special - Lights Out

“Fire a revolver shot for 600% damage. Kills reset all your cooldowns.”

Imgur

The Lights Out is a single-bullet hitscan ability with a unique mechanic. If it kills an enemy on hit, all Bandit’s skills have their cooldown (stacks) reset. There are a couple of ways of achieving this mechanic. An easy one is to listen for the Kill event just before applying the Damage Container, then apply a reset GE.

Blueprint

Engineer

Primary - Bouncing Grenades

“Charge up to 8 grenades that deal 100% damage each.”

Demo

The Bouncing Grenades is a projectile-based skill with a small area of damage. The skill blueprint largely consists of the spawning logic, similar to the AtG Missile Mk 1. However, as a skill, the Damage Container needs to be passed to the projectile. Below is an example of applying an external Damage Container.

Imgur

Secondary - Pressure Mines

“Place a two-stage mine that deals 300% damage, or 900% damage if fully armed. Can place up to 4.”

Demo

The Pressure Mines is a projectile-based skill similar to Bouncing Grenades. The big difference is in the projectile logic and, as we’ll discuss, the number of projectiles that can be active at a time.

To limit the number of active mines we first need to store all the mines the skill spawned. For every last mine that exceeds the limit, we call a generic Deactivate function from a GameplayAbilitySpawnable interface and remove it from the data structure. Essentially a LIFO (Last In First Out).

Limit Blueprint

This is not sufficient, however, as the mines can explode before having a chance to leave the data structure. We can safely remove it by listening to OnDestroy delegate. All of this can be abstracted into a base blueprint GS_BaseProjectileLimit so designers can easily create skills with this mechanic.

Remove on Destroy

Utility - Bubble Shield

“Place an impenetrable shield that blocks all incoming damage.”

The Bubble Shield is a simple spawn skill. It spawns an actor with a sphere mesh that uses complex collision, thus allowing for the collision to be one-sided.

Demo

Special - TR12 Gauss Auto-Turret

“Place a turret that inherits all your items. Fires a cannon for 100% damage. Can place up to 2.”

Demo

The TR12 Gauss Auto-Turret skill spawns an AI character that inherits the owner’s inventory. It uses the WaitTargetData AbilityTask with a custom TargetActor to determine whether the placement is valid or not.

The custom TargetActor is a GATargetActor_Placement C++ class, which does a couple of traces based on parameters such as the collision height, radius, distance from the actor and max slope angle. By using the confirmation type User Confirmed, we can run the collision check on Tick and use the result on the IsConfirmTargetingAllowed method. If it is successful, the TargetActor waits for the confirmation input from the user.

Imgur

Below is an example of targeting. The user can only confirm the action if the turret is green.

Placement

The turret actor is a character just like the Engineer, with its own set of attributes, skill set, abilities and tags. As the turret has an inventory too, the skill passes the owner inventory as a spawn parameter.

Turret

AI

Designers can use the flexibility of GameplayAbilities together with Behavior Trees to create complex tasks for AI characters. GameplayTags are particularly useful for triggering context-specific abilities. One straightforward example is to activate skills based on their slot (Primary, Secondary, etc.).

BT

This BehaviorTreeTask retrieves the ASC from the AI character and tries to activate an ability using the skill slot selected by the designer:

Skill by Slot Blueprint

BTT_UseSkillByType blueprint.

It’s also easy to activate skills based on other properties such as distance, like triggering a melee skill if close or a random ranged one if far away. If the AI gets a debuff, using a skill that clears it is possible by using GameplayTags.

While some skills are partially character specific, many are not. This means that AI can use skills created for player controlled characters. AI characters can even use items without any necessary adjustments. Here’s an example of an AI character using Engineer’s Bouncing Grenades, as well as the Atg Missile Mk 1 item.

Grenade with Item


This is it! If you have questions, need help figuring something out, or have a suggestion, please feel free to comment below 😃. Unreal’s official Discord Server, Unreal Slackers, has an amazing channel focused on GAS. Check it out!