Skip to content

Combat Overview

The combat system provides a component-based architecture for managing attacks, damage, and combat state. It integrates with GAS for all damage, abilities, and status effects while coordinating specialized subsystems for combos, charging, poise, and hit detection.

Architecture

+---------------------------+
|      AEternalCharacter    |
+---------------------------+
            |
            v
+---------------------------+
|    UCombatComponent       |  <-- Base class (shared functionality)
|    (ICombatInterface)     |
+---------------------------+
      /            \
     v              v
+---------------+  +------------------+
| UPlayerCombat |  | UEnemyCombat     |
| Component     |  | Component        |
+---------------+  +------------------+
| - Combo State |  | - Hit Reaction   |
| - Charge State|  | - AI Integration |
| - Weapon Mgmt |  +------------------+
| - Montage Mgmt|
+---------------+

Supporting Components (all on Character):
+-------------------------+  +----------------------+  +---------------------+
| UHitTraceActorComponent |  | UPoiseSystemComponent|  | UCombatEffectsManager|
| - Socket-based tracing  |  | - Stagger/break      |  | - VFX/Audio          |
| - Multi-profile support |  | - GAS attribute      |  | - Shake/flash/impulse|
+-------------------------+  +----------------------+  +---------------------+
                                                                  |
                                             requests, never owns |
                                                                  v
                                                   +---------------------------+
                                                   | UCombatHitStopSubsystem   |
                                                   | (World subsystem)         |
                                                   | - Sole owner of world     |
                                                   |   time dilation           |
                                                   +---------------------------+

Why This Design?

Component-Based Inheritance

The base UCombatComponent handles functionality common to all combatants (hit direction, invincibility, montage execution), while specialized subclasses add player-specific systems (combos, charging) or enemy-specific behavior (hit reaction thresholds, AI counterattacks).

The base also owns server-authoritative knockback so it can fire independently of the hit-react ability. This lets the deterministic poise path (see Poise System) launch a target on a poise break without routing through a GAS hit-react montage:

  • ApplyKnockback() - root-motion knockback along the last attacker's facing, scaled by hit severity (server-only, enemies only).
  • Per-attack transport slots - the attacker stamps these on the victim just before the hit resolves, decoupling the value from the ability that later consumes it:
Slot Setter / Consumer Sentinel
IncomingKnockbackDistance SetIncomingKnockbackDistance() / ConsumeIncomingKnockbackDistance() -1 = unset (fall back to severity table)
IncomingHitStopIntensity SetHitStopIntensity() / ConsumeHitStopIntensity() 1.0 = neutral (no scaling)
  • Static severity tables drive distance, scaling, and duration for hits that don't stamp a per-attack value (AOE, projectiles):
Method Purpose
GetKnockbackDistanceForSeverity() Fallback distance when no per-attack value was stamped
GetSeverityKnockbackMultiplier() Distance multiplier by severity (harder hits launch further)
GetKnockbackDurationForSeverity() Force duration by severity (shorter = snappier)

Player vs Enemy Specialization

Aspect UPlayerCombatComponent UEnemyCombatComponent
Attack System Combo chains, charged attacks AI-driven attack selection
Hit Response Stagger via Poise Hit reaction threshold + counterattack
Montage Source Weapon-based (updates on equip) Character-specific DataTable
Damage Calc Weapon stats + combo/charge multipliers Fixed ability damage

Effect Rows Are Layer Compositions

A combat-effect row (FCombatEffectConfig in CombatEffectsConfiguration) is a list of effect layersTArray<TInstancedStruct<FCombatEffectLayer>>, the same instanced-struct idiom as item fragments. Each channel is its own layer struct carrying only its own knobs and owning its own gating policy: FNiagaraEffectLayer (world or owner-attached), FAudioEffectLayer, FCameraShakeLayer (target+instigator two-camera dedup, local controllers only), FHitStopLayer (attacker-local gate, routes to UCombatHitStopSubsystem), FCameraImpulseLayer, FScreenFlashLayer (instigator-local gate checked before any load), FSilhouetteEchoLayer (routes to USilhouetteEchoSubsystem). A row only serializes the channels it actually uses, and adding a channel means adding a layer struct — no god-row growth, no manager sequence edit.

UCombatEffectsManager lives on each character and is now just resolution + dispatch: resolve the row (tag map or weapon×surface impact matrix), build one FCombatEffectPlayContext (owner, instigator, target, location, final intensity, locality — computed once), and let every layer play itself. The per-channel entry points (TriggerVisualEffect / TriggerSoundEffect / TriggerCameraShake / TriggerHitStopForTag) survive as layer-type-filtered plays because UAnimNotify_CombatEffect drives channels individually with its own toggles.

Impact Feedback Is Per-Character, World Time Is Not

The local channels of an impact — VFX, sound, camera shake, directional and zoom impulses, screen flash — play on each machine where the row fires. Hit stop is the exception: it dilates world time, which is global state, so it is owned by UCombatHitStopSubsystem and the layer only requests it. Silhouette echoes (pose-frozen ghost copies of the character wearing a distortion material) are likewise owned by a world subsystem, USilhouetteEchoSubsystem, which pools the poseable-mesh ghosts and preloads echo materials so the first shout doesn't pay a synchronous load; it does nothing on a dedicated server.

The distinction is not academic. When each manager drove world dilation itself, two overlapping hits each cached "the original" dilation and each restored it, so the second could adopt the first one's hit stop as its baseline and its timer could cut the first window short. Centralising the owner means the baseline is captured once while idle, overlapping requests extend rather than replace (longest remaining wins), and the strongest scale in a cluster is the one that reads.

Two rules follow from all of this being cosmetic and local:

  • Local machine, local decision. Shakes go to local controllers only (RPC'ing them to remote controllers doubles them on a listen host); the flash and hit stop test for a genuinely local pawn. IsPlayerControlled is not that test — PlayerState replicates, so it answers true for every remote player's pawn on every machine.
  • Hit stop refuses on servers that have clients. World dilation replicates, so a dedicated server (or a listen server with a client connected) would slow everyone's world. A listen server with nobody connected — single-player PIE — takes the window normally.

A breaking hit suppresses its own impact row entirely so the poise Sunder row can own the frame. See Break Presentation for the seam and the reasoning.

Every Damage Path Asks One Question About Factions

Friendly fire is not an AI concern — it is a property of the damage path, and any ability that reaches a target passes through the same gate: UCombatFactionStatics::ShouldBlockFriendlyFire(Attacker, Victim, bAttackCanHitAllies).

EEternalFaction is deliberately narrow — Neutral, Players, Enemies — and enemy identity is a pure class check (AEternalEnemy), so it holds even for an unpossessed enemy. V1 policy blocks only same-faction enemy hits; a player-faction attacker is never gated, so player-vs-player keeps its existing behavior. The per-attack opt-in is bCanHitAllies on the ability, which is what a hazard like the larva death explosion sets to damage its own side.

It is an enum behind a statics class rather than an inline check at each site precisely so that when factions become data-driven, only GetFaction changes. If you author an ability that should hit allies, the flag is your job — the gate defaults to protecting them.

See Pack Coordination for the AI-side behavior this enables.

Event-Driven Communication

Components communicate via delegates rather than direct calls: - OnHit - Broadcast when character receives a hit - OnDeath - Broadcast when health reaches zero (deferred one tick — see Deferred Death Broadcast) - OnPoiseBreak - Broadcast when poise breaks

How Combat Flows

Player Attack Flow

Input -> PlayerCombatComponent::StartCombo()
           |
           v
     FComboState updated (count, multiplier)
           |
           v
     Ability activated via GAS event
           |
           v
     Montage plays with animation notifies
           |
           +--[ComboHitWindow]-> HitTraceActorComponent traces
           |                              |
           |                              v
           |                    OnItemAdded.Broadcast(HitResult)
           |                              |
           |                              v
           |                    CombatComponent::OnActorHit()
           |                              |
           |                              v
           |                    GAS event -> Damage Execution
           |
           +--[ComboInputWindow]-> Buffer next attack

The buffer is a single server-side rolling slot (last press wins); see Combo System for the full promotion rules.

Hit Processing Flow

HitResult received
       |
       v
Calculate hit direction (Front/Back/Left/Right)
       |
       +-> Set on victim's CombatComponent
       |
       v
Send GAS event with TargetData
       |
       v
Damage Execution calculates final damage
       |
       v
Apply to victim's Health attribute
       |
       +-> If Health <= 0: death broadcast queued for next tick
       +-> If Poise broken: OnPoiseBreak.Broadcast()

Deferred Death Broadcast

The fatal branch does not broadcast inline. UCombatComponent sets bDeathBroadcastPending, captures PendingDeathCharacter / PendingDeathController as weak pointers, and schedules BroadcastPendingDeath via SetTimerForNextTick.

The reason is re-entrancy: the fatal handler runs inside PostGameplayEffectExecute, and the death cascade removes gameplay effects, cancels abilities, flips collision and ragdoll, and ends hazard overlaps — all mutating the very effect container that is still executing. A direct weapon hit usually lands at a montage boundary and gets away with it; a damage-over-time tick killing mid-montage is exactly where this bites.

Two guarantees come with the defer:

  • Nothing is swallowed by a Destroy. EndPlay flushes a pending broadcast, so loot, kill credit, and encounter-clear still fire even if the pawn is torn down inside the one-tick window. With no world to defer through (teardown edge) the broadcast happens inline rather than being dropped.
  • A second lethal tick in the window is coalesced. bDeathBroadcastPending gates the schedule, so the death broadcast happens exactly once.

On the server, a non-Light enemy hit also calls UCombatComponent::ApplyKnockback(). The launch is suppressed while the victim holds State.HyperArmor - unless the hit broke poise, in which case the severity is upgraded to Stagger and the knockback fires anyway.

Component Ownership

Component Owner Why
UCombatComponent Character Dies with pawn; combat state shouldn't persist
UHitTraceActorComponent Character Traces weapon sockets on pawn mesh
UPoiseSystemComponent Character Poise resets on respawn
UCombatEffectsManager Character Effects tied to physical pawn

Key Contracts

ICombatInterface

Method Returns Purpose
GetHitDirection() EHitDirection Direction of last received hit
GetCombatComponent() UCombatComponent* Access base combat functionality
GetPlayerCombatComponent() UPlayerCombatComponent* Access player-specific combat (nullptr for enemies)
GetEnemyCombatComponent() UEnemyCombatComponent* Access enemy-specific combat (nullptr for players)

ICombatEffectsInterface

Method Parameters Purpose
TriggerEffectByTag() Tag, Location, Target, Intensity Spawn VFX/audio by tag
TriggerHitStop() Duration, TimeScale Freeze-frame on impact — forwards to UCombatHitStopSubsystem
TriggerSlowMotion() TimeScale, Duration Dramatic slow-motion

Hit Direction Calculation

Hit direction uses the angle between attacker and victim facing:

         Front (-45 to 45)
              |
    Left  ----+---- Right
  (-135 to   |    (45 to 135)
    -45)     |
         Back
   (135 to -135)

The resulting direction is stored as both EHitDirection enum and FGameplayTag for GAS integration.

Source References

Concept File
UCombatComponent class Source/ProjectEternal/Public/Combat/Components/CombatComponent.h
ApplyKnockback / severity tables / transport slots Source/ProjectEternal/Public/Combat/Components/CombatComponent.h
UPlayerCombatComponent class Source/ProjectEternal/Public/Combat/Components/PlayerCombatComponent.h
UEnemyCombatComponent class Source/ProjectEternal/Public/Combat/Components/EnemyCombatComponent.h
ICombatInterface Source/ProjectEternal/Public/Interface/CombatInterface.h
Hit direction & OnActorHit processing Source/ProjectEternal/Private/Combat/Components/CombatComponent.cpp
UCombatEffectsManager (shake/flash/impulse routing, local-pawn test) Source/ProjectEternal/Public/Combat/Components/CombatEffectsManager.h
UCombatHitStopSubsystem (world time dilation) Source/ProjectEternal/Public/Combat/Subsystems/CombatHitStopSubsystem.h
UCombatScreenFlashModifier (local screen flash) Source/ProjectEternal/Public/Camera/Modifiers/CombatScreenFlashModifier.h
UCombatEngagementSubsystem (engagement index + attack budget) Source/ProjectEternal/Public/Combat/Subsystems/CombatEngagementSubsystem.h
UCombatFactionStatics (friendly-fire gate) Source/ProjectEternal/Public/Combat/CombatFactionStatics.h

Recent Changes

Date Change Impact
- PlayerCombatComponent now owns montage updates: reacts to OnWeaponChanged() and calls UpdateCombatMontages() internally rather than being told which montages to use. Decouples weapon equipping from montage assignment.
- Montage source priority: right-hand weapon montages take priority, falling back to left-hand, then clearing if unarmed. Predictable montage selection on equip changes.
2026-06-17 Knockback moved to the shared UCombatComponent base (ApplyKnockback, per-attack transport slots, static severity tables). The deterministic poise path can fire knockback without routing through the hit-react ability.
2026-07-27 Block-stat resolution moved behind UCombatComponent virtuals (GetBlockWeaponFragment / GetBlockResourcePool / GetBlockResourceCost) so enemies can block through the player's mitigation path with their own stats and their own resource unit. UEternalAttributeSet stays free of enemy-type knowledge; see Block System.
2026-07-27 Hit stop extracted from the per-character effects manager into UCombatHitStopSubsystem; screen flash added as a camera modifier; camera shakes and flash gated to genuinely local pawns; a breaking hit suppresses its own impact row. Overlapping hits no longer corrupt each other's time-dilation baseline; break feedback stops being masked by the hit that caused it.
2026-07-28 Effect rows refactored from a 17-field god-row into instanced-struct effect layers (CombatEffectLayer.h); echo mechanics moved to USilhouetteEchoSubsystem (ghost pooling + material preload); bNoAudio became row-level bExpectedSilent (absence of an audio layer is the real silence signal); the never-used persistent Niagara/Audio components on the manager were deleted. Rows serialize only the channels they use; each channel's gating policy lives with its knobs; new payoff channels are one new layer struct instead of a pass over every row and the manager sequence.
2026-08-06 Death broadcast deferred one tick (bDeathBroadcastPending + SetTimerForNextTick), flushed from EndPlay, coalesced across a second lethal tick. The fatal handler runs inside PostGameplayEffectExecute and the death cascade mutates the executing effect container; a DoT tick killing mid-montage was the crashing case. Loot and kill credit still fire if the pawn is destroyed inside the window.