How-To: Accumulate Damage Over a Stagger Window
A scope accumulates every hit on a target for as long as some state lasts - a stagger, a posture break, an exposed wound - into one running total, then pays it off when the state ends. It is the difference between forty numbers scrolling past and one number climbing to 951.
During Status.Stagger: 142 -> 310 -> 588 -> 951 (one running total, on your screen)On tag removal: [ 951 ] (the payoff, sent to you alone)
UCrimsonCombatTextReplicationComponent, which keeps one running row per attacker, and every event it broadcasts carries that attacker's figure. Clients never accumulate: only the attacker's own client merges those hits into a climbing number, and a player who joined mid-stagger snaps to the right number on the next hit. Everything on this page happens on the server. See Concept: Style Resolution, Motion & Rendering -> The aggregation ledger.1. Declare the scope on a style
Open your style registry, expand the Styles row the accumulating hits will resolve to, and set its scope fields:
| Field | Meaning |
|---|---|
ScopeTag | While the target holds this tag, the run this style feeds never lapses |
ScopeSummaryStyle | Style key for the payoff broadcast on close. Unset reuses this style |
AggregationWindow | Seconds between consecutive hits before an unscoped run lapses. Declaring either this or ScopeTag makes the row a ledger root |
AggregationDisplay | How long one running number lives: FadeWithMotion (the default) fades it when its motion ends and re-pops the running total on the next hit, HoldWhileScoped pins it until the payoff retires it |
bInheritAggregation | On by default. A row declaring neither a window nor a scope takes its nearest ancestor's; clear it to keep the row's hits out of that run entirely |
Damage.Type.Fire, Damage.Type.Poison and Damage.Result.Blocked all land in the same stagger total with nothing authored on them. An AdvancedRules match inherits the same way - from the key the hierarchical lookup would have picked - so a rule cannot drop a hit out of its ledger.1b. Decide how long the number lives
A running total is personal. The server keeps one row per attacker, and only that attacker's own client merges those hits into one climbing number - every other client shows the same hit as a plain pop through its normal filters. Nothing has to be authored for that, and there is no setting that turns someone else's damage into a number on your screen.
What you do choose on the row is AggregationDisplay, and it is inherited from the ledger root, so a whole branch agrees. Declare it on the row that declares the scope.
AggregationDisplay decides how long one running number lives. FadeWithMotion - the default - lets the number fade when its own motion ends even while the scope is still open, and the next hit pops a fresh number already showing the running total. HoldWhileScoped pins the number for as long as the scope is open and lets the payoff retire it.
1c. Read the running total
One pure function on the replication component reports what an attacker's row holds. Instigator is required: it names whose row to read, is normalized the same way a hit is, and reads the shared unknown-attacker row when null. It is server-side, like everything else that touches the ledger.
Get Game State -> Get Component by Class (CrimsonCombatTextReplicationComponent) -> Get Combat Text Scope Total (Target, Scope Tag, Instigator). Pass the attacker whose number you want; leaving Instigator empty reads the unknown-attacker row. It is a pure node and it reads server state, so call it from a graph that already has authority.
2a. Drive it manually (no GAS required)
Call these on the server when your state begins and ends. This is the path to use if your project does not use the Gameplay Ability System, or if the target has no AbilitySystemComponent.
In a graph that has authority - a GameMode, a server-side ability, or any graph gated on Has Authority - do Get Game State -> Get Component by Class (CrimsonCombatTextReplicationComponent) -> Begin Combat Text Scope (Target = the actor, Scope Tag = your state tag). Call End Combat Text Scope with the same pair when the state ends. On a client both nodes log a warning and do nothing.
BeginCombatTextScope and EndCombatTextScope mutate authoritative state. Called without authority they warn and return, so a client cannot open a window nobody else can see.2b. Or let GAS drive it
If the target has an AbilitySystemComponent, you do not need to call anything. Declare ScopeTag on the style and apply that tag through a GameplayEffect as you normally would. The server watches the target's authoritative ASC for that tag: while it is held the run never lapses, and removing it closes the run and pays it off.
IAbilitySystemInterface from the GameplayAbilities plugin only - the target needs an ASC, not any particular subclass of one. The server registers one callback per target and scope tag, on the first hit that resolves a style naming that tag, which is why the tag has to be present before the first hit.RegistryPath in Project Settings -> Crimson -> Crimson Combat Text; with it unset, the server logs once and no hit ever accumulates.3. Expect your own payoff
When a scope closes, the server pays each attacker's row off to that attacker alone. It resolves the row's identity to its APlayerController and sends the summary through that controller's UCrimsonCombatTextClientComponent::Client_CombatTextSummary - reliable, never a multicast, never distance-culled. An attacker with no controller - an AI, or a player who has disconnected - gets no payoff, and that row is simply dropped.
A payoff wears the ledger root's ScopeSummaryStyle, or the root's own style when that is unset. With the example styles yours arrives in the plain Damage.Summary look.
ScopeSummaryStyle row bInheritAggregation = false (what the shipped Damage.Summary does) or a window of its own. Otherwise the payoff inherits the very root it is closing and feeds the run it just ended - validation reports it as an error.Edge cases already handled
- Target dies mid-scope - a destroyed actor never fires its removal callback, so dead targets are detected and their runs paid off, pinned to the row's last hit location.
- Zero-value hits still count - a Blocked, Immune or Miss hit worth 0 bumps the hit count and punches the running number, adds nothing to the total, and leaves the number's look alone.
- A held number never expires on its own - a
HoldWhileScopedrow holds just short of the fade-out region whilebLedgerScopeOpenis set, then retires when the payoff arrives. A held number whose target died expires by the clock instead of hanging forever. AFadeWithMotionrow never holds at all: it fades and the next hit re-pops the running total. - Late joiners and dropped packets - every event carries the running total, so a client that missed a hit shows the correct figure on the next one.
- Level teardown - every tag callback is unregistered and the ledger is dropped without broadcasting summaries.
- The cap cannot eat a held number -
MaxActiveTextsretires the oldest instance that is not scope-held, so aHoldWhileScopednumber is never recycled out from under its payoff.
Related: accumulating without a state
If you want repeat hits folded into one number but have no state tag to hang it on, declare AggregationWindow alone. The run then lapses when no hit lands for that many seconds - measured between consecutive hits, so a sustained damage-over-time keeps one number climbing rather than starting a fresh one each tick. Everything above about per-player totals, inheritance and zero-value hits applies unchanged, and a merged number holds at MergeHoldAlpha and punches rather than rewinding to the anchor.