I made an agent play Slay the Spire 2 on its own — and what unlocked it was the game saying 'no'
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.
What you'll take away
- The rejection-feedback pattern (the framework): how an environment error becomes persistent state that filters the agent's next actions — no retraining, no LLM, no re-prompt. Copyable to any agent ↔ synchronous system integration (game, engine, hardware).
- The "where to put the decision" rule: deterministic, local step-by-step for what's repeatable; LLM for what needs open context. Knowing where the LLM shouldn't be is an engineering decision, not a lack of boldness.
- The two instructive failures: a main-thread deadlock (
task.Wait()in Godot →Task.WhenAnywith timeout) and patches that break on every Early Access update (way out: defensive reflection).
The opening scene: the game already knew how to play itself
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, 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).
The thesis: this is not an LLM problem
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.
Architecture: the command channel
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:
-
Loopback-only. The mod only talks to
127.0.0.1:5000(MainFile.csv0.3.2, line 404:_aiServerUrl = "http://127.0.0.1:5000/update_state";_rejectionUrlat 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. -
The HTTP response is the command. Each game-state push gets a response; if the body is command JSON, the mod executes it (
TryManualPlay, line 74;combatManager.SetReadyToEndTurn, line 131). If it'sOK, it does nothing. A single channel, synchronous by construction — no queue, no polling. -
10 MCP tools (
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 — "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; 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 rejection-feedback pattern (the core of this piece)
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:
1. Fire-and-forget with a separate 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.
2. Error becomes state, not log
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.
3. The state filters the next action
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.
4. The real case: the GRAND_FINALE softlock
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.
Instructive failure #1: the .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:
// 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):
// 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.
Instructive failure #2: Early Access breaks contracts — defensive reflection
StS2 has been in Early Access since 05/03/2026 (official announcement: megacrit.com) 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:
- BaseLib's
PatchAllsilently aborts on the 1st broken target: a single[HarmonyPatch(typeof(X), "methodThatDisappeared")]takes down every other patch in the assembly. The mod appears loaded ("modded" in the save) and does nothing. Classic EA update symptom. - API knowledge has an expiration date: what validates on 08/09 can break in the next beta.
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:
// 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.
Honest QA: what was tested and what wasn't
-
Policy harness (verified for this publication):
scripts/autopilot_harness.pyloads thests2_bridge.pyv0.2.7 up to theimport fastmcp(which doesn't exist on the VM) and tests_autopilot_decidewith synthetic payloads. Result of this run: "Asserts OK … Harness OK" — 5 scenarios: normal turn, with Stomp, high threat/low HP, enemy buffing, 1 energy. The harness is a QA process, not certification — it proves the policy responds as expected on synthetic cases, not that it wins runs. -
e2e validation (session record): the 7-turn / 35-action / 0-rejection fight is a report from the 08/09 session, with no raw log archived. This VM doesn't run StS2; repro is on the PC with
STS2_SAVE_DIRset (writesstate_log.jsonlandrejections.jsonl) or a new test session. I publish it as a report with documented repro, never as auditable data. -
Intact artifacts (md5 computed today): 21 zips in
~/reviews/sts2-mcp/. The ones cited in this post:sts2-bridge-v0.2.7.zip5a555e906369d821dd0e6270a1236196(live fileserver/sts2_bridge.py, md5b4286ec5522e0edfc31591b644dd6449, identical to the zip's) andCommunication_Mod_v3selector_v0.3.2.zip516a4797f21c36f06a2b140bf94661af; the reflection layer lives inCommunication_Mod_camadas12_v0.2.0.zipd8ee2dfa955edb34bac28862621a2207. Any hash you see cited elsewhere that doesn't match these: be suspicious.
Explicit limits, no fine print:
- 1 combat validated e2e. There is no winrate metric — no "autopilot wins runs" headline would be sustainable with this evidence package.
- Multi-class (Silent/Defect/Necrobinder/Regent) is mapped by state mechanics (poison, orbs, Doom, summons, Star) via defensive reflection — not validated e2e.
- Nothing in this run was tested against the current game build; the last e2e validation is from 08/09 and EA breaks contracts without warning.
- I didn't compare with niche projects (ptrlrd/spire-codex, 276★ and a push on the day of this publication; elliotttate/sts2-modding-mcp, 21★, "autonomous playtesting" via MCP) — they're parallel approaches (static RE → API vs. playtest MCP), and comparison without my own benchmark would be unfair to both sides. They appear here as proof that the conversation is alive right now, not as adversaries.
Assumed limits and the proof that exists
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 — 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) 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), the public base mod (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.
Compressed ending
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.
Top comments (2)
The rejection-feedback pattern is the most transferable idea in this piece. "Environment error becomes persistent state that filters the next actions" is exactly what most agent integrations I've seen are missing: the environment says no, and the agent treats every rejection as a fresh surprise instead of a durable constraint.
The honest scoping at the top (session record, not benchmark) also does more for credibility than a green dashboard would. Too many agent demos hide the fact that the LLM was never in the decision path — saying plainly that 35 decisions were local greedy scoring is the interesting part, not an admission.
Genuine question about the score function: hand-tuned heuristics, or did it get weights from accumulated rejection stats? Because if rejections fed back into the scoring over sessions, the loop becomes self-improving without any training, and that's where this gets very interesting for anyone wiring agents to synchronous systems.
Great question — and the honest answer is "hand-tuned heuristics, with a feedback loop that's real but narrower than what you're describing."
The score function is 100% deterministic and hand-tuned: damage/cost, a kill bonus, AOE weighting, block rules — plus a manual override table for cards whose conditional effects the raw catalog doesn't capture (e.g. Rage's block-per-attack). No learned weights, no accumulated statistics feeding the score.
Where rejections DO feed back is intra-session and categorical: when the game rejects a play as NotPlayable, the card goes into an unplayable set that filters the hand on subsequent decisions. That's a binary filter, not a weight adjustment — and it resets on every bridge restart. The only persistent rejection log is written for auditing and never read back by the policy. Cross-session "learning" currently happens through a human oracle (I watch bad plays, then add an override).
That said — your instinct points at exactly the interesting upgrade: persisting rejection stats per card/situation and letting them bias the score across sessions. That's the natural v2, and it's a genuinely good idea for anyone wiring agents to synchronous systems. The rejection channel is already the right data source; the plumbing to make it self-improving is the next step