Skip to content

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:

PoiseDamage = BasePoiseDamage * ChargeLevel.PoiseDamageMultiplier

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

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: UChargeWindupAbility plays 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::HeldSeconds rather than from each machine's own (RTT-stale) charge state.
  • Max charge time derived: FChargeConfiguration::GetMaxChargeTime() reads the last level's MinChargeTime instead 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 by WeaponHand tag.
  • Hand-aware charge configuration: a weapon can define different charge behavior per hand context (solo vs shield vs dual wield).
  • Level transition delegate: OnChargeLevelChanged broadcasts so UI and other systems can react to level changes.