Attribute clamping issues with mixed instant and duration GameplayEffect

I want to implement a shielding effect that has some stacks and each stack completely mitigates a single damage instance. For that I added a UCombatSet::Shield and UCombatSet::MaxShield (in this scenario value of 3) attributes to keep track of the charges left.

I also have two GE:

  • Instant: Adds 1 shield (Add Base)
  • Duration: Gives 2 Shield (Add Base) for 5 seconds

Initially I did the clamping in my CombatSet class:

void UCombatSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
	Super::PreAttributeChange(Attribute, NewValue);
	
	if (Attribute == GetShieldAttribute())
	{
		NewValue = FMath::Clamp<float>(NewValue, 0.0f, GetMaxShield());
	}
}

and the damage mitigation in my DamageExecution class:

if (TargetTags->HasTag(TAG_STATUS_BUFF_SHIELDED))
{
	OutExecutionOutput.AddOutputModifier(
		FGameplayModifierEvaluatedData(UCombatSet::GetShieldAttribute(), EGameplayModOp::Additive, -1)
	);
}

This works totally fine when I work with only the instant GE as everything modifies the BaseValue of the attribute. However, there are a couple issues for the Duration GE.

  1. If I already have maxed out my shield and activate the duration GE the value overflows. E.g. BaseValue for Shield is 3 and I activate the GE the CurrentValue is set to 5. So even though in the UI it is capped as max 3 shield, two additional damage instance will be blocked.
    For this case I figure out that I have to implement UCombatSet::PostGameplayEffectExecute as well:
void UCombatSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
	Super::PostGameplayEffectExecute(Data);
	
	if (Data.EvaluatedData.Attribute == GetShieldAttribute())
	{
		SetShield(FMath::Clamp(GetShield(), 0.0f, GetMaxShield()));
	}
}
  1. This leads to a new issue now. When I have the duration buff active and receive 1 damage instance, the remaining temporary shield (CurrentValue) will be converted to BaseValue due to the implementation details of how SetShield works. Meaning if the buff expires I’m left with 1 BaseValue instead of 0. For that I tried to set the Shield.CurrentValue instead of the SetShield method in PostGameplayEffectExecute. This worked but lead again to a new issues which seems impossible to resolve.

So my question is: how to I implement such a mechanic? Do I have to use different attributes for the instant Shield and the temporary Shield? Or is there anything I’m completely misunderstanding? I was also thinking about using GE stacks with tag instead of an attribute for shield but couldn’t figure out how to do it properly.

mixing instant and duration GEs on one attribute is where the aggregator bookkeeping and your clamping start fighting. clamping inside PreAttributeChange rewrites the final CurrentValue, but the aggregator still tracks the raw additive the duration GE contributed, so when the duration expires and the modifier is removed, the recompute runs against numbers you already clamped. that is your overshoot and negative dip on expiry.

the split that works: PreAttributeChange does nothing but clamp NewValue into [0, MaxShield], cheap and symmetric, no game logic in there. all the “3 stacks max” logic goes in PreGameplayEffectExecute: if the attribute is GetShieldAttribute(), clamp Data.EvaluatedData.Magnitude to MaxShield - Shield before the aggregator ever sees it. then the stored additive values stay consistent with what the attribute actually holds.

design side for the expiry edge: do not make the duration GE the source of truth for charges. grant charges with instant GEs, let the duration GE only carry the tag and vfx for the timed buff, and remove the charge with an instant GE when it expires. with that shape the PreAttributeChange clamp never has to fight a rollback.