Charge System¶
The Charge System enables held-input charged attacks with multiple charge levels. Each level provides escalating damage, poise damage, and stamina cost multipliers. The wind-up is a predicted GAS ability; the charge measurement is server-authoritative and every machine derives the swing's charge from one claimed number that rides the attack payload.
Architecture¶
+-------------------+
| Input Held (RMB) |
+-------------------+
|
v
+------------------------------+
| UPlayerCombatComponent | (Orchestrator)
| ::StartCharging() |
+------------------------------+
|
v
+------------------------------+
| UChargeWindupAbility | <-- LocalPredicted ability
| (looping wind-up montage via | owns the VISUAL
| PlayMontageAndWait) |
+------------------------------+
| server instance drives
v
+-------------------+
| UChargeComponent | <-- measures + drives level VFX
+-------------------+
|
v
+------------------------+ +-----------------------+
| FChargeState |<--->| FChargeConfiguration |
+------------------------+ | (per-weapon, per-hand)|
| bIsCharging | +-----------------------+
| ChargeStartTime | | ChargeLevels[] |
| CurrentChargeTime | | ChargeMontageTag |
| CurrentChargeLevel | | MinChargeThreshold |
| PreviousChargeLevel | | ChargeRateMultiplier |
| bChargeReleased | | GetMaxChargeTime() |
| bChargeWasSuccessful | +-----------------------+
| FinalChargeTime |
+------------------------+
|
v (every 0.1s, SERVER ONLY)
+------------------------+
| UpdateChargeProgress() |
+------------------------+
|
v
Level transitions trigger:
- Visual effects
- Audio cues
- Delegate broadcasts
Clients observe level progression through OnRep_CurrentChargeState.
Why This Design?¶
The Wind-Up Is an Ability, the Measurement Is a Component¶
The looping wind-up montage plays through the stock ability montage pipeline on UChargeWindupAbility — predicted
on the owning client, replicated to simulated proxies by the ASC, and ending the ability stops exactly the montage
instance it started. A hand-rolled play/stop RPC pair could strand the loop on a machine or race the follow-up swing;
an ability cannot. UChargeComponent keeps only what the ability must not own: the authoritative hold measurement,
the replicated level progression that drives per-level effects everywhere, and the per-weapon config lookup.
The Hold Is Measured Where the Button Lives¶
The client measures its own hold in platform time and ships it on the swing payload as
FAttackIntentSnapshot::HeldSeconds. The server validates that claim against its own measured hold, rejecting anything
that runs more than MaxClaimedHoldSkew (0.3s) longer. The replicated CurrentChargeState is ~RTT stale on the
owning client and is deliberately never read to decide a swing — a tap could otherwise still show the previous
hold's time and mispredict as charged.
Level-Based Progression¶
Rather than linear scaling, charges use discrete levels with specific thresholds. This creates clear "breakpoints" that players can feel and react to, making the system more satisfying than smooth progression.
Charging Does Not Break the Chain¶
Starting a charge no longer cancels an active combo. A swing cancelled by movement leaves the chain in a grace window, and that window survives the hold: a tap-release continues the chain where it left off, while a charged release abandons it. This keeps holding heavy from silently costing the player their chain for repositioning.
How Charging Works¶
Charge Flow¶
[Heavy Input Pressed (Hold)]
|
v
UPlayerCombatComponent::StartCharging()
|
v
UChargeWindupAbility activates (LocalPredicted)
|
+---> plays the looping wind-up montage
+---> client stamps its own press time (platform clock)
|
v (server instance)
UChargeComponent::BeginCharge()
|
+---> bIsCharging = true
+---> ChargeStartTime = CurrentServerTime
+---> Start 0.1s update timer (server only)
|
v
[UpdateChargeProgress() loop — server]
|
+---> CurrentChargeTime = Now - ChargeStartTime
+---> Clamp to GetMaxChargeTime()
+---> NewLevel = GetChargeLevelIndexForTime(...)
|
+---> if NewLevel > CurrentChargeLevel:
| HandleChargeLevelChanged(NewLevel, OldLevel)
| Trigger VFX/Audio (clients: via OnRep)
|
[Heavy Input Released]
|
+---> ReleaseChargeWindup() -> ability ends
| server: EndCharge()
| FinalChargeTime resolved,
| tap vs charged decided
|
v
Client's own HeldSeconds >= MinChargeThreshold?
|
+----+----+
| |
Yes No
| |
v v
Charged Normal heavy
swing combo swing
(HeldSeconds on the attack payload; the server validates
the claim, then StoreChargeStateFromClaim derives the level)
Charge Levels¶
Each charge level defines a time range and associated multipliers. Values are authored per weapon; the retune pass sets every level's poise multiplier to the ladder below and raises damage to it as a floor, so a deliberately hotter authored weapon keeps its edge:
| Level | Damage | Poise | Typical Use |
|---|---|---|---|
| 0 (None) | - | - | Release below the threshold = normal attack |
| 1 | 1.6x | 1.6x | Quick charge |
| 2 | 1.9x | 1.9x | Standard charge |
| 3 | 2.3x | 2.3x | Full charge |
Levels beyond the third continue at +0.4 per level. The ladder exists because charge replaces the combo step multiplier on the swing it lands on, so every level must beat the strongest step it can replace (heavy finisher x1.5) in both damage and poise — otherwise charging is a downgrade.
Stamina multipliers stay per-level authored data (StaminaCostMultiplier) and are not touched by the ladder.
MinChargeThreshold (default 0.2s) distinguishes taps from holds. There is no authored charge cap:
GetMaxChargeTime() derives the ceiling from the level table as the moment the last level is reached, which removes
the dead-hold window where the meter kept running past the point anything improved.
Charge Level Transitions¶
When the charge crosses a level threshold:
Level Transition Detected (server tick, or OnRep on clients)
|
+---> Store PreviousChargeLevel
+---> Set CurrentChargeLevel
|
v
HandleChargeLevelChanged(NewLevel, OldLevel)
|
+---> Get effect tags from FChargeLevel
| ChargeEffectTag
| ChargeAudioTag
|
+---> Get "Socket_01" on the active weapon mesh
|
v
TriggerEffectByTag(ChargeEffectTag, SocketLocation)
TriggerEffectByTag(ChargeAudioTag, SocketLocation)
|
v
OnChargeLevelChanged.Broadcast()
The socket name is hardcoded to Socket_01 — every weapon mesh is expected to author it.
Integration with Damage¶
Damage Calculation¶
The released swing's charge level is derived from the payload-claimed hold via StoreChargeStateFromClaim(), and the
damage builder reads that single store:
BaseDamage (from weapon)
|
v
StoredChargeState -> ChargeLevel.DamageMultiplier
|
v
FinalDamage = BaseDamage * DamageMultiplier
Because level, success and final time are all functions of the one claimed number, every machine derives the same charge for the same swing.
Attack Speed Modification¶
Charged attacks play slower to convey weight. The play rate is computed by
UMeleeAbility::CalculateChargedAttackSpeed() from the weapon fragment plus the swing's charge state — there is no
fixed base reduction or per-level step.
Poise Damage Bonus¶
A charged swing uses the charge level's PoiseDamageMultiplier instead of the heavy-attack poise term, never on
top of it:
Input Handling¶
Press (Start Charge)¶
HandleHeavyAttackPressed():
|
+---> Dodge active? -> drop the press (the wind-up would interrupt the dodge)
|
+---> Combo active?
| |
| +---> Chain stale (no attack ability running)?
| | +---> in movement-cancel grace -> start charging
| | | (or swing if charging unavailable)
| | +---> otherwise -> reset chain, then start charging / swing
| |
| +---> In input window? -> execute the heavy swing immediately
| |
| +---> Nothing buffered yet? -> forward the press to the server's rolling
| | buffer (even outside the notify buffer window)
| |
| +---> Complete or already buffered -> drop
|
v
StartCharging()
|
+---> Success? -> stamp the press time for the hold measurement
+---> Failure (weapon authors no charge)? -> execute the heavy swing directly
Release (Execute Attack)¶
HandleHeavyAttackReleased():
|
+---> Wind-up ability not active on THIS machine? -> return
| (the predicted instance is the gate; replicated charge state is never consulted)
|
v
ReleaseChargeWindup() -> server: EndCharge() resolves tap vs charged
|
v
HeldSeconds (client platform clock) >= MinChargeThreshold?
|
+----+----+
| |
Yes No
| |
v v
ExecuteChargedAttack ExecuteComboAttack
(HeldSeconds on the (normal heavy swing)
attack payload)
Key Contracts¶
UChargeComponent API¶
| Method | Purpose |
|---|---|
BeginCharge() |
Authority: start the hold measurement and the level-progress timer |
EndCharge() |
Authority: resolve the hold — tap broadcasts a failed release and clears, charged leaves the release record for claim validation |
CancelCharge() |
Abandon a live charge with nothing to resolve (stance interrupted, prediction refused) |
ResetChargeState() |
Clear the timer and the live state |
StoreChargeStateFromClaim(HeldSeconds) |
Derive the swing's charge record from the payload-claimed hold |
ClearStoredChargeState() |
Drop the stored record after damage is applied |
GetCurrentChargeConfiguration() |
Per-weapon, per-hand charge config for the current weapon |
GetChargedAttackMontage() |
Charge montage by ChargeMontageTag, falling back to the heavy attack montage |
MaxClaimedHoldSkew (0.3s) is the tolerance the authority allows between a client's claimed hold and its own
measurement, covering honest RPC-timing jitter between the start and release legs.
FChargeState¶
| Member / Method | Purpose |
|---|---|
bIsCharging |
Hold in progress |
ChargeStartTime / CurrentChargeTime |
Server-time stamp and elapsed hold |
CurrentChargeLevel / PreviousChargeLevel |
Level index (-1 = none reached) and the previous one for transitions |
bChargeReleased |
The hold has been resolved |
bChargeWasSuccessful |
The resolved hold met MinChargeThreshold |
FinalChargeTime |
Hold duration at release |
Reset() / StartCharging() / UpdateCharge() / ReleaseCharge() |
Inline state helpers driven by the component |
FChargeLevel Properties¶
| Property | Type | Purpose |
|---|---|---|
MinChargeTime |
float | Start of this level's time range |
MaxChargeTime |
float | End of this level's time range |
DamageMultiplier |
float | Damage scaling for this level |
PoiseDamageMultiplier |
float | Poise damage scaling |
StaminaCostMultiplier |
float | Stamina cost scaling |
ChargeEffectTag |
FGameplayTag | VFX to trigger on level-up |
ChargeAudioTag |
FGameplayTag | Audio to trigger on level-up |
FChargeConfiguration¶
| Member | Returns | Purpose |
|---|---|---|
ChargeLevels |
TArray |
Authored level table for this weapon/hand |
ChargeMontageTag |
FGameplayTag | Tag key for the released charged swing's montage |
MinChargeThreshold |
float | Tap-vs-hold cutoff (default 0.2) |
ChargeRateMultiplier |
float | How fast charge builds up |
GetMaxChargeTime() |
float | Derived ceiling — the last level's MinChargeTime |
GetChargeLevelForTime() |
FChargeLevel* | Level struct for a given charge time |
GetChargeLevelIndexForTime() |
int32 | Level index (-1 if below the first level) |
Source References¶
| Concept | File | Line |
|---|---|---|
| UChargeComponent | Source/ProjectEternal/Public/Combat/Components/ChargeComponent.h | 29 |
| UChargeWindupAbility | Source/ProjectEternal/Public/Abilities/ChargeWindupAbility.h | 26 |
| UChargeWindupAbility impl | Source/ProjectEternal/Private/Abilities/ChargeWindupAbility.cpp | - |
| FChargeLevel struct | Source/ProjectEternal/Public/Combat/ChargeState.h | 14 |
| FChargeState struct | Source/ProjectEternal/Public/Combat/ChargeState.h | 58 |
| FChargeConfiguration struct | Source/ProjectEternal/Public/Combat/ChargeState.h | 141 |
| GetMaxChargeTime() | Source/ProjectEternal/Public/Combat/ChargeState.h | 167 |
| ICombatDataProvider interface | Source/ProjectEternal/Public/Interface/CombatDataProviderInterface.h | 18 |
| BeginCharge() | Source/ProjectEternal/Private/Combat/Components/ChargeComponent.cpp | 27 |
| UpdateChargeProgress() | Source/ProjectEternal/Private/Combat/Components/ChargeComponent.cpp | 43 |
| EndCharge() | Source/ProjectEternal/Private/Combat/Components/ChargeComponent.cpp | 76 |
| StoreChargeStateFromClaim() | Source/ProjectEternal/Private/Combat/Components/ChargeComponent.cpp | 125 |
| HandleChargeLevelChanged() | Source/ProjectEternal/Private/Combat/Components/ChargeComponent.cpp | 167 |
| StoredChargeState | Source/ProjectEternal/Public/Combat/Components/ChargeComponent.h | 166 |
| FAttackIntentSnapshot::HeldSeconds | Source/ProjectEternal/Public/Combat/Types/AttackIntentTargetData.h | 56 |
| Heavy press / release input flow | Source/ProjectEternal/Private/Input/EternalInputSubsystem.cpp | 1070 |
Related Documentation¶
- Combat Overview - Component hierarchy
- Combo System - The attack-intent payload and the chain the charge rides on
- Poise System - Poise damage multipliers from charge
- Combat Animation - Charged attack montage selection
- Ability Classes -
UChargeWindupAbilityandUMeleeAbility
Server Authority Notes¶
StoredChargeState¶
StoredChargeState preserves the released swing's charge between release and damage application. It is not a copy
of the live charge state: StoreChargeStateFromClaim(HeldSeconds) derives level, success and final time purely from
the hold duration the payload claimed, so client and server land on the same charge for the same swing. Damage
calculation and stamina pricing both read this one store, and ClearStoredChargeState() drops it once the swing ends.
What the client may claim¶
HeldSeconds is a claim. The authority bounds it against its own measured hold (MaxClaimedHoldSkew) and
SanitizeFromWire() clamps it to a sane range the moment it comes off the wire; a rejected claim ends the swing.
Recent Changes¶
- 2026-08-06 — doc reconciled with the payload-driven charge flow: wind-up documented as
UChargeWindupAbility, client-measured hold and its server validation, the derived max charge time, the 1.6/1.9/2.3 damage and poise ladder, and the press/release input flow. Removed the combo mutual-exclusion rationale and the legacy charge RPC surface. - Charge ladder retune: every weapon's charge levels moved to 1.6 / 1.9 / 2.3 — poise set to the ladder, damage raised to it as a floor. Charge replaces the combo step multiplier, so it must beat the heavy finisher (x1.5) in both.
- Wind-up became a predicted ability:
UChargeWindupAbilityplays the loop through the stock montage pipeline;Server_StartCharging/Server_ReleaseCharge/ the charge montage multicasts and their generation counters are gone. - Charge travels on the attack payload: the swing's charge is derived from
FAttackIntentSnapshot::HeldSecondsrather than from each machine's own (RTT-stale) charge state. - Max charge time derived:
FChargeConfiguration::GetMaxChargeTime()reads the last level'sMinChargeTimeinstead of an authored cap that could disagree with the level table. - Charging no longer cancels the combo: the movement-cancel grace keeps the chain alive across the hold; a tap-release continues it, a charged release abandons it.
- Extracted to UChargeComponent: charge logic moved from UPlayerCombatComponent to a dedicated component. PlayerCombatComponent acts as orchestrator.
- ICombatDataProvider interface: replaces TFunction callbacks with a stable interface for accessing weapon data, mesh, and montages.
- UUtils::GetCurrentServerTime: server time utility moved to the shared Utils library, eliminating duplicate implementations.
- Charge configurations are weapon-specific: each weapon defines charge levels and thresholds via
FWeaponFragment::ChargeConfigs, keyed byWeaponHandtag. - Hand-aware charge configuration: a weapon can define different charge behavior per hand context (solo vs shield vs dual wield).
- Level transition delegate:
OnChargeLevelChangedbroadcasts so UI and other systems can react to level changes.