A quality-of-life mod for Slay the Spire 2 that adds damage tracking, a teammate hand viewer, quick restart, and more.
- What it does: Appends a
(total)value to multi-hit enemy intent labels. For example, if an enemy attacks for6x3, the label becomes6x3 (18). - How it works: Patches
NIntent.UpdateVisuals(Harmony Postfix). After the game renders the intent label, it checks if the attack is multi-hit (total > single hit damage). If so, it appends(totalDamage)to the existing label text. - Key classes:
IntentLabelPatch, usesAttackIntent.GetSingleDamage()andAttackIntent.GetTotalDamage().
- What it does: Displays a number above the player (and pet, if applicable) showing how much HP damage they will take this turn after accounting for block, pet absorption, debuff powers, and end-of-turn card effects.
- How it works: The
DamageTrackerclass gathers damage from three sources, simulates the full damage sequence, and renders GodotLabelnodes positioned above each creature.- Red number above player = damage the player will take to HP.
- Green "0" above player = player is fully protected (block/pet absorbs everything).
- Orange number above pet = damage the pet will absorb.
- Pet filtering: Only pets with
MaxHp > 0andMonster.IsHealthBarVisible == trueare treated as damage absorbers. This excludes non-hittable pets like Pael's Legion, which exist as creatures but have no real HP pool.
Enemy attack intents — all AttackIntents (single and multi-hit).
End-of-turn damage powers (hardcoded — must be updated if new ones are added):
| Power | Blockable? | Notes |
|---|---|---|
ConstrictPower |
Yes | Amount damage per turn |
DemisePower |
No (unblockable) | Amount damage per turn, bypasses block entirely |
MagicBombPower |
Yes | Amount damage, instanced (can have multiple), only fires if applier enemy is alive |
DisintegrationPower |
Yes | Amount damage per turn, stacks cumulatively (6 → 13 → 21 from Knowledge Demon) |
End-of-turn hand card damage (generic — automatically handles all current and future cards):
Any card with HasTurnEndInHandEffect == true is included. Cards with a "Damage" DynamicVar are treated as blockable; cards with an "HpLoss" DynamicVar (e.g. Beckon) are treated as unblockable (HP loss bypasses block).
Currently covers: Burn, Decay, Toxic, Debt, Doubt, Shame, Regret, Beckon, BadLuck, Infection.
New cards matching this pattern are picked up automatically with no code changes.
Frost orb passive block (Defect):
The simulation accounts for Defect's frost orbs, which grant passive block at end of turn before enemies attack. Each FrostOrb in PlayerCombatState.OrbQueue.Orbs contributes its PassiveVal (which already accounts for Focus) to the simulated block total.
- Frost orb passive block added to simulated block total
- Blockable end-of-turn damage (powers + cards with
Damage) → consumed by block first - Unblockable end-of-turn damage (Demise + cards with
HpLosslike Beckon) → straight to HP - Enemy attack damage → remaining block → pet HP → player HP
- Recalculation triggers (each is a separate Harmony patch):
NCreature.RefreshIntents(Postfix) - when enemy intents update.CombatManager.SetReadyToEndTurn(Postfix) - when player ends turn.NIntent._Process(Postfix) - periodic recalc every 250ms during combat to catch block/HP changes from card plays.
- Cleanup triggers (hide labels):
CombatManager.Reset(Postfix)CombatManager.EndCombatInternal(Prefix) - victoryCombatManager.LoseCombat(Prefix) - defeat
- Overlay hiding: Labels automatically hide when full-screen overlays are open:
NCapstoneContainer.InUse- deck view, discard pile, exhaust pile screensNOverlayStack.ScreenCount > 0- card selection overlays (potion picks, card rewards, etc.)
- Font: Attempts to match the game's font by searching the scene tree for existing intent labels, falling back to a recursive font search (depth 3).
- What it does: Hold the R key for 1.5 seconds to abandon the current run and start a new one with the same character, acts, modifiers, and ascension level (but a new seed).
- How it works:
InputPatchpatchesNGame._Inputto track R key press duration. When 1.5s elapses,RestartTracker.RestartRun()fires:- Captures current run state (character, acts, modifiers, ascension) via
RunManager.Instanceand HarmonyTraverse. - Calls
NGame.ReturnToMainMenu()asynchronously. - Waits 0.5s via a Godot scene tree timer.
- Creates a new
Player,RunState, and callsRunManager.SetUpNewSinglePlayer()+NGame.StartRun()via reflection (AccessTools.Method).
- Captures current run state (character, acts, modifiers, ascension) via
- Error logging: On failure, writes to
<user_data_dir>/betterspire2_error.txt.
- What it does: Skips the logo/splash animation on game startup.
- How it works: Patches
NGame.LaunchMainMenu(Harmony Prefix), setting theskipLogoparameter totrue.
- What it does: Press F3 to open a compact panel showing every player's current hand in combat. Each card is rendered as a small thumbnail (100x80) with the card name. Hovering over any card shows a full tooltip with the card's description using the game's native
NHoverTipSetsystem. - How it works:
DeckTrackerclass reads each player's hand fromPlayerCombatState.Hand.CardsviaCombatManager.DebugOnlyGetState(). Cards are displayed in a grid layout grouped by player.- Pagination: If there are more than 4 players,
<>arrow buttons appear to page through them. Page Up / Page Down keyboard shortcuts also work. - Tooltips: Each card is wrapped in a
CardPanel(customPanelContainersubclass) that wires upMouseEntered/MouseExitedsignals. On hover, it creates anIHoverTipvia boxed struct reflection (sinceHoverTipis arecord structthat takesLocString, not plain strings) and callsNHoverTipSet.CreateAndShow(). The tooltip node is reparented from the game'sHoverTipsContainerinto the mod'sCanvasLayerso it renders above the panel. - Drag/close: Mouse events are routed through
InputPatchtoDeckTracker.HandleMouseInput(). Supports click-and-drag to reposition, click-outside-to-close (with mutual awareness of SettingsMenu viaIsPointInPanel()), and a close button. - Position memory: Panel position is saved across open/close cycles.
- Pagination: If there are more than 4 players,
- Key classes:
DeckTracker,DeckTracker.CardPanel,DeckTracker.HandPanel.
All features can be toggled on/off individually.
- In-game menu: Press F1 to open/close the settings panel (styled Godot UI overlay on
CanvasLayer10, z-index 200). The panel is draggable, supports click-outside-to-close, and has a close button. Position is remembered between opens. - Persistence: Settings are saved as a JSON file of
boolvalues at<user_data_dir>/betterspire2_settings.json. - Defaults: All features are enabled by default.
{
"MultiHitTotals": true,
"PlayerDamageTotal": true,
"ShowExpectedHp": true,
"HoldRToRestart": true,
"SkipSplash": true,
"ScaleToActivePlayers": true,
"ShowTeammateHand": true
}DamageCounter/
DamageCounter.sln # Solution file
DamageCounter/
DamageCounter.csproj # Project file (outputs BetterSpire2.dll)
Program.cs # Entry point, core classes, multiplayer
Patches.cs # All Harmony patches
DamageTracker.cs # Incoming damage simulation + labels
DeckTracker.cs # F3 teammate hand viewer
RestartTracker.cs # Hold-R restart logic
SettingsMenu.cs # F1 settings panel
ModSettings.cs # Settings persistence (JSON)
ModLog.cs # File logger
| File / Class | Description |
|---|---|
ModLog |
File logger (writes to betterspire2_log.txt) |
ModSettings |
Static settings with JSON load/save |
SettingsMenu |
F1 toggle panel (Godot UI) + party section |
DeckTracker |
F3 hand viewer with card tooltips + pagination |
PartyManager |
Multiplayer: mute drawings, kick, clear drawings |
MuteDrawingsPatch |
Block drawings from muted players (manual patch) |
MuteClearDrawingsPatch |
Block clear from muted players (manual patch) |
ModEntry |
Entry point - loads settings, applies patches |
IntentLabelPatch |
Multi-hit total label (Harmony postfix) |
DamageTracker |
Player/pet damage simulation + label rendering |
| Recalc/Hide patches | Triggers for DamageTracker updates and cleanup |
RestartTracker |
Hold-R restart logic + async restart flow |
InputPatch |
F1/F3 menu, PgUp/PgDn, hold-R input handling |
SkipSplashPatch |
Splash screen skip |
- .NET 9.0 SDK
- Slay the Spire 2 installed (for
sts2.dllreference)
GodotSharp4.4.0 - Godot engine bindings (the game runs on Godot)Lib.Harmony2.4.2 - Runtime method patchingMonoMod.Core1.2.3 - Low-level runtime detours (required by Harmony on ARM64)
The project references the game assembly directly via the Sts2Dir MSBuild property in DamageCounter.csproj. Update this property to match your Steam install path:
<Sts2Dir>G:\STEAM\Install\steamapps\common\Slay the Spire 2</Sts2Dir>- Assembly name:
BetterSpire2.dll - On build, the
CopyToModstarget automatically copies the DLL to$(Sts2Dir)\mods\if the mods folder exists.
The game's mod loader calls ModEntry.Init() (via [ModInitializer("Init")]), which:
- Initializes file logging
- Loads settings from disk
- Creates a Harmony instance (
com.jdr.betterspire2) - Applies each patch class individually with try/catch (one failing patch doesn't break others)
- Manually patches drawing-related methods via
harmony.Patch()for cross-platform compatibility
| Patch Class | Target | Type | Purpose |
|---|---|---|---|
IntentLabelPatch |
NIntent.UpdateVisuals |
Postfix | Append multi-hit totals to labels |
RecalcOnRefreshIntentsPatch |
NCreature.RefreshIntents |
Postfix | Recalculate damage tracker |
RecalcOnEndTurnPatch |
CombatManager.SetReadyToEndTurn |
Postfix | Recalculate damage tracker |
RecalcPeriodicPatch |
NIntent._Process |
Postfix | Recalculate every 250ms |
HideOnResetPatch |
CombatManager.Reset |
Postfix | Hide damage labels |
HideOnWinPatch |
CombatManager.EndCombatInternal |
Prefix | Hide damage labels on victory |
HideOnLosePatch |
CombatManager.LoseCombat |
Prefix | Hide damage labels on defeat |
InputPatch |
NGame._Input |
Postfix | F1/F3 menus, PgUp/PgDn, hold-R restart |
SkipSplashPatch |
NGame.LaunchMainMenu |
Prefix | Set skipLogo = true |
MuteDrawingsPatch* |
NMapDrawings.HandleDrawingMessage |
Prefix | Block drawings from muted players |
MuteClearDrawingsPatch* |
NMapDrawings.HandleClearMapDrawingsMessage |
Prefix | Block clear from muted players |
* Patched manually (not via [HarmonyPatch] attributes) for cross-platform compatibility.
CombatManager.Instance.DebugOnlyGetState()- Access current combat state (enemies, player creatures)AttackIntent.GetSingleDamage()/GetTotalDamage()- Calculate damage values per intentRunManager.Instance+Traverse- Access run state (character, acts, modifiers, ascension)NGame.Instance.ReturnToMainMenu()- Async return to menuNGame.StartRun()- Start a new run (invoked via reflection)Player.CreateForNewRun()/RunState.CreateForNewRun()** - Create new run objectsNCombatRoom.Instance.GetCreatureNode()- Get the Godot node for a creature (for label positioning)TaskHelper.RunSafely()- Fire-and-forget async task runner from the gameCreature.GetPower<T>()/GetPowerInstances<T>()- Get debuff powers on a creature (for damage calculation)PowerModel.Amount- The stacked amount of a power (used as damage value for Constrict, Demise, MagicBomb, Disintegration)PlayerCombatState.Hand.Cards- Cards currently in the player's hand (for end-of-turn card damage)CardModel.HasTurnEndInHandEffect- Flag indicating a card has an end-of-turn effect while in handCardModel.DynamicVars["Damage"].BaseValue- The base damage value of a card's damage variableCardModel.DynamicVars["HpLoss"].BaseValue- The base HP loss value (unblockable, e.g. Beckon)PlayerCombatState.OrbQueue.Orbs- List of channeled orbs (for frost orb passive block calculation)OrbModel.PassiveVal- The passive value of an orb (returnsdecimal, cast toint); forFrostOrbthis is the block amountCreature.MaxHp/Monster.IsHealthBarVisible- Used to filter out non-hittable pets (e.g. Pael's Legion)NHoverTipSet.CreateAndShow()/NHoverTipSet.Remove()- Game's native tooltip system (used by DeckTracker for card tooltips)HoverTiprecord struct - Tooltip data container; set via boxed reflection since constructors requireLocStringCardModel.Title/CardModel.Description- Card name and description text (viaGetFormattedText())
All features work in multiplayer. Each player sees their own damage numbers calculated independently based on their own block, pet, and debuffs. Both players need the mod installed, but versions don't need to match.
In multiplayer, the F1 settings menu includes a Party section showing all players by Steam name and character class. From here you can:
- Hide Drawings — Block another player's map drawings from appearing on your screen. Also clears their existing drawings when toggled.
- Clear All Drawings — Wipe all drawings from the map at once.
- Kick (Host only) — Remove a player from the session.
PartyManager— Manages per-player drawing mute state, kick viaINetHostGameService.DisconnectClient, and drawing clear via reflection onNMapDrawingsprivate methods.MuteDrawingsPatch/MuteClearDrawingsPatch— Harmony Prefix patches onNMapDrawings.HandleDrawingMessageandHandleClearMapDrawingsMessage. Patched manually (not via attributes) for cross-platform compatibility.- Steam names — Resolved via
PlatformUtil.GetPlayerName(PlatformType.Steam, netId).
Things most likely to break on a game update:
- Method signatures changed - Any of the patched methods could be renamed, have parameters added/removed, or be refactored. Check the Harmony patches table above against the new
sts2.dll. DebugOnlyGetState()removed - This is a debug API that could be removed. The damage tracker depends on it entirely.NGame.StartRunsignature changed - The restart feature calls this via reflection; any parameter changes will break it silently.RunState/Playerconstructor changes - The restart feature creates these manually.- Godot version bump - The project pins
GodotSharp 4.4.0. If the game upgrades Godot, update this dependency. - Intent rendering changes - If intent label structure changes (e.g.,
MegaRichTextLabelreplaced), the multi-hit patch will need updating. - Pet/creature hierarchy changes - The damage simulation assumes
Creature.Petsexists and that pet absorbs damage before player. - New end-of-turn damage powers added - Powers are hardcoded (Constrict, Demise, MagicBomb, Disintegration). If a new power deals damage at end of turn, it must be added manually to
DamageTracker.Recalculate(). Search the DLL forAfterTurnEndorAfterTurnEndLatein thePowersnamespace to find candidates. End-of-turn hand cards are handled generically and don't need updating.
- All errors are logged to
<user_data_dir>/betterspire2_log.txtwith timestamps and stack traces - The log also records OS info, patch success/failure counts, and settings load status on startup
- All patches have
try/catchblocks that log errors without crashing the game - Use
GD.Print()/GD.PrintErr()for debug logging (shows in Godot console)
- Mac (ARM64 / Apple Silicon) is currently unsupported. The game's Harmony/MonoMod runtime does not function on macOS ARM64 —
MonoMod.Core.dllis missing from the Mac distribution and the native interop layer is absent. All Harmony patches fail withNotImplementedException. This is a game engine issue affecting all Harmony-based mods. - Linux is currently unsupported. MonoMod's native detour helper (
mm-exhelper.so) fails to load at runtime withundefined symbol: _Unwind_RaiseException, likely due to a missing libgcc/libunwind linkage in the Steam Runtime environment. All Harmony patches fail withDllNotFoundException. This is a game engine issue affecting all Harmony-based mods. - Both platforms will work automatically once the developers patch the game's Harmony/MonoMod runtime.