Most modifier magnitudes in the Gameplay Ability System ( GAS ) never need code. A ScalableFloat is enough for “this effect deals 25 damage, 40 at level two”, and an AttributeBased magnitude can handle “drain 10% of MaxMana”. You can go really far with just those two.
But eventually combat design asks for more. The first request that needs code is usually a formula: “physical damage is weapon base plus Strength scaling through a curve”. The formula reads several inputs and does custom math, but in the end it still produces one number. The second request is a hit that touches many things at once: one sword swing that lowers Health, chips Shield, adds Poise damage and pushes a status meter, in a specific order.
GAS has a calculation class for each case: a Modifier Magnitude Calculation ( MMC, UGameplayModMagnitudeCalculation ) for the formula, and an Execution Calculation ( UGameplayEffectExecutionCalculation ) for the hit that writes many attributes. Choosing between them confused me for a long time, until I realized the rule is really simple: count the outputs, not the inputs. It does not matter how complex the formula gets: if only one number changes at the end you want an MMC. The moment a single hit has to write several attributes, you want an execution.
I have been shipping GAS combat for years on Lords of the Fallen and Killsquad, and in my Quod Combat Framework every hit in the game resolves through one damage execution, so I have used both classes a lot. In this post we will look at the mechanism they share ( attribute capture ), what each one can and can not do, and then we will build a damage pipeline end to end, from the ability activating to the Health bar moving.
Attribute capture, the part both tools share
Neither of the two classes reads stats by grabbing the AttributeSet and calling getters. Both go through capture definitions. A capture definition is a struct where you decide three things:
FGameplayEffectAttributeCaptureDefinition AttackPowerDef(
UOffenseAttributeSet::GetAttackPowerAttribute(), // which attribute
EGameplayEffectAttributeCaptureSource::Source, // whose ASC to read
false); // bSnapshot: when to read it
- Which attribute, and on which attribute set.
- Whose ASC. Source reads from the instigator ( the caster, the thing that applied the effect ). Target reads from whoever the effect lands on. A damage calculation will usually capture the attacker’s offense stats as Source and the victim’s defenses as Target.
- When to read it. This is the bSnapshot flag, and it is important enough to have its own section.
Snapshot or live
This bool is really important, and it is the one I see new GAS users get backwards most often.
bSnapshot = true reads the value once, at spec creation: the moment MakeOutgoingSpec builds the FGameplayEffectSpec, before the effect is even applied to anyone. The value gets frozen into the spec and it will not change again. This is what you want for “the stat the caster had when they acted”. A fireball should scale with the Intelligence the caster had at cast time, not with whatever value it has three seconds later when the projectile lands and the buff is already gone.
bSnapshot = false ( the default ) keeps tracking the live value at every evaluation. This is what you want for “the stat right now”: the victim’s Armor at the moment of impact, not when the attacker started the swing.
Nothing else in the API tells the two modes apart, the whole difference is this one bool. If you get it backwards you will have a projectile that deals stale damage forever, or one whose damage changes silently after it has already left the caster’s hand. What I do is say the decision out loud for every capture: if it sounds like “the stat they had when they acted”, snapshot it, if it sounds like “the stat right now”, keep it live.
The empty-tags trap
To read a capture back you need one more thing, and skipping it causes a bug I see reported all the time. Every read needs an FAggregatorEvaluateParameters filled with the tags captured by the spec:
FAggregatorEvaluateParameters EvalParams;
EvalParams.SourceTags = Spec.CapturedSourceTags.GetAggregatedTags();
EvalParams.TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();
float AttackPower = 0.f;
GetCapturedAttributeMagnitude(AttackPowerDef, Spec, EvalParams, AttackPower);
Why does this matter? The modifiers inside an aggregator can be gated by tags ( ex: a modifier that only contributes while the source has an Enraged tag ). If you pass empty tag containers, every tag-gated modifier will be silently discarded. The buff is on the character, the aggregator knows about it, but your calculation still reads the unbuffed value. If you have ever received a “my conditional buff never applies inside the damage calc” bug report, this was probably it. So fill both fields from the spec, every time.
MMCs: many reads, one float
An MMC computes the magnitude of exactly one modifier. You subclass UGameplayModMagnitudeCalculation, declare your captures in the constructor, and override one function:
UMMC_MeleeDamage::UMMC_MeleeDamage()
{
AttackPowerDef = FGameplayEffectAttributeCaptureDefinition(
UOffenseAttributeSet::GetAttackPowerAttribute(),
EGameplayEffectAttributeCaptureSource::Source, false);
WeaponScalingDef = FGameplayEffectAttributeCaptureDefinition(
UWeaponAttributeSet::GetScalingCoefficientAttribute(),
EGameplayEffectAttributeCaptureSource::Source, false);
RelevantAttributesToCapture.Add(AttackPowerDef);
RelevantAttributesToCapture.Add(WeaponScalingDef);
}
float UMMC_MeleeDamage::CalculateBaseMagnitude_Implementation(const FGameplayEffectSpec& Spec) const
{
FAggregatorEvaluateParameters EvalParams;
EvalParams.SourceTags = Spec.CapturedSourceTags.GetAggregatedTags();
EvalParams.TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();
float AttackPower = 0.f, ScalingCoefficient = 1.f;
GetCapturedAttributeMagnitude(AttackPowerDef, Spec, EvalParams, AttackPower);
GetCapturedAttributeMagnitude(WeaponScalingDef, Spec, EvalParams, ScalingCoefficient);
return AttackPower * ScalingCoefficient;
}
It can read as many captures as it wants, Source and Target, plus the SetByCaller magnitudes on the spec and the source and target tags. And after all that reading it returns one float, because that is all an MMC is allowed to do.
There are two details that make MMCs better than they look:
- You get the scaling math for free. The magnitude type that owns the MMC ( the Custom calculation option on a modifier,
FCustomCalculationBasedFloat) applies its own coefficient, pre-multiply add, post-multiply add, and an optional final curve lookup on top of your float. So keep the MMC focused on the part of the formula that really needs code. There is no need to duplicate the coefficient math the editor already gives you. - Non-snapshot captures recalculate automatically. This is the real reason to use an MMC instead of hardcoding the formula in the ability. A “+20% damage while below half Health” modifier simply stays correct, because the aggregator re-evaluates the MMC whenever Health moves, without any tick or polling on your side, which is incredibly useful.
In exchange there is one hard rule: an MMC must be a pure function. CalculateBaseMagnitude can be called many times, at moments you do not control, every time the aggregator re-evaluates. If you fire an event or spawn an effect in there, you will get duplicated events and magnitudes that change depending on the evaluation order. Unfortunately nothing enforces this: the function is a BlueprintNativeEvent, the class is fully Blueprintable, and adding a node with side effects is really easy. Treat it like a math function.
Before we move on, there is a hook that not many people know about. Sometimes a magnitude depends on state that GAS has no attribute for ( ex: a difficulty setting, or a day/night cycle ). In that case you can override GetExternalModifierDependencyMulticast and return a long-lived delegate owned by that system:
FOnExternalGameplayModifierDependencyChange* UMMC_DifficultyScaledDamage::GetExternalModifierDependencyMulticast(
const FGameplayEffectSpec& Spec, UWorld* World) const
{
if (UDifficultySubsystem* Difficulty = World ? World->GetSubsystem<UDifficultySubsystem>() : nullptr)
{
return &Difficulty->OnDifficultyChanged; // must be long-lived, never a temporary
}
return nullptr;
}
Every active effect that uses this MMC recalculates the moment the delegate broadcasts, and you did not have to turn difficulty into an attribute just to get that. Take into account that the binding is per calculation class, not per effect instance, and that it only makes sense for Duration or Infinite effects. An Instant effect’s MMC runs once and it is gone before the delegate can ever fire.
Executions: one pass, many writes
An execution is the heavier tool. You override a single function that can read a lot of things and write to many attributes:
void UExecCalc_Damage::Execute_Implementation(
const FGameplayEffectCustomExecutionParameters& ExecutionParams,
FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const;
Through ExecutionParams you can access both ASCs, every capture declared by the class, and the spec’s tags and SetByCaller values. Through OutExecutionOutput you emit the results: AddOutputModifier pushes one modifier against one attribute, and you can call it as many times as you want, against as many attributes as you want, with any branching C++ in between. Parry checks, resistance math, critical rolls, “does the shield absorb this before Health”… all of that lives here, in one pass, and the order between the writes is guaranteed.
In my Quod Combat Framework, this class is where all the combat rules live. Every hit in the game ( weapons, spells, enemy attacks… ) resolves through one damage execution that captures every offense, defense and resistance stat on both sides. A lot of teams split executions per interaction type instead, and I understand why, one big execution looks ugly. But defensive mechanics depend on the order things resolve. The parry has to answer before the block math runs, poise has to resolve knowing if the block held, and the status meter only builds up if damage actually landed. When each mechanic lives in its own execution, sooner or later they disagree about ordering. When they live in one, ordering is just line numbers. That decision has paid off on every project since.
Executions have two hard limits, and both of them change how you design around them:
- Instant or Periodic effects only. An execution runs when a Gameplay Effect executes, which happens once for an Instant effect and once per tick for a Periodic one. A plain Duration or Infinite buff never executes, so it can not run one. Buffs should use normal modifiers instead. ( Duration policies have their own traps, I covered them in Making Sense of Gameplay Effect Durations. )
- Authority only. Executions never predict, and this one deserves its own section.
Damage never predicts
Plain attribute modifiers can be locally predicted, so a client can show a stamina cost instantly and reconcile later. Executions can not, and it is not some flag you missed: the prediction documentation in the engine’s GameplayPrediction.h says explicitly that executions are not predicted. The output of a damage execution only exists after the server runs it and the resulting attribute change replicates back.
So you have to design the feedback around that round trip. Hit VFX, sound, hitstop and damage numbers should come from a client-predicted gameplay cue or an ability-level event at the moment of contact, never from waiting on the execution’s output. The victim’s real Health arrives a network round trip later, and if your feedback waits for it the melee combat will feel laggy and unresponsive.
I will add an honest note from coop land, because tutorials only give you the textbook answer. I started fully server-authoritative, the way GAS wants to be used. Over the years, on coop games, I have moved more and more toward trusting the client: the attacker’s client shows the full result of its own hit immediately, the server stays the only writer of the real attribute state, and the two reconcile when the replicated result lands. GAS fights you a little bit on this, it is not the model the system was built for, but in coop the responsiveness is worth it. Your players are cooperating, so cheating is not the problem it is in PvP, and I would not make the same trade there.
Scoped modifiers, briefly
A GE that owns an execution can also carry scoped modifiers: modifiers that run just before the execution and feed it pre-computed values. Usually each one writes into a meta attribute that the execution then captures. There is also a Transient mode that skips attributes completely and writes into a throwaway aggregator identified by a tag, which you can read back inside the execution with AttemptCalculateTransientAggregatorMagnitude. You can think of it as a temporary variable that only exists during one execute. Use it when several scoped calculations should accumulate into one intermediate value that nobody else needs to see, and prefer a meta attribute when the UI or another effect may want to read that intermediate result.
The decision ladder
Start at the first step and only move to the next one when the design forces you:
- ScalableFloat. A number, optionally leveled through a curve table. No code.
- AttributeBased. One captured attribute times a coefficient, plus pre- and post-adds and an optional curve. Still no code.
- MMC. It can read many things ( captures, SetByCaller, tags ) and do custom math, but it outputs one float. It still predicts like a plain modifier, it works with any duration policy, and it recalculates when live dependencies change.
- Execution. It can write many attributes, with real branching and a guaranteed order. The cost is that it is authority-only, it never predicts, it only works on Instant and Periodic effects, and it never recalculates because it runs once per execute.
When a design spec is ambiguous, count the outputs first. A heal, a resource drain, a single-stat buff… if only one number changes it never needs an execution, no matter how many inputs feed the formula. An MMC can handle quite complex reads. You only need an execution when one application actually writes several attributes, or when the logic has to branch knowing everything that was captured, in one place, in a fixed order. As you can see, that is the shape of almost every damage pipeline, so let’s build one.
A damage pipeline, end to end
This is the shape I use. The class names are written for this post, but the flow and the API calls are stock GAS. The whole pipeline is four steps:
- The attack ability builds a spec and puts the design-time numbers on it as SetByCaller.
GE_Damage, an Instant effect, carries the execution.- The execution captures the stats of both sides, computes the final number, and writes it to a meta attribute called Damage.
- The attribute set’s PostGameplayEffectExecute turns Damage into a Health change, clamps it, and fires the cue.
1. The ability sets SetByCaller and applies the spec
The ability owns the design-time inputs ( ex: weapon base damage, or a combo multiplier ) and passes them as SetByCaller magnitudes instead of baking them into the GE asset. This is what allows one GE_Damage definition to serve every weapon and attack in the game.
FGameplayEffectContextHandle Context = ASC->MakeEffectContext();
Context.AddSourceObject(this);
FGameplayEffectSpecHandle SpecHandle =
ASC->MakeOutgoingSpec(DamageEffectClass, GetAbilityLevel(), Context);
if (SpecHandle.IsValid())
{
SpecHandle.Data->SetSetByCallerMagnitude(
FGameplayTag::RequestGameplayTag(FName("Data.Damage.Base")), WeaponBaseDamage);
ASC->ApplyGameplayEffectSpecToTarget(*SpecHandle.Data, TargetASC);
}
2. The GE registers the execution
UGE_Damage::UGE_Damage()
{
DurationPolicy = EGameplayEffectDurationType::Instant;
FGameplayEffectExecutionDefinition ExecutionDef;
ExecutionDef.CalculationClass = UExecCalc_Damage::StaticClass();
Executions.Add(ExecutionDef);
}
One note about versions: since UE 5.3, Gameplay Effects are built from components instead of the old inline properties ( I covered the change in the Gameplay Effect Components post ), so check how your engine version exposes the execution list before assuming this exact constructor shape.
3. The execution computes and emits
Executions declare their captures once, in a statics struct shared by every instance, using two macros from GameplayEffectExecutionCalculation.h:
struct FDamageStatics
{
DECLARE_ATTRIBUTE_CAPTUREDEF(AttackPower);
DECLARE_ATTRIBUTE_CAPTUREDEF(Defense);
DECLARE_ATTRIBUTE_CAPTUREDEF(Damage);
FDamageStatics()
{
DEFINE_ATTRIBUTE_CAPTUREDEF(UOffenseAttributeSet, AttackPower, Source, false);
DEFINE_ATTRIBUTE_CAPTUREDEF(UDefenseAttributeSet, Defense, Target, false);
DEFINE_ATTRIBUTE_CAPTUREDEF(UHealthAttributeSet, Damage, Target, false);
}
};
static const FDamageStatics& DamageStatics()
{
static FDamageStatics Statics;
return Statics;
}
Every capture is live ( bSnapshot = false ): attack power and defense should reflect the buffs that are active at the moment the hit resolves, not when the ability started. Then the execute itself:
void UExecCalc_Damage::Execute_Implementation(
const FGameplayEffectCustomExecutionParameters& ExecutionParams,
FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const
{
const FGameplayEffectSpec& Spec = ExecutionParams.GetOwningSpec();
FAggregatorEvaluateParameters EvalParams;
EvalParams.SourceTags = Spec.CapturedSourceTags.GetAggregatedTags();
EvalParams.TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();
float AttackPower = 0.f, Defense = 0.f;
ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(
DamageStatics().AttackPowerDef, EvalParams, AttackPower);
ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(
DamageStatics().DefenseDef, EvalParams, Defense);
const float BaseDamage = Spec.GetSetByCallerMagnitude(
FGameplayTag::RequestGameplayTag(FName("Data.Damage.Base")), false, 0.f);
const float FinalDamage = FMath::Max(BaseDamage + AttackPower - Defense, 0.f);
if (FinalDamage > 0.f)
{
OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(
DamageStatics().DamageProperty, EGameplayModOp::Additive, FinalDamage));
}
}
Notice that it writes to Damage, a meta attribute on the health set, not to Health directly. A meta attribute is a scratch attribute with no gameplay meaning of its own, and this convention is worth keeping even in a simple pipeline. It gives the attribute set a single place where the number gets applied, and it gives every other system ( the damage-number popup, an AI perception hook… ) one attribute to observe, no matter which execution produced the value.
4. The attribute set consumes the meta attribute
void UHealthAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
Super::PostGameplayEffectExecute(Data);
if (Data.EvaluatedData.Attribute == GetDamageAttribute())
{
const float LocalDamage = GetDamage();
SetDamage(0.f); // meta attribute: consume it immediately
if (LocalDamage > 0.f)
{
SetHealth(FMath::Clamp(GetHealth() - LocalDamage, 0.f, GetMaxHealth()));
if (UAbilitySystemComponent* ASC = GetOwningAbilitySystemComponentChecked())
{
FGameplayCueParameters CueParams;
CueParams.RawMagnitude = LocalDamage;
CueParams.EffectContext = Data.EffectSpec.GetContext();
ASC->ExecuteGameplayCue(
FGameplayTag::RequestGameplayTag(FName("GameplayCue.Damage.Hit")), CueParams);
}
if (GetHealth() <= 0.f)
{
// hand off to your death system; don't apply effects from inside this callback
}
}
}
}
This follows the attribute callback rules I described in the attribute sets post: PostGameplayEffectExecute is where the result gets clamped and where the reactions go, never inside the execution itself.
Why this shape scales
Every weapon, spell and status effect reuses the same GE_Damage + UExecCalc_Damage pair, so adding new things becomes pretty painless. A new elemental damage type is a new capture pair ( a Source attack stat and a Target resistance ) added to the statics block, plus one term in the formula. The ability layer never knows about it. For a new attack archetype you add a new SetByCaller tag that the ability sets and the execution reads as a multiplier. And a new on-hit consequence ( ex: a knockback, or a status buildup meter ) is one more AddOutputModifier against one more meta attribute, consumed by that attribute’s own PostGameplayEffectExecute. This is how a single damage pipeline can absorb all the combat content of a game without becoming a mess of branches that nobody wants to touch. ( The other half of the story, how the hit finds its target in the first place, is in the hitbox post. )
The traps
These four things can pass a code review and still bite you later:
- Stack count silently multiplies your output. After an execution runs, the engine multiplies every output modifier you emitted by the current stack count of the effect, unless you opt out with OutExecutionOutput.MarkStackCountHandledManually(). A stackable damage-over-time volume at three overlapping stacks deals triple damage per hit, and you will never see it while testing with a single stack. Call it on every code path, and if your execute has branches and early returns, the safe pattern is an RAII guard whose destructor calls it.
- Snapshot backwards. This one gives you a projectile whose damage changes mid-flight, or a value that should have been frozen at cast time and keeps changing. Say the bSnapshot decision out loud for every capture: snapshot means “the stat they had when they acted”, live means “the stat right now”.
- Side effects inside an MMC. With this one you get duplicated events and magnitudes that depend on evaluation timing. The aggregator calls your MMC whenever it wants, so anything that fires, spawns or notifies should go in an execution, which runs exactly once per execute.
- A Blueprint MMC that never runs. The class is Blueprintable and prototyping in Blueprint is fine. That being said, some projects have optimization paths that call the native implementation directly. Check which path your effects actually use before trusting a Blueprint override, and keep the hot-path damage MMCs in native code anyway.
Recap
- bSnapshot decides when a capture reads its value: true freezes it at MakeOutgoingSpec, false keeps tracking the live value. If you get it backwards you get stale damage or drifting damage, and nothing but that bool tells the two modes apart.
- Two traps that are easy to miss: the output of an execution gets multiplied by the stack count unless you call MarkStackCountHandledManually(), and an empty
FAggregatorEvaluateParameterssilently discards every tag-gated modifier, so your conditional buffs never apply inside the calculation. - The rule for choosing: count the outputs. If only one number changes you want an MMC ( or AttributeBased ), no matter how many attributes feed the formula. An execution is for writing many attributes in one ordered pass, and it pays for that by being authority-only, never predicted, and only available on Instant and Periodic effects.


