Description
Calling cash_out as soon as the game reaches ROUND_EVAL collects less money
than the game owes — usually short exactly one blind reward ($3/$4/$5), and
$0 in the worst case. A human cannot reproduce this: the game only spawns the
Cash Out button after the eval rows have finished paying out, but the endpoint
bypasses that gate.
Root Cause
src/lua/endpoints/cash_out.lua:
requires_state = { G.STATES.ROUND_EVAL } (line 22) passes the moment the
state flips, while the dollar rows (blind reward, interest, per-joker payouts)
are still being queued and committed by the event manager.
execute then calls G.FUNCS.cash_out({ config = {} }) immediately (line 28).
The endpoint carefully waits for the SHOP transition on the response side, but
does not wait for eval completion on the entry side.
Evidence
Found with a differential-replay harness that drives a vanilla-faithful simulator
and balatrobot with identical action traces and deep-diffs the full gamestate
after every action (~1,200 organic games).
- Signature: after cash-out, live money == expected money minus exactly one
blind reward. Confirmed 4× before we added a workaround.
- Partial commits happen: one case committed interest but dropped the $5 boss
reward (seed LSOYQISY).
- Zero-payout extreme: when
hands_left == 0 and there is no interest (the
payout is only the reward row), cash-out commits $0. Reproduced
deterministically across bosses (Psychic, Pillar) and across game sessions
(seeds LSAZ99YB, LS88BC4Z).
- Workaround that eliminates it completely: a flat 12s wait before calling
cash_out (normal speed, --no-audio).
The under-collection is irreversible once collected — the money is simply gone
for the rest of the run.
Impact
Any bot that auto-cashes on seeing ROUND_EVAL is systematically poorer than
the game intends. balatrollm does exactly this (bot.py:
case "ROUND_EVAL": ... call("cash_out")), so BalatroBench run economies — and
any result that turns on being a few dollars short in the shop — are affected.
Suggested Fix
Gate execution the way the game gates the button: add a condition event that
waits until the round-eval UI has finished creating its rows (e.g. the Cash Out
button exists), and only then call G.FUNCS.cash_out. This mirrors the
endpoint's existing SHOP-side wait.
We can provide full repro bundles (action trace + both gamestates + field diff
at the divergence step) for any of the seeds above.
Description
Calling
cash_outas soon as the game reachesROUND_EVALcollects less moneythan the game owes — usually short exactly one blind reward ($3/$4/$5), and
$0 in the worst case. A human cannot reproduce this: the game only spawns the
Cash Out button after the eval rows have finished paying out, but the endpoint
bypasses that gate.
Root Cause
src/lua/endpoints/cash_out.lua:requires_state = { G.STATES.ROUND_EVAL }(line 22) passes the moment thestate flips, while the dollar rows (blind reward, interest, per-joker payouts)
are still being queued and committed by the event manager.
executethen callsG.FUNCS.cash_out({ config = {} })immediately (line 28).The endpoint carefully waits for the SHOP transition on the response side, but
does not wait for eval completion on the entry side.
Evidence
Found with a differential-replay harness that drives a vanilla-faithful simulator
and balatrobot with identical action traces and deep-diffs the full gamestate
after every action (~1,200 organic games).
blind reward. Confirmed 4× before we added a workaround.
reward (seed
LSOYQISY).hands_left == 0and there is no interest (thepayout is only the reward row), cash-out commits $0. Reproduced
deterministically across bosses (Psychic, Pillar) and across game sessions
(seeds
LSAZ99YB,LS88BC4Z).cash_out(normal speed,--no-audio).The under-collection is irreversible once collected — the money is simply gone
for the rest of the run.
Impact
Any bot that auto-cashes on seeing
ROUND_EVALis systematically poorer thanthe game intends. balatrollm does exactly this (
bot.py:case "ROUND_EVAL": ... call("cash_out")), so BalatroBench run economies — andany result that turns on being a few dollars short in the shop — are affected.
Suggested Fix
Gate execution the way the game gates the button: add a condition event that
waits until the round-eval UI has finished creating its rows (e.g. the Cash Out
button exists), and only then call
G.FUNCS.cash_out. This mirrors theendpoint's existing SHOP-side wait.
We can provide full repro bundles (action trace + both gamestates + field diff
at the divergence step) for any of the seeds above.