I made an agent play Slay the Spire 2 on its own — and what unlocked it was the game saying 'no' A developer built a system that lets an AI agent play Slay the Spire 2 autonomously, completing a full combat with no human intervention. The agent's actions were decided by a local deterministic policy rather than an LLM, with the LLM acting as an operator. The project highlights that the key challenges were command-channel and feedback issues, not smarter AI. On 08/09/2026, an agent played an entire solo combat of Slay the Spire 2 with no human intervention : 7 turns, 35 actions, 0 rejections. Before any enthusiasm, the honest scope: this is a session record , not an audited benchmark — I don't have the raw log of that fight archived, and reproduction goes through a synthetic harness + versioned artifacts with md5 details in the QA section . And the detail that drives this piece: none of the 35 actions was decided by an LLM . Each play's decision was a local, score-based greedy policy running inside the Python bridge. The LLM Hermes, which operates the system was outside the critical path. And the two hardest problems in this project weren't solved with "smarter AI" — they were solved when the game said "no" and the agent learned from it. task.Wait in Godot → Task.WhenAny with timeout and It all started with reverse engineering. StS2 runs on a Godot fork with the logic in C /.NET — the main DLL is sts2.dll 9.3 MB . Decompiling with ilspycmd on my PC, I found an internal AutoSlay system MegaCrit.Sts2.Core.AutoSlay : an AutoSlayer that orchestrates the whole run map, combat, reward, shop just for smoke testing, with a random card selector — and it's not exposed in the player UI . No claim of novelty: a public autoslay mod already exists in the community STS2AutoSlayMod on Nexus https://www.nexusmods.com/slaythespire2/mods/216 , since 21/03/2026 . What matters for this project is something else: if the game has an autoslayer, then an official card-selection hook exists somewhere . I found it: ICardSelector , in MegaCrit.Sts2.Core.TestSupport test namespace, but public , with GetSelectedCards options, minSelect, maxSelect and GetSelectedCardReward ... . The same hook the game uses for discard, reward, upgrade and removal — one selector handles everything detail in the EA section . Everyone assumes that "an AI agent playing a game" = LLM calling LLM on every action. The counterintuitive part of this project: Making an agent play a real game is not an LLM problem — it's a command-channel problem and a feedback problem. The game needs to receive the action and needs to say when it failed. Combat decisions live in a deterministic local policy — autopilot decide gate, trigger , inline in the bridge since v0.1.3, present in v0.2.7 at line 396 of server/sts2 bridge.py . It's greedy by score: damage/cost with kill bonus, preventive and desperation block, scaling powers played early in long fights, poison when the hand can't kill, target picked by the enemy's intent. Zero LLM calls in the loop verified by grep on the zip . The rule I used, and it applies to any agent project: | Decision type | Where it lives | Why | |---|---|---| | Repeatable step-by-step which card, which target | Local deterministic policy score | Determinism, latency, zero cost per decision | | Open context what's happening, what changed | LLM as operator/observer | Judgment, natural language, explanation | The LLM operates the system: turns the autopilot on/off, reads state, sees rejections, tunes the policy. It is not the brain of the play — and selling "an LLM agent playing" would be the lie by omission that kills credibility on the spot. The full pipeline code verified in the artifacts with md5, QA section : Slay the Spire 2 EA, C /Godot └─ mod BaseLib + Harmony, MainFile.cs ← compiles and patches the public repo │ POST http://127.0.0.1:5000/update state ← loopback, fire-and-forget ▼ bridge Python/FastMCP sts2 bridge.py v0.2.7 │ HTTP response to EACH push = command channel │ {"Type":"PlayCard","HandIndex":N,"TargetIndex":M} | {"Type":"EndTurn"} | OK ▼ MCP 10 tools → Hermes operator/observer Three points worth highlighting: 127.0.0.1:5000 MainFile.cs v0.3.2, line 404: aiServerUrl = "http://127.0.0.1:5000/update state" ; rejectionUrl at line 27 . The bridge rejects any origin that isn't localhost route update state , line 645; route rejection , line 704 . The game never opens a port — it only POSTs. TryManualPlay , line 74; combatManager.SetReadyToEndTurn , line 131 . If it's OK , it does nothing. A single channel, synchronous by construction — no queue, no polling. sts2 bridge.py , lines 761–908 : sts2 status , sts2 get state , sts2 get combat , sts2 history , sts2 rejections , sts2 autopilot , sts2 set hold , sts2 play card , sts2 end turn , sts2 clear pending . The base mod is public: Manuelbbl/Communication Mod STS2 https://github.com/Manuelbbl/Communication Mod STS2 — "A powerful API mod that exports the complete live game state of Slay the Spire 2 to a local server for AI training and bot development", with BaseLib as a strict requirement. It's the base I compile and patch; what's mine bridge, autopilot, harness is public at github.com/brmarcosbr/sts2-mcp-bridge https://github.com/brmarcosbr/sts2-mcp-bridge ; what stays only in the versioned zips are the patches on top of the mod selector v3, rejection channel , because the base repo has no license that allows redistributing them see Limits . The mod is fire-and-forget : when the game refuses a play, the command failed silently — the game doesn't crash, it just doesn't execute. Without feedback, the agent would retry the same card forever. The solution was a dedicated error channel : When TryManualPlay returns false, the mod POSTs to a dedicated /rejection endpoint — outside the state queue which is a single slot overwritten by pushes : // MainFile.cs v0.3.2 , lines 74 and 84–119 payload 105–116, trigger 119 bool playSuccess = requiresTarget ? cardToPlay.TryManualPlay null : cardToPlay.TryManualPlay targetCreature ; // 74 if playSuccess { string reason; // 87 — inferred below ... if requiresTarget && targetCreature == null reason = targetIndex < 0 ? "MissingTarget" : "InvalidTarget"; // 93 ... reason = cost = 0 && energyNow = 0 && energyNow < cost ? "NotEnoughEnergy" : "NotPlayable"; // 102 var rejectionPayload = new Dictionary