When you implement a dodge for the first time, the most common approach looks like this: activate the ability, set some bInvulnerable flag, start a 0.4 second timer, and clear the flag when the timer fires. It works, and for a prototype it is fine. The problem comes the moment an animator retimes the roll animation. Now your invulnerability starts before the character has moved and ends while the character is still in the air, and the timer has no way to know about any of this. The i-frame window is really a property of the animation, so if you hardcode it in the ability it will break every time the animation changes.
In my Quod Combat Framework ( the GAS combat layer I carry between my action projects ) the dodge ability contains no invulnerability logic at all. i-frames are three small pieces:
- an anim notify state that applies a gameplay effect for exactly its own duration
- an infinite gameplay effect whose only job is to grant one tag
- a damage pipeline that checks the tag and reports every negated hit as a gameplay event
The third piece is my favorite part, because that event is how you get perfect dodges almost for free.
The window is a notify state
The base class is a generic anim notify state that keeps a gameplay effect applied while the notify is active. It knows nothing about dodging:
UCLASS(Abstract)
class UQuodANS_GameplayEffectWindow : public UAnimNotifyState
{
GENERATED_BODY()
protected:
// The effect this window keeps applied while it is active.
UPROPERTY(EditAnywhere, Category = "Effect")
TSubclassOf<UGameplayEffect> EffectClass;
virtual void NotifyBegin(USkeletalMeshComponent* MeshComp,
UAnimSequenceBase* Animation, float TotalDuration,
const FAnimNotifyEventReference& EventReference) override;
virtual void NotifyEnd(USkeletalMeshComponent* MeshComp,
UAnimSequenceBase* Animation,
const FAnimNotifyEventReference& EventReference) override;
};
void UQuodANS_GameplayEffectWindow::NotifyBegin(USkeletalMeshComponent* MeshComp,
UAnimSequenceBase* Animation, float TotalDuration,
const FAnimNotifyEventReference& EventReference)
{
AActor* Owner = MeshComp ? MeshComp->GetOwner() : nullptr;
if (!Owner || !Owner->HasAuthority() || !EffectClass)
{
return; // only the server decides who is invulnerable
}
UAbilitySystemComponent* ASC =
UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(Owner);
if (!ASC)
{
return;
}
const FGameplayEffectSpecHandle Spec =
ASC->MakeOutgoingSpec(EffectClass, 1.f, ASC->MakeEffectContext());
ASC->ApplyGameplayEffectSpecToSelf(*Spec.Data);
}
void UQuodANS_GameplayEffectWindow::NotifyEnd(USkeletalMeshComponent* MeshComp,
UAnimSequenceBase* Animation, const FAnimNotifyEventReference& EventReference)
{
AActor* Owner = MeshComp ? MeshComp->GetOwner() : nullptr;
if (!Owner || !Owner->HasAuthority() || !EffectClass)
{
return;
}
if (UAbilitySystemComponent* ASC =
UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(Owner))
{
ASC->RemoveActiveGameplayEffectBySourceEffect(EffectClass, nullptr, /*StacksToRemove=*/1);
}
}
One thing to be careful with on that RemoveActiveGameplayEffectBySourceEffect call: it removes a stack of that effect class, which is fine while only one window of a given effect can be open at a time. If your montages can overlap two windows of the same effect, you will want to store the FActiveGameplayEffectHandle that ApplyGameplayEffectSpecToSelf returns and remove exactly that one. My framework does this handle bookkeeping, but my recommendation is to skip it for a dodge, as a dodge montage only has one invulnerability window.
After this, the i-frame notify is a one line subclass. This way the animator gets a notify with a proper name for this exact purpose, instead of a generic one with a dropdown that somebody can misconfigure:
UQuodANS_InvulnerableWindow::UQuodANS_InvulnerableWindow()
{
EffectClass = UGE_Intangible::StaticClass();
}
And that is the whole class. Now the animator can drag it onto the dodge montage and stretch it across the frames that should be safe. If the roll gets retimed later, the window moves together with the animation, and no programmer needs to be involved.
The effect is infinite on purpose
GE_Intangible grants exactly one tag and nothing else:
UGE_Intangible::UGE_Intangible()
{
DurationPolicy = EGameplayEffectDurationType::Infinite;
UTargetTagsGameplayEffectComponent& TargetTags =
FindOrAddComponent<UTargetTagsGameplayEffectComponent>();
FInheritedTagContainer Tags;
Tags.Added.AddTag(QuodTags::Combat_State_Intangible);
TargetTags.SetAndApplyTargetTagChanges(Tags);
}
Setting the duration to Infinite may look wrong for something that lives a third of a second, but it is done on purpose. The notify state already owns the lifetime: begin applies the effect, end removes it. If the effect also had a duration of its own, now you would have two clocks measuring the same window, and the moment a play rate change or a hitstop stretches the montage, the two clocks would disagree. ( If durations in GAS are still confusing to you, I wrote about them in Making Sense of Gameplay Effect Durations, and the TargetTags component is covered in the Gameplay Effect Components post. )
The nice thing about using a tag is that CombatStateIntangible only says that the character can not be hit right now, it does not say anything about who granted it or why ( I talk about this in general in Using Gameplay Tags to Store Game State ). The dodge grants it through a notify, but a parry ability, a cutscene or some spawn protection can grant it too, and every one of them gets working invulnerability without having to touch the damage code.
Damage asks one question
The damage pipeline does not know what a dodge is. Before applying a melee hit, it simply checks the tags of the target:
bool FQuodDamagePipeline::TryApplyHit(AActor* Target, const FQuodHitData& Hit)
{
UAbilitySystemComponent* TargetASC =
UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(Target);
if (!TargetASC)
{
return false;
}
if (TargetASC->HasMatchingGameplayTag(QuodTags::Combat_State_Intangible))
{
// No damage, no hit reaction. But we do not want to lose this
// information, so we send it to whoever is listening on the target.
FGameplayEventData Payload;
Payload.Instigator = Hit.Attacker;
Payload.Target = Target;
TargetASC->HandleGameplayEvent(QuodTags::Event_Combat_HitNegated, &Payload);
return false;
}
// ... normal damage path
return true;
}
As with the notify, this only runs on the server. The important line here is the HandleGameplayEvent one. A negated hit is really valuable information: something was going to hit you and it did not. It would be a waste to throw that away, so the pipeline turns it into an EventCombatHitNegated event on the ASC of the target.
Perfect dodge, almost for free
Now comes my favorite part. The dodge ability starts its montage and stays alive until the montage ends, and while it is alive, it listens for that event:
void UGA_Dodge::ActivateAbility(/* ... */)
{
// ... direction resolved, montage task started ...
UAbilityTask_WaitGameplayEvent* WaitNegated =
UAbilityTask_WaitGameplayEvent::WaitGameplayEvent(
this, QuodTags::Event_Combat_HitNegated,
nullptr, /*OnlyTriggerOnce=*/true);
WaitNegated->EventReceived.AddDynamic(this, &UGA_Dodge::OnHitNegated);
WaitNegated->ReadyForActivation();
}
void UGA_Dodge::OnHitNegated(FGameplayEventData Payload)
{
if (GetAbilitySystemComponentFromActorInfo()->HasMatchingGameplayTag(
QuodTags::Combat_Window_PerfectDodge))
{
// Dodged *through* an attack inside the tight window.
PlayPerfectDodgeFollowup(CachedActivationData);
}
}
There are two nested windows on the montage, both authored with the same notify mechanism. The outer one is the i-frame window. The inner one is tighter and grants CombatWindowPerfectDodge ( a second UQuodANS_GameplayEffectWindow subclass, nothing new ). If a hit gets negated while the inner tag is present, it means the player really read the attack, so the ability upgrades the dodge: a follow-up montage, an attack buff, whatever makes sense for your game. CachedActivationData is the input payload the dodge activated with, we keep it around so the follow-up can reuse the direction the player chose at the beginning.
As you can see, there are tons of things we did not have to write: no per-enemy special cases, no “was an attack active near me” queries. The attacker’s swing already had to find the target in order to hit it, so we simply let the negation travel back as an event.
Why the timeline is the right owner
Authoring i-frames per montage may sound like more work than having a single tuning constant, but in my experience it is the opposite. There are three reasons for this.
Iteration speed. If you want to widen the window of the fat roll, you grab the edge of the notify in the montage editor and drag it. No compiling, no hunting through data tables, and the person doing the tuning ( a designer or an animator ) can do it alone.
Different dodges want different windows. Every Souls player knows that light, medium and fat rolls do not share invulnerability: the community has documented the frame counts for the Elden Ring roll types on the wiki, and Zullie the Witch has been visualizing these windows frame by frame for years. Monster Hunter even turns the width of the i-frames into a player skill ( Evade Window ). So more than a luxury, authoring the window per animation is what these games already do.
The window can never desync from the animation. It lives inside the animation, so you can retime it or swap the montage entirely and the safe frames will always be the ones the animator wanted to be safe.
If this sounds familiar, it is because the offensive half of the framework works the same way: the damage windows in the hitbox post are notify states on the montage too. An attack has frames where it can hit, and a character has frames where it can not be hit. Both are windows that need to line up with the animation, so we author both with notifies the animator can move.
One warning before you ship this: everything in this post runs on the server. The window opens when the server reaches those frames, and with real latency that is not the same moment the client sees on screen, so a player with high ping can dodge on time on their screen and still eat the hit. How much of the window the owning client is allowed to predict ( and what happens when the server disagrees ) is something you will need to spend real time on. We may go over that in another article in the future.
Recap
- The gotcha is the Infinite duration: it looks wrong for an effect that lives a third of a second, but it is on purpose. The notify already owns the lifetime ( begin applies the effect, end removes it ), so a duration on the effect would be a second clock for the same window, and the two would disagree when something stretches the montage. The only job of the effect is granting
CombatStateIntangible. - The dodge ability grants zero invulnerability. An anim notify state applies
GE_Intangibleon begin and removes it on end, so the i-frame window of every montage is authored on its own timeline and moves with the animation. - The damage pipeline suppresses hits against the tag and raises
EventCombatHitNegatedon the target. The dodge ability is still running, so it listens for that event, checks the tighter nestedCombatWindowPerfectDodgetag, and upgrades the dodge. That is the whole perfect dodge system.


