Gameplay Ability System·Combat Design·Programming·Unreal Engine·Quod Combat Framework

Dodge i-Frames with GAS: Anim Notify Windows and Perfect Dodges

Dodge i-frames with GAS: an anim notify state applies an infinite effect, the damage pipeline checks one tag, and perfect dodges come almost for free.

In my Quod Combat Framework, I put the invulnerability window on the dodge montage. The animator can stretch it over the frames that should be safe. We also use this setup for perfect dodges.

You could activate the ability, set a bInvulnerable flag and clear it with a 0.4 second timer. The problem comes when an animator retimes the roll. The timer still has its old duration, so the character can be invulnerable before moving and vulnerable while still in the air.

The dodge ability itself has no invulnerability logic. Here is what we need around it:

  • an anim notify state that applies a gameplay effect for as long as the notify lasts
  • an infinite gameplay effect that only grants one tag
  • a damage pipeline that checks the tag and sends a gameplay event for every hit it negates

The notify state

The base class is a generic anim notify state that keeps a gameplay effect applied while it is active. It does not know anything 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 is the RemoveActiveGameplayEffectBySourceEffect call, as it removes a stack of that effect class. This is fine while only one window of a given effect can be open at the same time. If your montages can overlap two windows of the same effect, store the FActiveGameplayEffectHandle that ApplyGameplayEffectSpecToSelf returns and remove that one instead. My framework does this, but for a dodge montage with a single invulnerability window I would not bother.

With this base class, the i-frame notify is a one line subclass. This way the animator gets a notify with a proper name for this exact thing, instead of a generic one with a dropdown that somebody can misconfigure:

UQuodANS_InvulnerableWindow::UQuodANS_InvulnerableWindow()
{
    EffectClass = UGE_Intangible::StaticClass();
}

The animator can now drag this notify onto the dodge montage. If the roll gets retimed, they can adjust the window there without changing code.

The effect

GE_Intangible only grants one tag:

UGE_Intangible::UGE_Intangible()
{
    DurationPolicy = EGameplayEffectDurationType::Infinite;

    UTargetTagsGameplayEffectComponent& TargetTags =
        FindOrAddComponent<UTargetTagsGameplayEffectComponent>();

    FInheritedTagContainer Tags;
    Tags.Added.AddTag(QuodTags::Combat_State_Intangible);
    TargetTags.SetAndApplyTargetTagChanges(Tags);
}

The duration is Infinite even though the effect lives for a third of a second. The notify applies it on begin and removes it on end. Giving the effect its own duration would leave us with two clocks, which can disagree when hitstop or a play rate change stretches the montage.

I explain durations in Making Sense of Gameplay Effect Durations, and TargetTags 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 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, a cutscene or some spawn protection can grant it too, and all of them get working invulnerability without touching the damage code.

The damage check

The damage pipeline does not know what a dodge is. Before applying a melee hit, it 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;
}

This also only runs on the server. Through HandleGameplayEvent, we send an EventCombatHitNegated event to the target’s ASC when a hit is prevented. That lets other abilities react to the hit even though no damage was applied.

Perfect dodge

This is 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 through a second subclass of the same notify. If a hit is negated while the inner tag is present, the player read the attack well, 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 that the follow-up can reuse the direction the player chose.

The attack already found the target during its hit check. Sending the event back means we do not need another query for nearby attacks, or a special case per enemy.

Why put the window on the animation

If you want to widen the window of the fat roll, drag the edge of its notify in the montage editor. A designer or animator can do this without compiling or editing a data table.

At the same time, different dodges want different windows. In Souls games the light, medium and fat rolls do not share invulnerability ( the frame counts for the Elden Ring roll types are on the wiki ), and Monster Hunter even turns the width of the i-frames into a player skill with Evade Window.

The safe frames stay with the animation when we retime it or swap the montage.

If this sounds familiar, the damage windows in the hitbox post are notify states on the montage too. The frames where an attack can hit and the frames where a character can not be hit are both windows that need to line up with the animation, so both are authored the same way.

Before you ship this, take into account that everything in this post runs on the server. The window opens when the server reaches those frames, and with 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 get hit. How much of the window the owning client is allowed to predict ( and what happens when the server disagrees ) is a whole topic, we may go over it in another article in the future.

Recap

  • Put the window on the montage. The notify applies GE_Intangible on begin and removes it on end, so the dodge ability does not grant invulnerability itself. Keep the effect Infinite to avoid a second timer.
  • When CombatStateIntangible prevents damage, send EventCombatHitNegated. The dodge ability checks CombatWindowPerfectDodge when it receives that event to decide whether to upgrade the dodge.

Keep reading

Dashes and Knockbacks with Root Motion Sources

Dashes and Knockbacks with Root Motion Sources

No root motion in the clip. The task builds the motion from plain numbers and hands it to the movement component, the animation is just cosmetic.
Building Souls-Style Input Buffering on Top of GAS

Building Souls-Style Input Buffering on Top of GAS

A buffered press is simply a struct, a gate tag and one drain function that an anim notify state calls.
MMCs and Executions in GAS: Count the Outputs, Not the Inputs

MMCs and Executions in GAS: Count the Outputs, Not the Inputs

If only one number changes you want an MMC, no matter how complex the formula. Only a hit that writes many attributes needs an execution.