Jackdaw

Bit-exact headless Balatro simulator for reinforcement learning

View the Project on GitHub TylerFlar/jackdaw-balatro

The Python API

The game engine is written in Rust (crates/jackdaw-core) and runs in-process through the jackdaw._core extension. jackdaw.core is its Python face. It reproduces Balatro 1.0.1o exactly, down to the RNG: it was ported function by function from the game’s Lua source, checked step by step against a Python reference engine (whose results are frozen in the test fixtures), against LuaJIT itself for the RNG and the hand evaluation, and in lockstep against the live game (Lockstep against the live game).

For reinforcement learning, start with the RL Project Guide: the environments wrap this API. Use jackdaw.core directly for scripting, analysis, search, or your own environment.

A run

from jackdaw.core import Game
from jackdaw.engine.actions import SelectBlind, PlayHand, Discard, CashOut, BuyCard, NextRound

game = Game.new("b_red", stake=1, seed="TUTORIAL")   # back key, stake 1-8, seed
game.step(SelectBlind())                              # the round starts, the hand is dealt
game.step(PlayHand(card_indices=(0, 2, 3)))           # hand indices
print(game.phase, game.chips, game.hands_left)

The actions are SelectBlind, SkipBlind, RerollBoss, PlayHand, Discard, SortHand, SwapHandLeft/Right, SwapJokersLeft/Right, CashOut, BuyCard, BuyAndUse, SellCard, UseConsumable, RedeemVoucher, OpenBooster, PickPackCard, SkipPack, Reroll and NextRound.

for action in game.legal_actions():
    print(action)

legal_actions(Legality.BALATROBOT) keeps only what the live BalatroBot also executes. Use it for agents that will play the real game. It drops sim-only actions such as RerollBoss and BuyAndUse, and moves the bot refuses.

Reading the run

Scalars are properties: phase, seed, ante, round, dollars, chips, hands_left, discards_left, won. Areas and the rest are read-only views:

Property Type
hand, jokers, consumables, deck, discard_pile tuple[CardView, ...]
shop_jokers, shop_vouchers, shop_boosters, pack_cards tuple[CardView, ...]
blind BlindView (key, chips to beat, dollars, boss, disabled) or None
hand_levels mapping hand name → HandLevel (level, chips, mult, played, visible)
tags held tag keys
last_score Score (hand_type, chips, mult, total, dollars, debuffed) or None

A CardView has:

Views are frozen: they describe the run when they were read.

score = game.preview_play((0, 1, 2, 3, 4))   # the real play on a copy
print(score.hand_type, score.total)

branch = game.clone()                        # an independent copy, well under a microsecond
branch.step(Discard(card_indices=(5, 6, 7)))

preview_play runs the actual step on a copy, so it includes every joker, enhancement, edition, seal, the boss and the RNG. The run itself is unchanged. clone() is cheap enough for tree search. scoring_cards(positions) gives the hand those cards make and which of them would score (("Pair", [1, 3])), without playing them.

How a hand scored

game.record_scoring = True
game.step(PlayHand(card_indices=(0, 1, 2, 3, 4)))
play = game.last_play                  # a Play; None after a step that played no hand
print(play.hand, play.level, play.score.total)
for e in play.events:
    print(e.kind, e.card, e.target, e.chips, e.mult, e.x_mult, e.hand_chips, e.hand_mult)

With record_scoring on, each play records how it scored, in the order G.FUNCS.evaluate_play shows it. A Play has:

Each ScoreEvent has a kind, the card it belongs to (where the game shows it) and its target when that’s another card. It records what changed: chips and mult added, x_mult multiplied, dollars paid. It also carries the hand’s chips and mult after it (hand_chips, hand_mult), so the last event’s pair is the score’s.

kind What happened
level_up a joker levelled the hand up before it scored (Space Joker): the new level and base
hand scoring starts: the hand’s base chips and mult at level
blind the boss changed them (The Flint)
card a scoring card’s own chips, mult, x mult or money (enhancements, Gold Seal)
held a held card’s bonus (Steel Card)
edition an edition’s bonus, on a scoring card, a joker or a consumable
joker a joker’s effect, on target when it acts on a card
retrigger target scores again, or counts again in hand: card is the Red Seal’s card or the joker
debuffed a scoring card the boss debuffs, which adds nothing
not_allowed the boss doesn’t allow the hand: it scores nothing
plasma Plasma Deck balanced the chips and mult
destroyed a scoring card destroyed after the hand: a Glass Card that shattered, or Sixth Sense’s 6

Recording is off by default and changes nothing: the run, its snapshot and hash, the observations and the masks are the same either way.

Building a state

from jackdaw.core import GameBuilder

game = (
    GameBuilder("b_red", stake=2, seed="DEMO0001")
    .boss("bl_hook")
    .select_blind()
    .joker("j_blueprint")
    .joker("j_joker", edition="polychrome")
    .card("H_A", enhancement="m_glass", seal="Red")   # into the hand during a round
    .money(50)
    .build()
)

The additions go through the same operations as BalatroBot’s debug commands add and set (Game.debug_add, Game.debug_set, Game.debug_restock_shop).

Saving and comparing runs

BalatroBot

See BalatroBot on the simulator.

Observations

observation_buffer() returns the RL observation (encoder v1) as raw float32 data, and jackdaw.env.encode_observation(game) wraps it as an Observation; jackdaw.env.get_action_mask(game) gives the action mask. The environments in jackdaw.env (BalatroEnvironment, BalatroGymnasiumEnv, BalatroVectorEnv) build on these; see the RL Project Guide.

Where the source doesn’t decide

A few things the Lua source leaves open, and the engine’s choices:

Speed

On one core of a Ryzen 9 9950X3D (Windows, MSVC release build):

Operation Time
A new run 4 µs
clone() 0.3 µs
A play, no jokers 1.0 µs
A play with five jokers 2.1 µs
Cash out and fill the shop 2.2 µs
Opening a booster pack 1.7 µs
A reroll 1.1 µs
An RNG draw 0.05 µs

cargo test --release -p jackdaw-core --test perf -- --ignored --nocapture prints these.

The environments step about 155,000 times a second (BalatroEnvironment) and 180,000 times a second (BalatroGymnasiumEnv) on one core, and BalatroVectorEnv about 1.1 million times a second on 8 threads (256 envs), counting the time inside the environment, not the policy’s (uv run pytest -m benchmark).