Source: https://oliviercouthaud.com/balatro · Status: In progress · 2026 · Tags: Rust, PyTorch, PPO, MCTS # BalatroAI A fast copy of Balatro that I can check against the real game, and an AI that learned to play it. [Image: 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.] Figure: 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 early version won 19 of 500 games (3.8%). Later versions win about two games in three on White stake. ## 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. The best from-the-start result on Gold stake is 2 to 5 wins in 100, and the Gold results below give the detail. 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. **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. Figure: 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 matched the plain network within noise. 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 pass kept to simple play, with no packs or blind skips, 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 games | Win 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 | Figure: 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. Figure: 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 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. Higher stakes are where it gets harder. 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. Figure: 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.** | Stake | Win rate | Mean ante | |---|---|---| | White | 0.660 | 7.4 | | Red | 0.500 | 6.5 | | Green | 0.187 | 5.3 | | Black | 0.057 | 4.4 | | Blue | 0.043 | 4.1 | | Purple | 0.003 | 3.6 | | Orange | 0 | 3.4 | | Gold | 0 | 3.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 the reason is still to be investigated. ### The same network on all 15 decks White stake, 120 games per deck, played greedily. Red is the only deck it ever trained on. ## What I ran into **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 rather than 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. **God mode.** The Rust copy exposes the full game state, so a search can look ahead through the real shuffle and card order, which the real game would never allow. That is the perfect-information search above, and I treat it as a cheating upper bound. It is labelled as such and is not comparable with the fair runs. I watched some of these games live as it played, and my cousin watched with me. The agent made some silly-looking moves and some smart ones, and some of the silly-looking ones were not: it knew what was coming and we did not, so a move that made no sense to us could turn out, a couple of actions later, to have been a good one. **Gold stake.** Search matched 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 lowered the early-game results. 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. Joining these skills to normal play is where it stands. In unified runs from ante 1 the normal-game win rate stayed near 0 and mean ante moved 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 while the gate was closed; in that state the new skill did not engage, and opening the gate lowered 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. ## Where it stands The open question is a from-scratch agent that wins Gold stake. Early survival and engine-building have not yet been combined in one set of weights. The combined stakes-and-decks training run, which I estimate at about $8 of GPU time, is next, and 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