← Back to blog
Game Development Published on Jul 21, 2026 9 min read

Setting up a combat system with GAS

When I think about combat architecture, the Gameplay Ability System (GAS) is the framework that comes to mind. In this article, I describe some of the challenges I faced while setting up GAS for Oathbreakers.

  • Unreal Engine 5
  • C++
  • Gameplay Ability System
  • Gameplay Architecture
  • Multiplayer
Afonso Pordeus Unreal Engine, C++, and game development studies.
Warrior facing a burning fortress beside the title GAS Gameplay Ability System.
Game Development
In this study11 sections

When I started developing the combat system for Oathbreakers, one of the first important decisions was how to organize the abilities, attributes, and effects applied to the characters.

Attacks, dodges, blocks, parries, stamina costs, damage, posture, and temporary states are all part of the same combat system, but they have different responsibilities. Implementing all these mechanics directly in the character classes would quickly create dependencies that would be difficult to maintain.

For that reason, I decided to use the Gameplay Ability System, commonly known as GAS.

GAS is a framework provided by Unreal Engine for building abilities, attributes, and gameplay effects. It is mainly used in games with complex combat systems, multiplayer, temporary effects, different states, and a large number of interactions between characters.

However, starting to use GAS does not simply mean creating an ability and activating it.

Before implementing the mechanics, it is necessary to decide where the system will be stored, how it will be initialized, and which responsibilities will belong to players, enemies, and bosses.

In this article, I present some of these decisions and the challenges I encountered while setting up the combat system for Oathbreakers.

What is GAS?

The Gameplay Ability System is more than an ability system.

It is a set of structures that work together to organize different parts of gameplay. Its main elements include:

  • AbilitySystemComponent;
  • GameplayAbility;
  • GameplayEffect;
  • AttributeSet;
  • GameplayTag;
  • GameplayCue.

Each of these structures has a specific responsibility.

The AbilitySystemComponent, or ASC, acts as the center of the system. It stores abilities, active effects, tags, and the information required for a character to participate in GAS.

A GameplayAbility represents an action that can be performed, such as an attack, a dodge, a spell, or a block.

A GameplayEffect changes values or states. It can deal damage, consume stamina, increase movement speed, apply a temporary tag, or permanently modify an attribute.

AttributeSets store the attributes used by the system, such as health, stamina, posture, defense, and attack power.

GameplayTags represent states and identifiers. For example, they can indicate that a character is attacking, blocking, stunned, or unable to use a particular ability.

Finally, GameplayCues are mainly used to represent the visual and audio consequences of gameplay effects, such as particles, sounds, impacts, and temporary changes in appearance.

A common flow can be represented as follows:

Input

AbilitySystemComponent

GameplayAbility

GameplayEffect

Attribute

GameplayCue

This complete sequence does not need to occur in every ability. One ability may not modify attributes, while another may not use a Gameplay Cue.

The main idea is that each layer is responsible for one part of the behavior.

The input requests an action. The ability controls its execution. The effect represents the gameplay change. The attribute stores the value, and the Gameplay Cue communicates the result visually.

This separation was one of the main reasons I chose GAS for Oathbreakers.

Defining the mechanics of Oathbreakers

Oathbreakers is an isometric action RPG focused on combat against enemies and bosses, with support for cooperative play.

From the beginning, the combat system was designed around mechanics such as:

  • light and heavy attacks;
  • dodging;
  • sprinting;
  • blocking;
  • parrying;
  • stamina consumption and regeneration;
  • a posture system;
  • damage and resistances;
  • boss-specific abilities;
  • temporary effects;
  • authoritative multiplayer.

Even though some of these mechanics are still at different stages of development, they need to share the same foundation.

An attack, for example, may have an animation, a stamina cost, a hit window, damage application, and a visual effect. A dodge may consume stamina, temporarily change the character’s state, and block certain abilities while it is running.

With GAS, these behaviors can be divided into stages.

An attack ability can:

  1. verify that the character has enough stamina;
  2. block incompatible abilities;
  3. play an animation;
  4. open an attack window;
  5. apply a damage Gameplay Effect;
  6. finish the ability;
  7. remove the tags used during execution.

This structure prevents the character class from having to control the entire mechanic directly.

Before implementing these abilities, however, I encountered a more fundamental problem: players, enemies, and bosses did not initialize the Ability System in the same way.

Why PlayerState changes the architecture

In Oathbreakers, every combat character derives from a base class.

This hierarchy allows them to share common behavior, such as:

  • access to attributes;
  • damage application;
  • death states;
  • interaction with the ability system;
  • information required by the UI;
  • combat-related components.

However, the AbilitySystemComponent is not stored in the same place for every character.

For enemies and bosses, the ASC belongs to the Character itself.

For the player, it belongs to the PlayerState.

This difference exists mainly because of multiplayer and the character lifecycle.

The Character represents the body controlled by the player in the world. That body can be destroyed and recreated during a respawn, a character swap, or a transition between match states.

The PlayerState represents the player’s state within that session. It can continue to exist even when the controlled character is replaced.

By storing the ASC in the PlayerState, attributes, abilities, and effects can remain associated with the player when the Avatar changes.

This decision is useful for multiplayer, but it also completely changes how the system is initialized.

For an enemy, the character itself owns the component and executes the abilities.

For the player, the PlayerState owns the component, but the Character is the object that physically executes the abilities in the world.

This was the point where I needed to better understand the difference between the Owner Actor and the Avatar Actor.

Owner Actor and Avatar Actor

When initializing the Ability System, GAS needs to know about two important objects:

  • Owner Actor;
  • Avatar Actor.

The Owner Actor is the object that owns or represents the persistence of the Ability System.

The Avatar Actor is the object currently used to execute abilities in the world.

For an enemy, both roles normally belong to the Character itself:

Owner Actor: EnemyCharacter
Avatar Actor: EnemyCharacter

The initialization can be performed like this:

AbilitySystemComponent->InitAbilityActorInfo(EnemyCharacter, EnemyCharacter);

The first argument is the Owner, while the second is the Avatar.

For the player, the relationship is different:

Owner Actor: PlayerState
Avatar Actor: PlayerCharacter

The initialization needs to use both objects:

AbilitySystemComponent->InitAbilityActorInfo(PlayerState, PlayerCharacter);

In this case, the PlayerState keeps the system, while the Character acts as the body that executes the abilities.

This difference may look small when considering only the function call, but it has consequences for the entire architecture.

If the base class automatically initialized every character using Character, Character, it would configure NPCs correctly but configure the player incorrectly.

The responsibility of the base class

My first idea was to initialize the Ability System directly in the combat base class.

That seemed reasonable because every character uses GAS. The base class, however, did not have enough information to make that decision.

It knew that the character participated in the combat system, but it did not necessarily know which object should be the ASC’s Owner.

The solution was to separate two different responsibilities:

  1. configure the relationship between the ASC, Owner, and Avatar;
  2. execute the common steps after that configuration.

The base class contains shared functions such as:

virtual void InitializeAbilityActorInfo();

void InitializeDefaultAttributes();
void GrantStartupAbilities();
void BindAbilitySystemDelegates();

InitializeAbilityActorInfo can be overridden by derived classes because each character type knows its own structure.

The other steps can remain in the base class, as long as they are called only after the ASC has been initialized correctly.

This separation allows the base class to continue concentrating common behavior without assuming that every character has the same lifecycle.

It also avoids a situation where the player is first initialized as an NPC and then has its Ability Actor Info replaced.

Initializing NPCs

The initialization flow is more direct for enemies.

Because the ASC belongs to the Character itself, the character can use itself as both Owner and Avatar:

void AOathEnemyCharacter::InitializeAbilityActorInfo()
{
    AbilitySystemComponent->InitAbilityActorInfo(this, this);

    InitializeDefaultAttributes();
    GrantStartupAbilities();
    BindAbilitySystemDelegates();
}

The same model can be used for bosses when they also store the Ability System in the character itself.

In that case, enemies and bosses share the same structural configuration even if they have different abilities, attributes, and behaviors.

A boss can have:

  • exclusive abilities;
  • additional Attribute Sets;
  • combat phases;
  • specific posture mechanics;
  • arena-controlled states;
  • its own Gameplay Effects.

The location of the ASC does not determine which mechanics the character has. It only determines who keeps the system and which object is acting as the Avatar.

An evolution of this architecture that I noticed while studying the Lyra project would be to create an intermediate class for characters whose ASC belongs to the Character itself:

CombatCharacterBase
├── PlayerCharacter
└── AbilityCharacterBase
    ├── EnemyCharacter
    └── BossCharacter

I am still evaluating whether this layer would actually reduce complexity or simply increase the number of classes.

A new abstraction is useful only when it represents a real and recurring difference in the project.

Initializing players

Player initialization requires more care because the PlayerState may become available at different times on the server and the client.

On the server, the character can normally initialize the Ability System when it is possessed by the PlayerController.

On the client, the PlayerState needs to be replicated before the character can correctly access the ASC.

For this reason, different events may call the same initialization function.

The basic idea is:

void AOathPlayerCharacter::InitializeAbilityActorInfo()
{
    AOathPlayerState* OathPlayerState = GetPlayerState<AOathPlayerState>();

    if (!OathPlayerState) return; // Validate that PlayerState is available

    AbilitySystemComponent = OathPlayerState->GetAbilitySystemComponent(); // PlayerCharacter receives the ASC from PlayerState

    CombatAttributeSet = OathPlayerState->GetAttributeSet(); // The ASC can access the attributes; I may remove this getter later

    AbilitySystemComponent->InitAbilityActorInfo(OathPlayerState, this); // this = AOathPlayerCharacter

    InitializeDefaultAttributes(); // Initialize attributes using Gameplay Effects
    GrantStartupAbilities(); // Grant the default abilities
    BindAbilitySystemDelegates(); // Bind delegates after initialization
}

On the server, this function can be called during PossessedBy:

void AOathPlayerCharacter::PossessedBy(AController* NewController)
{
    Super::PossessedBy(NewController);

    InitializeAbilityActorInfo();
}

On the client, it can be called when the PlayerState is replicated:

void AOathPlayerCharacter::OnRep_PlayerState() // Called when PlayerState is replicated
{
    Super::OnRep_PlayerState();

    InitializeAbilityActorInfo();
}

These events represent different moments, but both try to prepare the same relationship between PlayerState, ASC, and Character.

Centralizing this process prevents the entire initialization logic from being duplicated across different lifecycle functions.

Avoiding duplicate initialization

When more than one event can trigger initialization, another issue appears: some operations cannot be performed repeatedly.

Updating the Ability Actor Info may be necessary when the Avatar changes. Granting the same abilities twice or binding the same delegate multiple times, however, can cause incorrect behavior.

For that reason, I started treating initialization not as a single operation but as multiple stages with different rules.

For example:

  • Ability Actor Info can be updated when necessary;
  • startup abilities should be granted only by the server;
  • initial attributes should not be reapplied unnecessarily;
  • delegates must avoid duplicate bindings;
  • widgets need to react when the ASC becomes available;
  • references to the Avatar need to be updated after respawns.

Granting abilities can use an authority check:

void AOathCombatCharacterBase::GrantStartupAbilities()
{
    if (!HasAuthority() || !AbilitySystemComponent)
    {
        return;
    }

    if (bStartupAbilitiesGranted)
    {
        return;
    }

    bStartupAbilitiesGranted = true;

    // Grant the startup abilities.
}

This flag is only one possible solution. Depending on the structure, it is also possible to check the existing abilities or use a specific class to represent ability sets.

The most important point is to define which operations are idempotent—in other words, which ones can run again without incorrectly changing the result.

This concern becomes even more important in multiplayer, where the server, client, replication, and respawn all participate in the character lifecycle.

Attribute Sets and startup abilities

The characters in Oathbreakers share several combat attributes.

These include:

  • health;
  • maximum health;
  • stamina;
  • maximum stamina;
  • stamina regeneration;
  • posture;
  • maximum posture;
  • posture regeneration;
  • attack power;
  • defense;
  • movement speed.

These attributes can exist in a common set used by players, enemies, and bosses.

Sharing a foundation, however, does not mean that every character needs to have exactly the same data.

The player may have specific resources related to UI, progression, or equipment. A boss may have attributes used by its phases or exclusive mechanics.

For that reason, I organized the system around different AttributeSets, such as:

CombatSet
HealthSet
BossSet

CombatSet stores data used by most characters.

The other sets exist only when a particular category actually needs them.

The same logic can be applied to startup abilities.

Every character needs a way to receive its abilities, but the list should not be hardcoded in the base class.

A player may receive:

GA_Player_AttackLight
GA_Player_Dodge
GA_Sprint
GA_Parry

A boss may receive:

GA_Boss_GroundSlam
GA_Boss_AttackSequence
GA_Boss_SpecialMechanic

The base class can know how to grant abilities, while each character or configuration defines which abilities should be granted.

This model makes it possible to share the process without mixing the content of each character type.

What I learned from this architecture

The main difficulty when starting with GAS was understanding that the system is not limited to Gameplay Abilities.

Before implementing attacks and effects, it is necessary to understand the ASC lifecycle and the relationship between its different participants.

Players, enemies, and bosses can use the same combat architecture, but that does not mean they need to be initialized in the same way.

The base class should share only what is genuinely common.

When a difference represents an important system decision, hiding it inside inheritance can make the code more confusing instead of simplifying it.

These are some rules I intend to maintain in the project:

  1. the class that knows the Owner and Avatar should configure the Ability Actor Info;
  2. the player’s ASC should remain in the PlayerState;
  3. NPCs can use their own Character as both Owner and Avatar;
  4. startup abilities should be granted only by the authority;
  5. each initialization stage must define whether it can be repeated;
  6. common Attribute Sets do not prevent specialized sets from existing;
  7. Gameplay Abilities should control actions, not store every rule of the character;
  8. Gameplay Effects should represent changes to states and attributes;
  9. Gameplay Tags should communicate states without creating direct dependencies between systems;
  10. visual effects should not be confused with authoritative combat logic.

GAS has a considerable learning curve because it requires understanding several layers at the same time.

However, this separation is also its main advantage.

When responsibilities are well defined, it becomes possible to add new mechanics without placing all behavior inside the character classes.

Next steps

The current setup can still evolve in several areas.

One of my next goals is to centralize startup abilities and effects in configuration structures.

Instead of keeping individual references directly in the character classes, each archetype could receive a set containing:

  • abilities;
  • startup Gameplay Effects;
  • related Attribute Sets;
  • ability levels;
  • additional tags.

This model is closer to the Ability Sets concept used in larger Unreal Engine projects.

I also want to improve communication between the Ability System and the UI.

At the moment, some Widget Controllers need to find the PlayerController, access the PlayerState, obtain the ASC, and register listeners for the attributes.

This flow works, but it creates dependencies between several classes and assumes that all these objects are already available.

A better architecture should allow the UI to explicitly receive the references it needs when the ASC is ready, without having to search for objects globally.

Another important point will be reviewing the respawn and Avatar replacement flow. Because the ASC remains in the PlayerState, the system needs to correctly update its references whenever a new Character is created.

The Gameplay Ability System does not remove the complexity of a combat system.

It provides tools to distribute that complexity among components with clearer responsibilities.

In Oathbreakers, this foundation will be important for continuing to implement attacks, parries, dodges, effects, bosses, and multiplayer without turning the character classes into a single block responsible for the entire game.

Continue exploring

Enjoyed this study?

Read other articles to follow my studies in Unreal Engine, C++, Blueprints, and gameplay programming.

View all articles →