Spire Pilotcombat model deep-dive
← How it works Live runs Training Encounters For engineers
sts-automator · rewrite program · spec rev 3.7

How the combat model plans a turn

The one-sentence mental model: the transformer never plays a card. It grades boards and grades candidate actions; a C++ graph search plays the turn, one decision at a time, re-reading the real board after every action; and randomness is handled by sampling worlds and averaging over them. This page walks the machine part by part, then goes deep on the two questions asked of it: are chance nodes modeled optimally, and is within-turn action generation the right shape — including why the "it plays the wrong card because it generates token-by-token" memory points at a real failure with a different mechanism.

This is the target design — the rewrite program (spec rev 3.7). The searcher, chance model, and label pipeline run today; the rest lands as the rewrite ships, so not every piece is wired into live play yet. Grounded in spec §0–§9 + Appendix A/B, mcts_search.cpp:378–512, and the Phase-0 labeler.


Part 1 · The machine

Four parts, one loop

Everything on a decision path lives in one C++ daemon, sts_brain: the perfect-fidelity engine, the MCGS searcher, the single C++ tokenizer, and the net compiled to an AOTI artifact running on the GPU. Python exists only offline — it trains the net from daemon-emitted shards and hands back a sealed bundle. The net is a leaf evaluator and prior; the search is the planner.

flowchart LR
  GAME["Game client / CommMod"] --- ADP["thin adapter — zero decision logic"]
  ADP --- MCGS
  subgraph BRAIN["sts_brain — one C++ daemon"]
    direction TB
    MCGS["MCGS searcher — Gumbel root, CVaR risk"]
    ENG["engine — perfect fast simulator"]
    TOK["tokenizer — one C++ source for live + training"]
    NET["CombatPlannerNet — AOTI artifact on GPU"]
    KV["per-fight KV bank, resident on GPU"]
    MCGS -->|"step action"| ENG
    ENG -->|"next board"| MCGS
    MCGS -->|"leaf board"| TOK
    TOK --> NET
    KV --> NET
    NET -->|"action priors + value distribution + P(death)"| MCGS
  end
  BRAIN -.->|"teacher labels, shards"| TRAIN["Python trainer — offline only"]
  TRAIN -.->|"atomic bundle: net + calibration + config"| BRAIN
Spec §7.1. The same binary runs live play, self-play, teacher labeling, and gate evals — so the state the net trains on is byte-identical to the state it sees live.

Part 2 · The net

What attention does, what the MLP does, what the decoder does

The board is tokenized as a set of entities, not a sequence: player, each monster, each monster's intent, each reactive trigger (thorns, mode-shift counters), each status, each card instance per zone, each relic with its live counter, each potion, plus a CTX token carrying turn number, phase, energy, and played-this-turn counters. There is no token order to get wrong — hand tokens are permutation-invariant, and the only real orders in the game (known top-of-draw, the turn axis) get explicit learned embeddings.

Attention = relation routing

Self-attention layers decide which tokens inform which. The rewrite makes the important relations explicit with typed edge biases added per head: card→legal-target, monster→intent, monster→monster (summoner/minion), trigger→owner, relic→card (Pen Nib↔next attack, Kunai↔attacks), candidate→deck-copy. The old net had none of these — a flat 4-layer soup where "which Louse is curled" and "whose intent is whose" had to be rediscovered from scratch, per encounter, from self-play. Scheduled danger (Time Eater's 12-card counter, Guardian's mode shift) is embedded as turns-until-fire countdowns, so "it fires in 2 turns" is a feature, not an inference.

Feed-forward = per-token computation, minus arithmetic

The MLP blocks synthesize nonlinear features per token — "this card kills that monster through its block, given my strength." The design's load-bearing choice here: it never asks the MLP to do the multiplication. Modifier-applied damage, block, and kill-availability (playmath) are computed by the engine and fed as input features, always-on. This is proven in-repo, not theoretical: the fleet shipped for weeks with playmath gated off, and the result was systematic strength-blindness — the exact class of "it can't count lethal" misplay. Transformers are bad at arithmetic; engines are perfect at it. Feed, don't learn.

The decoder = queries, not generation

Every legal action becomes a query scored against the encoded board: per-instance card logits read from the hand tokens themselves (so Strike+ ≠ Strike and two Ritual Daggers with different banks are distinct), a target pointer over monster tokens conditioned on the chosen card, per-potion queries, the same pointer machinery for every choose-screen, and END_TURN as a learned query. The only autoregression in the entire system is the two-step factorization inside a single action — pick card, then pick target given the card (the AlphaStar pointer pattern). No turn-length sequence is ever generated.

flowchart TB
  subgraph INV["fight-invariant bank — encoded ONCE per fight, cached on GPU"]
    direction LR
    T1["deck identity + upgrades"]
    T2["relics"]
    T3["encounter · character · archetype"]
  end
  subgraph VAR["fight-variant tokens — encoded per search leaf"]
    direction LR
    C["CTX: turn, phase, energy, played-this-turn"]
    P["PLAYER"]
    M["MONSTERS + INTENTS + TRIGGERS"]
    H["CARD instances by zone, playmath attached"]
  end
  VAR --> ENC["edge-bias self-attention ×8 — typed relations, countdown embeddings"]
  INV -->|"cross-attention into cached KV"| ENC
  ENC --> F["forecast queries h1..h4 — grounded on determinized teacher rollouts"]
  ENC --> RO["multi-readout: CTX + player + monster pool + hand pool + forecast pool"]
  F --> RO
  ENC --> AQ["ACTION QUERIES: card→target · potion · select · END_TURN"]
  RO --> V["value: milestone × 21 HP-graded bins"]
  RO --> D["P(death) head"]
  RO --> MH["marginal + horizon heads"]
  RO --> NM["aux: next-move + next-draw prediction"]
Spec §3.0–§3.6. The variant/invariant split is why a deeper net is affordable inside search: each leaf re-encodes only what changed, cross-attending the cached fight bank. A parity test enforces cached == full forward.

Two heads matter for the planning question. The forecast module (h1..h4) predicts the board distribution one to four turns out — incoming damage, HP, boss scaling, P(alive) — supervised by real determinized teacher rollouts, never by imagination. The value head attends over those forecasts, so "Inflame pays off in two turns" is visible to a single forward pass. This is how the net knows a plan's payoff without ever emitting the plan.


Part 3 · Planning a turn

The search plays the series; the graph makes it cheap

A turn is planned as a search over the real engine, from the current decision. Within a turn, Slay the Spire is deterministic given the sampled world — so the region between "your turn starts" and "END_TURN" is a directed graph the searcher explores. The rewrite's central move (§3.8): search it as a DAG, not a tree — whenever two play orders produce byte-identical engine states, they merge into one node with shared statistics. The merge criterion is engine-computed state identity, never an assumption that card order doesn't matter. Bash→Strike and Strike→Bash do not merge: Bash's Vulnerable amplifies a Strike played after it, so the afterstates differ — they converge only if the enemy was already Vulnerable or Artifact eats the debuff, and in exactly those cases the state key detects the equivalence automatically. What does merge is everything genuinely order-free — most commonly, where the block cards fall between the attacks.

flowchart TD
  ROOT["ROOT — your decision. hand: Bash, Strike, Defend vs Cultist · 3 energy"]
  ROOT -->|"Bash"| A["Cultist −8 · vuln 2"]
  ROOT -->|"Defend"| C["block 5"]
  A -->|"Defend"| AD["−8 · vuln 2 · block 5"]
  C -->|"Bash"| AD
  A -->|"Strike ×1.5"| AS["−17 · vuln 2"]
  AD -->|"Strike ×1.5"| F1["afterstate F1: −17 · vuln 2 · block 5 — ONE node, 3 orders converge"]
  AS -->|"Defend"| F1
  ROOT -->|"Strike, no vuln yet"| B["Cultist −6"]
  B -->|"Bash"| BB["−14 · vuln 2"]
  BB -->|"Defend"| F2["afterstate F2: −14 · vuln 2 · block 5 — SEPARATE node: order mattered"]
  F1 -->|"END_TURN"| CH{{"CHANCE NODE — re-determinize hidden state"}}
  CH -->|"world 1, salt(1, depth)"| W1["turn-2 board, world 1"]
  CH -->|"world 2, salt(2, depth)"| W2["turn-2 board, world 2"]
  CH -->|"world c"| W3["…"]
  W1 --> L1["net leaf: value distribution + priors"]
  W2 --> L2["net leaf"]
Merging is by canonical engine-state key (mcts_search.cpp:128-376) — equivalence is computed, never assumed. Defend's placement commutes (vs a thornless Cultist), so three orders share F1's statistics; Strike-before-Vulnerable does not, so F2 stays its own node — and ranking F1 against F2 by completed-Q is the Bash-first decision. Values back up only along the traversed path (per-edge statistics) — the two hard rules that keep DAG search unbiased (Czech et al. 2020, spec §3.8). At the root, Gumbel sequential halving picks by completed-Q: every candidate gets evaluated, and there is no visit-count window for a correct-but-under-visited action to fall out of.

Read the diagram bottom-up and the whole answer to "how does it complete the turn" appears: the deterministic region concentrates visits on afterstates — which end-of-turn board you hand the enemy — rather than on orderings; the chance node prices what the next turn can do to you across sampled worlds; the net's value distribution at each leaf says how good that future is; completed-Q backs it all up to the root; CVaR then applies risk-aversion over the distribution. The search is the turn-completion mechanism, and it is closed-loop: after the chosen action executes in the real game, the next decision re-encodes the actual new board — drawn cards revealed, random targets resolved — and plans again. Nothing is ever committed beyond one action.

Why this fixed the old "plays the wrong card" class: in the old tree search, every equivalent interleaving was a separate subtree — with two Defends and three attacks in hand, the same effective line gets re-explored many times over — so 1200 sims spread across copies of identical states and no line ever looked well-explored, and three hand-tuned override layers (dominance_prune, planner_pick_override, damage_tiebreak_eps) existed to un-break the final pick. The old prior couldn't help either: pc[CardId] + pt[slot] is additive — structurally incapable of expressing "Bash first". The rewrite's promotion gate is that all three overrides are deleted with no regression.


Part 4 · Chance

Every random thing, and where the design handles it

"Chance node" means something precise here. Slay the Spire has exactly one big information gap (your draw order) and a family of small RNG events. The design handles each at a specific place:

RandomnessMechanismWhere
Hidden draw order at the decision (the information set) N root determinizations: shuffle the unknown pile prefix N ways (teacher: N=8), search each world, aggregate at the root. Labels marginalize across all N — no label ever encodes one seed's future. labeler determinize(); spec §4.1
Mid-turn RNG (Pommel Strike's draw, random targets, Snecko costs) Resolves inside each determinized world; the root average over N worlds covers the distribution. Sound because the real agent observes each outcome before its next decision — play is closed-loop, so per-world perfect continuation is legitimate, not clairvoyant. engine RNG in BattleContext
Turn boundary (enemy move roll + next turn's hand) An explicit chance node on every END_TURN edge: c child worlds, each a fresh re-determinization; traversal picks the least-visited world, so the backup converges to a uniform Monte-Carlo expectation over worlds. mcts_search.cpp:447–478
Cross-line comparability Common random numbers: world s at turn-depth d is the same sampled future for every line in the tree — the salt depends only on (s, d), never on the path. Lines are compared against paired worlds (variance cut), and the search cannot "shop" for a lucky world (the code's own term: the clairvoyance firewall). mcts_search.cpp:463–470
Beyond the chance horizon After chance_turns boundaries, playouts continue in the last sampled world (a deterministic tail). The teacher searches chance-aware at 6000–12000 sims; depth quality lives in the labels the net distills. mcts_search.cpp:452 fall-through
DAG merging vs chance Transposition dedup applies only pre-chance (within the current turn). Post-chance states belong to specific worlds; merging them would average across worlds and collapse the outcome distribution — which CVaR risk selection needs intact. mcts_search.cpp:482–507; §3.8 rule 3

Is this optimal? The honest comparison

This is the sampled-expectimax / determinization family, and its known failure mode has a name — strategy fusion (planning one strategy that secretly assumes knowledge of which world you're in). The design neutralizes it at every point where it bites: the root marginalizes over N worlds before labels or picks exist; chance boundaries re-determinize so no plan runs on a stale world; CRN salts are path-independent so the search can't steer toward lucky worlds; and the label rule bans seed-clairvoyance uniformly (§4.1). The alternatives each lose something real:

Verdict: the family is right, and the family's classic bugs are specifically engineered against. What remains inside the family is budget, not structure — N and c are sampling-width knobs (variance, not bias), and the CRN pairing is exactly the variance reduction that makes small N honest for comparisons.


Part 5 · The token-by-token question

The failure was real; the mechanism wasn't generation

The premise to correct first, because everything else hangs on it: the old system never generated card plays token-by-token, and neither does the new one. Both pick one action per decision via search. What the old system got wrong was everything around that pick: a tree that fragmented visits across orderings (F15), an additive prior that couldn't express order or instance (F4), a flat value head that couldn't see a sequence's payoff, and a root rule that excluded correct-but-under-visited actions. Those four mechanisms produced the "plays the wrong card" behavior that felt like decoder drift — but no decoder was drifting, because no decoder was generating.

Now the constructive half. The instinct — "determine the proper series up until a chance node" — is correct, and it is already the centerpiece of the rewrite, in its strongest available form. Planning the series to the chance boundary is precisely what MCGS does: the within-turn deterministic region is searched as a merged graph whose terminal frontier is the chance boundary, value accumulates on afterstates (the state you end the turn in), and the net's forecast-coupled value prices those afterstates against the sampled futures behind the chance node. The series is planned; it is planned by the component that can verify it against the real engine, rather than by the component that can only remember.

Why the stronger-sounding version would be a regression

The tempting upgrade — have the net emit the whole turn's action sequence in one shot — was evaluated against the literature and the design, and it loses on four independent grounds:

  1. Open-loop vs closed-loop. A sequence committed at step 0 cannot react to what the turn reveals: the card Pommel Strike draws, a Discovery screen, a random target roll. Re-encoding the real board after every action strictly dominates under stochasticity — this is the oldest result in stochastic control, and it's why the daemon re-plans per decision.
  2. Strategy fusion by construction. One emitted sequence must serve all N determinizations simultaneously — the exact pathology the chance design exists to prevent. Different worlds need different continuations after the first draw event; only a per-decision policy can express that.
  3. Exposure bias is real — for generators. Compounding error from feeding a model its own outputs is a genuine decoder-only-LM failure mode. The action-query decoder sidesteps it entirely: each decision is scored fresh against the full re-encoded board. The "history" a language model would carry in its context window is carried here by the Markov state itself — played-this-turn counters, zone membership, statuses. StS combat is Markov-complete once counters are emitted (§3.3); a state that fully remembers needs no generation history.
  4. Label economics. Per-decision completed-Q labels transfer across every context that reaches a similar board. Whole-sequence labels are combinatorially sparse, and every engine or balance change stales them wholesale.

The instructive precedent is AlphaStar, which faced a much larger action space and landed exactly here: pointer-network scoring per decision, with autoregression only inside one composite action (unit → target). That is the §3.6 card→target factorization, adopted deliberately.

The reward function, audited on the same pass

"End the fight with the best game state" is now literally the target. The old value zeroed on death (v = (…)·won) — so once predicted death saturated, HP preservation was worth nothing and the searcher stopped blocking (the measured free-block inversion; the ascension-wall loss mechanism). The new target is an outcome vector: HP-graded 21-bin categorical crossed with run milestones, losses graded too (HP lost, turns survived, death-turn), maxHP/gold/heals/potions predicted natively instead of via hand-tuned leaf weights, and run-level noise factored out (the combat net predicts the fight's outcome distribution; RunNet composes run value from it). Distributional value is also what makes CVaR risk honest — it reads a real distribution instead of a rollout death rate that saturates flat at 1.0 on every threatened board.


Part 5½ · Follow-up ideas

Three proposals, adjudicated

1 · "Duplicate attention in parallel so heads attend to different patterns"

That is multi-head attention, and it is in: the old net ran 4 layers × 4 heads; the rewrite starts at 8 layers × 8 heads at d=256 and scales up until the 5090 latency gate binds. But the design goes one step past generic multi-head. Plain heads are only given the chance to specialize; the typed edge biases (§3.3) are added per head, so "this head routes card→target legality, that head routes monster→intent ownership" is engineered rather than hoped for. That is the user-intuition — attend to different types of information at once — in its strong form. On cost: at fixed width, more heads reshape the same matrices (near-zero FLOP change); real capacity (width, layers) is funded by the levers the spec names in order — KV-cache split, FP8, CUDA graphs, Gumbel needing fewer sims — before any layer is ever sacrificed. And "do we need more capacity?" is not a judgment call: it is literally the question the Phase-0 probe readout answers (train the current arch on deep-search labels; if it fits them, the problem was signal; where it can't, that residual is the measured capacity gap).

2 · "Have the net generate turns up to the chance node, then MCGS scores the top-X"

This is a beam proposal + verifier — a respectable architecture, and the closest of the three to a real design change. Two things sink it as a turn-level mechanism. First, sequence probability decays geometrically: a line of p plays carries the product of its per-step priors, so top-X-by-sequence-probability is dominated by short, typical lines — and the lines that matter most here are the opposite. The infinite/loop-to-lethal family (Appendix B class 6) wins through 10–20-play sequences that a net which hasn't already mastered them assigns ~zero mass; top-X proposal would never surface them, while MCGS reaches them because per-step exploration recombines — the search can assemble a line no single proposal contained. Second, proposal-only scoring caps the teacher at the student's imagination: expert iteration improves precisely because search discovers lines the policy wouldn't propose, and those discoveries become the next labels. Gate the search to the net's top-X and the improvement loop closes on itself. The sound versions of this exact idea are already placed where they belong: the Gumbel root is "take the net's top-m candidates, verify by sequential halving" per decision — with a proven policy-improvement guarantee — and sampled-subset search (§3.8) is literally propose-then-verify for the one place enumeration is impossible (Gamble/scry subsets). Per-decision proposals compose into every sequence; turn-level proposals don't decompose back.

3 · "Chance node as a special type of token"

The substance of this is present — in distribution form, which is strictly more informative than a marker token. The INTENT token carries the engine-derived next-move FSM distribution (the turn-boundary chance node's outcome distribution, as input); the unknown draw zone is a multiset token with membership-certainty and cards-until-reshuffle features; the adopted next-draw aux head is trained to predict the true draw distribution (the other chance stream, as a supervised target); and the forecast heads h1..h4 are distributions across the chance boundary by construction. A literal chance-token belongs to the two model families the spec swept and rejected for cause: trajectory sequence models (Decision Transformer — rejected §4.7b, we have a real searcher) and learned-dynamics models (Stochastic MuZero's chance codes — moot, we own a perfect simulator). So: right instinct, already carried in a stronger representation.


Part 6 · Verdict

What stands, what to touch, what to refuse

KeepAt or near the ceiling — do not redesign

  • Action-query set scoring over any generative head — right shape for a Markov-complete, legality-masked action space.
  • MCGS within-turn DAG + pre-chance gate — the "plan the series to the chance node" idea, already in its strongest form.
  • Explicit END_TURN chance nodes with CRN-paired worlds — sampled expectimax with the variance reduction and the clairvoyance firewall built in.
  • Det-marginalized completed-Q labels + Gumbel root — kills both visit-starvation and single-seed label leakage.
  • Grounded forecast tokens instead of learned dynamics; engine-computed playmath instead of MLP arithmetic.

ImproveReal, small, concrete

  1. Teacher-side chance width is a free knob: at 6000–12000-sim teacher budgets, a larger chance_c (or progressive widening) is cheap label-variance reduction. Measure it as an ablation; don't assume.
  2. A filed contingency, no action now: if a draw-gamble case family (the "play Battle Trance, then decide" class) ever fails gates, the teacher-only remedy is treating explicit draw actions as chance boundaries within the turn. It costs branching and shrinks the MCGS dedup region — only if evidence demands it.

RejectEvaluated and refused, with reasons

  • Whole-turn autoregressive plan head — open-loop under stochasticity, strategy fusion by construction, reintroduces exposure bias, combinatorially sparse labels (Part 5). The softer variant — net proposes top-X turn sequences, search scores them — fails on geometric prior decay and on capping the teacher at the student's imagination (Part 5½); its sound forms are the Gumbel root and sampled-subset search, both already in.
  • Information-set merging across chance outcomes — collapses the per-world value distribution CVaR reads; spec §3.8 rule 3 already forbids it, correctly.
  • Learned dynamics / latent search (MuZero-class) — solves a problem we don't have; every dynamics error becomes an unauditable planning bias. Banned in §0.
  • Seed-clairvoyant labels — banned uniformly across both rooms (§4.1); remains available only as an explicitly user-gated future experiment.

Bottom line: the design already is the proposed idea, executed in the form the literature and this codebase's own measured failures both point to. The remaining upside lives in sampling-width knobs and the label pipeline now being built — not in restructuring how turns are generated.

Sources: spec rev 3.7 §0, §3.0–§3.8, §4.1–§4.5, §7, Appendix A/B · sts_lightspeed/bindings/mcts_search.cpp:378–512 · Phase-0 labeler (n_det=8, sims=6000, 481/483 enc1 states labeled) · Cowling, Powley & Whitehouse 2012 (ISMCTS); Czech et al. 2020 (MCGS); Danihelka et al. 2022 (Gumbel, completed-Q); KataGo; AlphaStar; Świechowski et al. 2022 (MCTS survey). A fresh literature check (July 2026) surfaced nothing newer that changes the family choice.