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.
Targeting¶
UTargetingComponent (on the character) owns hard lock, the soft-target assist that attacks lean on, and the action
rotation override described in Combat Animation.
Soft target (direction-scored cone)¶
FindSoftTarget(AttackDirection) is FindTargetInDirection(Direction, SoftTargetRange, SoftTargetConeHalfAngle) —
the one direction-scored acquisition primitive, shared with gamepad lock acquisition (same call at lock range).
| Knob | Default | Why |
|---|---|---|
SoftTargetRange |
450 | 300 read as "assist off" one enemy-step out — most felt on pad, where a neutral aim stick probes along the facing instead of a hovered cursor |
SoftTargetConeHalfAngle |
60° | Widened past 45° because coarse stick / facing-relative aim misses at angles a hovered cursor never produces |
DirectionScoreAnglePenalty |
0.5 | Candidate score = distance × (1 + penalty × angleOff/90°) — alignment with the aim beats raw proximity, which is what keeps the wider cone from grabbing a near enemy off to the side over the one aimed at. 0 restores pure nearest-wins; at 0.5 an enemy 90° off must be a third closer to outscore an aligned one |
Gamepad aim + sticky lock¶
On mouse the attack resolver classifies a cursor world point. On gamepad there is no cursor, so the player controller
synthesizes one: ComputeGamepadAimOffset(Stick, FGamepadAimTuning) (Combat/Types/GamepadAim.h, pure, spec-tested)
maps right-stick deflection onto the intent distance number line in the camera-ground basis the movement input uses
(stick-up aims where stick-up walks), and that point feeds FAttackIntentSnapshot exactly as a click would.
FGamepadAimTuning |
Default | Why |
|---|---|---|
StickDeadband |
0.25 | Subtracted before the response curve so the curve shapes intentional deflection, not sensor noise. Inside the deadband the aim is the character's own location, which the resolver reads as a no-direction attack (keep facing + soft-target assist) — the same reading a feet-click gets |
ResponseExponent |
1.5 | >1 grants fine control near the character and fast reach toward the rim |
MaxAimDistance |
600 | Must sit past the free-aim reach gate or a pad player can never express a deliberate free-aim break |
DirectionalMinDistance |
150 | The resolver's ClickDirectionDeadZone, read from the targeting component so the two can never disagree about where "carries a direction" starts |
The stick shape values come from UEternalInputSettings; the floor comes from UTargetingComponent.
Charged releases aim along SelectChargedReleaseAimStick(Live, HoldCached, Deadband): the live stick wins while
deflected; a neutral live stick falls back to the last past-deadband deflection sampled during this hold, because
the thumb often leaves the stick before the trigger releases (see Charge System).
Lock behaviour is sticky on pad (UsesStickyLock()): no continuous cursor rescore — acquisition and switching
belong to the lock toggle and CycleTarget() (bound to the gamepad cycle input). AcquireDirectionalLock() searches
a cone of LockAcquireConeHalfAngle (45°) along the aim stick (facing when neutral), wide enough to forgive stick
imprecision, narrow enough that locking still expresses a direction choice; the death handoff passes
bFallbackAnyDirection so the lock is not dropped just because the next enemy stands behind the player. Callers also
use UsesStickyLock() to skip cursor-position probes that only mean something when a real cursor exists.
Projectiles¶
FProjectileConfiguration (Combat/Projectiles/ProjectileTypes.h) is the per-ability projectile knob set, spawned by
UProjectileAbility / UEnemyProjectileAbility (see Ability Classes).
| Group | Knobs | Notes |
|---|---|---|
| Behavior | EProjectileBehavior::{Straight, Homing, DelayedHoming, Arc}; HomingBreakAngleDegrees (100, DelayedHoming only) |
DelayedHoming runs through EHomingPhase::{Hover, Accelerating, Homing}; once the target is more than the break angle off the flight line the projectile stops steering |
| Hover | HoverDuration, HoverBobAmplitude, HoverBobFrequency |
The DelayedHoming pre-flight phase (EProjectileState::Hovering) |
| World | bCollideWithWorld |
Required for Arc projectiles that should stop on terrain; also gates the ground-hazard spawn |
| Explosion | bExplodes, ExplosionRadius (min 1), ExplosionCueTag |
The blast is the projectile's only damage — the actor it struck is caught by the overlap like everyone else, so nothing is hit twice. Fires on impact, on stopping against the world, and on lifetime expiry (an armed projectile does not vanish). Damage and poise reuse the spec the ability already built, so there are no separate explosion numbers. The cue is routed through the instigator's ASC so it reaches clients even though the projectile has none |
| Audio | FlybySound, FlybyRadius (500) |
One-shot played locally when the projectile passes near the local player |
Engagement slots¶
UCombatEngagementSubsystem (world subsystem) is the engagement index + attack budget + the slot ring: a
world-anchored ring of bearings per target that spreads a pack around the player. The behaviour (solve cadence, solo
exemption, consumers) is documented on the AI side in
Engagement Slots; the combat-side contract is:
| API | Purpose |
|---|---|
GetOrClaimEngagementBearing(...) → FEngagementSlotResult |
Claim (or re-read) this enemy's bearing on the target's ring |
FindEngagementSlot(Enemy, Target) |
Read-only query of the current assignment |
GetEffectiveEngagementRadius(AI, OrbitRadius, WaitingRange) |
Radius a movement task should hold — the waiting ring promotes/demotes with the attack budget |
ReleaseEngagementSlot(Enemy, Target) / ReleaseAllEngagementSlots(Enemy) |
Free claims on disengage / death |
SolveRing(...) / ScoreSlot(...) |
Internal assignment: cost = angular travel + archetype preference |
EEngagementSlotPreference {Any, Front, Flank, Rear} (Combat/Types/EngagementSlotTypes.h) is the archetype's
preference in the target's facing frame — brutes front, skirmishers flank, ranged rear. The ring itself stays
world-anchored; only the assignment cost reads the facing frame, so the pack never counter-rotates when the target
spins. FEngagementSlotResult carries WorldBearingDeg, bShared (overflow shares a slot at reduced bias) and
HalfSlotAngleDeg (the strafe deadzone, so slot bias and crowd separation don't fight).
Ring geometry is deliberately global (cvars below), not per-enemy config: two enemy types must never author
contradictory rings for the same player. Per-enemy spatial personality lives in FCombatBehaviorConfig.
Debug console variables¶
| CVar | Default | Effect |
|---|---|---|
Eternal.Debug.Targeting |
false | Logs every retarget decision (winner changed, switch happened / blocked by margin) — not every check |
Eternal.AI.Slots.NumSlots |
8 | Bearings on the per-target engagement ring |
Eternal.AI.Slots.SurroundBias |
0.5 | 0 = claim nearest free bearing (one-sided fan), 1 = maximize dispersion (pack wraps the target) |
Eternal.AI.Slots.TravelWeight |
1.0 | Assignment cost weight for angular travel to a bearing |
Eternal.AI.Slots.PreferenceWeight |
1.0 | Assignment cost weight for archetype front/flank/rear preference |
Eternal.AI.Slots.Debug |
false | World-draw slot spokes + claims (listen-server / standalone only) |
Camera¶
UDynamicCameraComponent composes the spring-arm length from one resolver (player zoom + combat/profile multipliers +
target framing extension) so manual zoom is never blocked by a framing blend; locked-target framing biases the pivot
and extends the arm with distance instead of a fixed nudge. Design and status:
DynamicCameraTargetFraming. CompactInitialZoomLevel
(default 0 = closest) replaces InitialZoomLevel under the Compact layout profile (Steam Deck / handheld), because a
closer default keeps the action readable on a small display.
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 + slot ring) | Source/ProjectEternal/Public/Combat/Subsystems/CombatEngagementSubsystem.h |
| Engagement slot types / slot cvars | Source/ProjectEternal/Public/Combat/Types/EngagementSlotTypes.h; Source/ProjectEternal/Private/Combat/Subsystems/CombatEngagementSubsystem.cpp |
UTargetingComponent (soft target, sticky lock, Eternal.Debug.Targeting) |
Source/ProjectEternal/Public/Combat/Components/TargetingComponent.h |
| Gamepad aim mapping | Source/ProjectEternal/Public/Combat/Types/GamepadAim.h; Source/ProjectEternal/Private/Character/EternalPlayer.cpp (synthetic aim point) |
| FProjectileConfiguration | Source/ProjectEternal/Public/Combat/Projectiles/ProjectileTypes.h |
UDynamicCameraComponent (arm resolver, CompactInitialZoomLevel) |
Source/ProjectEternal/Public/Camera/Components/DynamicCameraComponent.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. |
| 2026-08-20 | New sections: Targeting (soft-target cone retune SoftTargetRange 450 / SoftTargetConeHalfAngle 60° / DirectionScoreAnglePenalty angle-weighted scoring), Gamepad aim + sticky lock (FGamepadAimTuning, ComputeGamepadAimOffset, SelectChargedReleaseAimStick, UsesStickyLock / AcquireDirectionalLock / CycleTarget), Projectiles (FProjectileConfiguration: DelayedHoming + hover, explosion-only damage, flyby), Engagement slots (UCombatEngagementSubsystem slot ring API + EEngagementSlotPreference), debug cvar table, Camera (CompactInitialZoomLevel). |
The gamepad pass retuned soft-target globally and added a synthetic aim point; none of it, nor projectiles or the slot ring, had a combat-side home before. |