DevLog

Combat Overhaul: One VFX Pipeline, a BG3-Style Layout, and a Cleaner UX

At a Glance

The last twenty-five commits land a single, coherent combat overhaul. If you have not played in a couple of weeks, here is what is different the moment you load into a fight:

  • The combat log is no longer a permanent right column. It is a pinnable dock with an unread badge; pin it on ultrawide, collapse it on a laptop.
  • The HUD and action bar are now one in-flow bottom dock with eighty-four pixel cards, rotated section labels, and a horizontally scrolling row of abilities and spells.
  • Every active class ability has its own activation banner and family-driven flourish. Rage no longer looks like Bardic Inspiration which no longer looks like Channel Divinity.
  • Damage numbers actually clear the token. Spell-target hashes stop appearing on corpses. The victory panel waits for the killing blow to finish playing before it fades in.

Underneath all of that, the combat animation surface collapsed from two parallel pipelines down to one, and a class of "second arena drags and the VFX disappear" bugs got tracked into the Aspire dashboard logs and put down for good.

The rest of this post walks through the architecture and then the player-facing wins.


One Pipeline Instead of Two

The animation surface used to have two parallel layers. The engine wrote CombatLogEntry records on the canonical path; a separate _uiState overlay layer wrote CombatFloatieEvent and CombatProjectileEvent records on the side. Both layers fed the renderer, both fired animations for the same hit, and the engine's authoritative state -- HP, defeated flag, position -- snapped ahead of whichever layer was slower.

The visible symptom: HP bars dropped before the spell beam landed. Tokens faded out before the killing blow's lunge played. Multi-target spells collapsed onto one DOM node when two enemies shared a name.

The fix was to delete the legacy overlay layer entirely. There is now exactly one channel:

engine log -> CombatAnimationEventBuilder -> CombatAnimationBridge -> JS playEvent

Three discipline changes shipped alongside the deletion:

  1. Lagging HP. Every CombatLogEntry snapshots ActorHpAfter, TargetHpAfter, and the matching instance ids at the moment the engine writes the entry. The combat orchestrator subscribes to PlaybackStarted and advances _displayedHpByInstanceId from those snapshots. The renderer reads the displayed dictionary, not the engine, so HP bars and defeated states catch up to engine state when the animation begins, not when the underlying calculation resolved a few hundred milliseconds earlier.
  2. Same-name disambiguation. ResolveCombatant accepts an optional InstanceId. Twelve logger methods now thread targetInstanceId (and actorInstanceId on LogEnemyAttack); twenty-plus call sites were updated across DoT ticks, AoE damage, ally heals, consumable splashes, and status apply/expire. Two Vampire Spawns no longer share a token.
  3. Per-hex AoE rendering. Sphere, Cube, Cylinder, and Wave drop the central ring and instead flash every affected hex through a new boardVfx.showHexFlash and ICombatVfxTarget.ShowHexFlashAsync. Cone and Line still render as a shape, but anchored at the caster's hex and aimed at the centroid of affected hexes (cone) or the farthest hex (line). The engine emits AoeTargetHexes; the web layer mirrors them onto TokenRefs.

Net diff for that single commit: forty-three files changed, plus one thousand sixteen, minus two thousand five hundred eighty-three. Engine and web compile clean. Forty-seven hundred nine tests pass.


Class-Ability Animations, End to End

With the pipeline unified, every active class ability across all ten classes -- thirty-seven activatables and six passive boosts -- got wired into the single VFX channel. Each ability now has a class-distinct activation banner and a family-driven flourish instead of the generic gold ring with a label that all of them used to share.

The plumbing change is small but load-bearing: AbilityId is threaded end-to-end from CombatLogEntry through CombatLogEntryWeb and into CombatAnimationEvent. ICombatLogger gains an optional abilityId on every Log* overload plus a new LogClassAbilityActivation helper. A new CombatAnimationKind.ClassAbility routes BuffApplied entries with AbilityId set to a banner-plus-caster-pulse handler, replacing the generic Status:Buff dispatch that used to produce double pulses with sibling LogBuff entries.

Six new VFX primitives ship out of that:

  • ShowActivationBurstAsync -- eight archetypes (ember, warhorn, musicnote, arcane, shieldmesh, holypillar, shadowveil, crosshair) covering fourteen abilities.
  • ShowHealFlourishAsync -- four archetypes layered on top of the green bloom for Lay on Hands, Channel Divinity, Second Wind, and Swig of Rum.
  • ShowMovementTrailAsync -- four archetypes filling the previously empty playMove slot for Shadow Step, Swift Step, Cunning Action: Dash, and Disengage.
  • ShowWeaponArcAsync -- replaces the Sphere ring for Whirlwind (spin three-sixty) and Cleave (sweep front).
  • ShowArrowRainAsync -- replaces the Sphere ring for Multi-Shot and Volley with descending arrow showers.
  • ShowDebuffMarkAsync -- orbiting stars for Stunning Strike, ghostly insult bubbles for Vicious Mockery, branded sigil for Hunter's Mark.

Each primitive ships through the canonical five-touch pattern: a boardVfx render branch, an ICombatVfxTarget method, the Hex and Canvas implementations, an EffectsHost relay, and a JS fire* helper. One C# method per family, not per ability. Adding a new ability is one engine call site change plus one ABILITY_FAMILIES entry; no new C# method needed.

While the activation surface was getting rebuilt, four AoE and multi-strike paths were rewritten as a side effect. Whirlwind, Cleave, Multi-Shot, and Volley moved from direct HP mutation plus LogInfo to LogAoeShape plus per-target LogPlayerAttack, so each target gets its own damage popup and recoil. Primary-versus-secondary multipliers (Whirlwind one point zero and zero point five, Cleave fifty percent splash) are preserved through the actualDamage override on LogPlayerAttack. Cutlass Flurry, Flurry of Blows, Extra Attack, Action Surge, and Ranger Extra Attack moved from direct HP mutation to sequential LogPlayerAttack so each strike gets its own animation event.

The CSS palette grew ten class tokens -- --class-barbarian through --class-ranger -- in design-system.css. Wizard and Cleric were tuned to avoid Necrotic and Radiant collisions, since those damage types already own colors in the spell layer. Tokens are resolved at runtime via a new cssVarToRgba JS helper.


BG3-Style Layout: Three Slices

The legacy combat layout was a stack of floating overlays: a header overlay on top, an initiative overlay just below it, an action dock floating at the bottom, an HUD overlay on top of the action dock, and a permanent three-hundred-twenty-pixel log column on the right. The hex board reserved a hundred fifty to a hundred seventy pixels of top padding to stay clear of the overlays, and the action panel was a separate floating overlay sitting in front of the board.

Phase 12 restructured all of that into a three-row CSS grid (topstrip / battle / dock) plus a single column for the log. It shipped in three slices:

  1. Top strip in flow. The header overlay and the initiative overlay collapsed into one in-flow CombatTopStrip component spanning the top row. The hex board reclaimed its top padding.
  2. Unified bottom dock. The floating HUD overlay and the floating action dock fused into a single in-flow CombatBottomDock component sitting in the bottom row. HUD and actions share one gold rim and backdrop. CombatActionsPanel got an InDock flag that suppresses its own panel header and section headers when the dock owns the chrome. In multiplayer the HUD segment is suppressed because the party panel owns player portraits.
  3. Pinnable log plus token hover. The log column became a CombatLogDock component with two states. Pinned, it docks into the layout grid's log area at three-hundred-twenty to three-hundred-eighty pixels. Unpinned, it collapses to a tab handle on the right edge with an unread badge, and opens as an overlay on click. Pin state persists via UserProfile.CombatLogPinned. Each enemy token wraps in a MudTooltip with a three-hundred-millisecond delay and a rich enemy-hover-zoom card showing HP, AC, attack, status, intent, and classification, so the in-board roster strip became redundant and was hidden on Desktop and Wide.

The multiplayer party stack moved from top-left vertical (where it crushed the initiative band) to bottom-left vertical, anchored above the new bottom dock so it does not fight the top strip.

A small but visible follow-up: on ultrawide displays in pinned-log mode, the previous one-thousand-six-hundred-pixel cap on the topstrip and dock was making the action panel look like an island with two-hundred-seventy-pixel dead rails on either side. The cap now only fires when the log is collapsed; pinned-log mode lets the dock flex to the actual column width.

The dock itself got a design pass after the structure landed:

  • Cards back up to eighty-four by eighty-four pixels (was sixty-four by fifty-six on wide) with bigger icons, readable names, and first-class cost and cooldown chips.
  • Horizontal overflow is allowed and styled with a custom gold-tinted scrollbar instead of wrapping or shrinking.
  • Section labels ("ACTIONS", "SPELLS") rotated ninety degrees and pinned in the row's left margin via CSS ::before, replacing the old vertical-eating header rows.
  • Hover zoom on cards (translateY(-3px) scale(1.04)) for game feel.
  • A subtle gold-gradient divider between the HUD column and the actions column, only when the HUD segment is shown so multiplayer layouts stay clean.
  • Targeting strip restyled: red gradient backdrop when an enemy is targeted, slate gradient when there is no target, bigger HP bar (two-hundred-forty-pixel flex, twelve-pixel tall), bigger name typography.

Combat Log: Save Outcomes Inline

Two log lines that read as contradictory got merged. Players were seeing this:

Hill Giant succeeds Wisdom saving throw!
Thunder casts Echoing Screech on Hill Giant (50%) for 6 damage!

That parses as a clean dodge followed by damage anyway. The fifth-edition behavior is correct -- a successful save against an AoE damage spell halves damage -- but the log was not making it obvious.

LogSpell now accepts an optional save type, save roll, and save DC. When threaded, it builds a single combined line:

Thunder's Echoing Screech clips Hill Giant for 6 damage (Hill Giant saved Wis 16 of 16, half).

PlayerSpellResolver and MonsterSpellCaster drop the separate LogSavingThrow calls at the AoE per-target site and the single-target SavingThrow site. Pre-rolled save info is threaded into ApplyDamageWithModifierAsync via new optional parameters, which also kills a pre-existing double-roll bug -- the same save was being rolled twice on single-target SavingThrow paths because both the dispatch switch and ApplyDamageWithModifierAsync had their own save check. Bard's Vicious Mockery combined its save and damage into one LogInfo line. The debuff path keeps its separate save log because that is full-negation save-or-suck and the existing "resists Spell" line reads correctly there.

A follow-up commit refactored the three nullable scalars (save type, save roll, save DC) traveling together through every damage-spell call site into a SpellSaveOutcome record struct in the Logging namespace, with a Saved computed property as the single source of truth for the half-damage check. BuildSpellMessage was extracted as a private static helper so LogSpell's body is a thin orchestration call instead of a thirty-line if/else block. Net effect: minus ninety-eight, plus ninety-seven LoC, but one type instead of three loose scalars.


Bridge Lifecycle: The "Second Arena" Bug

Two related bugs caused a particularly nasty symptom: enter a second battle and the VFX would silently disappear, the game would drag at every turn boundary for eight seconds, and IsInitialized would still report true on the bridge.

The first cause was a double-dispose. EffectsHost.DisposeAsync used to call await Bridge.DisposeAsync(). Bridge.DisposeAsync completes the channel permanently via _channel.Writer.TryComplete(). When EffectsHost re-mounted on the next battle or layout-tier swap, OnAfterRenderAsync ran InitializeAsync and started a fresh pump task -- but that pump immediately exited because await foreach (var evt in _channel.Reader.ReadAllAsync(ct)) on a completed channel yields zero events. _module stayed set, so IsInitialized reported true. Every subsequent EnqueueAsync wrote to a closed channel, silently throwing ChannelClosedException under the fire-and-forget _ = ...AsTask(), each one bumping _inFlight without a matching pump-side decrement. WaitForDrainAsync at every turn boundary then hit its eight-second timeout. That was the drag. And no events ever reached the JS module. That was the missing VFX.

The fix: EffectsHost.DisposeAsync no longer calls Bridge.DisposeAsync. The bridge is a Scoped service whose lifetime matches the Blazor circuit, not this component. The DI container disposes it at circuit teardown, which is the correct point. The component still cleans up its own ReplayBadge subscription and DotNetObjectReference. Defensively, Bridge.DisposeAsync no longer completes the channel either; _cts.Cancel() is enough to exit the pump's ReadAllAsync loop.

The second cause was a transient JS hiccup between battles. The pump used to return; permanently on JSDisconnectedException, but the IJSObjectReference was kept on the bridge, so IsInitialized stayed true, so EffectsHost.OnAfterRenderAsync's auto-heal path never re-ran InitializeAsync. The bridge entered a "looks healthy, will not dispatch" zombie state. Same symptom as the double-dispose case, different root cause.

The fix: JSDisconnectedException catch now disposes the dead JS module reference and sets _module = null before returning. EffectsHost polls Bridge.IsInitialized on every render; with _module null it sees false and re-runs InitializeAsync. An outer catch for any other unexpected pump exit also clears _module so the host re-init path can recover instead of leaving the bridge as a permanent zombie. The existing finally { Interlocked.Decrement(ref _inFlight); } block runs through both new exit paths so the counter does not leak for the in-flight event.

Both fixes are documented in the combat-animations skill's new section twenty on bridge lifecycle, including the smoke-test recipe (two arenas back to back, watch for [VFX BRIDGE] dispatching lines in the Aspire structured logs).


Smaller Combat-VFX Wins

Three quieter fixes worth calling out:

  • Damage popups and SAVE/RESIST floaties now spawn at minus sixty-four (damage) and minus seventy (floatie) pixels above the anchor, instead of minus twenty and minus twenty-four. The anchor sits at the token's ground, not its center, so the old offsets were spawning labels inside the token's body. Players reported it as "no floaties at all" because the only visible portion was a brief tail at the very end of the rise.
  • VfxRegistry registration is now a per-render guard, not a firstRender-only registration. A layout-tier switch (Wide to Desktop) or any other path that destroyed and recreated ZoneBattlefieldHex could leave VfxRegistry.Active null when EffectsHost forwarded a VFX call, and EffectsHost.ShowFloatieAsync silently returns when Active is null. The new check re-registers whenever the registry's Active does not match the current instance, with a ReferenceEquals short-circuit for the common no-op case.
  • The takeover panels (victory and defeat) now await Bridge.WaitForDrainAsync() before flipping RenderVictory or RenderDefeat. The killing-blow lunge, the death fade, the screen shake, and the floating damage number now all play before the panel covers the board. The drain has a built-in eight-second timeout so a hung pump cannot freeze the orchestrator.
  • Spell target hashes no longer paint around defeated mobs. IsValidSpellTarget now gates on the displayed HP through the lagging-HP system; once Combat.razor.cs flips _displayedHpByInstanceId to zero, the hex stops glowing. Mobs stay targetable until the death-fade animation actually finishes, which keeps cleanup spells working through the kill window.

Inventory and Merchant: Cleanup, Not Features

Two pages got a structural pass without adding new features.

The Merchant page was split into components (MerchantToast, MerchantPortraitCard, MerchantBuyConfirmDialog, MerchantSellConfirmDialog, MerchantItemDetailDrawer) and grew a code-behind partial class (Merchant.razor.cs) following the Combat.razor.cs pattern. The compare popover was deleted because the drawer and hover deltas already cover compare. The buy and sell flows are now canonical: the drawer's Buy and Sell buttons route through confirm dialogs. Sell-on-buy got a MerchantReplacementResolver that finds spare items by slot and type; the buy-confirm now shows a checklist with a combined gold-after preview. Aspiration tabs picked up X-of-Y badges, level-locked cards are greyed, and class-locked items hide behind an "Other classes" toggle. The portrait card was flattened (no shimmer, no gradient) and the purse moved into the header.

The Inventory page dropped sell entirely. QuickSellItem, the drawer's Sell buttons, the sell-price display, and the StoreService injection are all gone; selling is the merchant's job. The page got a code-behind partial class (Inventory.razor.cs); Inventory.razor is markup only. MerchantToast was promoted to Shared/ActionToast.razor so both pages share it. A new InventoryItemDetailDrawer is equip-only and has no price section. The density toggle moved into InventoryHeader. Items the character cannot equip are greyed in the weapon and armor sections and in the drawer; instead of a useless Equip button, the card shows "Lv. N" or " only" depending on which gate is failing.


Rest Events and Pet Bonuses

Two engine fixes that are not visible at the layout level but matter on the run.

Scripted rest events used to fire only on the Short Rest path. Press On silently skipped narrative-essential moments. The fix threads scripted events through both Press On and Short Rest, and a new forceTrigger parameter lets Short Rest bypass the RestEventFrequency roll so an explicit rest is never denied. Stale RestEventState fields are cleared at every transition, an IsMultiplayer guard prevents the single-player branch from racing the SignalR flow, and a broken @Icons.Material.Filled.{choice.Icon} interpolation is replaced with a reflection-based ResolveChoiceIcon helper that handles both PascalCase and snake_case author input.

LeaveAdventureAsync now snapshots HP percent and Mana percent and sets RestEventPending on AdventureProgress, then heals the live character to full so the tavern is not handicapped. On return, TryResumeRestEventFlowAsync restores HP and Mana proportionally to the current MaxHP and MaxMana (level-up safe) and replays the rest event flow instead of dropping into combat. The architecture, flow, schema, and effect types all live in the new .claude/skills/rest-events/ skill for future reference.

The pet bonus fix is the most important hidden bug of the batch. Pet ability bonuses were inadvertently being saved to character base stats, leading to cumulative inflation -- characters with a Constitution-boosting pet would end up with permanently higher Constitution after enough sessions. The fix introduces a [BsonIgnore] ActiveCombatPetBonus field on Character for transient combat-time stat boosts, and a Character.GetEffectiveAbilityModifier method that applies the pet ability bonus during combat (saves, initiative, spell DCs) without modifying the persisted base stats. ApplyPetBonusToCharacterAsync is refactored to use a dedicated PetBonusApplier service that only applies transient resource pool bonuses (HP, Mana, Ki) and no longer touches base ability scores. ActiveCombatPetBonus is cleared at the end of combat in TurnCoordinator to prevent state leakage. New unit tests cover both GetEffectiveAbilityModifier and PetBonusApplier.

If you have noticed your stats slowly creeping over the last few weeks, that is why. The fix stops the inflation at the source; existing characters with inflated stats are not retroactively repaired by this commit.


Inspector and Action Dock Polish

Two regressions from a prior polish commit got addressed in one cleanup pass.

The MudDrawer-based monster inspector never rendered its panel; only the modal backdrop appeared, even after the drawer was moved outside <main> and given an explicit Width. The fix swapped MudDrawer for a custom CSS slide-in matching the proven CombatLogDrawer pattern: a backdrop div plus a fixed-positioned drawer with a slideInRight animation, z-tier two-hundred and two-hundred-one per the documented summon-panel band. Width is three-hundred-eighty (Wide) and three-hundred-twenty (Desktop). Click the backdrop or the X icon to close, both routing through HandleInspectorDrawerToggled which nulls UI.SelectedMonster.

The action dock cards still would not collapse at one-thousand-nine-hundred-twenty by seven-hundred-eighty or one-thousand-nine-hundred-twenty by nine-hundred even after the threshold bump and the specificity bump. The root cause was probably the scoped CombatActionsPanel.razor.css rules and cascade order subtleties that the specificity ladder could not reliably win. Pragmatic over pure: !important was applied to the Compact and Expanded tier rules. The action dock density tiers are meant to dominate any sizing inherited from the generic .ability-card-row rules.


An unhandled error has occurred. Reload ๐Ÿ—™

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.