# I made an agent play Slay the Spire 2 on its own — and what unlocked it was the game saying 'no'

> Source: <https://dev.to/brmarcosbr/i-made-an-agent-play-slay-the-spire-2-on-its-own-and-what-unlocked-it-was-the-game-saying-no-2hc2>
> Published: 2026-09-09 19:59:10+00:00

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<string, object>                        // 105
    {
        ["Trigger"]    = "PlayRejected",
        ["CardName"]   = cardToPlay.Id.Entry,
        ["HandIndex"]  = handIndex,
        ["TargetIndex"]= targetIndex,
        ["Reason"]     = reason,
        ["Energy"]     = energyNow,
        ["CardCost"]   = (!hasCost) ? "?" : (cardToPlay.EnergyCost.CostsX ? "X" : cardToPlay.EnergyCost.GetResolved().ToString()),
        ["TurnNumber"] = currentState.RoundNumber,
    };
    _ = Task.Run(() => SendRejectionAsync(rejectionPayload));  // 119
}
```

The payload carries the context of the play that failed: card, reason, energy, cost, turn. `Reason` is inferred in the mod: `MissingTarget` (target -1), `InvalidTarget` (target out of the list), `NotEnoughEnergy` (cost vs energy parse), otherwise `NotPlayable`.

In the bridge, a rejection isn't just logged — it **becomes persistent state**:

```
# sts2_bridge.py (v0.2.7): state at lines 70/74, store_rejection body 98–109
self.rejections: deque[dict] = deque(maxlen=50)         # 70 — history of the last 50
self.unplayable: set[str] = set()                       # 74 — the "learning"

def store_rejection(self, body):                        # 98
    self.rejections.append({...})
    # NotPlayable card = the game refused (e.g.: Grand Finale with a full
    # draw pile). Marks it as unplayable so the bot does NOT insist this session.
    if body.get("Reason") == "NotPlayable" and body.get("CardName"):
        self.unplayable.add(body["CardName"])           # 109
```

A `deque(maxlen=50)` keeps the history (exposed in `/health` as `rejections_received`/` recent_rejections` and in the `sts2_rejections(n)` tool, lines 810–830). And an `unplayable` set keeps the **conclusion**: a card rejected as `NotPlayable` gets marked.

On the next decision, the hand is filtered before the policy runs:

```
# sts2_bridge.py (v0.2.7), _autopilot_decide, lines 411–414
# Filters cards the game rejected as NotPlayable this session (insisting on
# them softlocks the game — e.g.: Grand Finale with a full draw pile).
if gate.unplayable:
    hand = [(n, cid) for (n, cid) in hand if n not in gate.unplayable]
```

The agent **"learns"** by observing its own error — quotes on purpose, because there is no retraining, no LLM, no re-prompt. It's an environment error becoming a constraint in the agent's state. Behavior corrects on the next play, not on the next epoch.

The bug that paid for the pattern: the bot froze the game for good after a discard. Symptom: open turn (fresh `OnTurnStarted`), full hand, 37 seconds without an action, `rejections_received` climbing. Cause: the bot kept trying to play **GRAND_FINALE** (a Silent card that's only playable with the draw pile **empty**) — the game rejected it 3× with `NotPlayable`, and the policy didn't remember, retrying on the next turn. Softlock: the game got stuck inside `TryManualPlay`.

The fix was the pattern above: 1st `NotPlayable` rejection → card goes into `unplayable` → never proposed again this session. **Without the `/rejection` push, the bot would never know it failed.** That's the item that pays for everything else: environment feedback is what turns a blind fire-and-forget into an agent that corrects.

Accepted cost: the filter is by **name** (card IDs change per session) and marks the card for the rest of the session — if the condition changes (the draw empties and GRAND_FINALE becomes playable again), the bot still won't play it. Low cost vs. softlock risk.

`.Wait()` deadlock on Godot's main thread
The `AISmartCardSelector` implements `ICardSelector` (`MainFile.cs` line 196) and asks the bridge which card to pick via `POST /select_cards` (loopback route, bridge line 677). v0.3.0 did this with a **synchronous** call:

``` js
// MainFile.cs v0.3.0, line 243 — the pattern that deadlocks
var task = _httpClient.PostAsync(_selectUrl, content);
if (task.Wait(2000) && task.Result.IsSuccessStatusCode)
```

Symptom: game **stuck on the discard without selecting** — the log showed "AISmartCardSelector active" several times, but the discard UI never closed. Classic cause: `GetSelectedCards` is called by the game on **Godot's main thread**; `.Wait(2000)` blocks that thread waiting for the HTTP, but the POST only completes when Godot processes frames → deadlock (the selector never returns, the UI never closes).

The fix (real diff v0.3.0 → v0.3.1, `MainFile.cs` lines 240–244):

``` js
// REAL async (no synchronous .Wait() that deadlocks Godot's thread)
var postTask   = _httpClient.PostAsync(_selectUrl, content);
var timeoutTask = Task.Delay(2000);
var done = await Task.WhenAny(postTask, timeoutTask);
if (done == postTask && postTask.Result.IsSuccessStatusCode) { ... }
// otherwise: fallback = selects the first cards (doesn't freeze the game)
```

Transferable rule for any agent ↔ synchronous engine integration: **never a synchronous `.Wait()`/`.Result` in a callback the game calls on the main thread** — always async with a timeout via `Task.WhenAny`. After build v0.3.1, the discard resolved itself again.

Bonus from the same chain: the game calls `CardSelectCmd.Reset()` at the end of each combat, clearing the mod's selector → on the next combat the discard opened the UI again. The fix lives in the bridge: it detects a new combat (`TurnNumber` returns to 1) and re-sends `EnableSelector` (`sts2_bridge.py`, lines 128–143). Accepted cost: the **1st card of each combat is still manual** — the 1st push is spent re-activating the selector, the bot takes over from the 2nd.

StS2 has been in Early Access since 05/03/2026 (official announcement: [megacrit.com](https://www.megacrit.com/news/2026-03-05-early-access-launch/)) and updates frequently (latest beta v0.111.0 in mid-August 2026). Every update can rename/remove methods — and Harmony patches target **by method name**:

`PatchAll` `[HarmonyPatch(typeof(X), "methodThatDisappeared")]` takes down every other patch in the assembly. The mod appears loaded ("modded" in the save) and does The project's way out is **defensive reflection**: never assume an internal field name of the game. Read state with a candidate list and discover the schema at runtime:

``` js
// MainFile.cs v0.2.0 (camadas12), lines 245 / 509 / 542–546
var blockCandidates = new[] { "Block", "CurrentBlock", "Armor" };   // Player.Block
...
private static Dictionary<string, int> GetDynamicVarMap(CardModel card) { ... }
private static int ReadCreatureBlock(object creature) { /* tries Block, CurrentBlock, Armor */ }
```

`GetDynamicVarMap` enumerates all the card's `DynamicVars` via reflection → `{FieldName: int}` (handling the `.IntValue/.Value/.Amount` wrapper) — that's how the bot discovered the real schema at runtime (`Count` = hits, `Damage`, `Vulnerable` — session record; the layer's code is verified, the real combat output isn't archived). A missing/unreadable field is omitted: no crash, version-proof by construction.

`scripts/autopilot_harness.py` loads the `import fastmcp` (which doesn't exist on the VM) and tests `_autopilot_decide` with synthetic payloads. Result of this run: `STS2_SAVE_DIR` set (writes `state_log.jsonl` and `rejections.jsonl`) or a new test session. I publish it as a `~/reviews/sts2-mcp/`. The ones cited in this post: `sts2-bridge-v0.2.7.zip` `5a555e906369d821dd0e6270a1236196`` server/sts2_bridge.py`, md5 `b4286ec5522e0edfc31591b644dd6449`` Communication_Mod_v3selector_v0.3.2.zip` `516a4797f21c36f06a2b140bf94661af`` Communication_Mod_camadas12_v0.2.0.zip` `d8ee2dfa955edb34bac28862621a2207`
**Explicit limits, no fine print:**

Being direct about the project's condition: my GitHub profile had **0 public repositories** when I started this piece (verified via API on 09/09/2026) — and that's why the proof below takes the form it takes. Now it exists: **[github.com/brmarcosbr/sts2-mcp-bridge](https://github.com/brmarcosbr/sts2-mcp-bridge)** — the bridge v0.2.7, the autopilot and the 5-scenario harness, public and with a README. What's **not** there (and why): the mod patches (selector v3, rejection channel) — the mod's base repo ([Manuelbbl/Communication_Mod_STS2](https://github.com/Manuelbbl/Communication_Mod_STS2)) has no explicit license, so redistributing the patches would be a violation. They remain versioned as md5 zips, citable line by line in this post.

What exists as consumable proof right now: the **public** bridge + autopilot + harness ([sts2-mcp-bridge](https://github.com/brmarcosbr/sts2-mcp-bridge)), the public base mod ([Manuelbbl/Communication_Mod_STS2](https://github.com/Manuelbbl/Communication_Mod_STS2)), the 5-scenario harness (reproducible on any machine with Python), the md5s above for integrity checks, and every piece of code cited in this post with file and line. Realistic next step on my side: a video of the autopilot playing — when it's out, this post gets the link.

Agent ↔ game is a **command-channel** problem (how the action arrives) and a **feedback** problem (how the error comes back) — not an LLM problem. The environment saying "no" became state that filters the next action, and that fixed the softlock without retraining. And the most mature decision in this project was knowing where the LLM **shouldn't** be: outside the critical path, observing and operating — because a good agent isn't one that calls an LLM for everything, it's one that knows where to put the decision, what to do when the environment rejects, and how to survive when the contract changes.
