Roguelike Layer (A2)

The Balatro skin on top of faithful craps. The A2 feature set is shipped end-to-end (engine primitives + drafting heuristics + pricing + client UI); remaining work here is balance tuning, not missing functionality.

Run + antes (the “blinds”)

TableState.run?: RunState, created by the START_RUN action. The reducer’s post-action pass checks:

  • Blind clear — any bettor’s bankroll ≥ target → bump the ante, recompute the target from initialTarget * anteMultiplier^(ante-1).
  • Bust — total wealth across all bettors = 0 → run.ended = "bust" (independently terminal; distinct from a round ending via seven_out / winner).

Free-play A1 behaviour is preserved: with run undefined the whole check short-circuits.

Chip shop (between blinds)

On blind clear, run.shop = { offers, rerollCost } is populated. Actions: BUY_CHIP, REROLL_SHOP (both bettor-only). Offers are filtered to bettor-side chips at generation. START_ROUND clears the shop. A second blind clear overwrites an open shop — one shop break per “between blinds” window, by design.

Drafting heuristics

Offers are drawn rarity-weighted then synergy-biased (weighted-without-replacement, one rng.next() per draw → determinism + JSON round-trip untouched). See Chip Dispatch Contract → Drafting heuristics. Pricing is rarity-tiered (RARITY_BASE_COST). The shop UI shows rarity badges and a per-bettor synergy “fits your build” hint.

Bet leveling

run.betLevels?: Record<BetId, number> + the LEVEL_BET { player, betId } action. Cost = currentLevel × 100 (linear scaling). The resolver multiplies payoutNumerator by the level at win time (denominator unchanged — keeps fractions integer-clean). Level defaults to 1; non-A2 tables (no run) always behave at level 1. This mirrors Balatro’s planet-card mechanic.

Odds bets are not levelable (BET_NOT_LEVELABLE) — they pay true odds capped at the 3-4-5x max, so leveling would break both the true-odds math and the cap. See Bet Catalog → Odds cap.

Shooter rotation

CHANGE_SHOOTER { newShooterId }. The phase must be ended; the target must be a shooter / bot_shooter (WRONG_PLAYER_ROLE otherwise). Idempotent. The lobby/UI dispatches CHANGE_SHOOTER + START_ROUND in sequence — there is no auto-rotation.

Mid-game join + spectating (A3 foundation)

JOIN_TABLE { player, role: "bettor" | "spectator", bankroll? } adds a participant to a live table (players were previously fixed at initialState):

  • bettor → seated with a bankroll (default 1000);
  • spectator → a new "spectator" role, bankroll 0, no bets/chips.

Guards: PLAYER_ID_TAKEN, INVALID_PLAYER_ID. Spectators are rejected from betting/equipping (SPECTATOR_CANNOT_BET / _EQUIP). JOIN_TABLE is not token-gated (the joiner has no token yet) — it’s dispatched only by the POST /api/rooms/:id/join endpoint, never /actions. The client lobby (<LobbyPanel> browsing GET /api/lobby) claims a bettor seat or attaches as a read-only spectator. Spectator read-only enforcement is server-side.