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 layers —
TArray<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.
IsPlayerControlledis not that test —PlayerStatereplicates, 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.EndPlayflushes 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.
bDeathBroadcastPendinggates 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:
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 |
Related Documentation¶
- Combo System - Sequential attack chains
- Charge System - Held input charged attacks
- Poise System - Stagger and break mechanics
- Hit Tracing - Socket-based hit detection
- Combat Animation - Montage management
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. |