Jackdaw

Bit-exact headless Balatro simulator for reinforcement learning

View the Project on GitHub TylerFlar/jackdaw-balatro

BalatroBot on the simulator

Jackdaw speaks BalatroBot v1.5.2’s JSON-RPC API on top of its own engine. The command and view layer is a port of BalatroBot’s Lua (crates/jackdaw-bot, pinned to upstream commit 9052d76f14723293f6c6b2cecaa791a5c4ae68f3; the vendored source is in third_party/balatrobot/v1.5.2/), so a client written for the live game (an agent, a script, BalatroBot’s own test suite) drives the simulator unchanged, and an agent trained on the simulator can be pointed at the live game.

What “the same API” covers:

Game rules follow vanilla Balatro 1.0.1o, as the engine does everywhere. Where BalatroBot pushes the game past a gate a player can’t (selling an eternal joker, a greyed-out pack pick), the simulator refuses; where Steamodded changes vanilla, the difference is documented (Where the live game isn’t vanilla) rather than imitated.

Entry points

jackdaw serve runs BalatroBot’s HTTP server on the simulator:

jackdaw serve                                   # 127.0.0.1:12346, like BalatroBot
jackdaw serve --port 0                          # any free port (printed on start)
jackdaw serve --host 0.0.0.0 --fs-root runs/    # off loopback a filesystem root is required
jackdaw serve --debug                           # also BalatroBot's test endpoints

SimBackend is the same session in-process (jackdaw.bridge). handle(method, params) returns the result dict or raises RPCError with BalatroBot’s code, message and data = {"name": ...}. add also accepts the engine’s names (m_glass, foil, Gold), as LiveBackend does.

from jackdaw.bridge import SimBackend
from jackdaw.engine.actions import PlayHand

sim = SimBackend()
sim.handle("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"})
view = sim.handle("select", {})
sim.legal_actions()                             # BALATROBOT legality, as engine actions
sim.request_for(PlayHand(card_indices=(0, 2)))  # ("play", {"cards": [0, 2]})

jackdaw.core.Game (the Rust engine) has the bot layer too: bot_view() (the view as a dict), bot_request(action) (the request that performs an action, or None) and legal_actions(Legality.BALATROBOT). jackdaw._core.BotSession is the raw session (JSON text in and out) behind all of these.

LiveAdapter (jackdaw.bridge; also what BridgeAdapter(backend) gives) plays the environment over the protocol, with a shadow run in the Rust engine: each request is taken by the shadow through this layer first, then sent to the backend, and the backend’s view is compared with the shadow’s bot_view() (DivergenceError on a difference). The mask and legal actions follow BALATROBOT legality. Over LiveBackend that is the live game; known Steamodded and BalatroBot differences are handled by a deviation catalog, and jackdaw lockstep runs it at scale. Over SimBackend() it is BalatroBot’s API on the simulator. See Lockstep against the live game.

Command-space policy

The engine’s action set and BalatroBot’s endpoints don’t line up one to one.

Translated. Each engine action has one request (Game.bot_request, Rust jackdaw_bot::translate). SortHand and the Swap* actions go out as rearrange with the whole permutation (a sort is the permutation that sorts the hand). The sort mode (G.hand.config.sort) has no BalatroBot counterpart: after a translated sort by suit, cards drawn later are still placed by rank, in the live game and in the simulator alike.

Sim-only, masked under BALATROBOT legality. Moves vanilla allows that the live bot can’t make:

BalatroBot allows, vanilla forbids. The live endpoints call G.FUNCS directly and skip two UI gates. The simulator keeps vanilla’s rules and refuses, with NOT_ALLOWED:

play and discard click the requested cards as BalatroBot does: under Cerulean Bell the forced card stays highlighted and the selection stops at five, so a request that leaves the forced card out plays what the live game plays (B02). A request whose clicks end with no card highlighted (one index given twice) is refused: vanilla never plays an empty hand.

Debug only. add, set, save, load and menu change the run in ways no move does; they are never in a legal-action list.

Legality. Legality.VANILLA is the engine’s legal list (Balatro 1.0.1o). Legality.BALATROBOT keeps the vanilla actions whose request passes BalatroBot’s checks: the dispatcher’s argument and state checks and the endpoint’s own validation, run as a dry run that changes nothing. Every action it allows is one the live bot accepts. From Python: Game.legal_actions(Legality.BALATROBOT), SimBackend.legal_actions(); from Rust: jackdaw_bot::legal_actions(&game, Legality::Balatrobot) and, per action, jackdaw_bot::bot_accepts(&game, &action). A PlayHand / Discard with no cards (the legal list’s marker for “any selection”) is checked with one card, the forced one if any.

Endpoints

method states notes
health any  
gamestate any the view (MENU before a run)
rpc.discover any the vendored openrpc.json
menu any ends the run
start MENU random vanilla seed when none is given
select, skip BLIND_SELECT the Boss can’t be skipped
play, discard SELECTING_HAND clicks as above; at most 5 cards
cash_out ROUND_EVAL  
buy, reroll, next_round SHOP buy takes card, voucher or pack
sell, use SELECTING_HAND, SHOP eternal jokers refused
pack SMODS_BOOSTER_OPENED greyed picks refused
rearrange SELECTING_HAND, SHOP, SMODS_BOOSTER_OPENED the whole permutation
add, set see upstream debug
save, load see upstream snapshot files (below)
screenshot see upstream always INTERNAL_ERROR
test_* any only with --debug / debug=True

use highlights its targets the vanilla way: Cerulean Bell’s forced card first, then the given cards, a repeat skipped. BalatroBot clears the highlight with force first, dropping the forced card; for the requests the legal list makes (the forced card among the targets) the two agree.

The view

Save and load

save writes the run’s canonical snapshot (jackdaw.snapshot/1 JSON, snapshot-schema.md) and load reads one; neither touches the game’s .jkr saves, so files don’t move between the simulator and the live game. As in the live game, load deletes the run before reading the file: a bad file leaves no run (UNKNOWN) until menu or a good load. With --fs-root (required off loopback) every path must stay inside that directory. The runtime-only parts of the view restart on load: the deck limit becomes the playing-card count and the leftover shop areas are gone. The live game rebuilds its card areas from the save, limits included; this difference hasn’t been captured live.

The server

jackdaw serve follows server.lua: one connection at a time, one request per connection (Connection: close), POST / only (other methods: 405, other paths: 404), bodies up to 64 KiB. The JSON-RPC envelope: jsonrpc must be "2.0", id is required and must be an integer or a string and is echoed back, errors carry {code, message, data: {name}}. Endpoint errors use BalatroBot’s names and codes: INTERNAL_ERROR -32000, BAD_REQUEST -32001, INVALID_STATE -32002, NOT_ALLOWED -32003.

Differences from the live game

Besides the policy above:

How it is checked

Steamodded behaviour (SMODS.add_card, SMODS.add_booster_to_shop, SMODS_BOOSTER_OPENED) was read from Steamodded 1.0.0~BETA-1503a as installed with BalatroBot and from the Lovely dump of the patched game; none of it is vendored.