A high-octane super fast real Slay the Spire 2 deck damage simulator
gaming
README.md

StS2Sim #

A headless Slay the Spire 2 deck analyzer that runs the actual sts2.dll game logic outside the Godot engine. Reads your live current_run.save, spins up isolated combats against a high-HP dummy, and reports per-turn damage statistics with confidence intervals and per-turn play breakdowns over a live web UI.

Not a reimplementation. Card numbers, power interactions, hook orderings, RNG — all of it comes from the real game DLL. When MegaCrit patches a card, the sim updates with zero code changes.

StS2Sim dashboard — Ironclad deck with relics, live sim charts, and best-combat per-turn breakdown


What it does #

  • Reads your current run from %APPDATA%\SlayTheSpire2\...\current_run.save. No mod required, no game running.
  • Resolves your character + deck + relics via the game's ModelDb, then attaches them to a synthesized combat against BigDummy (the game's own infinite-HP test enemy).
  • Plays a configurable number of turns with an ε-greedy "highest damage" play policy that explores card-order space without getting stuck in obvious local maxima (the kind that miss setup cards like Inflame).
  • Best-of-K per shuffle seed × N seeds estimator. Per-seed averaging eliminates play-decision variance while preserving shuffle variance, giving you a clean "average outcome under near-optimal play" metric with a tight 95% CI.
  • Live-streams seed-by-seed progress over WebSocket: per-seed scatter plot, running average with CI band, damage histogram, and the best per-turn breakdown found so far (drew → played → autoplayed cascade).

Run #

cd StS2Sim
dotnet run -c Release -p:STS2GameDir="C:\Program Files (x86)\Steam\steamapps\common\Slay the Spire 2"

This builds, starts the embedded HTTP+WebSocket server on port 52324, and opens your browser. Pick seed/K/turn knobs in the sidebar, hit Run Sim, watch the charts fill in.

CLI modes:

  • dotnet run -c Release -- experiment — legacy console A/B output (no web UI)
  • dotnet run -c Release -- silent-tests — runs the 174-test Silent card battery

How the bootstrap works #

The game DLL was built for a Godot host. We trick it into running outside one. Five moving pieces, in order:

  1. TestMode.IsOn = true flips NonInteractiveMode.IsActive, which short-circuits every Cmd.Wait / Cmd.CustomScaledWait / SfxCmd / VFX-spawn site in the codebase via if (!NonInteractiveMode.IsActive && ...) guards. Without this, the very first damage call awaits a Godot timer that doesn't exist and hangs forever.

  2. Harmony shims for native interop (GodotShims.cs). A handful of Godot.* calls crash with 0xC0000005 because they P/Invoke into a native runtime we never loaded:

    • Logger.GetIsRunningFromGodotEditor → return false
    • ConsoleLogPrinter.Print → Console.WriteLine (skip GD.Print)
    • Godot.Time.GetTicksMsec → return 0
    • Creature.ToString → bypass localization (LocString hits null)
    • LocString.Exists / GetFormattedText / GetRawText → safe fallbacks (so cards with selection prompts like Nightmare don't NRE)
    • CardCmd.Preview → record subject for the play-event log instead of animating
    • CardCmd.AutoPlay → log autoplays (Hellraiser, etc.) into the per-turn timeline
    • CardPileCmd.Shuffle → synchronous Fisher-Yates with the player's own RNG, then fires Hook.AfterShuffle so StratagemPower / TheAbacus / BiiigHug actually trigger
  3. ModelDb.Init() + ModelIdSerializationCache.Init() + ModelDb.InitIds() directly. Registers all 1611 game models (cards, monsters, relics, powers) without touching Godot's atlas/locale loaders.

  4. Bypass CombatManager.SetUpCombat in favor of a minimal hand-built setup: reflection sets _state and IsInProgress = true, manual combat.AddPlayer(player) + combat.AddCreature(dummy). Saves us from NetCombatCardDb.StartCombat, RNG-driven shuffles, and GodotEvent invocations.

  5. Real card playback: every card in your deck plays through CardModel.OnPlayWrapper(...) exactly the way it would in the live game. Strike.OnPlay → DamageCmd.Attack(6).Execute → CreatureCmd.Damage → Creature.LoseHpInternal. Same code paths, same RNG seeds, same hooks.

What's actually wired up #

Everything here is the real game's behavior, not our reimplementation:

System Status
Damage pipeline (multi-hit, AOE, FromCard, hit VFX) ✅
Block pipeline (gain, clear, modifiers) ✅
Powers (Vulnerable, Weak, Strength, Poison, Frail, Dexterity, ...) ✅
Power application via PowerCmd.Apply (full Hook chain) ✅
Card piles (Hand/Draw/Discard/Play/Exhaust) + reshuffle ✅
Hook.ModifyDamage* chain (Vulnerable's 1.5×, Strength's +N, etc.) ✅
Hook.ModifyHandDraw (Ring of the Snake +2 turn 1, Bag of Preparation, ...) ✅
Hook.ModifyMaxEnergy + ShouldPlayerResetEnergy (Coffee Dripper, Ice Cream) ✅
Hook.ModifyCardPlayCount (Burst doubles next skill, replay enchantments) ✅
Hook.AfterRoomEntered at combat start (Vajra → +1 Strength, Bronze Scales → Thorns) ✅
Hook.BeforeSideTurnStart / AfterSideTurnStart (Bag of Marbles, Lantern, Snecko Eye) ✅
Player-turn-start chain (AfterEnergyReset → BeforeHandDraw → ModifyHandDraw → AfterModifyingHandDraw → draw with fromHandDraw:true → AfterPlayerTurnStart Early/regular/Late) ✅
End-of-turn chain (BeforeTurnEndVeryEarly / Early / regular → exhaust Ethereal cards → trigger TurnEndInHand → BeforeFlush / BeforeFlushLate → CardPileCmd.Add to discard fires AfterCardDiscarded → AfterCardRetained → EndOfTurnCleanup → AfterTurnEnd) ✅
Relics from save loaded onto the player (all rarities, all 5 characters' starters auto-included by Player.CreateForNewRun) ✅
Card upgrades applied via UpgradeInternal + FinalizeUpgradeInternal ✅
X-cost cards (Skewer, Outbreak, Malaise) — CapturedXValue set from current energy ✅
Card-select prompts (Armaments, Havoc, Nightmare's hand-pick) — auto-resolved by AutoCardSelector shim ✅
Power-driven autoplay (Hellraiser → Strikes auto-play on draw) ✅
Per-turn event log including which card a play targeted (Hidden Gem → Shiv+, Nightmare → DaggerSpray) ✅

Phase-3 items not yet wired:

  • Real enemy turns — BigDummy is a no-op. Bronze Scales is on the player but never thorns anyone. Anything that fires on incoming attacks is invisible.
  • Block valuation in the deck-quality metric (a deck that survives is worth more than a damage-equal one that dies).
  • Multi-target attack verification — only single-enemy (BigDummy) is exercised. AOE cards report total damage to the dummy correctly but multi-enemy cleave isn't tested.
  • Random-target attacks (Ricochet) — non-deterministic without a multi-enemy harness.
  • Multiplayer-only cards (Flanking) — RunManager.Instance.NetService.Platform is null, NREs on apply. Needs a RunManager shim.

Roadmap #

Two big directions for what comes next:

Deeper simulation #

Current sim is "play the deck against a punching bag." The next step is real STS2 combat:

  • Multi-enemy combats: real encounter setups (Jaw Worm + 2 Cultists, etc.) so multi-target attacks (DaggerSpray, Ricochet, KnifeTrap) and per-enemy debuff stacking actually exercise their full surface.
  • Real enemy turns: BigDummy is a no-op today, so anything that fires on incoming attacks (Bronze Scales thorns, Buffer, retaliate-style powers) is silently inert. Wiring up MonsterMoveStateMachine + intent display + actual damage-to-player would unlock survivability metrics ("does this deck survive the Hexaghost?") in addition to damage metrics.
  • Block valuation: a deck that survives at 30 HP is strictly better than a damage-equal one that dies. The avg-of-best damage metric should fold in survival.
  • Multi-turn AI: the policy currently optimizes per-turn damage greedily. A look-ahead policy that values setup cards (Inflame, Limit Break) more accurately when there are 5+ turns to cash them in.

Deck editing & A/B comparison #

The whole point of the harness is "should I add card X to deck Y?" Right now you can only sim your current deck. The UI should let you:

  • Click a card in your deck to remove it → re-run sim → see the damage delta + verdict (statistically significant or just noise).
  • Swap a card for another from your character's pool → A/B against the original.
  • Add a card you might pick up → see if it actually helps before you commit floor-time to grabbing it.
  • Save deck variants so you can compare "current" vs "current + Inflame" vs "current - Strike + Pommel Strike" all in one view.

The algorithm side already supports A/B (same seeds + same policy → z-test on the diff), so this is mostly a UI feature on top of the existing infrastructure.

The algorithm #

Best-of-K with ε-greedy base policy, per-seed averaging, two-sample z-test for verdicts.

For each shuffle seed s in N seeds:
    For each k in K samples:
        Play with policy = "ε of the time random, otherwise highest-damage"
        Record total damage
    Record max(damages) for seed s
metric = mean(per_seed_maxes)
ci95   = 1.96 × stderr(per_seed_maxes)

For deck A vs deck B comparison (same seeds + same policy):
    diff    = B.metric - A.metric
    z       = diff / sqrt(A.stderr² + B.stderr²)
    verdict = z > 2 → "ADD IT", z < -2 → "REMOVE", else "INCONCLUSIVE"

Why this and not other things:

  • ε-greedy not pure greedy because pure greedy can't see setup cards. Inflame deals 0 dmg → greedy never plays it → doesn't see the +2 Strength payoff on the Strike that follows. ε-random plays it 1-in-N times and best-of-K finds the higher ceiling.
  • ε-greedy not pure random because random wastes ~50% of plays on Defends and bad orderings. K=200 still below greedy on simple decks.
  • Best-of-K not just-mean because we want the ceiling under near-optimal play, not the average random-noise outcome. Maxing per seed eliminates play-decision variance while keeping shuffle variance intact.
  • Per-seed averaging not best-ever because best-ever measures luckiest shuffle, not deck quality. Useless for comparison.
  • Z-test on diff, not "does best go up" because a 1-dmg lift can be noise; ±CI tells us if the lift is real.
  • Not MCTS because MCTS solves "find optimal play for one fixed seed" — not what deck comparison asks. ε-greedy avoids MCTS's lock-in via uniform exploration.

Default knobs: ε=0.30, K=30, seeds=200, turns=5. Throughput ≈ 1000 trials/sec single-threaded → 25 sec per deck → ~1 min for a full A/B.

Test battery #

dotnet run -c Release -- silent-tests runs 174 per-card tests for all 87 Silent cards (base + upgraded), split into 6 thematic buckets (basic attacks / multi-hit & shiv / conditional / defensive / utility / powers & poison). Each test spins up an isolated combat, plays the card under controlled conditions, and asserts on HP/block/energy/power deltas.

Result classification: PASS / FAIL / CRASH / SKIPPED. Crashes always indicate harness bugs (missing shim, missing hook, unimplemented mechanic) and are surfaced separately from "test got the wrong number." Skipped tests document mechanics the harness can't reach yet (e.g. CombatRoom-gated cards, MultiplayerOnly cards, random-target cards) with the specific reason.

Current state: 166/174 PASS · 0 FAIL · 0 CRASH · 8 SKIP (skips are all genuine harness limitations, documented per skip).

The same pattern (per-bucket files, 4 reusable test helpers, parallel-agent-friendly directory layout) generalizes to the other 4 characters when those batteries get written.

Project layout #

File Role
src/Program.cs Entry point. Installs AssemblyLoadContext.Resolving to find game DLLs by name from STS2_GAME_DIR, then dispatches to server / experiment / silent-tests mode.
src/GodotShims.cs The Harmony patches that make the game DLL safe to call without a SceneTree (logger, Time, AutoPlay capture, Shuffle replacement, Creature.ToString, LocString fallbacks, CardCmd.Preview hook).
src/Harness.cs Bootstrap() (one-time game-state init) and BeginCombat<T> / BeginCombat(Type, ...) (per-trial combat setup). Loads relics from save and replaces the canonical starting deck. Owns ResolveCharacterType for save-driven character selection.
src/TurnHooks.cs Manual hook firing for things the game's Hook.X paths skip without LocalContext.NetId. Exposes FireOnAll, PrepareSideTurnStart, PlayerTurnStartDraw, EndOfPlayerTurn, FireAfterRoomEntered. The "this is what the real CombatManager would have done" layer.
src/Reflect.cs The handful of private-member pokes we need (CombatManager._state, IsInProgress, PlayerCombatState.Energy setter, Creature.CurrentHp setter).
src/PlayCapture.cs Per-turn chronological event log: every draw and play (manual or auto), with SubjectLabel for cards that target other cards (Hidden Gem, Nightmare).
src/AutoCardSelector.cs Auto-resolves CardSelectCmd prompts (Armaments, Havoc, Nightmare's pick-from-hand) by picking the first valid option. Records the picked card as the subject of the current play.
src/CardLabels.cs / src/CardIdResolver.cs Display formatting and string-ID-to-Type resolution.
src/SaveFileReader.cs Pure file IO + System.Text.Json. Walks %APPDATA%\SlayTheSpire2\steam\<steamid>\{,modded/}profile1\saves\current_run*.save, picks freshest by mtime, parses player[0].deck and relics. No game state needed.
src/DamagePerTurnSim.cs One trial = run N turns of "side turn start → draw → play via policy → end turn". RunSingleTrial(seed) is the brick.
src/Policies/*.cs One file per policy: GreedyAttackPolicy, HighestDamagePolicy, RandomPolicy, EpsilonGreedyPolicy(base, ε).
src/BestOfKRunner.cs The recommended algorithm. Per-seed: K samples, keep max. Average those across N seeds. Reports avg-of-best ± 95% CI.
src/SmokeTests.cs First-line assertion tests (Strike=6, Bash applies Vulnerable, Inflame+Strike=8, Hellraiser autoplays, etc.). Run via experiment mode.
src/SilentTests/ Per-card test battery for all Silent cards (174 tests across 6 buckets). Run via silent-tests mode.
src/Sim/ConvergenceRunner.cs Anytime mode for debugging policy behavior (console-only).
src/Sim/ExperimentMode.cs Wires up the K-vs-accuracy curve + Defend-for-Inflame swap A/B (console-only).
src/Server/SimServer.cs HttpListener on port 52324 with WebSocket. Routes /api/deck, /api/sim/start, /api/sim/stop, /ws, static files.
src/Server/SimJob.cs One end-to-end best-of-K run with progress events shaped for the web UI.
www/index.html + www/app.js Single-page UI. Plain JS + Chart.js (CDN). Three live charts + per-turn event timeline + run-info sidebar. StS2 color theme.

Known footguns #

  • CombatManager.Instance is a singleton. Cannot run two combats in parallel within one process. Subprocess parallelism if we need throughput.
  • Several hooks require LocalContext.NetId to be set, otherwise they early-return without firing listeners. We work around this by manually iterating state.IterateHookListeners() in TurnHooks — same effect, no netcode.
  • NullRunState.CreateCard throws — any card that creates a new card mid-combat (UpMySleeve→Shiv, Discovery-style cards) requires routing around RunState.CreateCard or accepting the crash.
  • NullRunState.CurrentRoom is null — any card with logic gated on room is CombatRoom (TheHunt, etc.) silently no-ops. Needs a real CombatRoom on the run state to fix.
  • Energy is set via reflection on PlayerCombatState.Energy for explicit-set test scenarios. Production sim uses the proper ResetEnergy / AddMaxEnergyToCurrent flow.
  • The DPT loop heals the dummy back to full each turn — combat never ends. We never tick IsEnding to true, so the engine just keeps running.

License #

MIT — fork it, modify it, ship it commercially, just keep the copyright line in the source.

The game DLLs it loads at runtime are MegaCrit's — you need a legal copy of Slay the Spire 2 installed. StS2Sim doesn't redistribute any game assets.

   the engine wants a stage
   we built it a closet
   the play goes on