Olivier Couthaud

BalatroAI

A fast copy of Balatro that I can check against the real game, and an AI that learned to play it.

Two stacked line charts of one won game. Top: the score in each round, spiking at the end of every blind, with boss rounds shaded and blind-cleared markers. Bottom: money in dollars rising to about 100.
One game an early version won. Top: the score in each round. Bottom: money. Dashed lines are targets cleared, shaded bands are the hardest rounds, triangles are jokers bought. I picked it because it won; that version won only 19 of 500 games (3.8%). Later versions win about two games in three on White stake.

My cousin plays a lot of Balatro, and when I tried it I understood why. It is a very good game. It also looked like something an AI could get good at, and I started wondering whether one could beat him. It couldn't. It never beat Gold stake in any way that counts: the best a version managed was a win in 2 to 5 games in 100. Gold is the hardest setting. How far it did get is still a decent story.

Balatro is a card game built on poker hands. You play hands to hit a score target, buy joker cards that bend the scoring rules, and try to get through eight stages, which the game calls antes. Each ante has three score targets, called blinds, and they keep climbing. There are eight difficulty levels, called stakes, and each one piles a new penalty on top of the last.

I wanted an AI that plays all of it, not just the card-playing: the shopping, the booster packs, which jokers to keep and which to sell. It learns by trial and error, so it needs millions of games, which means a copy of Balatro that runs very fast on my own computer and behaves exactly like the real one. The second part is the hard part. If my copy gets one probability wrong, the AI learns a slightly different game, like a pilot trained on a flight simulator that is off by a few percent. You only find out when they climb into the real plane.

I wrote the copy in Rust and checked it two ways. First I ran the real game's own code with no window and compared its answers with mine until they matched exactly, down to the random shuffles. Then I fed the same moves to the real game and to my copy and compared everything after every move: 727 moves, no differences.

Then I let the AI play. It started out knowing nothing, played about a billion moves in the copy over roughly 33 hours on a rented graphics card, and got a reward only for clearing a target and for winning. I never told it which jokers are good. On the easiest stake it now wins about two games in three (66 in 100 in one test, 73 in 100 in another, since the two tests are set up a little differently). The simple rule-based bot I wrote first wins about one in a hundred. Turn the dial to see what happens as the stakes go up.

Stake dial. Turn it, or pick a stake.

Each square is one game out of 100. Red squares are wins.

White stake: it wins about 66 games in 100.

The base game, no extra rules.

It falls apart quickly as the stakes go up. On Gold, the main version wins none of its 300 test games, and a version trained on Gold wins somewhere between 2 and 5 games in 100. That is nowhere near beating a person who plays the game a lot.

I tried to fix that with search, bigger networks and ways of teaching it to build a winning setup from scratch. None of it worked. The search taught me the most. In my test of 24 games, a version that could peek at the deck order won every game, and a version that couldn't won about two in three. That suggests most of the gap is hidden information rather than bad play, but it is a small test.

What's next. Winning Gold stake from scratch is the thing I haven't managed, and it is what I'd still like to get working. Beyond that I want one agent that handles every stake and every deck. My copy already supports all eight stakes and all fifteen decks, and the current agent, which only ever trained on the starting deck, wins more than half its games on 12 of the 15 decks on the easiest stake. The combined training run is planned but I haven't run it. The code isn't public, and I don't publish the game's own code or art.

What I wanted

My cousin plays a lot of Balatro. I found the game fun when I tried it, I could see how well an AI might do at it, and I wanted to know whether one could beat him. It couldn't. It never beat Gold stake reliably: the best from-the-start result was 2 to 5 wins in 100, and the Gold results below are the reason.

So I wanted an AI that plays all of Balatro: blinds, shops, booster packs, consumables, jokers, selling and rearranging. Training one takes millions of games, so the simulation has to be fast. It also has to be exact, because one wrong probability or card order and the agent is learning a different game, and nothing tells you. A public Python Balatro simulator, balatro-rl, runs at about 1,500 steps per second, and its PPO agent peaked at 2.35% wins. That is a different simulator, so the numbers aren't comparable with mine, but they told me what to beat.

How it works

The engine is a Rust reimplementation of Balatro v1.0.1o, checked against the real game. To check it, I run the game's Lua headless under LuaJIT and treat it as an oracle: scripts call the real functions to generate test vectors, and the Rust tests have to reproduce them. The random-number chain (pseudohash, pseudoseed, LuaJIT's own generator) is bit-exact. Hand evaluation, scoring with editions, a 774-case joker-by-boss test, shop generation, all 150 jokers and all eight stakes have parity tests. Since 21 August all 15 decks do too.

What is an oracle, and what is a parity test?

An oracle is something that already knows the right answer; here it is the real game's own code. A parity test gives the same situation to the oracle and to my engine and fails if the two answers differ at all. That beats checking against my memory of how the game works, because I can be confidently wrong about a card interaction.

diff state after every actionBalatro's own LuaLuaJIT, headlessFixture vectorsTSV from the oracleParity testsmust equal the oracleShipped gamevia balatrobot replayRust enginebit-exact RNG, scoringPyO3 envbalatro_env, masksTransformer policyentity tokensPPOoutcome-only rewardDeterminized MCTSno gain over greedyGo-Exploreperfect-info teacher

Rust engine. My Rust version of the game. The random numbers are bit-exact, so the same seed deals the same cards and builds the same shops as the real game.

Diagram. The pipeline as it exists. The right-hand column at the top is how I keep the engine honest. The shipped game is a check against the real game, and the two searches at the bottom did not beat the plain network. Tap, click or tab to any box for a line on what it is.

A second check uses balatrobot, an existing open-source mod from the coder GitHub organisation that exposes the shipped game over JSON-RPC. I replayed 10 seeds with identical actions in the game and in the engine and compared the full state after every action: 727 actions, 0 mismatches. That first scripted bot never bought packs or skipped blinds and left out 10 jokers that weren't implemented yet. A second pass covered packs, consumables, skips, sells, a win and all 28 bosses: 1,165 live actions over 11 runs, 0 real mismatches and 2 timing differences that I judged harmless. To reach ante 8 quickly, that pass sets the chips directly, so the win and the late bosses were checked for rules and state, not for the scoring I would have earned by playing there. Once, while I was watching the agent play live, the engine ended up a steady $8 ahead of the game. I traced it to the bridge dropping a payout, not to the engine.

The agent is an entity-token transformer: every card in hand, joker, consumable, shop item and pack choice is a token, and the remaining deck enters as summary features in a global token, next to global features such as money, ante and stake. Actions are factored and masked down to the legal ones. It trains with PPO and is rewarded only for outcomes, meaning blinds cleared and wins, with no hand-written hints about jokers or deck-building. End to end this ran at roughly 10,000 steps per second early on and about 8,500 averaged over the full 1-billion-step run, which took about 33 hours on one rented RTX 4090.

What is PPO?

PPO, proximal policy optimization, is a reinforcement-learning method. The agent plays, collects reward, and the network is nudged toward the moves that led to more of it, with a cap on how far one update can move things so training doesn't blow up. Nobody ever tells it the right move; it only sees how the game went.

What is a token here, and what does outcome-only mean?

A transformer reads a set of items and lets each one look at the others. Here each card, joker and shop item is one item, so the network can weigh a shop item against what it already owns. Outcome-only means the reward is just for clearing blinds and winning. Nothing says that a multiplier joker is good. That is harder to learn from, because credit for a purchase arrives many moves later, but none of my own opinions about the game get baked in.

Results

The game in the chart at the top comes from an early checkpoint of 17 July. It ended on ante 9, which means it cleared ante 8, after 24 blinds and with 5 jokers. That checkpoint won 19 of 500 games (3.8%) with a mean ante of 4.4. Shaded bands are boss blinds.

All of this is simulation on my engine, White stake and Red deck unless I say otherwise, with the policy played greedily, which means it always takes the network's top choice.

White stake, Red deck, policy played greedily. Conditions differ between rows, so they are not one benchmark.
Setting and gamesWin rate
Heuristic bot I wrote, no learning (scores each candidate play exactly by cloning the game). 2000 seeds, mean ante 3.29.0.010
Early PPO checkpoint, 17 July. 500 seeds.0.038
PPO run 2, 1B steps, 4.6M parameters; trained with random joker unlocks, tested on the fresh-profile pool (105 of 150 jokers). 200 fixed seeds; mean of the last 8 tests, best single test 0.77.0.73
Best later checkpoint, 6.8M parameters. About 0.8 when sampled instead of greedy.0.72
Base net used in the stake test, random joker unlocks. 300 seeds.0.660
0%10%20%30%12345678wonshare of games ending at each antehand-written bot, 2000 gamesearly trained net, 500 games
Where games ended, as a share of all games. Ante 8 is the last one, so "won" means clearing it. Both are from 17 July, long before the final run. The bot I wrote by hand mostly died around ante 3. The early network mostly died around ante 5, though a fifth of its games still ended on ante 1.
00.250.50.751.00250M500M750M1Bwin rate vs training steps
Win rate on 200 fixed White-stake games against training steps, for the 1-billion-step run (trained with random joker unlocks, tested with the fresh-profile pool of 105 of the 150 jokers). Each point is the mean of 8 consecutive tests, because a single test of 200 games moves by a few points. The test games are fixed and the training games are random, but I picked the best checkpoint on those same tests, so the best single figure (0.77) is a little optimistic.

Scrub through the training run

995M steps: win rate 0.73

Mean ante 8.0. Each reading is the mean of 8 tests of 200 games.

It learns fast at the start, goes slowly through the middle, and picks up again toward the end: the smoothed line sits at about 0.44 at 250M steps, 0.52 at 500M and 0.73 at 1B, and it is still rising when the run stops.

The stakes are where it falls apart. These numbers are the 6.8M-parameter base net with random joker unlocks, 300 games per stake, played greedily. It was trained on White only. Turn the dial.

Stake dial. Turn it, or pick a stake.

Each square stands for one game in 100 (the real figure comes from 300 games). Red squares are wins.

White stake: win rate 0.660, mean ante 7.4 (300 games, played greedily).

The base game, no extra rules.

Win rate by stake, 300 games each, greedy.
StakeWin rateMean ante
White0.6607.4
Red0.5006.5
Green0.1875.3
Black0.0574.4
Blue0.0434.1
Purple0.0033.6
Orange03.4
Gold03.0

A descendant trained on Gold won about 0.05 in an earlier test, and a later fixed 400-game test from a bare ante-1 start gave 0.025. The engine supports all 15 decks, but this net only ever trained on Red. I zero-padded its new deck input and tested it on every deck without any training on them, 120 games each. Painted wins none of its 120 games, and I haven't worked out why yet.

The same network on all 15 decks

Blue0.75Yellow0.72Green0.69Red0.68Erratic0.68Zodiac0.64Magic0.62Nebula0.57Ghost0.54Anaglyph0.53Checkered0.52Abandoned0.51Plasma0.41Black0.33Painted0.00

White stake, 120 games per deck, played greedily. Red is the only deck it ever trained on.

What didn't work

Search. I built a determinized tree search that resamples the hidden deck order before each simulation. On 24 matched seeds with 64 simulations per move, plain greedy play won 17 of 24 (0.708) and the search won 16 of 24 (0.667), a tie within noise at this sample size. The same search with perfect information won all 24. So the gap between 0.7 and 1.0 is the value of knowing the deck order, not a skill gap that more training can close. Training the net on its own search output diverged, and the target I had set, 95% of perfect-information play, rested on a baseline I had assumed and never measured.

What is a determinized search?

Balatro hides the order of the deck. A determinized search picks one possible shuffle of the unseen cards, plays moves out assuming that shuffle is real, repeats this over many guesses, and picks the move that did best across them. "Perfect information" means letting it see the real order, which is cheating, so I only use it as a ceiling.

Gold stake. Search was no better than the policy there (0.045 against 0.042), a bigger 17.6M network plateaued in the same place, and mixing normal early-game play with the engine-building skills made the early play worse. Gold needs a long set-up that scores nothing until it is complete, while its bosses ask for scores of 1e6 to 1e8.

Hand-built start states. I started the agent from constructed states, and it learned the set-up skills from zero: positioning jokers, converting the deck, buying jokers on a budget, each to a rate of 1.00 within about 1M steps. At Gold stake, with a partial engine seeded at ante 8, the win rate rose from 0.41 to 0.74. But none of it joined up with normal play. In unified runs from ante 1 the normal-game win rate stayed near 0 and mean ante fell from about 4 to between 2 and 2.5, at both a 40% and a 75% normal-game mix. Freezing the base net and adding a small gated side-network kept early play intact only while the gate was closed; then the new skill never engaged, and opening the gate eroded early play again.

Perfect-information search. A Go-Explore search that can restore game states won Gold from scratch on 7 of 40 seeds, but that is a retry advantage a real player doesn't have. Distilling its wins into a policy gave 0.0225 against 0.0199 for the base (400 games, about plus or minus 0.007, so no real difference). The best archetype-conditioned version reached 0.030 against 0.025 for the model it started from, which is also within noise.

Versatile run. I ran 100M steps of training with the stake randomised per episode, plus an anchor to keep White play from eroding. The anchor held, but mean win rate across stakes went from 0.181 to 0.173, within noise. The mid-stake gains I saw at 48 seeds, and a White figure of 0.775 at 40 seeds, were sample noise.

What is unfinished

The question I started with is still open: a from-scratch agent that wins Gold stake. Early survival and engine-building don't compose in one set of weights so far. The combined stakes-and-decks training run, which I estimate at about $8 of GPU time, hasn't been run, though the deck inputs and a warm-start shim for it exist. The code is not public, and I don't publish the game's own code or art. Balatro belongs to its developer, and the replay checks use the open-source balatrobot mod.

Stack
Rust (engine), PyO3, PyTorch, PPO, LuaJIT oracle, rented cloud GPUs (RTX 4090)
Status
In progress
Code
Not public

← All work