Balancing a roguelike by playing it yourself is slow and you get good at your own game, which hides problems. In Full House (a poker-scoring card roguelike template) I use a small bot that plays whole runs instead. It’s deliberately not clever. Here’s how it works and what it showed when I changed some numbers.
1. A greedy bot in three functions
Play: try every way to play 1 to 5 cards from the hand, score each one with the real scorer in preview mode, and keep the best. With 8 cards that’s at most 218 previews, which is quick.
static func best_play(rs: RoundState) -> Array:
var best := []
var best_total := -1.0
for s in subsets(rs.hand.size(), rs.max_cards):
var played := rs.cards_at(s)
var held: Array[Card] = []
for c in rs.hand:
if not played.has(c):
held.append(c)
var ctx := Scorer.score(rs.run, rs, played, held, true) # true = preview, no side effects
var t := ctx.total()
if t > best_total or (t == best_total and s.size() < best.size()):
best_total = t
best = s
return [best, best_total]
Play or discard: play if the best hand clears the target, if it’s the last hand, if there are no discards left, or if it’s at least 15% more than the average needed per remaining hand. Otherwise discard.
Discard and shop: keep cards toward a flush (3+ of a suit) or pairs and better, throw the rest; in the shop, buy charms ranked by a rough value (x Mult over +Mult over Points) and swap out the weakest when full.
That’s it. It doesn’t plan a build, which is the point: it gives a steady baseline that a change either moves or doesn’t.
2. Fixed seeds, so comparisons are fair
Every run is seeded from a string like “exp-standard-7”, so the same deck gets the same shuffles and shop rolls in every experiment. The experiment script is a few lines run headless:
for deck in cat.decks:
for k in 20:
var run := RunState.create(cat, deck, 1, "exp-%s-%d" % [deck.id, k])
var r := RunBot.play_run(run)
# count wins, average ante, and which ante each lost run ended on
6 decks x 20 seeds = 120 runs per version, about 2.5 minutes each.
3. What changed when I changed one number
Average ante the bot reached (out of 8), same 120 runs each time:
- As shipped (4 hands, 3 discards): 4.11
- One hand fewer per round: 3.31
- Targets 20% higher from ante 3 on: 3.98
- One more discard per round: 4.26
One hand fewer hurt far more than 20% bigger targets. With 3 hands, runs that ended at ante 1 went from 8 to 25. Bigger targets barely moved anything, which suggests the bot’s wall isn’t mainly about target size.
4. Look at where runs end, not just the average
The average hides the shape. As shipped, 55 of the 118 lost runs ended at ante 4. That’s a wall, and the targets test above suggests it’s probably where a run without a real charm build stops keeping up. If I wanted the middle of the run to feel smoother, that’s where to look (the shop and charm power around ante 3), not the targets.
5. What a bot can’t tell you
A greedy bot plays worse than a person who plans around their charms, so its numbers are a floor, not the real difficulty. I use it for “did this change make things easier or harder, and where”, and play it myself for “is this fun”. The same bot also runs inside the stress tests, which check the rules after every step of dozens of runs.
All of this is in Full House, a complete poker-scoring card roguelike template for Godot 4: poker hands as data, charms, bosses, a shop, decks and stakes, the bot and the tests, everything as resources you edit in the Inspector:
Free browser demo (the first 3 antes): Full House - Playable Demo by Spare Parts Studio
Happy to answer questions about any of it.