TRAINING ADVISORY - CLEARANCE LEVEL: ASSOCIATE The following technical orientation covers the monitoring and performance evaluation systems installed aboard your assigned vessel. Salvage Solutions Inc. has invested significant resources in these systems to ensure your workplace experience is optimized, measured, and (where necessary) concluded efficiently. Please study carefully. S.A.L.L.I. will be watching. Helpfully.
- S.A.L.L.I.
Why This Post Exists
I wrote a previous post about why I chose GAS for Deep Haul. That post covers the philosophy and architecture decisions. This one is different. This is the step-by-step setup guide I wish existed when I started.
GAS documentation is scattered across Epic's source comments, community wikis, and forum threads that may or may not still apply. The initialization order matters. The module dependencies matter. Getting one step wrong produces errors that point you in the wrong direction entirely.
This guide covers the correct setup sequence for a multiplayer UE5 project using C++. I am writing it from the perspective of a solo developer building a 2-4 player co-op game, but the patterns apply to any team size.
Step 1: Module Dependencies
Before writing a single line of GAS code, your Build.cs needs three modules. Miss one and you will get linker errors that do not mention GAS at all.
// DeepHaul.Build.cs
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"GameplayAbilities", // ASC, Gameplay Effects, Gameplay Abilities
"GameplayTags", // FGameplayTag, tag containers
"GameplayTasks" // Ability tasks (wait for events, montages, etc.)
});
Common mistake: forgetting GameplayTasks. Your project will compile fine until you try to use any UAbilityTask subclass, at which point you get an unresolved external symbol error that mentions nothing about missing modules. Add all three from the start.
Step 2: Choose Where the ASC Lives
This is the most important architectural decision in your entire GAS setup. The Ability System Component needs an owner, and where you put it determines how your game handles respawns, possession, and replication.
Option A: On the Character (simple, single-player)
The ASC lives on your Character class. When the Character is destroyed, the ASC and all active effects go with it. This is fine for single-player games or prototypes where you do not care about persisting state across respawns.
Option B: On the PlayerState (multiplayer, recommended)
The ASC lives on APlayerState. The Character becomes the "avatar" - the physical representation that the ASC drives. When the Character dies and respawns, the ASC survives because PlayerState persists for the duration of the player's connection.
For Deep Haul, I use Option B. Here is the PlayerState constructor:
// DeepHaulPlayerState.h
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Abilities")
TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;
UPROPERTY()
TObjectPtr<UDeepHaulAttributeSet> AttributeSet;
// DeepHaulPlayerState.cpp
ADeepHaulPlayerState::ADeepHaulPlayerState()
{
AbilitySystemComponent = CreateDefaultSubobject<UAbilitySystemComponent>(
TEXT("AbilitySystemComponent"));
AbilitySystemComponent->SetIsReplicated(true);
AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);
AttributeSet = CreateDefaultSubobject<UDeepHaulAttributeSet>(
TEXT("AttributeSet"));
}
The Mixed replication mode is the standard choice for multiplayer. Gameplay Effects replicate to the owning client in full detail (so the local player sees accurate attribute values), while other clients receive minimal data (just tags and cues). This saves bandwidth without sacrificing local accuracy.
Common mistake: using EGameplayEffectReplicationMode::Full because it sounds correct. Full replicates everything to every client. In a 4-player game that means 4x the GAS replication traffic for data most clients do not need. Use Mixed.
Step 3: The IAbilitySystemInterface
Any actor that owns or exposes an ASC must implement IAbilitySystemInterface. This is how the engine finds the ASC when it needs one. Both your PlayerState AND your Character need this.
// DeepHaulPlayerState.h
class ADeepHaulPlayerState : public APlayerState, public IAbilitySystemInterface
{
// ...
virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override
{
return AbilitySystemComponent;
}
};
// DeepHaulCharacter.h
class ADeepHaulCharacter : public ACharacter, public IAbilitySystemInterface
{
// ...
virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override;
};
// DeepHaulCharacter.cpp
UAbilitySystemComponent* ADeepHaulCharacter::GetAbilitySystemComponent() const
{
const ADeepHaulPlayerState* PS = GetPlayerState<ADeepHaulPlayerState>();
return PS ? PS->GetAbilitySystemComponent() : nullptr;
}
The Character's implementation reaches through to the PlayerState. This way, any system that calls UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(SomeActor) gets the right ASC regardless of whether it passed the Character or the PlayerState.
Common mistake: only implementing the interface on the PlayerState. Then any code that has a reference to the Character (which is most gameplay code) cannot find the ASC. Both classes need it.
Step 4: The Initialization Order (Where Everyone Gets It Wrong)
This is the step that causes the most grief. The ASC needs to know two things: its owner (the actor responsible for it) and its avatar (the physical pawn it drives). You tell it both by calling InitAbilityActorInfo.
The problem: this call must happen at exactly the right time, and that time is different on server vs. client.
// DeepHaulCharacter.cpp
// SERVER: Called when the controller possesses this pawn
void ADeepHaulCharacter::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
ADeepHaulPlayerState* PS = GetPlayerState<ADeepHaulPlayerState>();
if (PS && PS->GetAbilitySystemComponent())
{
// Owner = PlayerState, Avatar = this Character
PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this);
}
}
// CLIENT: Called when PlayerState replicates to this client
void ADeepHaulCharacter::OnRep_PlayerState()
{
Super::OnRep_PlayerState();
ADeepHaulPlayerState* PS = GetPlayerState<ADeepHaulPlayerState>();
if (PS && PS->GetAbilitySystemComponent())
{
PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this);
}
}
Why two places? On the server, PossessedBy fires when the controller takes ownership of the pawn. The PlayerState already exists at this point because the server created it. On the client, PossessedBy does NOT fire (the server did the possessing). Instead, the client learns about its PlayerState when it replicates, which triggers OnRep_PlayerState.
If you only initialize in PossessedBy, the server works but clients have a null avatar. Abilities will fail to activate on clients with cryptic "ability failed to activate" logs that never mention initialization.
If you only initialize in OnRep_PlayerState, the client works but the server never sets the avatar because OnRep only fires on clients.
You need both.
Common mistake: initializing in BeginPlay. The PlayerState may not have replicated to the client yet when BeginPlay fires. This produces a race condition that works 90% of the time in PIE testing and fails 40% of the time in packaged builds with real network latency. Do not use BeginPlay for ASC initialization.
Step 5: Build Your AttributeSet
Attributes are the numeric values that GAS manages: health, stamina, oxygen, speed multipliers. Each attribute is a FGameplayAttributeData property with the ATTRIBUTE_ACCESSORS macro for standardized access.
// DeepHaulAttributeSet.h
UCLASS()
class UDeepHaulAttributeSet : public UAttributeSet
{
GENERATED_BODY()
public:
// --- Health ---
UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_Health, Category = "Health")
FGameplayAttributeData Health;
ATTRIBUTE_ACCESSORS(UDeepHaulAttributeSet, Health)
UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_MaxHealth, Category = "Health")
FGameplayAttributeData MaxHealth;
ATTRIBUTE_ACCESSORS(UDeepHaulAttributeSet, MaxHealth)
// Replication
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
protected:
virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) override;
virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override;
UFUNCTION()
void OnRep_Health(const FGameplayAttributeData& OldHealth);
UFUNCTION()
void OnRep_MaxHealth(const FGameplayAttributeData& OldMaxHealth);
};
Every replicated attribute needs an OnRep function. Inside the OnRep, call GAMEPLAYATTRIBUTE_REPNOTIFY so the ASC knows the attribute changed on this client:
void UDeepHaulAttributeSet::OnRep_Health(const FGameplayAttributeData& OldHealth)
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UDeepHaulAttributeSet, Health, OldHealth);
}
And register them for replication:
void UDeepHaulAttributeSet::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME_CONDITION_NOTIFY(UDeepHaulAttributeSet, Health, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UDeepHaulAttributeSet, MaxHealth, COND_None, REPNOTIFY_Always);
}
REPNOTIFY_Always means the OnRep fires even when the replicated value has not changed. This matters when the server clamps a value back to the same number - the client still needs to know the ASC processed something.
Two-Layer Clamping
You need clamping in two places, and they serve different purposes:
void UDeepHaulAttributeSet::PreAttributeChange(
const FGameplayAttribute& Attribute, float& NewValue)
{
Super::PreAttributeChange(Attribute, NewValue);
if (Attribute == GetHealthAttribute())
NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxHealth());
}
void UDeepHaulAttributeSet::PostGameplayEffectExecute(
const FGameplayEffectModCallbackData& Data)
{
Super::PostGameplayEffectExecute(Data);
if (Data.EvaluatedData.Attribute == GetHealthAttribute())
SetHealth(FMath::Clamp(GetHealth(), 0.0f, GetMaxHealth()));
}
PreAttributeChange clamps the queried value - what you get when you call GetHealth(). But it does NOT clamp the internal base value. If a regen effect keeps ticking past max, the base value drifts above the cap. Then damage has to burn through invisible overflow health before the bar moves. PostGameplayEffectExecute clamps the actual base value after every effect execution.
I lost a full afternoon to this bug in Deep Haul before I understood the distinction. Clamp in both places.
Step 6: Initialize Default Attributes
Attributes start at zero unless you initialize them. The cleanest approach is a UGameplayEffect that sets starting values via Instant modifiers with Override operation:
// Blueprint: GE_DefaultAttributes (Gameplay Effect)
Duration Policy: Instant
Modifiers:
- Attribute: Health | Operation: Override | Value: 100.0
- Attribute: MaxHealth | Operation: Override | Value: 100.0
- Attribute: Oxygen | Operation: Override | Value: 180.0
- Attribute: MaxOxygen | Operation: Override | Value: 180.0
Apply this effect once on the server after InitAbilityActorInfo:
// In PossessedBy, after InitAbilityActorInfo
if (HasAuthority() && DefaultAttributeEffect)
{
FGameplayEffectContextHandle Context = ASC->MakeEffectContext();
Context.AddSourceObject(this);
FGameplayEffectSpecHandle Spec = ASC->MakeOutgoingSpec(
DefaultAttributeEffect, 1, Context);
ASC->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());
}
Why a Gameplay Effect instead of hardcoded values in the constructor? Because GEs are data assets. Designers can adjust starting health, oxygen capacity, or any other attribute in the editor without recompiling. Different character classes or difficulty modes can use different initialization GEs.
Step 7: Grant Default Abilities
Abilities are granted to the ASC at runtime, not assigned in a constructor. In the same PossessedBy flow, after initializing attributes:
// Server only: grant starting abilities
if (HasAuthority() && !bAbilitiesGranted)
{
for (const TSubclassOf<UGameplayAbility>& AbilityClass : DefaultAbilities)
{
FGameplayAbilitySpec Spec(AbilityClass, 1, INDEX_NONE, this);
ASC->GiveAbility(Spec);
}
bAbilitiesGranted = true;
}
DefaultAbilities is a TArray<TSubclassOf<UGameplayAbility>> exposed to the editor. For Deep Haul, every player starts with Sprint, Interact, and UseItem.
The bAbilitiesGranted guard prevents double-granting if PossessedBy fires more than once (which happens during seamless travel or repossession after respawn).
Common mistake: granting abilities on the client. Abilities should only be granted on the server. GAS replicates granted abilities to clients automatically. If you grant on both server and client, you get duplicate ability specs and activation becomes unreliable.
Step 8: Gameplay Tags Setup
Tags are the shared vocabulary that lets GAS, AI, animation, audio, and UI communicate without direct references. Define them in DefaultGameplayTags.ini or via a UDataTable with FGameplayTagTableRow.
Deep Haul uses 173 tags across these namespaces:
// DefaultGameplayTags.ini (abbreviated)
[/Script/GameplayTags.GameplayTagsSettings]
+GameplayTagList=(Tag="State.Alive",DevComment="Player is alive")
+GameplayTagList=(Tag="State.Dead",DevComment="Player has experienced career conclusion")
+GameplayTagList=(Tag="State.Suffocating",DevComment="Oxygen at zero")
+GameplayTagList=(Tag="Effect.Sprint",DevComment="Currently sprinting")
+GameplayTagList=(Tag="Effect.OxygenDrain",DevComment="Passive O2 drain active")
+GameplayTagList=(Tag="Cooldown.ItemUse",DevComment="Global item cooldown")
The critical mindset shift: tags replace booleans. Instead of bool bIsSprinting that you manually flip and replicate, the Sprint Gameplay Effect grants the Effect.Sprint tag. Any system can query or subscribe to that tag. Animation, audio, UI - they all listen for tags instead of polling booleans.
// Subscribe to a tag change (e.g., in AnimInstance)
ASC->RegisterGameplayTagEvent(
FGameplayTag::RequestGameplayTag(FName("State.Suffocating")),
EGameplayTagEventType::NewOrRemoved
).AddUObject(this, &UDeepHaulAnimInstance::OnSuffocatingChanged);
This fires exactly when the tag appears or disappears. No tick. No polling. No manual state sync. The tag IS the state.
Step 9: Your First Gameplay Effect
Create a Blueprint class derived from UGameplayEffect. This is a data asset, not an actor. It defines what happens to attributes when the effect is active.
Example: a Sprint effect that increases movement speed and oxygen drain.
// Blueprint: GE_Sprint
Duration Policy: Has Duration (Infinite)
Modifiers:
- Attribute: MovementSpeedMultiplier | Operation: Additive | Value: +0.5
- Attribute: OxygenDrainRate | Operation: Additive | Value: +0.5
Granted Tags:
- Effect.Sprint
Application Tag Requirements:
- Must NOT have: State.Dead
Infinite duration means the effect persists until explicitly removed. The additive operation stacks with other modifiers on the same attribute. Tag requirements prevent the effect from being applied to dead players.
That is the entire Sprint speed and oxygen penalty. No C++ math. No conditional logic. Data-driven configuration.
Step 10: Your First Gameplay Ability
Create a C++ class derived from UGameplayAbility (or your project's base ability class). The ability is the logic that decides when and how to apply effects.
// GA_Sprint.h
UCLASS()
class UGA_Sprint : public UDeepHaulGameplayAbility
{
GENERATED_BODY()
public:
UGA_Sprint();
virtual void ActivateAbility(...) override;
virtual void EndAbility(...) override;
protected:
UPROPERTY(EditDefaultsOnly, Category = "Sprint")
TSubclassOf<UGameplayEffect> SprintEffect;
FActiveGameplayEffectHandle ActiveSprintEffectHandle;
};
// GA_Sprint.cpp
UGA_Sprint::UGA_Sprint()
{
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
ActivationBlockedTags.AddTag(
FGameplayTag::RequestGameplayTag(FName("State.Dead")));
}
void UGA_Sprint::ActivateAbility(...)
{
if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
return;
}
FGameplayEffectSpecHandle Spec =
MakeOutgoingGameplayEffectSpec(SprintEffect, GetAbilityLevel());
ActiveSprintEffectHandle =
ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, Spec);
}
void UGA_Sprint::EndAbility(...)
{
if (ActorInfo && ActorInfo->IsNetAuthority())
{
UAbilitySystemComponent* ASC = ActorInfo->AbilitySystemComponent.Get();
if (ASC)
{
ASC->RemoveActiveGameplayEffect(ActiveSprintEffectHandle);
}
}
ActiveSprintEffectHandle.Invalidate();
Super::EndAbility(Handle, ActorInfo, ActivationInfo,
bReplicateEndAbility, bWasCancelled);
}
LocalPredicted means the client activates the ability immediately (no round-trip wait) and the server confirms. If the server rejects it, GAS rolls back the prediction. This is critical for responsive game feel in multiplayer.
The C++ is a "dumb applicator" - it applies the effect on activation and removes it on end. The effect defines what actually changes. This pattern scales to every ability in your game.
The Complete Initialization Order
Here is the full sequence, because getting it wrong in any order produces bugs that surface days later:
- Build.cs: Add
GameplayAbilities,GameplayTags,GameplayTasksmodules - PlayerState constructor: Create ASC (
SetIsReplicated(true),Mixedmode) and AttributeSet as default subobjects - IAbilitySystemInterface: Implement on both PlayerState and Character
- PossessedBy (server): Call
InitAbilityActorInfo(PlayerState, Character) - OnRep_PlayerState (client): Call
InitAbilityActorInfo(PlayerState, Character) - After init (server only): Apply default attribute GE
- After init (server only): Grant default abilities
- Gameplay Tags: Define in
DefaultGameplayTags.ini - Gameplay Effects: Create as Blueprint data assets
- Gameplay Abilities: Create in C++ or Blueprint, granted at runtime
Every step depends on the previous ones. If you skip step 4/5, effects will apply but the ASC will not know which pawn to drive. If you skip step 3 on the Character, Blueprint code calling GetAbilitySystemComponent on the Character will get null.
Mistakes That Will Cost You Days
I am listing these because each one cost me real debugging time during Deep Haul development:
1. Initializing in BeginPlay instead of PossessedBy/OnRep_PlayerState. Works in PIE, fails in packaged builds. The PlayerState has not replicated yet when the client's BeginPlay fires. You get null ASC on clients 30-40% of the time depending on network conditions.
2. Granting abilities on both server and client. You end up with duplicate ability specs. Activation becomes unpredictable because GAS picks one of the duplicates and it might not be the one with the right prediction key.
3. Only clamping in PreAttributeChange. Base values drift above max. Damage appears to do nothing because it is eating through invisible overflow. Clamp in both PreAttributeChange AND PostGameplayEffectExecute.
4. Using Full replication mode in multiplayer. Works, but replicates every effect detail to every client. In a 4-player game, you are sending 4x the GAS data. Mixed gives owning clients full detail and other clients minimal data.
5. Manually tracking effect state in C++. If you have a counter in C++ that shadows what GAS tracks internally, delete it. Query the ASC instead. Your manual tracking WILL desync from GAS's internal state, and the bugs are subtle and intermittent. Let GAS be the source of truth.
6. Snapshotting attributes in custom calculations. If your UGameplayModMagnitudeCalculation captures an attribute with bSnapshot = true, it reads the value at the moment the effect was applied, not the current value. For composable systems where multiple effects modify the same attribute, use bSnapshot = false so the calculation always reads the live value.
7. Forgetting GAMEPLAYATTRIBUTE_REPNOTIFY in OnRep functions. Without it, the ASC on the client does not know the attribute changed via replication. Delegates will not fire. UI will not update. The value changes silently.
Where to Go From Here
Once this foundation is in place, building new systems becomes fast. A new environmental hazard is a Blueprint GE and a box collision. A new player ability is a C++ dumb applicator class and a Blueprint GE. A new status effect is just a GE with duration and tag grants.
Deep Haul runs nine attributes, 20+ Gameplay Effects, 173 tags, and a handful of abilities on this exact foundation. The GAS setup I described in this post has not changed since month one. Everything I have built since then is configuration on top of it.
If you are starting a new UE5 project and debating whether GAS is worth the learning curve: the setup cost is two days. The time it saves you is the entire rest of development. Get the initialization right, understand the clamping, use tags instead of booleans, and let GAS be the state machine.
The framework is smarter than your custom code. I know because I wrote the custom code first, and then I deleted it.
"Implementation training complete. Associates who followed the initialization sequence correctly have been awarded a provisional Competency Marker. Associates who skipped to the code samples have been flagged for supplementary training. Remember: reading documentation thoroughly is a workplace safety behavior. Salvage Solutions Inc. cannot be held liable for systems initialized in the wrong order."
- S.A.L.L.I.