Concept: Style Resolution, Motion & Rendering
Once an event survives the per-player filter, four client-side systems turn it into pixels: the registry picks how it looks, the motion set picks how it moves, the formatter picks how the number reads, and the renderer decides how it is drawn.
Style resolution
Resolution runs in six ordered steps. You can watch all of them live in the Dev Tools Resolver tab, which is the fastest way to tell "my style is wrong" from "my tags are wrong".
- Advanced rules - only if
AdvancedRulesis non-empty. A matchingFGameplayTagQuerywith the highestPrioritywins outright. - Hierarchical tag match - each incoming tag walks up its parents until a
Styleskey hits. The most specific match across the whole container wins. - Fallback -
DefaultStylewhen nothing matched. - Modifiers - every
Modifierskey present in the container is applied on top, inApplyOrder. A modifier may override the motion preset, override the placement preset, and add to the placement offset; one that asks for a companion pop is recorded inCompanionModifierKeysfor the subsystem to emit separately. - Motion -
MotionPresetresolves against theMotionSet, falling back to the style's inlineMotion; the accumulatedDurationMultiplierfolds intoDuration. - Placement -
PlacementPresetresolves against thePlacementSet, and the style's plus every modifier'sPlacementOffsetaccumulate. No placement is legal and common: it means the pop anchors over its target.
Hierarchy is what keeps the registry small. A hit tagged Damage.Fire.Burn resolves against Damage.Fire if that row exists, otherwise Damage, otherwise DefaultStyle - so you author only the specificity you actually care about.
Crit modifier covers Damage.Fire.Crit, Damage.Ice.Crit and every other pairing. Without them a registry grows as styles x conditions; with them it grows as styles + conditions.Companion pops
A modifier normally changes the number it was applied to. It can instead raise a second, separate pop from the same event - a word beside the figure - by ticking bSpawnCompanionPop and authoring the CompanionPop style next to it.
The question that decides which you want is whether the thing is a property of the number or an event that happened alongside it. A crit is a property of the number: the same hit, bigger and gold, so it belongs in the modifier's own fields. A shield breaking is not - it happened because of the damage, and folding it into a FormatWrapper would make one string carry two facts and one motion carry two meanings. The shipped Damage.Result.ShieldBroken modifier is the pure form: every other field left at its default, and a CompanionPop reading "Shield Broken" on the Hover motion, offset upward so it sits above the number.
Resolution collects the keys of every applied modifier that wants a companion into CompanionModifierKeys, in ApplyOrder. The subsystem emits the main pop first, then walks that list and re-enters the same emit path once per companion - so each companion gets its own placement, its own fan slot and its own merge identity.
ResolveCompanionPop resolves the modifier's CompanionPop with an empty modifier list, so it can never pick up a companion-spawning modifier of its own - the chain is always exactly one pop deep.It merges only with its own kind. A companion pop is keyed by its own key rather than by the ledger root, so it merges with other companions of the same kind on the same target and never with the number it accompanies.
Nothing extra replicates. Companions are derived on each client from the same replicated event as the main pop, and the filter chain is not re-run - the request was already admitted as a whole.
MaxActiveTexts is reached sooner. Let the companion accumulate - its own ledger root, or an inherited one - or raise the cap, before turning one on for a modifier that matches constantly.The aggregation ledger
The server owns every running total, and it keeps one row per attacker. UCrimsonCombatTextReplicationComponent holds, per target and per ledger root, a running row for each attacker identity, and stamps that figure and its hit count onto every event it broadcasts. Clients do no arithmetic: the attacker's own client merges those hits into one climbing number, and every other client shows the same hit as a plain pop.
Two style fields make a row a ledger root, and they answer different questions. AggregationWindow is a time rule: the run stays alive while hits keep landing inside that many seconds of each other, measured between consecutive hits. ScopeTag is a state rule: while the target holds that tag the run never lapses at all. A run ends by window lapse, by the tag being removed from the target's authoritative AbilitySystemComponent, or by an explicit EndCombatTextScope on the component.
Closing a scope pays each attacker's row off with a pop styled by ScopeSummaryStyle (unset reuses the style itself), sent reliably to that attacker's own PlayerController and never distance-culled. With GAS nothing is called by hand: declare ScopeTag and apply the tag through a GameplayEffect as usual - which is why the tag has to be present before the first hit, since the server starts watching on the first hit that resolves a style naming it. Without GAS, BeginCombatTextScope and EndCombatTextScope on the component drive the same rows; both are authority-only. See How-To: Accumulate Damage Over a Stagger Window.
Inheritance: one ledger per branch
A row that declares neither a window nor a scope takes the nearest ancestor row that declares one. That ancestor is the row's ledger root, and the row borrows its window, its ScopeTag, its ScopeSummaryStyle and its AggregationDisplay. FCrimsonCombatTextResolvedStyle::AggregationRoot reports which key won, and is unset when no row in the chain declares either.
This is what makes one damage number behave like one damage number. The shipped Damage row declares a 0.6 s window, so Damage.Type.Fire, Damage.Type.Poison and Damage.Result.Blocked all accumulate into the same run with nothing authored on them. Give a row a window of its own and it becomes a root, splitting off into a number of its own; clear bInheritAggregation on it and its hits stay out of every running total - which is exactly what a summary style needs.
AdvancedRules inherit too. A rule-chosen style takes its ledger from the key the hierarchical lookup would have picked, so a fire hit that was blocked and resolved through a rule still joins Damage's ledger rather than dropping out of it.
Whose row a hit lands in
The attacker resolves to a PlayerState, never to the raw instigator actor. UCrimsonCombatTextPlayerSettings::ResolveInstigatorIdentity walks pawn -> controller -> PlayerState, accepts an ability system owner that is itself a PlayerState, and follows a projectile's spawn instigator. A respawn, a possession change, or a pawn that has dropped out of net relevance therefore all keep landing in the same ledger row. The server fills FCrimsonCombatTextEvent::InstigatorIdentity before it broadcasts, and that field replicates with the event: a PlayerState is always relevant even when the pawn that swung is not, which is how a client tells its own hits from someone else's. A client merges a hit into a running number only when that identity is its own local player; every other hit is a plain pop of that hit's value.
Fade or hold: how long a number lives
AggregationDisplay changes nothing about the arithmetic - only how long one running number sits on screen.
| Display | What happens |
|---|---|
FadeWithMotion (default) | The number fades when its own motion ends, even while the scope is still open. The next hit inside the window or the scope pops a fresh number already showing the running total, so nothing sits pinned over the target between swings |
HoldWhileScoped | The number is pinned on screen for as long as the scope is open, and the payoff retires it |
It is inherited from the ledger root exactly like the window and the scope: a row that declares neither takes the root's value. The shipped Damage row leaves it at FadeWithMotion.
Reading the ledger, and the payoff rule
GetCombatTextScopeTotal(Target, ScopeTag, Instigator) on the replication component reports what one attacker's row holds. Instigator is required - it names whose row to read, is normalized exactly as a hit's instigator is, and reads the shared unknown-attacker row when null. It reads authoritative state, so it belongs on the server.
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, reliably and without a distance test. An attacker with no controller - an AI, a player who has disconnected - gets no payoff and the row is dropped. A stagger broken by four players therefore ends in one pop on each of the four screens, never four totals on one.
A payoff wears the ledger root's ScopeSummaryStyle, or the root's own style when that is unset. A target that died mid-scope still pays off, pinned to its row's last hit location.
What a merge looks like
A merged pop neither restarts nor rewinds to the anchor. The base motion is held at MergeHoldAlpha - the number parks at that point of its lifetime and stays put - and a separate punch fires on top of it for MergePunchDuration seconds. MergePunchScaleCurve multiplies the held sample's scale and MergePunchRiseCurve adds upward layout units to its offset, so the number reacts visibly to each hit without ever drifting off its hold.
The punch runs on its own alpha, independent of the lifetime alpha, which is why back-to-back merges can re-punch while the base motion stays parked. EvaluateMergePunch is a pure function of that punch alpha, and the composer multiplies its scale into the evaluated sample and adds its rise, so every renderer inherits the behavior with nothing to implement.
MergeHoldAlpha and punches on a repeating cycle, so you shape MergePunchScaleCurve and MergePunchRiseCurve against the sustained case instead of guessing from the curve. See How-To: Author Motion Presets.Format tokens
The resolved style supplies a rich-text FormatString with these tokens:
| Token | Replaced with |
|---|---|
{Value} | The formatted number |
{Prefix} | The event's PrefixText (e.g. - or +) |
{Custom} | The event's CustomText (e.g. Blocked!) |
{ValueStyle} / {PrefixStyle} | The style's rich-text row names - wrap segments as <{ValueStyle}>{Value}</> |
A modifier's FormatWrapper wraps whatever the style produced, with {0} standing in for the base result - so "{0}!" turns 123 into 123!. Stacked wrappers nest outward in ApplyOrder.
Number formatting
The project default (UCrimsonCombatTextSettings::NumberFormat) applies unless the local player's NumberFormatOverride is 0 or greater:
| Format | Example | Notes |
|---|---|---|
CommaSeparated | 1,234,567 | Locale-style grouping, no decimals |
Abbreviated | 1.2M, 3.4K, 5.6B, 7.8T | Compact suffix notation with one decimal |
Raw | 1234567 | No formatting |
Motion
Motion is authored as curves over a normalized 0..1 lifetime alpha, not as velocity and gravity. The evaluated sample is a pure function of that alpha, which has two consequences worth understanding:
- Motion always completes exactly at `Duration`. The old failure mode - an arc cut off mid-fall because gravity and duration disagreed - cannot occur.
- Motion is reproducible. The same fan slot replays identically, so repeated attacks read consistently instead of scattering randomly.
Amplitudes are in layout units (post-DPI), so motion looks the same at 1080p and 4K. An empty curve falls back to a documented default rather than to zero, so a default-constructed motion still produces a sensible rise and fade.
The end-height ladder
Concurrent hits on one target are spread on two axes, because one is not enough. The fan spreads X: slot 0 goes straight up, then alternating sides widening out to FanWidth and saturating at FanSlots. RiseStaggerPerSlot and RiseJitter spread Y: the slot number scales RiseDistance by 1 + Slot * RiseStaggerPerSlot, and the per-pop jitter roll adds RiseSeed * RiseJitter on top. The two together mean a burst of eight ends as a readable ladder rather than one overlapping clump.
The ladder rung is the raw spread index, not the fan step. The fan pairs two hits per magnitude - that is what makes it symmetric - so keying the height off the step would give those two hits the same end height, the exact overlap this exists to break. The raw index also keeps laddering when bUseSpreadFan is off, where there is no step at all. Both terms scale RiseDistance rather than adding to it, so a motion with RiseDistance 0 stays pinned; the result is clamped at 0 so a negative stagger cannot invert a rise.
Because the whole thing lands in the evaluated sample's Offset, it is renderer-agnostic: the batched Niagara renderer projects that same offset onto the camera basis and inherits the ladder with no extra work.
Simulation and rendering
The subsystem owns every live instance and simulates them in one loop: advance time, ask each pop's placement where it is now, project to screen, evaluate motion, retire the expired. It then hands the whole frame to a renderer. Widgets are passive views - they carry no motion, no timing and no lifetime, and are marked DisableNativeTick.
DisableNativeTick is deliberately not the same as setting a widget's TickFrequency to Never. Never would also stop UWidgetAnimation and latent actions, silently breaking the AnimationName feature. The metadata flag drops only the native tick.Views are pooled and recycled, never destroyed mid-session. MaxActiveTexts caps concurrent instances and retires the oldest instance that is not scope-held first, which is what keeps an AoE spike from unbounded widget growth. A HoldWhileScoped number the server is holding open is deliberately exempt: recycling it would leave its payoff with nothing to retire. Creation is additionally throttled per frame when the pool is exhausted.