The Architectural Breakdown of Standard GAS Melee Approaches
Standard implementations of melee combat in Unreal Engine 5's Gameplay Ability System (GAS) typically rely on one of two flawed design patterns: chaining discrete `InstancedPerExecution` abilities for every swing in a sequence, or driving ability activation through visual scripting state trees. In hardcore action titles like Project Ronin, where hit-confirm evaluations must resolve inside sub-50ms reactive windows, both patterns fail under deterministic scrutiny. Read technical details on outlore game studio project ronin. Reference: Structuring Character State Machines in C++ for Action Combat. Reference: Souls-Like Parry Windows in Unreal Engine C++: An Architecture. Reference: Why UE5 Live Coding Crashes When You Edit Header Files.
Spawning a unique `UGameplayAbility` instance for every attack node produces continuous heap churn. Each activation triggers UObject initialization, spec setup, avatar binding, and garbage collection tracking. While recent community workflows experiment with StateTree tasks driving ability activation via Enhanced Input triggers, evaluating dynamic state transitions across blueprint-exposed graphs adds reflection overhead and timing jitter. When a player inputs a heavy attack cancel within a 3-frame window during frame drops, boolean-toggled AnimNotify states miss the evaluation edge entirely. Read technical details on studio story founder game development journey.
At Outlore Interactives, we circumvent dynamic allocation and visual graph overhead entirely. We implement a single, persistent `InstancedPerActor` melee coordinator ability. This persistent coordinator owns the entire combo graph as an immutable Directed Acyclic Graph (DAG) backed by pre-cached branch tables, resolving branch transitions deterministically against a fixed-size C++ circular input buffer. Read technical details on parry mechanics hit-stop input buffering combat design.
Memory Layout: InstancedPerActor Coordinator vs. Ephemeral Abilities
An `InstancedPerActor` ability is instantiated exactly once per avatar actor upon ability grant, persisting in memory until explicitly cleared. By maintaining state across multiple attack sequences within this single coordinator, we transition between combo nodes by mutating internal sequence cursors and montage sections rather than instantiating new gameplay abilities on the heap. Read technical details on game dev blog technical articles.
The nodes in our combo tree are represented as plain C++ structs within a primary `UDataAsset`. Each node contains its execution payload—montage reference, play rate, root motion warp profiles, and dynamic gameplay cue tags—alongside a sparse branch lookup table. Transition evaluation is a constant-time `O(1)` or deterministic `O(K)` branch check against authoritative gameplay tags.
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "Engine/DataAsset.h"
#include "ComboNodeData.generated."
class UAnimMontage;
UENUM(BlueprintType)
enum class EComboInputTrigger : uint8
{
LightAttack,
HeavyAttack,
Parry,
Evade
};
USTRUCT(BlueprintType)
struct FComboBranch
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly, Category = "Branch")
EComboInputTrigger RequiredInput = EComboInputTrigger::LightAttack;
UPROPERTY(EditDefaultsOnly, Category = "Branch")
FGameplayTagContainer RequiredStateTags;
UPROPERTY(EditDefaultsOnly, Category = "Branch")
FGameplayTagContainer BlockedStateTags;
UPROPERTY(EditDefaultsOnly, Category = "Branch")
FName NextNodeId = NAME_None;
};
USTRUCT(BlueprintType)
struct FComboNode
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly, Category = "Animation")
TObjectPtr<UAnimMontage> Montage = nullptr;
UPROPERTY(EditDefaultsOnly, Category = "Animation")
FName MontageSection = NAME_None;
UPROPERTY(EditDefaultsOnly, Category = "Motion Warping")
FName WarpTargetName = FName("Target");
UPROPERTY(EditDefaultsOnly, Category = "Branches")
TArray<FComboBranch> OutgoingBranches;
};
UCLASS(BlueprintType, Const)
class OUTLORE_API UComboTreeDefinition : public UDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, Category = "Combo Tree")
FName EntryNodeId = FName("Entry");
UPROPERTY(EditDefaultsOnly, Category = "Combo Tree")
TMap<FName, FComboNode> Nodes;
};Deterministic Input Buffering: The Circular Ring Buffer Protocol
Naive melee systems query boolean state flags set by AnimNotify states (such as `bAcceptingInput`). If a player presses an attack button 5 milliseconds prior to the notify triggering, or during an unexpected frame-rate fluctuation, the input drops. In precision action combat, inputs must never be discarded arbitrarily; they must be buffered alongside exact timestamps and hardware directional vectors.
We implement a fixed-size, stack-allocated circular ring buffer inside our coordinator component. As inputs arrive through the Enhanced Input subsystem, we push a raw input packet containing an monotonic game time timestamp, the input trigger enum, and the normalized 2D movement vector. When an animation notify signals an active input window or cancel window, the coordinator scans the buffer for the newest valid command that meets the window's time criteria.
#pragma once
#include "CoreMinimal.h"
#include "ComboNodeData.h"
struct FBufferedMeleeInput
{
EComboInputTrigger Trigger;
double Timestamp;
FVector2D DirectionalVector;
bool bConsumed;
};
template <size_t BufferCapacity>
class TInputRingBuffer
{
public:
TInputRingBuffer() : Head(0), ElementCount(0) {}
void Push(EComboInputTrigger InTrigger, double InTimestamp, const FVector2D& InDirection)
{
Entries[Head] = FBufferedMeleeInput{ InTrigger, InTimestamp, InDirection, false };
Head = (Head + 1) % BufferCapacity;
if (ElementCount < BufferCapacity)
{
++ElementCount;
}
}
bool ConsumeOldestValid(double CurrentTime, double MaxBufferWindow, EComboInputTrigger& OutTrigger, FVector2D& OutDirection)
{
if (ElementCount == 0)
{
return false;
}
// Traverse from oldest to newest to maintain FIFO command fairness
size_t OldestIdx = (Head + BufferCapacity - ElementCount) % BufferCapacity;
for (size_t i = 0; i < ElementCount; ++i)
{
size_t CurrentIdx = (OldestIdx + i) % BufferCapacity;
FBufferedMeleeInput& Candidate = Entries[CurrentIdx];
if (!Candidate.bConsumed && (CurrentTime - Candidate.Timestamp) <= MaxBufferWindow)
{
Candidate.bConsumed = true;
OutTrigger = Candidate.Trigger;
OutDirection = Candidate.DirectionalVector;
return true;
}
}
return false;
}
void Flush()
{
Head = 0;
ElementCount = 0;
}
private:
FBufferedMeleeInput Entries[BufferCapacity];
size_t Head;
size_t ElementCount;
};State Machine Execution & Tag-Driven Branch Selection
State transitions evaluate deterministically without runtime graph reflection. When a combo window opens (governed strictly by authoritative tags such as `Combat.Window.ComboBranch` or `Combat.Window.DodgeCancel`), the persistent coordinator interrogates the current `FComboNode`. It matches pending inputs in the buffer against the branch conditions.
This evaluation is executed purely via gameplay tags on the `UAbilitySystemComponent`. A branch succeeds if the character owns all tags in `RequiredStateTags` and none of the tags in `BlockedStateTags`. If a valid branch is resolved, the coordinator updates its internal state pointer, signals dynamic motion warping parameters, and initiates the target section or montage transition without calling `CommitAbility` or allocating new runtime state structures.
#include "AbilitySystemComponent.h"
#include "ComboNodeData.h"
bool EvaluateBranch(
const FComboNode& CurrentNode,
const UAbilitySystemComponent* ASC,
EComboInputTrigger Input,
FName& OutNextNodeId)
{
if (!ASC)
{
return false;
}
const FGameplayTagContainer& OwnedTags = ASC->GetOwnedGameplayTags();
for (const FComboBranch& Branch : CurrentNode.OutgoingBranches)
{
if (Branch.RequiredInput != Input)
{
continue;
}
// Fast tag containment checks: pre-hashed integer comparison
if (!Branch.RequiredStateTags.IsEmpty() && !OwnedTags.HasAll(Branch.RequiredStateTags))
{
continue;
}
if (!Branch.BlockedStateTags.IsEmpty() && OwnedTags.HasAny(Branch.BlockedStateTags))
{
continue;
}
OutNextNodeId = Branch.NextNodeId;
return true;
}
return false;
}Motion Warping and Frame-Independent Root Motion Deltas
In fast-paced melee combat, animations driven entirely by fixed Root Motion fail when the target dodges or changes elevation. Conversely, pure procedural translation can detach the attack swing from the character's planted feet. Epic's Motion Warping system solves this by dynamically scaling the Root Motion delta translation and rotation on a per-frame basis to match an authoritative warp target.
During execution of a combo node, the persistent coordinator calculates the optimal warp target using the directional vector captured alongside the buffered input. We feed this translation directly into the `UMotionWarpingComponent` before the montage reaches its strike frame. This eliminates snapping while guaranteeing frame-rate independent target alignment across varying client frame rates.
Mathematical Delta Scaling in Motion Warping
The Motion Warping modifier calculates a per-frame translation scale factor. Given the remaining Root Motion distance $D_{\text{anim}}$ and the real displacement to the target $D_{\text{target}}$, the scalar $\alpha$ scales the character's movement component step per tick:
$$\alpha = \frac{D_{\text{target}}}{D_{\text{anim}}}$$
Clamping $\alpha$ within a strict window (e.g., $0.5 \leq \alpha \leq 1.5$) ensures animations never unnaturally stretch or slide across large distances, preserving physical believability during dynamic branching.