Skip to content

Poise System

The Poise System manages stagger and break mechanics through a GAS-integrated component. Characters have a poise attribute that depletes when hit; when it drops below certain thresholds, they enter staggered or broken states that interrupt actions and leave them vulnerable.

Interruption is deterministic, not a random hit-react gate. Every hit flinches and (for enemies) knocks back unless the target is in an authored hyper-armor window; the threshold FSM below is only the stagger/break layer on top of that. See Interruption Model (Deterministic) for how the three mechanisms compose.

An enemy break is not just a stun — it is a Sunder: a loud, visible, timed vulnerability window that pays the pressure back as damage. The pool is the pressure meter, the break is the payoff, and the presentation layer exists to make both readable. See Sunder: The Break Payoff and Break Presentation.

Architecture

+-------------------------+
|  Incoming Poise Damage  |
+-------------------------+
           |
           v
+------------------------+
| UPoiseSystemComponent  |
| (IPoiseSystemInterface)|
+------------------------+
           |
           v
+------------------------+
| GameplayEffect applied |
| (SetByCaller magnitude)|
+------------------------+
           |
           v
+------------------------+
| UEternalAttributeSet   |
| - Poise (current)      |
| - MaxPoise             |
| - PoiseRecoveryRate    |
+------------------------+
           |
           v
+------------------------+
| OnPoiseAttributeChanged|
+------------------------+
           |
           v
+------------------------+
|   UpdatePoiseState()   |
+------------------------+
           |
           v
  State transition logic
  (thresholds determine state)

Why This Design?

GAS Integration

Poise is a first-class GAS attribute, allowing it to be modified by GameplayEffects, cues, and calculations. This means buffs/debuffs can affect poise capacity and recovery without special-case code.

Threshold-Based States

States are percentage bands of the pool, so a character with 100 max poise and one with 500 max poise both stagger at the same relative depletion. Note what this does and does not buy: because poise damage is authored in absolute poise units, the 500-poise character takes five times as many hits to reach that band — the pool is what carries tankiness, and the thresholds only decide where the reactions sit.

Poise itself is deliberately flat: neither pool nor poise damage has a level or item-level term on either side (Config/Balance/CombatScaling.jsonpoise, EnemyScaling.jsonMaxPoise with growth 0). Hits-to-break is therefore constant at every depth by construction and never needs re-anchoring when content moves. What moves break pressure is weapon class, charge tier, the Staggering affix and max_poise — build choices, not progression.

Historical note: poise damage used to be divided by the target's MaxHealth and rescaled by MaxPoise, which cancelled MaxPoise out of the arithmetic entirely. Every poise multiplier in the game was inert and break landed at a fixed fraction of the way to the kill. If you find documentation or comments asserting that poise "scales with health", they predate the decoupling.

Accumulation, Not Instant Threshold (ruled 2026-07-26)

Poise is a pool with memory: hits deplete it, the depletion persists between hits, and a break comes from total pressure over time. The alternative considered and rejected was an instant threshold, where each hit is judged alone against a cutoff, nothing accumulates, and small hits contribute nothing toward a later stagger — which is what "poise" means in the Souls games, where it is interrupt resistance rather than a meter.

The deciding argument is structural rather than aesthetic: under a threshold model in co-op, the player with the single largest hit owns the mechanic outright and nobody else can contribute to it. Accumulation lets a party apply pressure together, which is also why every co-op-facing game in the genre uses it.

The Stance Bar (enemy readout)

The pool depletes internally (starts full, drains, refills on recovery), but it is displayed as a bar that fills — the display direction is a free choice (1 - poise / maxPoise) and the two read very differently. A depleting bar next to a health bar is visually the same object doing the same job, which is exactly the "it feels like a second health bar" reaction the first playtest produced; a filling bar reads as a charge meter the player is building toward a reward, and on recovery it visibly drains the player's progress rather than looking like the enemy healing. It is labelled stance, not poise, because players who know the Souls games read "poise" as an invisible passive stat.

Decision Shipped behavior Why
Who gets a bar Elite tier and above only A bar over trash that dies in 4 hits is clutter; the break beat belongs to duels
Fill direction Fills toward the break Progress toward a reward, not a second health bar
Broken state The label replaces the bar A full filling bar and a broken enemy otherwise draw identically; during the window the bar carries no information anyway
Data source The replicated Poise / MaxPoise attributes GetPoisePercentage() reads the server-only component and returns 1.0 on clients — a bar bound to it would sit flat
Reveal trigger Health damage, or a successful parry See Parry reveals the stance

The bar lives on the per-enemy UStatusViewModel (see Widget Library), which also forced AEternalEnemy.ThreatTier to replicate — tier previously lived only in the server-side balance config, so clients had no way to apply the elite gate at all.

Label-replaces-bar is the project's one worked example of a wider presentation rule: at a big beat, the loud thing replaces the quiet thing on a channel rather than stacking on top of it. Break Presentation extends that same rule from UI to audio, camera and time.

Delay-then-Refill Recovery

Enemies recover nothing for poiseRecoveryDelay seconds after the last poise hit, then refill the whole pool in poiseRefillSeconds (both authored per threat tier in ThreatTiers.json, scaled per archetype in Archetypes.json). Every poise hit re-applies the block, so recovery only starts once pressure actually stops.

This shape is load-bearing, not incidental. A continuous drip makes time-to-break a race between two rates with a build-dependent flip in it — below the flip an enemy never breaks at all, above it breaks every fight — which is non-monotonic and effectively unauthorable. With recovery held off under sustained pressure, hits-to-break collapses to (1 - breakThreshold) * MaxPoise / poisePerHit: monotonic, and more pressure is always strictly faster.

Invariant: every authored delay must exceed the player's slowest attack cadence (1.25s at the 0.80-speed end of the weapon roster), with margin for a dodge. Below that, sustained attack lets recovery start mid-combo and the closed-form breaks down.

The player is the exception, by ruling: playerRecoveryDelaySeconds is 0, so the player keeps a continuous drip. That drip is what makes a clean duel survivable while a pack is dangerous — incoming pressure multiplies with pack size while recovery does not — with no special case anywhere.

Interruption Model (Deterministic)

Interruption is built from three orthogonal, deterministic mechanisms — there is no random "shrug off this hit" roll. Each fires from its own place on the hit path, so they compose cleanly: a target can flinch and be knocked back without being staggered, or absorb everything under hyper armor yet still break if the hit is big enough.

Mechanism Where Trigger
Flinch UHitReactAbility (Effects.HitReact) / UEnemyCombatComponent::OnCharacterHit Every hit, unless State.HyperArmor
Knockback UCombatComponent::ApplyKnockback (server-only) Every non-Light enemy hit, unless State.HyperArmor — but a Stagger-upgraded hit overrides the armor
Stagger / Break Poise FSM → GA_Stunned (Effects.Staggered) / GA_Broken (Effects.Broken) Poise crosses the configured Stagger / Break thresholds

Why poise is never suppressed: flinch and knockback are gated by State.HyperArmor, but poise damage is not. Poise keeps accruing even while the target is powering through a committal attack, so a big enough hit eventually breaks through the armor. When the FSM crosses a threshold it upgrades the hit's severity to Stagger (UpgradeSeverityToStaggerForLaunch) before the attacker's ApplyKnockback runs — so the same single knockback that frame launches at the heaviest tier. One apply, no double-shove.

Hyper Armor

Hyper armor is a committal-attack armor window. The native loose tag State.HyperArmor is added/removed server-only by UAnimNotifyState_HyperArmor over an authored span of an attack montage (the active swing frames). While present it:

  • suppresses flinchUHitReactAbility lists it in ActivationBlockedTags, and UEnemyCombatComponent::OnCharacterHit re-checks it at hit time as a timing-safe backstop;
  • suppresses knockbackUCombatComponent::ApplyKnockback early-returns if the tag is present and the hit is not Stagger-severity;
  • does NOT suppress poise — poise damage applies normally, so the armor can be broken through.

Because it is a loose tag added/removed by the notify, the window opens and closes with the animation; NotifyEnd fires on interrupt and blend-out, so the tag cannot stick open. See Combat Animation for the notify authoring.

Break → Launch

UPoiseSystemComponent::UpgradeSeverityToStaggerForLaunch() is called from both HandleStaggerState() and HandleBrokenState(). It sets the target's UCombatComponent hit severity to EHitSeverity::Stagger so the per-hit knockback the attacker is about to apply fires at the heaviest tier — it deliberately does not call ApplyKnockback itself (that would double-apply).

This relies on a synchronous-ordering invariant:

ApplyPoiseDamage  -->  Instant GE applies  -->  attribute delegate
   -->  UpdatePoiseState -> HandleStagger/Broken -> severity = Stagger
        ... all BEFORE ...
   attacker's OnHitEvent  -->  ApplyKnockback (reads severity)

For this to hold, PoiseDamageEffectClass MUST be an Instant GameplayEffect. A Duration/Periodic policy would defer the attribute change a frame, the FSM transition would land after ApplyKnockback, and the break would silently fail to launch. BeginPlay asserts this with an ensureMsgf (a None class is allowed — it just means no poise — but a set class must be Instant).

Single Apply Path

Every hit path routes poise damage through one static entry point:

UPoiseSystemComponent::ApplyPoiseDamageToActor(Target, PoiseDamage, DamageSource)

It resolves the target's poise component, null-guards, and calls Execute_ApplyPoiseDamage once. Callers: PlayerCombatAbility (single + skill-AOE), EnemyAbility, EnemyAoeAbility, EternalAOEDamageAbility, ProjectileBase, and BlockAbility (parry). Funneling every site through one function removes the per-call-site drift that previously let PoiseDamageEffectClass go None on individual enemies (which silently inert-ed the whole poise FSM).

Poise States

Poise %:  100%      70%       40%       10%       0%
           |         |         |         |         |
           +---------+---------+---------+---------+
           | Stable  |Weakened |Staggered| Broken  |
           +---------+---------+---------+---------+
                                    |         |
                                    v         v
                              [Cannot Act] [Cannot Act]
                                    |         |
                    (pool untouched)|         |(pool refilled + grace window)
                                    |         |
                                    +----+----+
                                         |
                                         v
                             (band logic resumes, no
                              special transitional state)

Stagger and break are edge-triggered on a downward crossing of their threshold, not bands a character sits in. A level test would re-fire the reaction the instant its timer expired — the pool that triggered it is still just as empty — so each reaction fires once per crossing. Stable and Weakened have no reaction attached and stay plain bands.

State Descriptions

State Poise Range Can Act Recovery Duration
Stable 100% - 70.1% Yes Active -
Weakened 70% - 40.1% Yes Active -
Staggered crossing below 40% No Blocked 0.8s (configurable). Normal-tier enemies skip this state entirely — see below
Broken crossing below 10% No Blocked BreakDuration (3.5s), then pool refills + grace. For enemies this is the Sunder window
~~Recovering~~ No longer entered. Retained in the enum because it replicates and Blueprints/HUD may switch on it

Normal-tier enemies skip Staggered (bNormalTierSkipsStagger, default on): their pool drains straight to the break with no flinch window on the way. Break is already incidental on trash (~4.5 hits to break against 4-6 to kill), so a stagger there is a beat the fight is too short to read — the buildup telegraph is worth screen time only on elites and bosses. Players never skip, and non-enemy owners never skip. This is a hypothesis knob for the feel-check playtest, not a settled rule.

Recovering used to gate every re-stagger and re-break, and its only exit was upward past the stagger threshold. Under sustained attack that exit never fired, so a target sat in it stagger- and break-immune for the rest of the fight — meaning a break was unreachable by accumulated pressure at all, only by a burst inside the 0.8s stagger window or by a single hit worth 60% of the pool. The post-break grace window replaced that role.

Sunder: The Break Payoff

A break on an enemy opens a timed vulnerability window — the target takes extra damage for exactly as long as it is broken. Without a payoff the meter is pressure with nothing at the end of it, which is precisely what the first feel gate reported: the same meter read "kind of flat, there is little payoff" with no reward attached and "much better, I wanted it again, feels earned" once the break paid one.

Piece How it works
The amplifier A server-applied GE bumps the target's existing DamageTakenMultiplier attribute by BrokenDamageTakenBonus (1.0 = double damage)
Why that attribute Both damage executions already capture it, so DoTs and every damage source are amplified for free — zero execution surgery
Duration BreakDuration. One number drives the stun, the recovery block and the amplifier, so they cannot desync
Stacking Additive with any other flat DamageTakenMultiplier debuff. Self-stacking is structurally impossible: the FSM plus the post-break grace serialize breaks, so a target never carries two windows
Who gets it Enemies only. The player's break already costs them control; doubling incoming damage on top of a stun is a lethality decision to take on its own terms

BreakDuration and BrokenDamageTakenBonus live on FPoiseConfiguration — per-character authored config, not live-reload balance knobs in CombatScaling.json. They moved there once they stopped being experiment dials: a number that three separate mechanisms read is exactly the number you do not want two homes for.

Build-sweep caveat: BuildEvaluator treats a sentinel DamageTakenMultiplier of 0 as 1.0, so Sunder uptime must be fed in explicitly before any sweep that includes it can be trusted.

Parry reveals the stance

A parry deals no health damage, and the enemy status widget's only spawn path was the damage-number cue — which returns early on zero magnitude. So the entire parry → reflect → stance-crack → break chain was invisible: no amount of poise damage could produce the bar, because the bar was a consumer of a health-damage-created widget rather than a producer. UStatusController::RevealStatus is the damage-less create-or-refresh path that fixes it, called from the parry-reflect cue. It keeps the same Elite+ gate — parrying trash still shows nothing, which matches both the trash stagger skip and the contrast budget below.

Break Presentation

The break is synchronous inside the breaking hit, so without arbitration the Sunder moment is the shatter cue stacked on top of a full impact row (VFX + SFX + camera shake + hit stop + directional impulse + zoom impulse), plus max-magnitude knockback and the stun montage. The poise-specific signal ends up the quietest thing in the loudest frame — observed verbatim in playtest as "mostly hear the hit thud".

The operating rule is one dominant channel per beat: a big event replaces the small one rather than adding to it. This is the same ruling as label-replaces-bar, extended from UI to audio, camera and time.

Ordinary hit                          Breaking hit
------------                          ------------
impact row: VFX + SFX + shake         impact row SUPPRESSED (all six channels)
          + hitstop + impulses                    |
                                                  v
                                      Sunder row owns the frame:
                                        shatter VFX + shatter SFX (ducks the combat bus)
                                        heavy shake + long hitstop + impulses
                                        + short full-screen desaturation flash
                                        + SUNDER label replaces the stance bar
                                        + damage numbers restyle for the window
Mechanism Implementation
Impact suppression UPoiseSystemComponent::ShouldSuppressImpactFeedback(Target) is checked before every impact-cue emission (player melee, enemy melee, AOE, projectile). The cue simply never executes, so all six channels vanish on every client with nothing to replicate
Break-this-frame flag A frame-counter stamp (DidPoiseBreakThisFrame) rather than a lingering bool — the synchronous Instant-GE ordering already guarantees the break lands in the same frame as the hit
Suppression pairs with presentation Suppression is gated on PlaysBreakPresentation as well, the same predicate the cue emitter uses. Unconditional suppression made a player break silent: the impact row dropped and the enemy-only shatter never replaced it
Hit stop UCombatHitStopSubsystem (world subsystem) is the single owner of world time dilation
Screen flash UCombatScreenFlashModifier, a camera modifier on one local player's camera
Audio duck The shatter sits on its own SoundClass (SCL_SFXCombatBreak, a child of the combat class) and a passive SoundMix briefly ducks the parent while excluding its children. See Audio Architecture

Why hit stop is a subsystem

Hit stop dilates world time, which is global state, but it used to be driven from each attacker's effects manager independently. Two overlapping hits each cached "the original" dilation and each restored it, so the second could restore a value that was itself a hit stop, and the second timer could cut the first short. Centralising it means the baseline is captured once, while idle, an overlapping request extends rather than replaces the window (max-remaining wins), and a stronger request is never softened (lowest time scale wins) — so the heaviest beat in a cluster is the one that reads.

The subsystem refuses on a dedicated server, and on a listen server with a client connected: world dilation replicates, so a host-side window would slow every remote player's world. A listen server with nobody connected — the shape single-player PIE actually runs in — takes the window exactly as a standalone client does. Host-side hit stop in real co-op is an open design question, not a bug.

Local-player gating

Everything in the presentation layer is cosmetic and local, driven off a cue that already replicated. Three gates matter and one of them is a recurring trap:

  • IsPlayerControlled is not a local-player test. PlayerState replicates, so it answers true for every remote player's pawn on every machine. Hit stop and the screen flash both use a genuine local-pawn test instead; gating hit stop on IsPlayerControlled froze bystanders' worlds when a remote player caused a break.
  • Hit stop is requested where the montage plays, on every machine, and the attacker gate takes it only on that player's own client. Asking for it under HasAuthority() stranded it on the server, where the subsystem refuses — so in a networked session no machine ever ran it.
  • Camera shakes go to local controllers only. RPC'ing them to remote controllers doubles the shake for that player on a listen host.

Cue layer

State transitions fire gameplay cues through one shared, enemy-gated body (ExecuteEnemyPoiseCue), which is the sanctioned MP path: server executes, all relevant clients play.

Cue tag Fired from Carries
GameplayCue.Poise.Sunder HandleBrokenState The shatter beat — VFX, shake, hit stop, impulses, flash, and the ducked shatter sound
GameplayCue.Poise.Stagger HandleStaggerState The cheap "not yet" tell. Deliberately quiet and subordinate to the shatter
GameplayCue.Poise.ParryReflect OnDamageParried, on the attacker's ASC The posture-crack on a parried attacker; also reveals that enemy's stance bar

Effects.Poise.Sunder and Effects.Poise.ParryCrack are the matching UCombatEffectsManager config rows. The rows are authored by subtraction — one owner per channel per frame — so where the impact row keeps a channel the Sunder row zeroes it, and vice versa. There is deliberately no global priority framework: one suppression seam, one audio duck, and per-row authoring discipline covered the whole problem.

Standing guard — the contrast budget. This hierarchy works because ordinary hit feel stays modest, so the break reads big by contrast. A future "make normal hits juicier" pass (more shake, more hit stop per swing) silently spends that budget and erodes break saliency. Re-check the break frame after any per-hit juice tuning.

Damage numbers during the window

Damage numbers restyle for the duration of the window, which is the cheapest possible explanation of the payoff: the reward literally is damage, and the numbers returning to normal is the "window closed" read with no tutorial. Crits already own a distinct number style and a crit inside a Sunder window is common by design, so precedence is ruled on two different axes — Sunder owns the outline and colour, crit keeps its size and punch — and both survive together. The styles are text-style assets, so the ruling can be flipped in content rather than code, and an unset style degrades to pre-Sunder behavior.

How Poise Works

Poise Damage Flow

Attack hits target
       |
       v
Calculate poise damage
(weapon base * charge multiplier * etc.)
       |
       v
IPoiseSystemInterface::ApplyPoiseDamage()
       |
       v
Create GameplayEffect spec
       |
       +---> Set magnitude via SetByCaller (negative value)
       |
       v
Apply to target's ASC
       |
       v
Attribute changes trigger OnPoiseAttributeChanged()
       |
       v
UpdatePoiseState()
       |
       v
Compare PoisePercentage to thresholds
       |
       +---> Below BreakThreshold (10%)? -> HandleBrokenState()
       +---> Below StaggerThreshold (40%)? -> HandleStaggerState()
       +---> Below WeakenedThreshold (70%)? -> Set Weakened
       +---> Above all thresholds? -> Set Stable

State Transition Handling

When entering Staggered or Broken:

HandleStaggerState() / HandleBrokenState():
       |
       +---> Apply PoiseRecoveryBlockEffect
       |         (duration matches state duration)
       |
       +---> Activate stagger/broken ability via GAS tag
       |         (plays animation, disables input)
       |
       +---> Set timer for state exit
       |
       v
[Timer expires]
       |
       v
OnStateTimerExpired()
       |
       +---> After a STAGGER: pool is left as it is, so accumulated
       |     pressure carries straight on toward the break
       |
       +---> After a BREAK: pool is handed back FULL and a grace
       |     window opens (poise damage refused until it expires),
       |     so a burst already in flight cannot instantly re-break
       |
       v
[Band logic resumes]
       |
       v
[Poise > StaggerThreshold]
       |
       v
Transition to Weakened or Stable

Recovery Mechanics

Passive Recovery

Poise regenerates via a periodic GameplayEffect that adds to the Poise attribute based on PoiseRecoveryRate (per-second: the GE's 0.1 coefficient at a 0.1s period). Enemies have this rate authored at spawn by AEternalAIController::ApplyPoiseRecovery, derived from the final pool and the tier's poiseRefillSeconds.

The GE has always been armed on every enemy Blueprint, but nothing ever set PoiseRecoveryRate, so it pumped exactly zero — enemy poise did not regenerate at all before the decoupling changeset.

Recovery Blocking

During Staggered/Broken states: 1. A blocking GameplayEffect is applied with matching duration 2. This effect adds a tag that the recovery effect checks 3. Recovery effect skips application while tag is present

Recovery flow:
       |
       v
Has blocking tag?
       |
  +----+----+
  |         |
 Yes        No
  |         |
  v         v
Skip     Apply
(wait)   recovery

Configuration

FPoiseConfiguration Properties

Property Default Purpose
WeakenedThreshold 0.7 (70%) Below this = Weakened state
StaggerThreshold 0.4 (40%) Below this = Staggered state
BreakThreshold 0.1 (10%) Below this = Broken state
StaggerDuration 0.8s How long Staggered lasts
BreakDuration 3.5s How long Broken lasts — and, by construction, the stun, the recovery block and the damage-taken window. 2.0s was too short to cash in: at a 0.80 swing rate it fits 1.6 swings, and the break's own knockback spends part of it walking back into range
BrokenDamageTakenBonus 1.0 Extra fraction of incoming damage during the break window (1.0 = double). 0 restores pressure-with-no-reward behavior. Enemies only
bNormalTierSkipsStagger true Normal-tier enemies drain straight to the break with no stagger on the way. Players and non-enemy owners never skip

The thresholds have static constexpr defaults on the struct that the HUD reads directly, so tick placement on the poise bar and the config member defaults cannot drift apart. Per-character poise config is not replicated, so client-side band logic assumes the defaults (which the player's config matches).

Required GameplayEffects

Effect Purpose
PoiseDamageEffectClass Applies negative poise via SetByCaller. MUST be Instant (see Break → Launch)
PoiseRecoveryEffectClass Periodic poise regeneration
PoiseRecoveryBlockEffectClass Prevents recovery during stagger/break

Per-Enemy Poise Tuning

Enemies carry an FPoiseTuning on FEnemyData.PoiseTuning that lets a designer make a specific enemy poise-tanky or fragile without touching the shared component config.

Property Default Purpose
MaxPoiseMultiplier 1.0 Scales the balance-derived MaxPoise. >1 = resists stagger (tanky), <1 = fragile. Genuinely load-bearing since the decoupling; under the old health normalization this knob did nothing at all
bOverridePoiseConfig false Opt in to replace the component's thresholds/durations with Config below
Config (FPoiseConfiguration) default Per-enemy thresholds + stagger/break durations, applied only when bOverridePoiseConfig is set

Tuning is applied once at spawn in AEternalAIController::ApplyPoiseTuning, which runs after ApplyBalancedAttributes (which set the balance MaxPoise) and after PossessedBy's default poise init — so the override sticks instead of being clobbered. The multiplier always scales MaxPoise; the threshold/duration Config is applied via InitializeWithConfiguration only when bOverridePoiseConfig is set (opt-in, so a behavior-neutral default never overwrites a Blueprint-authored PoiseConfig). All defaults are behavior-neutral — an enemy with the default FPoiseTuning behaves exactly as before.

Poise Damage Sources

Poise damage comes from multiple combat sources, each calculating its own poise value:

All of them run through one kernel, UCombatStatics::PoiseDamageForSwing, so no hit path can invent its own scale:

poisePerHit = basePoiseDamage
            * weaponPoiseMultiplier            // authored per weapon, incl. the Staggering affix
            * (referenceAttackSpeed / attackSpeed)
            * commitmentMultiplier             // charge tier on a charged swing, heavyAttackPoiseMultiplier on a heavy one
Source Calculation
Melee attacks UCombatStatics::PlayerPoiseDamage — weapon poise multiplier, weapon attack speed, and one commitment term
Body skills / AOE basePoiseDamage times the ability's own AOE poise multiplier (no weapon involved)
Enemy attacks UCombatStatics::EnemyPoiseDamage — a flat constant, not threat-tier-scaled
Parry reflect A fraction of the attacker's MaxPoise

Light vs heavy is one term, heavyAttackPoiseMultiplier (1.5). A heavy swing removes 1.5× the poise of a light one, so a reference weapon breaks a 100-pool Normal enemy in 3 heavy hits against 4.5 light — heavy is the pressure tool, light is the damage tool. It is deliberately a single constant rather than a per-combo-step array: the step already scales damage and stamina, and a poise array would add ~8 numbers against a four-dimension target. A charged swing uses its authored PoiseDamageMultiplier (1.6/1.9/2.3) instead of, never on top of, the heavy term — charge prices the same commitment. The ladder sits above the heavy term deliberately: charge replaces the combo step multiplier it lands on, so it has to beat the strongest step it can replace or charging is a downgrade. A full charge alone already breaks a 100-pool Normal enemy in two hits; multiplying the charge tier by the heavy term on top would take roughly three quarters of that pool in one swing, which is a break in all but name.

Stamina follows the same commitment story from already-authored data: the swing's combo step selects its cost from LightComboStaminaMultipliers / HeavyComboStaminaMultipliers, and a charged swing uses the charge level's StaminaCostMultiplier — the value the charge tooltip already printed. The step is read via UComboComponent::PeekNextComboStep, because stamina is charged before the combo advances and the live state would bill the previous swing.

The attack-speed term makes pressure a property of the commitment rather than the swing: poise-per-second comes out independent of how fast a weapon attacks, so break pressure is decided by weapon class instead of by attack rate. Without it, the fastest weapon in the roster is always the best stance-breaker.

Enemy poise damage is flat and tier-independent on purpose: a boss hit costs the player the same poise as a trash hit, which is what keeps the player's hits-to-break constant across depth. Tier lethality rides health damage.

Blocking and Parry

ApplyPoiseDamage inspects the receiver's state tags before applying:

  • While blocking (State.Blocking): incoming poise damage is reduced by the weapon's BlockPoiseDamageReduction fraction. The hit still chips poise, just less.
  • On a successful timed parry (State.Parrying and State.Blocking): incoming poise damage is fully negatedApplyPoiseDamage returns false/0 immediately rather than reducing it. The parried hit lands no poise pressure at all.

Instead, a parry turns the gauge around on the attacker. BlockAbility applies poise-gauge damage to the attacker equal to ParryPoiseDamageFraction of the attacker's MaxPoise, passed directly in poise units through the same ApplyPoiseDamageToActor entry point. Because it is a fraction of the attacker's own pool, a parry reads identically against trash and against a boss — deliberate, since it is defensive mastery rather than accumulated pressure. This makes the poise gauge the parry's posture-break path: a well-timed parry doesn't just deny the hit, it pushes the attacker toward their own stagger. See Timed-Block Parry.

Key Contracts

IPoiseSystemInterface

Method Parameters Returns Purpose
ApplyPoiseDamage() PoiseDamage, DamageSource bool Apply poise damage to target
GetPoisePercentage() - float Current poise as 0-1 percentage
CanAct() - bool False if staggered/broken

UPoiseSystemComponent Delegates

Delegate Parameters Fires When
OnPoiseStateChangedDelegate OldState, NewState State transitions
OnPoiseDamageAppliedDelegate PoiseDamage, RemainingPoise After damage applied

GAS Tags

Tag Purpose
Stats.Effects.Poise.Recovery Applied while recovery effect active
Stats.Effects.Poise.RecoveryBlock Applied during stagger/break
Effects.Staggered Triggers stagger ability (GA_Stunned)
Effects.Broken Triggers break ability (GA_Broken)
Data.PoiseDamage SetByCaller key for poise damage
State.HyperArmor Committal-attack armor window: suppresses flinch + knockback, not poise
GameplayCue.Poise.Sunder Executed on a break (enemies only); the shatter beat
GameplayCue.Poise.Stagger Executed on a stagger (enemies only); the quiet "not yet" tell
GameplayCue.Poise.ParryReflect Executed on the attacker's ASC on a successful parry; posture-crack + stance reveal
Effects.Poise.Sunder / Effects.Poise.ParryCrack UCombatEffectsManager config rows behind those cues

The stagger/break payoff for enemies requires both GA_Stunned (responds to Effects.Staggered) and GA_Broken (responds to Effects.Broken) to be granted via EnemyData.StartupAbilities. Without them the FSM still transitions but TryActivateAbilitiesByTag is a silent no-op — the enemy crosses the threshold yet plays no stagger/break.

Multiplayer & Replication

The poise FSM is server-only. The poise component's ASC is initialized solely on the authority (via server PossessedBy / the AI controller), so UpdatePoiseState, timer-driven transitions, ability activation, and recovery GEs all run server-side in OnPoiseStateChanged_Internal.

CurrentPoiseState replicates with ReplicatedUsing = OnRep_PoiseState. OnRep_PoiseState(EPoiseState OldPoiseState) is the sole client-side notify path: it broadcasts OnPoiseStateChangedDelegate on simulated/autonomous proxies, which drives the poise HUD indicator (and any VFX hooks) on remote clients. Replication never invokes OnRep on the authority, so this cannot double-broadcast against the server's own internal handler.

Knockback (ApplyKnockback), the hyper-armor loose tag, stagger/break, and the poise apply itself are all server-authoritative; ApplyKnockback self-guards HasAuthority() and the hyper-armor notify is server-only.

Presentation crosses the wire once, as a cue. The FSM executes GameplayCue.Poise.* from the server, and everything downstream of that — shatter VFX, shake, hit stop, screen flash, the stance reveal — is local, cosmetic and needs no authority of its own. The consequence is that each of those effects must decide for itself whether the local machine should play it:

Effect Gate
Impact-row suppression Server-side break flag, paired with PlaysBreakPresentation so the suppressed row is always replaced
Hit stop Local player pawn, and refused on dedicated servers / listen servers with clients connected
Screen flash Local player controller only
Camera shake Local controllers only — never RPC'd to remote ones
Stance reveal on parry The defender pawn being locally controlled; the cue itself reaches every client

Two known gaps, deliberately left: projectile impact effects are server-only in their entirety (a pre-existing hole — routing them client-side first needs an answer for the suppression seam, whose break flag is authority-only), and the hit-stop window runs on the dilated clock, so an authored 0.12s @ 0.2 lasts roughly 0.6s wall-clock. The authored values are feel-tuned against that, so changing the clock silently shortens every window five-fold.

Source References

Concept / Class File
UPoiseSystemComponent, ApplyPoiseDamage, ApplyPoiseDamageToActor, UpgradeSeverityToStaggerForLaunch, OnRep_PoiseState, BeginPlay Instant-GE assert Source/ProjectEternal/Public/Combat/Components/PoiseSystemComponent.h
Suppression seam (ShouldSuppressImpactFeedback, DidPoiseBreakThisFrame, PlaysBreakPresentation), ExecuteEnemyPoiseCue Source/ProjectEternal/Public/Combat/Components/PoiseSystemComponent.h
EPoiseState, FPoiseConfiguration (BreakDuration, BrokenDamageTakenBonus, bNormalTierSkipsStagger) Source/ProjectEternal/Public/Combat/Types/PoiseTypes.h
UCombatHitStopSubsystem (world-time ownership, overlap policy, server refusal) Source/ProjectEternal/Public/Combat/Subsystems/CombatHitStopSubsystem.h
UCombatScreenFlashModifier (local-only desaturation flash) Source/ProjectEternal/Public/Camera/Modifiers/CombatScreenFlashModifier.h
UGameplayCueNotify_PoiseSunder / _PoiseParryReflect Source/ProjectEternal/Public/AbilitySystem/GameplayCues/GameplayCueNotify_PoiseSunder.h
Screen-flash + hit-stop effect layers (FScreenFlashLayer, FHitStopLayer; bExpectedSilent on the row) Source/ProjectEternal/Public/Combat/Data/CombatEffectLayer.h
RevealStatus / FindOrCreateStatus (damage-less stance reveal) Source/ProjectEternal/Public/UI/Controllers/StatusController.h
FPoiseTuning, FEnemyData.PoiseTuning Source/ProjectEternal/Public/AI/Data/BaseEnemyDataAsset.h
ApplyPoiseTuning (per-enemy tuning at spawn) Source/ProjectEternal/Private/AI/Controllers/EternalAIController.cpp
ApplyKnockback + severity tables Source/ProjectEternal/Public/Combat/Components/CombatComponent.h
Parry poise negation + attacker-poise (ParryPoiseDamageFraction) Source/ProjectEternal/Public/Abilities/BlockAbility.h
State.HyperArmor, State.Parrying native tags Source/ProjectEternal/Public/EternalGameplayTags.h
UAnimNotifyState_HyperArmor Source/ProjectEternal/Public/Combat/AnimNotifies/AnimNotifyState_HyperArmor.h
Poise attributes Source/ProjectEternal/Public/AbilitySystem/EternalAttributeSet.h

Recent Changes

Earlier (undated): poise became a replicated GAS attribute using standard attribute replication; recovery blocking moved from timers to GameplayEffects.

Date Change Impact
2026-06-17 Deterministic interruption model (4df2921ab): flinch + knockback every hit unless State.HyperArmor, poise still accrues under armor, break upgrades severity to Stagger for a single heavy launch; State.HyperArmor window via UAnimNotifyState_HyperArmor; single ApplyPoiseDamageToActor entry point; Instant-GE ordering invariant asserted in BeginPlay; per-enemy FPoiseTuning applied at spawn; OnRep_PoiseState as the sole client notify path. Enemy interruption is now learnable, not random; poise FSM is the stagger/break layer on top of always-on flinch/knockback; per-enemy tankiness is data-driven.
2026-06-17 Parry poise interaction + enemy-BP poise fix (65827bb0d): a timed parry (State.Parrying + State.Blocking) fully negates incoming poise and instead deals ParryPoiseDamageFraction of the attacker's max poise back to the attacker; the previously unset PoiseDamageEffectClass on enemy Blueprints was wired so enemy poise depletes. Parry becomes a posture-break path that turns the gauge on the attacker; enemies whose poise GE had drifted to None now stagger/break correctly.
2026-07-25 Decoupled poise from health (2664a438b, merged d5c546053): the health-pool normalization is gone, poise damage is authored in poise units through one kernel, enemy recovery is delay-then-refill, stagger/break are edge-triggered, and a post-break grace window replaced the Recovering gate. MaxPoise and every pool/archetype/tier multiplier became live for the first time; hits-to-break separated from hits-to-kill and is flat at every depth.
2026-08-06 Charge poise ladder retuned to 1.6 / 1.9 / 2.3 across every weapon (poise set to the ladder, damage raised to it as a floor), alongside a poise-recovery tuning pass. Charging is no longer a poise downgrade against the heavy finisher it replaces: a full charge now breaks a 100-pool Normal enemy in two hits, and the doc's charge-vs-heavy arithmetic was recomputed against the new ladder.
2026-07-26 Accumulation ruled over instant-threshold (c3dedd93b); light vs heavy separated by one term, heavyAttackPoiseMultiplier (2d4960b2f), with combo-step and charge stamina multipliers reaching the cost path. Poise is a pool with memory, and heavy attacks are the pressure tool — the first evidence that the meter moves an attack decision.
2026-07-27 Sunder shipped: break pays a DamageTakenMultiplier window (BreakDuration 3.5s / BrokenDamageTakenBonus, folded onto FPoiseConfiguration), enemy stance bar on elites+ with the label replacing the bar, GameplayCue.Poise.Sunder/.Stagger/.ParryReflect, bNormalTierSkipsStagger, and restyled damage numbers. The break is a readable, rewarding beat instead of an invisible stun; poise-break becomes a payoff loop rather than pressure with no terminal reward.
2026-07-27 Break presentation layer: impact-row suppression on a breaking hit (ShouldSuppressImpactFeedback), UCombatHitStopSubsystem as sole owner of world time dilation, UCombatScreenFlashModifier, a break-bus audio duck, bSingleInstance on the shake CDOs, and local-player gating throughout. One dominant channel per beat; the shatter is no longer masked by the impact row it lands inside. Fixed a silent player break, a hit stop stranded on the server, and shakes doubling on a listen host.