< Return to Engineering Blog
Engine ArchitectureOCT 12, 2026 // 6 MIN READ

UE5 GAS Melee Combo Tree Architecture in C++

Architect a zero-allocation, deterministic melee combo tree in UE5 GAS using a persistent InstancedPerActor coordinator and ring input buffer.

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.

DIAGRAM // ARCHITECTURE
CLIENT_INPUT Sub-frame Buffering SIMULATION_CORE Hit-Stop & Physics GPU_PIPELINE Zero-Stutter Presentation
// Figure 1: Architectural pipeline and execution flow for ue5 gameplay ability system melee combo tree architecture.

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.

cpp
#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.

cpp
#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.

cpp
#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.

KEY ARCHITECTURAL TAKEAWAYS

  • 01.Instantiating abilities per attack step incurs unnecessary heap allocation and Garbage Collection pressure; use a single, persistent InstancedPerActor ability to manage the entire combo cycle.
  • 02.Replace fragile AnimNotify boolean flags with a timestamped circular ring buffer to ensure inputs are reliably buffered across fluctuating frame times.
  • 03.Rely on pre-cached FGameplayTagContainer hash lookups over sparse branch tables to achieve zero-allocation, deterministic combo branching.
  • 04.Couple branch evaluations with Motion Warping target updates to dynamically scale root motion translation without introducing visual skating.

FREQUENTLY ASKED QUESTIONS

Why not use UE5 StateTree tasks to drive melee combo logic?

State Trees are powerful for hierarchical state selection, but driving continuous melee execution through StateTree tasks introduces unnecessary reflection and struct evaluations per frame. In high-frequency, sub-50ms hit-confirm scenarios, native C++ branch lookup tables on pre-cached data assets provide zero-allocation, deterministic execution without intermediate graph interpretation overhead.

How does the InstancedPerActor coordinator replicate over the network?

The persistent coordinator uses standard GAS RPC channels. The client predicts local branch transitions instantly from the ring buffer and sends an authoritative Server RPC with the selected Node ID and confirmed input timestamp. If the server detects a desynchronization, it forces montage playback to the correct authoritative node section via replicated tags.

How are hit-confirm pauses (hitstop) handled without breaking Root Motion?

Hitstop is executed by dynamically setting the character's CustomTimeDilation to near-zero (e.g., 0.05) for a fixed duration (typically 50-80ms) rather than pausing the montage completely. This preserves the root motion tick updates and prevents physics glitches in the CharacterMovementComponent.

// CONNECTED_ENGINEERING_DOSSIERS

All Articles >