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 |
Related Documentation¶
- Combat Overview - Component hierarchy
- Charge System - Alternative to combo (hold vs tap)
- Hit Tracing - Hit detection during combo windows
- Combat Animation - Montage selection per combo step
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 theComboCompletenotify (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/HeavyComboKnockbackDistanceon 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:
FComboStateusesReplicatedUsingto 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
UComboConfigurationDataAssettoFWeaponFragment::ComboConfigsTMap keyed byWeaponHand. Each weapon defines combo feel per hand context.ICombatDataProvidergainedGetCurrentWeaponHand()for resolution. - Heavy combo last-hit fix:
HandleHeavyAttackPressedno longer restarts combo during the last step's montage.