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

Building Souls-Style Input Buffering on Top of GAS

GAS has no input buffering built in, so let's build it: gameplay events, a try-then-buffer component, a gate tag, anim notify windows and a drain function.

You press attack a little bit before your current swing has recovered. The ability can not activate yet, and the press goes nowhere. From the player’s side it feels like the game ignored the button.

Input buffering keeps that press until a follow-up window opens. Fighting games have refined this for years, and Souls games use it throughout combat. Let’s build it on top of the Gameplay Ability System ( GAS ).

GAS does not have a built-in buffer, which I think is fine. We still need to decide when inputs can be queued, what opens the follow-up window, and which press to use if several are waiting. GAS gives us the pieces to implement those decisions.

The version here is from my Quod Combat Framework, and it is more or less the buffer we had on Lords of the Fallen. It uses a buffer struct, a gate tag, two anim notify windows and a function that consumes the buffered inputs.

How input reaches GAS

Before buffering anything, you need to decide how a key press reaches GAS at all. If the GAS pieces below are new to you, start with the GAS map post. Stock GAS gives you three ways:

  • Legacy input IDs. An integer on the ability spec, bound through BindAbilityActivationToInputComponent. The press carries no payload and remapping means touching binding code.
  • Lyra-style input tags. A data asset pairs each Enhanced Input action with a gameplay tag, and a per-frame pass matches the pressed tags against the tags on each granted spec. Remapping becomes data, which is nice.
  • Gameplay events. The input handler calls SendGameplayEventToActor with a tag and a FGameplayEventData payload, and any granted ability with a matching trigger activates.

For combat I use gameplay events. FGameplayEventData carries target data, so a dodge press can include its stick direction and a charged attack can include its charge level. An integer or a bare tag does not hold that information.

We can also store the event and use the same call to fire it later. That is useful for the buffer.

The call is HandleGameplayEvent:

// GameplayAbilities/Public/AbilitySystemComponent.h
virtual int32 HandleGameplayEvent(FGameplayTag EventTag, const FGameplayEventData* Payload);

It returns the number of abilities that the event activated, so zero means that nobody wanted this press right now.

Try, then buffer

I put the buffer component on the PlayerController, since it stores the player’s inputs independently of the possessed character. Every combat input goes through this function, which tries to activate it before queueing:

USTRUCT()
struct FBufferedAction
{
    GENERATED_BODY()

    // The gameplay event this press wanted to fire.
    FGameplayTag EventTag;

    // Which follow-up window is allowed to consume it.
    FGameplayTag AbilityTag;

    // Stick direction, charge, target. Stored at press time,
    // refreshed at consume time.
    FGameplayEventData Payload;

    float Timestamp = 0.f;
};
void UQuodActionBufferComponent::TryActivateOrBuffer(FGameplayTag EventTag,
    FGameplayTag AbilityTag, const FGameplayEventData& Payload)
{
    UAbilitySystemComponent* ASC = GetOwnerAbilitySystemComponent();
    if (!ASC)
    {
        return;
    }

    // Live path first.
    FGameplayEventData LivePayload = Payload;
    if (ASC->HandleGameplayEvent(EventTag, &LivePayload) > 0)
    {
        // Something ran, so every queued press is now stale.
        BufferedActions.Reset();
        return;
    }

    // Nothing wanted the press. Queue it, but only while a running
    // action says that buffered input makes sense.
    if (!ASC->HasMatchingGameplayTag(QuodTags::Input_Combat_BufferOpen))
    {
        return;
    }

    FBufferedAction& Entry = BufferedActions.AddDefaulted_GetRef();
    Entry.EventTag = EventTag;
    Entry.AbilityTag = AbilityTag;
    Entry.Payload = Payload;
    Entry.Timestamp = GetWorld()->GetRealTimeSeconds();
}

The BufferedActions.Reset() on the live path clears queued presses once a new press activates something. Without this, a press can fire twice.

The tag check controls when queueing is allowed. In this setup, an idle character should not queue anything. We only keep a press while another action is running that it could follow up from.

For that we use InputCombatBufferOpen on the ASC ( Ability System Component ).

The windows on the montage

The gate tag is opened and closed by an anim notify state, and the window that drains the buffer is a second notify state. This is the same idea as in the i-frames post: windows that need to line up with the animation belong on the animation, where an animator can drag their edges without a programmer.

The first notify state simply holds the gate open for its own duration:

void UQuodANS_BufferWindow::NotifyBegin(USkeletalMeshComponent* MeshComp,
    UAnimSequenceBase* Animation, float TotalDuration,
    const FAnimNotifyEventReference& EventReference)
{
    if (UAbilitySystemComponent* ASC = GetASC(MeshComp))
    {
        ASC->AddLooseGameplayTag(QuodTags::Input_Combat_BufferOpen);
    }
}

void UQuodANS_BufferWindow::NotifyEnd(USkeletalMeshComponent* MeshComp,
    UAnimSequenceBase* Animation, const FAnimNotifyEventReference& EventReference)
{
    if (UAbilitySystemComponent* ASC = GetASC(MeshComp))
    {
        ASC->RemoveLooseGameplayTag(QuodTags::Input_Combat_BufferOpen);
    }
}

In practice the buffer window covers most of the active and recovery frames of the attack, and the animator decides where it starts and ends per montage, the same way they own the damage windows and the i-frame windows.

The second notify state marks the frame where a queued press can become a real action. On my attack montages it sits where the recovery becomes cancellable, and its only job is calling the drain:

void UQuodANS_ComboWindow::NotifyBegin(USkeletalMeshComponent* MeshComp,
    UAnimSequenceBase* Animation, float TotalDuration,
    const FAnimNotifyEventReference& EventReference)
{
    ACharacter* Character = MeshComp ? Cast<ACharacter>(MeshComp->GetOwner()) : nullptr;
    if (!Character || !Character->IsLocallyControlled())
    {
        return; // input is a client thing, only the owning client drains
    }

    if (UQuodActionBufferComponent* Buffer = FindBufferComponent(Character))
    {
        Buffer->ProcessBufferedActions(WindowAbilityTag);
    }
}

WindowAbilityTag is a property the animator sets on the notify, and it says which action family can chain here. The drain matches it hierarchically, so a window tagged AbilityAttack accepts light and heavy follow-ups, and a tighter one can accept only AbilityDodge.

Everything here is client-side. The buffer, the gate check and the drain run on the owning client, and the only thing that leaves the machine is the gameplay event of a drained press, which goes through the normal activation path and predicts like a live one.

The drain

The drain function is where the rest of the design decisions live:

void UQuodActionBufferComponent::ProcessBufferedActions(FGameplayTag WindowAbilityTag)
{
    const float Now = GetWorld()->GetRealTimeSeconds();

    // Oldest press first, the player pressed them in this order.
    BufferedActions.StableSort([](const FBufferedAction& A, const FBufferedAction& B)
    {
        return A.Timestamp < B.Timestamp;
    });

    for (FBufferedAction& Entry : BufferedActions)
    {
        if (!Entry.AbilityTag.MatchesTag(WindowAbilityTag))
        {
            continue; // this window does not accept that action
        }

        if (Now - Entry.Timestamp > MaxBufferedAge)
        {
            continue; // too old, the player already forgot about this press
        }

        RefreshPayload(Entry);

        if (GetOwnerAbilitySystemComponent()->HandleGameplayEvent(
                Entry.EventTag, &Entry.Payload) > 0)
        {
            break; // one press per window
        }
    }

    // Whatever did not fire is dropped.
    BufferedActions.Reset();
}

Why oldest first? Because the player pressed the buttons in that order, and keeping it is what makes a buffered light-heavy string come out as light and then heavy. One press per window for the same reason, the next press belongs to the next window.

MaxBufferedAge is around 0.4 seconds in my framework. Without it, a press the player did five seconds ago fires when some unrelated window opens, which feels like the game acting on its own. Also notice that the clock is real time seconds. Hitstop and slow motion stretch game time, and how long a press stays valid should follow the wall clock of the player.

Re-sampling the stick

The stored payload has the stick direction from when the button was pressed. By the time the window opens, a few hundred milliseconds later, the player may be steering somewhere else. A dodge using the direction from 300 milliseconds ago can feel wrong.

So the drain refreshes direction payloads just before firing:

void UQuodActionBufferComponent::RefreshPayload(FBufferedAction& Entry) const
{
    const FVector StickNow = ComputeWorldSpaceInputVector();

    // Only override the stored direction if the player is
    // actually steering right now.
    if (StickNow.Length() > 0.5f)
    {
        SetDirectionOnTargetData(Entry.Payload.TargetData, StickNow);
    }
}

The half deflection threshold matters. If the stick is near neutral at consume time the player stopped steering, and the direction they chose when pressing is still the best information you have, so we keep it. My dodge payload is a small custom target data type with a net quantized input vector, and that is what makes this refresh possible, the direction is data on the event instead of a read inside the ability.

I also use this for two-button inputs. Two events arriving within 0.15 seconds of each other, in either order, produce a third event that buffers like any other press. A kick that requires attack plus block becomes another FBufferedAction in the same queue.

Recap

We try HandleGameplayEvent first. If it returns zero and InputCombatBufferOpen is present, we queue the press. A live activation clears the queue.

One notify holds that gate open and another consumes the queue, oldest first, with one press per window and the rest dropped. The age cap is ~0.4 seconds in real time. At consume time we refresh the direction if the stick is clearly deflected, or keep the original direction if it is near neutral.

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.
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.
Dodge i-Frames with GAS: Anim Notify Windows and Perfect Dodges

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

The dodge ability grants no invulnerability at all. A notify on the montage does it, and you get perfect dodges almost for free.