Most modifier magnitudes in the Gameplay Ability System ( GAS ) do not need any code. A ScalableFloat covers “this effect deals 25 damage, 40 at level two”, and an AttributeBased magnitude covers “drain 10% of MaxMana”. You can go quite far with these two.
But at some point design asks for a formula. Ex: “physical damage is weapon base plus Strength scaling through a curve”. The formula reads several inputs, but in the end it produces one number. Later design asks for 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 that order.
GAS has a class for each case. A Modifier Magnitude Calculation ( MMC, UGameplayModMagnitudeCalculation ) computes the formula. An Execution Calculation, UGameplayEffectExecutionCalculation, handles the hit that writes to several attributes.
Choosing between them confused me for a long time. What helped was looking at how many outputs I needed. An MMC produces one number, however complex the formula. An execution lets one hit write several attributes.
Both use attribute capture, so we will start there, go through what each class can do, and then build a damage pipeline.
Attribute capture
Both classes read stats through capture definitions, instead of calling getters on the AttributeSet. The definition holds the attribute, whose value to read and when to read it:
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 is the instigator, the thing that applied the effect, and Target is whoever the effect lands on. A damage calculation usually captures the offense stats of the attacker as Source and the defenses of the victim as Target.
- When to read it. This is the bSnapshot flag, and it deserves its own section.
Snapshot or live
bSnapshot = true reads the value once, when MakeOutgoingSpec builds the FGameplayEffectSpec, and freezes it into the spec. This is what you want for “the stat the caster had when they acted”. Ex: a fireball should scale with the Intelligence the caster had at cast time, not with the value three seconds later when the buff is gone.
bSnapshot = false, which is the default, keeps reading the live value every time the modifier is evaluated. This is what you want for “the stat right now”. Ex: the Armor of the victim when the hit lands.
The bool is the only API difference between these modes. Getting it backwards can make a projectile deal stale damage or change its damage mid flight. For each capture I ask whether I want the value from when the character acted, or the value right now. That tells me whether to use snapshot or live.
The empty tags trap
When reading a capture, fill FAggregatorEvaluateParameters 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);
Modifiers inside an aggregator can be gated by tags. Ex: one that only contributes while the source has an Enraged tag. Empty tag containers silently discard these tag gated modifiers, leaving the calculation with the unbuffed value. Fill both fields from the spec on every read.
MMCs
An MMC computes the magnitude of 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, plus the SetByCaller magnitudes and the tags of the spec, and after all that reading it returns one float. That is all an MMC is allowed to do.
MMCs also get some behavior from the magnitude type and the aggregator:
- You get the scaling math for free. The magnitude type that owns the MMC,
FCustomCalculationBasedFloat, applies its own coefficient, pre and post adds and an optional curve on top of your float, so the MMC only does the part that needs code. - Non-snapshot captures recalculate by themselves. A “+20% damage while below half Health” modifier simply stays correct, as the aggregator re-evaluates the MMC whenever Health moves, which is incredibly useful.
Keep the MMC as a pure function. CalculateBaseMagnitude can run many times, at moments you do not control. Firing an event there can duplicate it. The function is a BlueprintNativeEvent, so nothing prevents you from adding a node with side effects, but it should only calculate a value.
For a magnitude that depends on something outside GAS attributes, such as a difficulty setting, override GetExternalModifierDependencyMulticast. 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 when the delegate broadcasts. 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, as an Instant effect runs its MMC once and it is gone.
Executions
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 tags and SetByCaller values of the spec. Through OutExecutionOutput you emit the results with AddOutputModifier, one modifier against one attribute, as many times as you want and with any C++ in between. Parry checks, resistance math, critical rolls, “does the shield absorb this before Health”… all of that lives here, in one pass, in a guaranteed order.
In my Quod Combat Framework, every hit resolves through one damage execution. Other teams split executions by interaction type. I understand that choice, as one large execution is not particularly pretty.
I keep them together for the ordering. The parry check comes before block math, poise needs to know whether the block held, and the status meter only builds up if damage landed. With one execution I can follow that order in the code.
Executions have two limitations:
- Instant or Periodic effects only. An execution runs when a Gameplay Effect executes, once for an Instant effect and once per tick for a Periodic one. A Duration or Infinite buff does not execute, so it can not run one. Buffs should use normal modifiers instead. Duration policies have their own traps, I wrote about them in Making Sense of Gameplay Effect Durations.
- Authority only. Executions are not predicted, which changes how you design the feedback of a hit.
Damage is not predicted
Plain attribute modifiers can be locally predicted, so a client can show a stamina cost instantly and reconcile later. Executions can not, GameplayPrediction.h in the engine says so explicitly. The output only exists after the server runs it and the attribute change replicates back.
So hit VFX, sound, hitstop and damage numbers should come from a client predicted gameplay cue or an ability event at the moment of contact. If your feedback waits for the real Health of the victim, it arrives a network round trip later and the melee combat feels laggy.
That being said, on coop games I have ended up giving the client more authority than the textbook model. I talk about that in the GAS intro post.
Scoped modifiers
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 through a meta attribute. There is also a Transient mode that writes into a throwaway aggregator identified by a tag, which you read back with AttemptCalculateTransientAggregatorMagnitude. Think of it as a temporary variable that only exists during one execute.
Which one should I use then?
I start with the simplest magnitude type that can do the job:
- 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. Custom math over captures, SetByCaller values and tags, but one float out. It still predicts, works with any duration policy and recalculates when live dependencies change.
- Execution. Many attributes out, with branching and a guaranteed order. In exchange it is authority only, not predicted, only for Instant and Periodic effects, and it does not recalculate.
A heal, a resource drain or a single stat buff changes one number, so I would use a magnitude calculation even if its formula has many inputs. For a damage pipeline that writes several attributes in a fixed order, I use an execution. Let’s build that example.
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.
1. The ability sets SetByCaller and applies the spec
The ability owns the design-time inputs, like the weapon base damage or a combo multiplier, and passes them as SetByCaller magnitudes instead of baking them into the GE asset. This way one GE_Damage serves 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);
}
By the way, since UE 5.3, Gameplay Effects are built from components instead of the old inline properties ( more on that in the Gameplay Effect Components post ), so check how your engine version exposes the execution list before copying this constructor.
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, as attack power and defense should include the buffs that are active when the hit resolves. 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, and not to Health directly. A meta attribute is a scratch attribute with no gameplay meaning of its own. This way the attribute set has a single place where the number is applied, and things like a damage number popup have one attribute to observe.
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 callback rules from the attribute sets post: PostGameplayEffectExecute is where the result gets clamped and where the reactions go, not inside the execution.
To add an elemental damage type, we add a capture pair and a term in the formula. A new attack archetype gets a SetByCaller tag. An on-hit consequence such as knockback gets another AddOutputModifier against a meta attribute.
Finding the target happens before all of this. I cover that in the hitbox post.
Things to take into account
- The stack count multiplies your output. After an execution runs, the engine multiplies every output modifier by the stack count of the effect, unless you call OutExecutionOutput.MarkStackCountHandledManually(). A damage over time volume at three stacks deals triple damage, and you will not see it while testing with one stack. If your execute has early returns, an RAII guard whose destructor calls it is the safe option.
- Snapshot backwards. A projectile whose damage changes mid flight, or a value that should have been frozen at cast time.
- Side effects inside an MMC. Anything that fires, spawns or notifies goes in an execution, which runs once per execute.
- A Blueprint MMC that does not run. Some projects have optimization paths that call the native implementation directly, so check which path your effects use, and keep the hot path damage MMCs in native code anyway.
Recap
Use an MMC or AttributeBased magnitude for one output number, and an execution for several attributes written in order. Executions are authority only, not predicted, and work with Instant and Periodic effects.
Check bSnapshot for every capture: true freezes the value at MakeOutgoingSpec, false reads the live value. Also remember to fill FAggregatorEvaluateParameters with the captured tags, or tag gated modifiers are discarded. Execution output is multiplied by the stack count unless you call MarkStackCountHandledManually().


