If you have played any action game you know the feeling: you press attack a bit before your current swing has recovered, the game ignores you, and the combo you were doing simply dies. Nothing is really broken. The ability could not activate at that exact frame, so the press went nowhere. But from the player point of view, they did everything right and the game ate their input.
Games that feel responsive keep that press. This is called input buffering, and action games have been doing it forever ( fighting games perfected it, and if you play Souls games you are using it all the time without noticing ): an input that can not fire right now is stored, and the moment a follow-up window opens, it fires.
The Gameplay Ability System ( GAS ) ships nothing for this. There is no buffering primitive anywhere in the plugin, and honestly I think that is the right call, as buffering is a bunch of design decisions that no engine default could make for you: when is queueing allowed, what opens the replay window, and which press wins if there are several waiting. What GAS does give you is a really good foundation to build it on.
I have spent years working on this exact layer on Lords of the Fallen, where the input buffer carries a lot of the combat feel, and the version I keep in my Quod Combat Framework ( the GAS combat layer I carry between my action projects ) is what survived playtesting. The whole thing is much smaller than it sounds: a buffer struct, a gate tag, two anim notify windows and one drain function.
How input reaches GAS in the first place
Before buffering anything you need to decide how a key press reaches GAS at all, and this choice matters here. ( If the GAS pieces I name below are still new to you, the GAS map post is the place to start. ) Stock GAS gives you three ways to do it:
- Legacy input IDs. An integer on the ability spec, bound through BindAbilityActivationToInputComponent. It works, but 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, presses accumulate during the frame, and a per-frame pass matches them against the tags stamped on each granted spec. Remapping becomes pure data, which is nice.
- Gameplay events. The input handler simply calls SendGameplayEventToActor with a tag and a
FGameplayEventDatapayload, and any granted ability with a matching trigger activates on the spot.
For combat I use the third one, for two reasons. The first one is the payload: FGameplayEventData carries target data, so a dodge press can travel with its stick direction, a charged attack with its charge level… None of that fits through an integer or a bare tag. The second reason is the one that makes buffering almost trivial: an event is a value, you can store it, and firing it later is exactly the same as firing it live because both go through the same call. ( As a bonus, AI and level scripting can activate combat abilities through this same door, which pays for itself the first time a boss script needs to force a dodge. )
That call is HandleGameplayEvent, and it has a return value that most people ignore:
// GameplayAbilities/Public/AbilitySystemComponent.h
virtual int32 HandleGameplayEvent(FGameplayTag EventTag, const FGameplayEventData* Payload);
It returns the number of abilities that the event activated. Zero means that nobody wanted this press right now. As you can see, this return value is the whole foundation of what comes next.
Try, then buffer
The buffer lives in a component on the PlayerController, as it is a property of the player’s hands and not of the possessed character. Every combat input funnels through one function that tries the live path first and only queues when that fails. The entry we queue is small:
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();
}
There are two lines here that prevent real bugs. The BufferedActions.Reset() on the live path is the first one: the moment a live press activates something, every queued press is stale. If you skip that reset a press can fire twice, once live and once again when the next window drains it.
The other one is the gate tag check. Buffering should never be unconditional. A character standing idle should not queue anything, an idle press either activates immediately or it means nothing. A press is only worth remembering while something is running that a follow-up could chain from, so the rule is a gameplay tag, InputCombatBufferOpen, that is present on the ASC ( Ability System Component ) exactly while that is true. Which brings us to the question: who grants it?
The windows live 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 argument I made in the i-frames post: windows that need to line up with animation belong on the animation, where an animator can drag their edges without needing a programmer. The frames of an attack where an early press should be remembered, and the frame where the follow-up is allowed to start, are exactly this kind of window.
The first notify state is as simple as it gets. It just 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 attack active and recovery frames, and the animator owns 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 names which action family can chain here. The drain matches it hierarchically, so a window tagged AbilityAttack accepts both light and heavy follow-ups, while a tighter one can accept only AbilityDodge.
One thing to notice: everything here is client-side, on purpose. The buffer, the gate check and the drain all run on the owning client, and the only thing that ever leaves the machine is the gameplay event that a drained press turns into. From there it goes through the normal activation path with the normal prediction handshake, so a buffered press predicts exactly like a live one and the two can never behave differently.
The drain
The drain function is where the remaining 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 an order, and keeping that order is what makes a buffered light-heavy string come out as light and then heavy. And one press per window for the same reason: the next press in the queue belongs to the next window. If we drained everything at once, a deliberate string would collapse into a single frame.
MaxBufferedAge is around 0.4 seconds in my framework, and the bug it prevents is a classic. Without an age cutoff, a press the player did five seconds ago ( and already forgot about ) will fire the moment some unrelated window opens, and for the player this feels like the game acting on its own. Also note the clock we use: real time seconds. Hitstop and slow motion stretch game time, and how long a press stays valid should follow the player’s wall clock, and that one does not slow down with the game.
Re-sample the stick at consume time
There is one detail left, and it took shipping a game to learn it. It is the difference between a buffer that players praise and one that gets bug reports. The payload you stored contains the stick direction at press time. By the time the window opens, a few hundred milliseconds later, the player is often already steering somewhere else, and a dodge that fires in the direction the stick pointed 300 milliseconds ago feels exactly like a bug, even though the system replayed the press faithfully.
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 it means the player stopped steering, and in that case the direction they chose when pressing is still the best information you have, so keep it. My dodge payload is a small custom target data type holding a net-quantized input vector, and that is what makes this refresh possible: the direction is data on the event instead of a hardcoded read inside the ability.
The buffer also makes some features almost free. For instance, two-button inputs: in my framework, two events landing within 0.15 seconds of each other ( in any order ) synthesize a third event that buffers like any other press. The kick that requires attack plus block simply becomes one more FBufferedAction in the same queue, with the same age cutoff and the same drain rules.
Recap
- The pattern is try-then-buffer: fire HandleGameplayEvent first, and only if it returns zero and the
InputCombatBufferOpengate tag is present, queue tag, payload and timestamp. One notify state holds the gate open, a second one drains the queue oldest-first, one press per window, and clears the rest. A successful live press must clear the queue too, if not a press can fire twice. - Put an age cap on buffered entries ( ~0.4 seconds, in real time so hitstop does not stretch it ). Without it, a forgotten press will fire whenever some unrelated window opens.
- Refresh direction payloads at consume time when the stick is clearly deflected, and keep the press-time direction when it is near neutral. Buffered and live presses go through the same call, so they can never behave differently, and prediction does not care which one it was.


