Skip to content

Combo System

The Combo System manages sequential attack chains where each hit in the chain provides escalating damage and stamina cost multipliers. Players can chain attacks through input buffering, with animation notifies controlling the timing windows.

Architecture

+-------------------+     +----------------------+
|   Player Input    |---->| UPlayerCombatComponent| (Orchestrator)
|   (LMB/RMB)       |     +----------------------+
+-------------------+              |
                                   | ICombatDataProvider
                                   v
                          +------------------+
                          |  UComboComponent |  <-- Extracted component
                          +------------------+
                                   |
                                   v
                          +----------------+
                          |  FComboState   |  <-- Tracks current combo
                          +----------------+
                          | CurrentCount   |
                          | MaxCount       |
                          | ComboType      |
                          | DamageMultiplier|
                          | BufferedInputTag|
                          | bIsInInputWindow|
                          | bCanBufferInput |
                          +----------------+
                                   |
                                   v
                    +----------------------------+
                    | FComboConfiguration        |  <-- Per-weapon, per-hand
                    | (from FWeaponFragment)     |
                    +----------------------------+
                    | LightComboCount: 5         |
                    | HeavyComboCount: 3         |
                    | DamageMultipliers[]        |
                    | StaminaMultipliers[]       |
                    | KnockbackDistance[]        |
                    +----------------------------+

Why This Design?

State-Based Tracking

All combo information lives in a single FComboState struct that replicates atomically. This prevents desync between combo count, multipliers, and input state that could occur with separate replicated variables.

Data-Driven Configuration

Weapon-specific combo behavior is defined in FWeaponFragment::ComboConfigs, a TMap keyed by WeaponHand tag. Each weapon defines combo counts and multiplier curves per hand context (solo, with shield, dual wield), allowing the same weapon to feel different depending on the off-hand.

Animation-Driven Windows

Rather than using timers or fixed durations, animation notifies control when: - Hits can register (ComboHitWindow) - Input can advance the combo (ComboInputWindowState) - Input can be buffered (ComboInputBufferWindowState)

This keeps timing synchronized with the animation regardless of playback rate.

How Combos Work

Combo Flow

[No Combo Active]
       |
       v
StartCombo(LMB) -----> CurrentComboCount = 1
       |               MaxComboCount = 5 (from config)
       |               DamageMultiplier = 1.0
       v
[Animation Playing]
       |
       +--[Input Window Opens]
       |          |
       |          +-- Player presses LMB
       |          |         |
       |          |         v
       |          |   AdvanceCombo()
       |          |   CurrentComboCount = 2
       |          |   DamageMultiplier = 1.1
       |          |
       +--[Buffer Window]
       |          |
       |          +-- Player presses LMB (buffered)
       |          |         |
       |          |         v
       |          |   BufferedInputTag = InputTag.LMB
       |          |   (executes when buffer window ends)
       |
       v
[Animation Ends / ComboComplete]
       |
       v
ResetCombo() -----> CurrentComboCount = 0

Input Windows vs Buffer Windows

Window Type Purpose When Active
Input Window Direct combo advancement During attack recovery frames
Buffer Window Queue next attack During any active frames

Buffer windows are typically wider than input windows, allowing players to "mash" while still getting responsive combos.

Combo Configuration

Default Values by Attack Type

Attack Type Combo Length Damage Progression Stamina Progression Knockback (uu)
Light (LMB) 5 hits 1.0 -> 1.0 -> 1.0 -> 1.0 -> 1.0 1.0 -> 1.0 -> 1.1 -> 1.2 -> 1.3 120 -> 130 -> 140 -> 160 -> 220
Heavy (RMB) 3 hits 1.3 -> 1.3 -> 1.5 1.2 -> 1.2 -> 1.4 200 -> 260 -> 450

Why Light Damage Is Flat

Light steps deal full weapon damage on every step — the flat x1 array is deliberate, not un-tuned. The animation carries the combo's shape instead of a hidden per-step math ramp, and x1 keeps the attack tooltip digit-identical to the item tooltip. Heavy follows the same rule with one authored exception: uniform steps plus a weighted finisher, so there is still one honest per-swing number to print. Deviating from either is a deliberate per-weapon authoring choice on the fragment.

Heavy stamina mirrors the damage shape for the same reason a steep curve was abandoned: an escalating cost made the full chain eat most of the stamina pool while the per-swing recovery block held regen at zero, so the chain could never finish.

When an authored multiplier array is shorter than the combo count, lookups clamp to the last authored entry rather than falling back to 1.0.

Animation Notify Integration

Combat timing is controlled by these notify states placed on attack montages:

Montage Timeline:
|--[Startup]--|--[Active]--|--[Recovery]--|
              |            |              |
              |  HitWindow |              |
              +------------+              |
                           |              |
                           |  InputWindow |
                           +--------------+
              |                           |
              |      BufferWindow         |
              +---------------------------+

Notify Responsibilities

Notify Begin End
ComboHitWindow Enable hit tracing Disable hit tracing
ComboInputWindowState Set bIsInInputWindow = true Set bIsInInputWindow = false
ComboInputBufferWindowState Set bCanBufferInput = true Execute buffered input, reset
ComboComplete Reset the chain immediately (authority only) -

Input Processing Logic

Player presses attack button:
       |
       v
Is combo active?
       |
  +----+----+
  |         |
  No        Yes
  |         |
  v         v
Start    Is in input window?
Combo    |
         +------+------+
         |             |
         Yes           No
         |             |
         v             v
     Advance       Can buffer?
     Combo         |
                   +------+------+
                   |             |
                   Yes           No
                   |             |
                   v             v
               Buffer        Forward to the
               Input         server's rolling
                             slot (early press)

The client never drops an early press: outside the notify buffer window it still forwards the press, and the server's rolling slot holds it until the next window opens.

Key Contracts

FComboState API

Method Returns Purpose
IsActive() bool True if CurrentComboCount > 0 and ComboType valid
IsComplete() bool True if CurrentComboCount >= MaxComboCount
IsInInputWindow() bool True if input window notify is active
HasBufferedInput() bool True if BufferedInputTag is set
Reset() void Clear all state to inactive

UComboComponent API

Method Parameters Purpose
StartCombo() ComboType (GameplayTag) Initialize new combo chain
AdvanceCombo() - Increment count, update multiplier
AdoptComboStep() Step, ComboType Land the chain on a payload-carried step verbatim — both machines run this from the same number
ResetCombo() - End combo, clear state
BufferComboInput() InputTag Set the buffered input tag for the next window
NoteRollingPress() InputTag, Intent Server: capture a press made outside the buffer window (last-input-wins)
OnBufferWindowOpened() - Server: promote the latest rolling press into the pending buffered input
FlushRollingBuffer() - Server: discard a captured-but-unpromoted press
ConsumePendingBufferedIntent() - Take (and forget) the intent the last promoted press carried
PeekNextComboStep() AttackType The 1-based step the next attack of this type will land on
GetStaminaMultiplierForStep() AttackType, Step Authored stamina multiplier for an explicit step
ValidateClaimedStep() Step, AttackType, ServerNow, ClaimedInterval, OutReason Server: accept or refuse a client-claimed step
NoteAcceptedSwing() ServerNow, ClaimedInterval Record an accepted payload swing for the rate gate
NoteMovementCancelKeptCombo() - Arm the grace window after a movement cancel
IsInMovementCancelGrace() - True while a movement-cancelled chain is still continuable
GetComboState() - Access current FComboState

UComboComponent receives weapon data via the ICombatDataProvider interface implemented by UPlayerCombatComponent.

The Attack-Intent Payload

The client resolves the whole swing decision at press time and ships it with the ability activation as FAttackIntentSnapshot:

Field Purpose
ComboStep The 1-based step this swing lands on (0 = no intent)
AimYaw / bHasAim The yaw the click classified to, and whether it resolved at all
TargetPawn The pawn the click resolved to (warp destination), or null for free aim
ClaimedIntervalSeconds Client-measured seconds since its previous dispatched press, for the rate gate
bChargedSwing / HeldSeconds Charged-or-not and the measured hold, for a heavy release

Both machines then run AdoptComboStep() from the same number, so montage index, play rate and the damage / stamina multipliers can never diverge. Any consumer that re-derives one of these from local state re-opens the desync.

Rate Gate

Because the payload lets the client name its own step, the server bounds how fast those swings may arrive:

Knob Default Purpose
MinAcceptedSwingInterval 0.15s Floor on the rolling average of claimed swing intervals
RateGateWindowSwings 4 How many recent accepted swings the average covers
MaxClaimedIntervalDrift 0.5s How far the claimed swing clock may run ahead of the server's, accumulated

The gate runs on the client's claimed interval rather than server arrival spacing, because arrival spacing lies under packet loss — two honest swings can bunch into one tick. An average rather than a per-swing cut lets one fast-fingered press through while sustained macro spam fails. The drift accumulator is what stops a client from simply inflating every claim to beat the floor: honest bunching spends it once and re-syncs, a liar exhausts it. The floor sits far below any real swing duration by design — it exists to stop macro spam advancing the chain, not to model attack speed.

A refused claim is corrected, not silently dropped: Client_CorrectComboState pushes the server's state back to the client so the next press resolves from corrected state instead of re-claiming the refused step.

Movement-Cancel Grace

Cancelling a swing by moving is repositioning, not abandoning the fight. NoteMovementCancelKeptCombo() arms a ComboChainGraceSeconds (2s) window during which the next press continues the chain instead of restarting it — for light and heavy alike, and across a heavy hold (a tap-release continues, a charged release abandons). Every other interrupt — dodge, skill, hit react — still resets. A complete chain has nothing to continue and resets immediately.

Source References

Concept File Line
UComboComponent Source/ProjectEternal/Public/Combat/Components/ComboComponent.h 28
FComboState struct Source/ProjectEternal/Public/Combat/ComboState.h 14
FComboConfiguration struct Source/ProjectEternal/Public/Combat/ComboState.h 98
FAttackIntentSnapshot Source/ProjectEternal/Public/Combat/Types/AttackIntentTargetData.h 19
ICombatDataProvider interface Source/ProjectEternal/Public/Interface/CombatDataProviderInterface.h 18
StartCombo() Source/ProjectEternal/Private/Combat/Components/ComboComponent.cpp 28
AdvanceCombo() Source/ProjectEternal/Private/Combat/Components/ComboComponent.cpp 62
ResetCombo() Source/ProjectEternal/Private/Combat/Components/ComboComponent.cpp 95
GetCurrentComboConfiguration() Source/ProjectEternal/Private/Combat/Components/ComboComponent.cpp 351
Rate gate knobs Source/ProjectEternal/Public/Combat/Components/ComboComponent.h 157
ComboChainGraceSeconds Source/ProjectEternal/Public/Combat/Components/ComboComponent.h 150
ComboHitWindow notify Source/ProjectEternal/Public/Combat/AnimNotifies/ComboAnimNotifies.h 18
ComboInputWindowState notify Source/ProjectEternal/Public/Combat/AnimNotifies/ComboAnimNotifies.h 66
ComboComplete notify (authority-only reset) Source/ProjectEternal/Private/Combat/AnimNotifies/ComboAnimNotifies.cpp 72
Early-press forwarding Source/ProjectEternal/Private/Input/EternalInputSubsystem.cpp 1114

Recent Changes

  • 2026-08-06 — doc reconciled with the payload-driven combo flow: documented the attack-intent payload and AdoptComboStep, the server rate gate, the rolling early-press buffer, the movement-cancel grace window, and the flat light damage multipliers. Corrected the buffered-input state (a tag, not a bool), the heavy damage/stamina progressions, and the ComboComplete notify (immediate authority-only reset, no timer).
  • Combo step travels on the swing: the client resolves the step at press time and both machines adopt that same number, so montage index, play rate and multipliers cannot diverge. The server validates the claim and corrects the client on refusal.
  • Rolling early-press buffer: a press made before any buffer window is held in a server-side last-input-wins slot until the next window opens, instead of being dropped client-side. Flushed on combo reset and attack interrupt.
  • Movement-cancel grace: a swing cancelled by moving keeps the chain continuable for 2s; other interrupts still reset.
  • Light damage flattened to x1 per step: the attack tooltip stays digit-identical to the item tooltip; heavy keeps a uniform-plus-finisher shape in both damage and stamina.
  • Per-step knockback distance: LightComboKnockbackDistance / HeavyComboKnockbackDistance on the combo configuration.
  • Extracted to UComboComponent: Combo logic moved from UPlayerCombatComponent to dedicated UComboComponent. PlayerCombatComponent now acts as orchestrator.
  • ICombatDataProvider interface: Replaces TFunction callbacks with stable interface for accessing weapon data.
  • Delegate forwarding: PlayerCombatComponent forwards combo events from ComboComponent for backward compatibility.
  • Combo state is replicated atomically: FComboState uses ReplicatedUsing to ensure all fields update together on clients.
  • Damage multipliers pulled from configuration: Previously hardcoded, now read from FWeaponFragment::ComboConfigs.
  • Hand-aware combo configuration: Combo config moved from standalone UComboConfigurationDataAsset to FWeaponFragment::ComboConfigs TMap keyed by WeaponHand. Each weapon defines combo feel per hand context. ICombatDataProvider gained GetCurrentWeaponHand() for resolution.
  • Heavy combo last-hit fix: HandleHeavyAttackPressed no longer restarts combo during the last step's montage.