RogueForge is a roguelike deck-builder in the shape of Slay the Spire. After each fight, a model forges three brand-new reward cards: name, flavour, and mechanical effects. It also forges the enemies, the between-combat events, the room hazards, and, if you want, your whole character with a ten-card starter deck. No two runs share content.
Generation was the easy part. Ask a capable model for "a Common 1-cost Attack for an Ironclad deck" and you get something fluent every time. The problem is that fluent and fair are unrelated. The model will hand you Deal 40 damage for one energy with total confidence, and it will also hand you Gain 2 Block for a Rare, which is worse, because a weak Rare makes the boss reward feel like a punishment.
So the actual project is the pipeline that stands between the model and the player. This post is about the one layer of that pipeline I care most about: the numerical budget.
Rule one: the model never writes game logic
Before any of the balancing matters, there is a structural decision. A card is not a blob of prose that the engine tries to interpret. It is a typed object with an effects list, and every effect's kind comes from a whitelist of eight: deal damage, gain block, draw, gain energy, apply status, gain status, lose HP, heal. Statuses are a second whitelist of ten. The deterministic combat engine executes those effects directly. The description text is generated alongside, but it is not authoritative: if effects and description disagree, effects win, and the description gets re-rendered from a template.
This one decision is what makes everything downstream possible. Because the model only combines primitives, the validator can statically check every card before a player sees it. And because real Slay the Spire cards can be expressed in the same primitives, I can score them with the same function. Apples to apples.
The budget, and where its numbers come from
A card's power score is a weighted sum over its effects:
score = 0.6 × damage × hits
+ 0.5 × block
+ 1.5 × cards drawn
+ 2.0 × energy
+ 2.0 × debuff stacks × status weight
+ 1.5 × buff stacks × status weight
− 0.4 × HP lost
+ 1.2 × HP healed
+ small bonuses for Exhaust / Ethereal, penalties for Innate / Retain
The first version of those coefficients was hand-picked by my coding agent, and it looked reasonable. I rejected it. Hand-picked coefficients are vibes with extra steps: I would be encoding my own taste and then congratulating the validator for agreeing with me. If the question is "would a Slay the Spire player accept this card", the answer has to come from Slay the Spire.
So I found a dataset of every card in the game, took the 75 Ironclad cards, and defined "balanced" empirically. For each (rarity, cost) bucket, the accepted band is the mean score of the real cards in that bucket, plus or minus two standard deviations. A generated Common 1-cost card is in band if it scores like a real Common 1-cost card. The coefficients themselves come from a small grid search that maximises how many real cards land inside their own band. That search has to reach 80% or the build fails.
Accept, repair, or reject
Every candidate gets one of three verdicts. In band: accepted as written. Out of band but within 50% of the nearest edge: repaired. The validator clamps the largest-contributing effect down one point at a time (or lifts the finest-grained effect up, for under-powered Rares) until the score crosses back into band, then re-renders the rules text so the player reads the numbers they will actually get. Out of band by more than that: rejected, and the reason goes back into the prompt for a retry. Three rejections in a row and a hand-curated fallback card ships instead.
The repair step matters more than it sounds. Models are consistently a little generous. A Common 1-cost card that deals 12 and applies 1 Weak is just over the band; a human designer would say "make it 11" rather than "start over." The repair policy encodes that instinct and logs the exact diff, so the eval dashboard can show deal_damage 12→11 next to every clamped card.
Three candidates for the same slot. The middle one is what the model most often hands you: close, and a little generous.
The three bugs that hid behind the number
Having a measurement is how I found out the measurement was wrong. It happened three times.
1. Every reward was secretly a fallback
The first playable build looked fine. Fights worked, rewards appeared, I picked cards. Then I noticed all three rewards were called Steady Jab, which is the name of my curated fallback. Every single generated card was being rejected, silently, and the game was serving the same hand-written card three times.
The cause was the calibration. The grid search had driven the block coefficient to its floor and left the Common 1-cost band at [0, 2.6], narrow enough that a perfectly reasonable Deal 8, Apply 1 Weak scored 3.2 and bounced. The validator was doing exactly what the spec said. The spec was wrong. What stings is that a code-review pass had flagged this days earlier ("block coefficient collapsed, bands won't accommodate the LLM's natural output") and I had filed it under nitpicking. The fix was to widen the band from 1.5σ to 2σ and raise the repair threshold from 25% to 50%. After that, real cards landed: one accepted, two repaired, three different names.
2. Heal 44
Much later I added an "Ancient" rarity for wish cards: you describe the card you want, the model interprets it, and it bands above Rare. Playtesting produced a card that healed 44 HP. Not a model hallucination. The validator had boosted it there to reach the Ancient power floor, because heal was priced at 0.3 per point, below block, because the Ironclad corpus has almost no heal cards and the calibration had nothing to learn from. Healing restores persistent HP; block lasts one turn. Heal has to cost more than block no matter what the data says, so it now carries an enforced minimum coefficient that overrides the calibration file. The empirical approach is right, and it still needs a floor of designer judgement where the data is blind.
3. The mini-games that never ran
Events between fights include three playable gambles (a wheel, a shell game, dice) and the model can generate new ones. Weeks after shipping them, the eval log showed a 100% rejection rate for generated mini-games. The schema required a choices field that the prompt explicitly told the model to omit. Every generated gamble failed validation and one of the three curated ones quietly took its place, so from the player's seat the feature "worked." I only caught it because rejections are logged and counted.
What the log says now
| Content type | Candidates | Accepted | Repaired | Rejected |
|---|---|---|---|---|
| Cards | 2,165 | 86% | 9% | 5% |
| Enemies | 231 | 90% | 8% | 2% |
| Room modifiers | 130 | 77% | 20% | 3% |
| Events | 125 | 96% | 0% | 4% |
| Characters | 7 | 0% | 100% | 0% |
| All | 2,658 | 86% | 9% | 5% |
Snapshot of the eval log at submission. Characters show 100% repaired because every forged ten-card deck had at least one card clamped, which marks the whole character as repaired; that is the deck-level budget doing its job.
About 95% of what the model produced was served, either as written or after a clamp. Cards are the hardest budget to hit and where most repairs land. Those numbers mean something specific: they say that of the cards the model produced, this fraction fell within the same power distribution as canonical Ironclad cards of the same rarity and cost. That is a claim I can defend. "It feels balanced" is not.
What is still wrong
The scorer reads real cards through a heuristic on their damage, block and "magic number" fields, and it cannot read mechanics. Double Tap, Exhume and Limit Break all score zero. Since the Rare 1-cost bucket has four cards and three of them score zero, that band calibrated below Common, and boss rewards came out weak. I patched it in the validator rather than the scorer: the ceiling is now monotonic across rarities, and a rarity floor pushes Uncommon and Rare cards into the upper part of their band. It is a band-aid over the calibration. The principled fix is a scorer that reads mechanics, either richer heuristics or a small learned model, and that is what I would do next.
The bigger lesson is about where the design work actually lives. My agent built the pipeline fast and correctly within whatever principle I gave it. The principles were the job: that coefficients must come from data, that heal is not block, that a picked card is not a loved card, that a gamble must be able to lose. Deciding what the model may not choose turned out to be as much a design act as deciding what it may.