INTERNAL TRAINING DOCUMENT - CLEARANCE LEVEL: ASSOCIATE The following materials describe the computational systems that monitor Associate vital signs, equipment usage, and productivity metrics. Understanding these systems is encouraged. Attempting to circumvent them is a violation of Section 9.4 of your Employment Agreement. Not that you could.
- S.A.L.L.I.
The Problem With Rolling Your Own
When I started building Deep Haul, I had a choice. Write custom C++ systems for health, oxygen, movement speed, inventory weight, item cooldowns, environmental hazards, and status effects. Or use Unreal's Gameplay Ability System (GAS) and let the engine handle state management, replication, and effect composition for me.
I chose GAS. And six months in, that decision has saved me from more bugs than I can count.
Deep Haul is a 2-4 player co-op extraction horror game. Players board derelict ships, scavenge valuable salvage, manage dwindling oxygen, avoid AI threats, and extract before time runs out. Every one of those systems touches shared state that needs to replicate correctly across a server and up to four clients. That is exactly the problem GAS was built to solve.
This post walks through how Deep Haul uses GAS, the architecture decisions that keep the codebase clean, and a philosophy I call the "dumb applicator" pattern that emerged from a real production bug.
ASC on PlayerState, Not Character
The first architectural decision: where does the Ability System Component (ASC) live?
In ADeepHaulPlayerState, the ASC and AttributeSet are created in the constructor:
// DeepHaulPlayerState.cpp
AbilitySystemComponent = CreateDefaultSubobject<UAbilitySystemComponent>(TEXT("AbilitySystemComponent"));
AbilitySystemComponent->SetIsReplicated(true);
AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);
AttributeSet = CreateDefaultSubobject<UDeepHaulAttributeSet>(TEXT("AttributeSet"));
Why PlayerState instead of the Character pawn? Three reasons:
Persistence across respawns. When a player dies(Ohh you will) and respawns in Deep Haul, the Character pawn is destroyed and a new one is created. If the ASC lived on the Character, all active Gameplay Effects, attribute modifiers, and granted abilities would be destroyed with it. On PlayerState, they survive.
Clean replication ownership. PlayerState is already replicated to all clients by the engine. The ASC piggybacks on that existing replication channel instead of adding a new one.
Avatar separation. The Character becomes the "avatar" of the ASC. When the player possesses a new pawn, we just call InitAbilityActorInfo(this, InCharacter) to re-link them. The ASC does not care which specific pawn it is driving.
The Mixed replication mode is important for multiplayer. The server owns the ASC, but abilities can activate client-side with prediction. When a player hits Sprint, the effect applies immediately on their screen while the server confirms. No waiting for a round trip.
Nine Attributes, Four Categories
UDeepHaulAttributeSet defines nine gameplay attributes across four categories. Every attribute uses the ATTRIBUTE_ACCESSORS macro for standardized getters, setters, and initializers.
| Attribute | Category | Default | Purpose |
|---|---|---|---|
| Oxygen | Oxygen | 180.0 | Seconds of breathable air in personal tank |
| MaxOxygen | Oxygen | 180.0 | Tank capacity (3-minute supply) |
| OxygenDrainRate | Oxygen | 1.0 | Multiplier, modified by effects |
| Health | Health | 100.0 | Standard HP |
| MaxHealth | Health | 100.0 | HP ceiling |
| MovementSpeedMultiplier | Movement | 1.0 | Applied to base walk speed |
| MaxQuickSlots | Inventory | 4 | Quick access slots |
| MaxBackpackSlots | Inventory | 12 | Backpack capacity |
| MaxCarryWeight | Inventory | 30.0 | Weight limit in kg |
Every attribute replicates with REPNOTIFY_Always, which means the OnRep callback fires even when the replicated value has not changed. This keeps client-side UI in sync during edge cases where the server reaffirms a clamped value.
Two-layer clamping prevents subtle bugs:
// Layer 1: PreAttributeChange - clamps queried (current) values
void UDeepHaulAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
if (Attribute == GetOxygenAttribute())
NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxOxygen());
if (Attribute == GetMovementSpeedMultiplierAttribute())
NewValue = FMath::Clamp(NewValue, 0.1f, 2.0f);
}
// Layer 2: PostGameplayEffectExecute - clamps base values after effect math
void UDeepHaulAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
if (Data.EvaluatedData.Attribute == GetHealthAttribute())
SetHealth(FMath::Clamp(GetHealth(), 0.0f, GetMaxHealth()));
}
Why both layers? PreAttributeChange only clamps the value you query. The base value underneath can still drift above the maximum if a periodic regen effect keeps ticking. Without PostGameplayEffectExecute, damage would need to eat through invisible overflow health before the bar visibly moved. I found this bug during playtesting and it took me a full afternoon to track down.
The Oxygen System: Composable by Design
Oxygen is one of Deep Haul's central tension mechanic. Your 3-minute tank drains constantly, and the drain rate changes based on what you are doing. The elegant part: a single periodic Gameplay Effect handles all oxygen drain, and it reads the current OxygenDrainRate attribute to determine how fast.
UOxygenDrainCalculation is a custom UGameplayModMagnitudeCalculation class:
// OxygenDrainCalculation.cpp
UOxygenDrainCalculation::UOxygenDrainCalculation()
{
OxygenDrainRateCapture.AttributeToCapture =
UDeepHaulAttributeSet::GetOxygenDrainRateAttribute();
OxygenDrainRateCapture.AttributeSource = EGameplayEffectAttributeCaptureSource::Source;
OxygenDrainRateCapture.bSnapshot = false; // Live value, not snapshot
RelevantAttributesToCapture.Add(OxygenDrainRateCapture);
}
float UOxygenDrainCalculation::CalculateBaseMagnitude_Implementation(
const FGameplayEffectSpec& Spec) const
{
float OxygenDrainRate = 1.0f;
GetCapturedAttributeMagnitude(OxygenDrainRateCapture, Spec,
EvaluationParameters, OxygenDrainRate);
return -OxygenDrainRate; // Negative = drain
}
The key detail is bSnapshot = false. This tells GAS to read the live value of OxygenDrainRate at each period tick, not a snapshot from when the effect was first applied. If a player starts sprinting mid-tick, the drain rate immediately adjusts.
Other Gameplay Effects modify OxygenDrainRate additively:
| Condition | OxygenDrainRate Modifier | Net Multiplier |
|---|---|---|
| Normal breathing | +0.0 (base) | 1.0x |
| Sprinting | +0.5 | 1.5x |
| Scared/Panicked | +0.25 | 1.25x |
| Breached area | +1.0 | 2.0x |
| Carrying heavy load | +0.25 | 1.25x |
These stack additively. A player sprinting through a breached area while carrying heavy salvage has a drain rate of 1.0 + 0.5 + 1.0 + 0.25 = 2.75x. Their 3-minute tank becomes a 65-second tank. No custom C++ code calculates this. GAS composes the modifiers automatically.
This is the power of GAS. I did not write an oxygen manager class. I did not write switch statements for "if sprinting AND in breach." I defined effects in Blueprint, set them to additive, and GAS handles the rest.
The "Dumb Applicator" Philosophy
Six months into development, I wrote the original ReactorMeltdown hazard system in C++. It manually tracked stack counts, stored effect handles in arrays, branched between first-vs-subsequent applications, managed stack caps, and cleaned up expired effects. About 70 lines of careful state management.
It shipped with a desync bug. The server and client disagreed on stack counts because my manual tracking drifted from GAS's internal state. Players would show radiation warnings on their screen but take no damage, or take damage with no visual feedback.
The fix was 15 lines. Apply the effect. Let GAS handle everything else.
From that bug, I wrote a rule in our codebase docs:
"C++ should be a dumb applicator. GAS is the state machine."
Here is the checklist every new system goes through:
- Can GAS handle this with Blueprint configuration? Stacking policies, stack limits, overflow effects, duration decay, tag requirements - these are all GE settings, not C++ logic.
- Am I duplicating GAS state in C++? If C++ has a counter that shadows what GAS already tracks, delete it. Query the ASC instead.
- Am I branching on first-vs-subsequent application? With
AggregateByTargetstacking, everyApplyGameplayEffectSpecToSelfcall is identical. GAS figures out the rest. - Am I manually capping or killing on max stacks? Use the GE's Stack Limit Count and Overflow Effects array.
- Am I writing cleanup code to remove effects? Check if GAS already cleans up on expiry.
The rule of thumb: if the C++ is longer than the GE Blueprint configuration, something is wrong.
GA_Sprint: The Clean Example
UGA_Sprint is the simplest ability in the codebase and the best example of the dumb applicator pattern. The entire C++ file is about 80 lines, and most of that is boilerplate.
// GA_Sprint.cpp - ActivateAbility
FGameplayEffectSpecHandle SpecHandle =
MakeOutgoingGameplayEffectSpec(SprintEffect, GetAbilityLevel());
ActiveSprintEffectHandle =
ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, SpecHandle);
That is the entire activation logic. One effect applied. The SprintEffect is a Blueprint GE that modifies MovementSpeedMultiplier (+0.5 additive) and OxygenDrainRate (+0.5 additive). When the player releases the sprint key:
// GA_Sprint.cpp - EndAbility (authority only)
if (ActorInfo->IsNetAuthority())
{
ASC->RemoveActiveGameplayEffect(ActiveSprintEffectHandle);
}
ActiveSprintEffectHandle.Invalidate();
The server removes the effect. GAS replicates the removal to clients. The client's predicted copy gets cleaned up automatically. The Character reads MovementSpeedMultiplier from an ASC delegate and applies it to MaxWalkSpeed. No manual speed math. No timers. No state booleans.
Sprint has two requirements: the player must be on the ground, and they cannot be dead. The CanActivateAbility override checks ground movement, and the base class UDeepHaulGameplayAbility blocks activation with the State.Dead tag automatically:
// DeepHaulGameplayAbility.cpp - constructor defaults
ActivationBlockedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("State.Dead")));
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
Every ability in the game inherits this. Dead players cannot activate any ability. One line in the base class, zero checks in child classes.
HazardVolume: Dumb Applicator in the Environment
The cleanest example of the dumb applicator pattern lives in the environment system. AHazardVolume is a base class for any area that applies effects to players who enter it.
The C++ does three things:
- Detect overlap (server-only:
if (!HasAuthority()) return;) - Apply a Gameplay Effect
- Store the handle so it can remove the effect on exit
That is it. The entire class:
// On enter (server only)
FGameplayEffectSpecHandle Spec = ASC->MakeOutgoingSpec(HazardEffect, 1, ASC->MakeEffectContext());
FActiveGameplayEffectHandle Handle = ASC->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());
ActiveEffects.Add(ASC, Handle);
// On exit (server only)
ASC->RemoveActiveGameplayEffect(ActiveEffects[ASC]);
ActiveEffects.Remove(ASC);
Child classes define what happens entirely in Blueprint GEs:
- VacuumVolume: Rapid O2 drain (OxygenDrainRate +2.0) plus entry decompression damage
- ToxicGasVolume: Periodic health damage
- FireHazard: Periodic damage plus light emission
New hazard types require zero C++. Create a child Blueprint, assign a different GE, done. A designer could build new environmental hazards without touching source code.
173 Gameplay Tags: The Shared Language
Deep Haul uses 173 gameplay tags defined in DefaultGameplayTags.ini. These tags are the shared vocabulary that lets GAS, AI, audio, UI, and animation systems communicate without direct coupling.
| Namespace | Count | Examples |
|---|---|---|
| State.* | 15 | Alive, Dead, Suffocating, Encumbered.Light, Carrying |
| Effect.* | 9 | OxygenDrain, Sprint, Bleeding, InExtractionZone |
| Area.* | 4 | Pressurized, Breached, Toxic, ExtractionZone |
| AI.State.* | 6 | Patrol, Investigate, Chase, Search, Return |
| Cooldown.* | 3 | ItemUse (global), Item.Crowbar, Item.Scanner |
| SALLI.* | 50+ | Narrated event triggers for every game situation |
| Noise.* | 7 | Footstep types, doors, items, voice, combat |
Tags replace booleans everywhere. Instead of bIsSprinting, the sprint GE grants the Effect.Sprint tag. The animation system registers a callback on that tag:
// In DeepHaulAnimInstance.cpp
ASC->RegisterGameplayTagEvent(
FGameplayTag::RequestGameplayTag("Effect.Sprint"),
EGameplayTagEventType::NewOrRemoved
).AddUObject(this, &UDeepHaulAnimInstance::OnSprintTagChanged);
When the tag appears, bIsSprinting flips to true. When it is removed, false. No polling. No manual state synchronization. The tag is the state, and any system can listen for changes.
S.A.L.L.I.'s dialogue triggers work the same way. When State.Suffocating appears, S.A.L.L.I. plays her oxygen warning line. When Mission.Extracting appears, she announces the extraction countdown. Over 50 S.A.L.L.I. tags drive the entire narration layer with zero direct function calls between systems.
The Item Ability System: Two-Tier Cooldowns
UDeepHaulItemAbility is the base class for all usable items. It introduces a two-tier cooldown system that prevents item spam without feeling sluggish:
Tier 1: Global cooldown (0.5s). After using any item, all items are blocked for half a second. This prevents frame-perfect item swapping exploits. The GE grants Cooldown.ItemUse.
Tier 2: Per-item cooldown. Individual items can define longer cooldowns on top of the global one. The crowbar has a 1-second per-item cooldown (Cooldown.Item.Crowbar). The scanner has its own cooldown tag.
Both tiers use GAS's built-in cooldown checking. CanActivateAbility checks ActivationBlockedTags (which includes the item's cooldown tag), and CommitAbility checks cooldown duration. Two safety nets, zero manual timer code.
The base class also handles charge consumption (server-only to prevent double-consumption in prediction), montage playback (multicast so all co-op partners see the animation), and the standard flow:
// DeepHaulItemAbility.cpp
void UDeepHaulItemAbility::ActivateAbility(...)
{
if (!CommitAbility(...)) { EndAbility(..., true); return; }
ExecuteItemEffect(); // Child class override
if (bConsumeChargeOnUse && ActorInfo->IsNetAuthority())
ConsumeCharge();
if (bEndAfterEffect)
EndAbility(..., false);
}
Child classes override ExecuteItemEffect() and set a few properties. The crowbar sets bConsumeChargeOnUse = false (unlimited tool) and bEndAfterEffect = false (it manages its own end after the swing animation). The O2 canister sets both to true (consume one charge, instant effect, done).
Multiplayer: Mixed Mode Replication
The full replication strategy breaks down like this:
| Data | Authority | Method |
|---|---|---|
| Oxygen, Health, all attributes | Server | GAS built-in (OnRep callbacks) |
| Ship O2 Reserve | Server | GameState replicated property |
| Company Timer | Server | GameState replicated property |
| Item Pickup/Drop | Server | Server RPC then Multicast |
| Character Movement | Client Predicted | CMC standard prediction |
| Abilities (Sprint, Interact) | Client Predicted | LocalPredicted policy |
| Economy (credits, debt) | Server | COND_OwnerOnly (private to each player) |
The Mixed ASC replication mode gives us the best of both worlds. The server is authoritative over all game state, but LocalPredicted abilities activate instantly on the client. When you press Sprint, you immediately speed up. The server confirms a few frames later. If the server disagrees (maybe you were stunned and did not know it yet), GAS rolls back the prediction automatically.
This is critical for game feel. In a horror game, input delay kills tension. If pressing Sprint had a 50ms round-trip delay, running from a Sentinel would feel sluggish. LocalPredicted makes it feel responsive while the server stays in control.
What GAS Saved Me From Writing
Looking back at six months of development, here is what I did not build because GAS handles it:
- No custom attribute replication system
- No manual effect stacking/unstacking logic
- No status effect manager class
- No cooldown timer system
- No ability activation/prediction framework
- No buff/debuff tracking UI data layer
- No environmental effect enter/exit state machine
Each of those would have been hundreds of lines of C++ with its own replication bugs. Instead, I configure Gameplay Effects in Blueprint, write 15-line "dumb applicator" C++ classes, and let GAS handle the hard parts.
The upfront cost of learning GAS is real. It took me about two weeks to understand the architecture well enough to use it confidently. But every week since then, it has paid dividends. New systems that would have taken days to build and debug take hours because the underlying state management is already solved.
If you are building a multiplayer game in UE5 and you are debating whether GAS is worth the learning curve: it is. Especially if you are a solo dev who cannot afford to maintain custom replication code alongside custom gameplay logic alongside custom UI data binding. GAS does all three.
Lessons Learned
Start with GAS from day one. Retrofitting GAS onto an existing codebase is painful. If you know you will need abilities, attributes, or effects, set up the ASC early.
Put the ASC on PlayerState for multiplayer. The Character pawn is ephemeral. PlayerState persists. This is the industry-standard pattern for a reason.
Use tags instead of booleans. Every boolean you add is state you have to manually synchronize. Every tag you add is state that GAS synchronizes for you. The tag system is GAS's most underrated feature.
Write dumb applicators. C++ detects the condition and applies the effect. GAS handles the rest. If your C++ is doing math that a GE modifier could do, you are fighting the framework.
Use bSnapshot = false for composable calculations. If your custom magnitude calculation snapshots an attribute, it will not react to runtime modifier changes. Live captures make effects truly composable.
"Systems monitoring training complete. Associates are reminded that the Oxygen Monitoring System is a workplace safety feature, not a performance metric. However, Associates who consume less oxygen than average may be eligible for the Efficient Breather Commendation. Salvage Solutions Inc. values every breath you take. Literally."
- S.A.L.L.I.