This is an
agents.md— a repo-level orientation doc written for coding agents (Claude Code, Cursor, etc.) but perfectly readable by humans too. It's the "what is this project and how does it fit together" briefing you'd give a new contributor on day one.The mod: head-to-head (and co-op, solo endurance) Balatro. Two players, same seed, same shops, same packs — but at boss blinds you play against the opponent's live chip score. Built as a Steamodded mod with Lovely patches on top of vanilla Balatro. Server is a thin TCP relay; both clients simulate the game locally and only sync opponent scores.
- Balatro (>=1.0.1o) — LÖVE2D poker roguelike. Vanilla source at
../Balatro-Src/for reference. - Lovely (>=0.8) — Binary patcher/injector for LÖVE2D games. Patches in
lovely/*.tomldo regex/pattern matching on vanilla Lua source to inject mod code at load time. Can also target SMODS source via=[SMODS _ "src/..."]. - Steamodded (SMODS) (>=1.0.0~BETA) — Balatro mod loader/API. Provides
SMODS.GameObject,SMODS.Atlas,SMODS.load_file,SMODS.calculate_context,SMODS.current_mod, center pool injection, loc_text processing. Gotcha:SMODS.GameObjectvalidatesrequired_paramsduring the constructor (__call), not duringinject(). Any fields that must pass validation need to exist on the init table before construction — they cannot be deferred toinject().
Multiplayer.json — SMODS mod manifest (id: Multiplayer, prefix: mp). Loads core.lua as main file.
core.lua initializes the global MP table (= SMODS.current_mod) and loads everything via MP.load_mp_file / MP.load_mp_dir (wraps SMODS.load_file). Load order: lib/ → overrides/ → compatibility/ → networking/ → gamemodes/ → layers/ → rulesets/ → ui/ → objects/.
MP— Mod root. ContainsLOBBY,GAME,UI,ACTIONS,UTILS,INSANE_INT,EXPERIMENTAL,SANDBOX, etc.MP.LOBBY— Lobby state (code, config, host/guest, deck selection, ruleset, gamemode).MP.GAME— In-game state (lives, enemy state, timer, scores, stats).MP.ACTIONS— Network action handlers (send/receive).Client.send({action = "...", ...})— Send message to server.G— Balatro's global game state (vanilla).
Three layers of hooks, from deepest to highest-level:
- Lovely patches (
lovely/*.toml): Pattern-match vanilla source strings and inject code at/before/after match points. Used for deep hooks into game state (round eval, dollar calculation, blind selection, shop flow, card initialization for sticker support). - Lua overrides (
overrides/): Store function reference (local fn_ref = SomeFunc), redefine, call original inside. Used for wrapping vanilla/global functions with MP-specific behavior. Hooks:ease_dollars,Card:sell_card,G.FUNCS.reroll_shop,G.FUNCS.buy_from_shop,G.FUNCS.use_card,G.FUNCS.evaluate_round. - SMODS APIs:
SMODS.calculate_contextfor joker/card evaluation hooks.SMODS.GameObject:extendfor custom object types (Gamemode, Ruleset).
TCP socket via love.socket running on a separate LÖVE thread (love.thread.newThread). Communication via love.thread.getChannel — two channels: uiToNetwork (client→server) and networkToUi (server→client). JSON-encoded messages. Server at configurable URL (default: balatro.virtualized.dev:8788).
Client.send() pushes JSON to the outbound channel. MP.ACTIONS.* functions in networking/action_handlers.lua handle inbound server messages.
A match follows vanilla Balatro's ante structure (small blind → big blind → boss blind → shop), but with a life system and PvP blinds layered on top. Both players play simultaneously from the same seed.
- Ante 1 is always vanilla blinds (no PvP). Players build their decks independently.
- Ante 2+ (Attrition): the boss blind slot is replaced by
bl_mp_nemesis— the PvP blind. Small and big blinds remain vanilla. - Ante 3+ (Showdown): all three blind slots become
bl_mp_nemesis. - Survival: no PvP blinds ever — pure solo endurance with 1 life.
During a PvP blind, the score target is the opponent's actual score, received over the network in real-time. A 3-second countdown precedes each PvP blind. Players play hands to beat the opponent's chip total before running out of hands.
- Starting lives configurable per ruleset/gamemode (Survival forces 1).
- Failing a PvP blind (chips < opponent's score when hands run out) costs 1 life.
- On life loss, the player receives gold compensation (amount varies by stake / ruleset).
- Game over when lives reach 0 — server sends
loseGame/winGameto the respective players.
- Active during PvP blinds only. Per-ante duration configurable per ruleset.
- On timeout: if
timer_forgiveness > 0, one free pass; otherwise the round auto-fails.
The server is a relay, not an authority. Key messages:
playHand(score, handsLeft)— sent after each hand played.enemyInfo(score, handsLeft, skips, lives)— server broadcasts opponent state to both clients every ~3 seconds.endPvP— server signals both clients that the PvP round is over.loseGame/winGame— terminal state.
Both players run the same game locally from the same seed — there is no authoritative server simulating the game. The lobby seed is set by the host and shared at game start. All RNG (shop offerings, pack contents, boss blind selection, tags, joker pool order) is derived deterministically from that seed on each client independently. Both players see the same shops, same packs, same blinds.
Because Balatro's RNG uses per-slot pseudorandom queues (not a single sequential stream), player choices (buying vs skipping a joker, picking different cards) do not desync the RNG state — both clients stay in sync regardless of divergent play decisions.
The only networked gameplay data is the opponent's score during PvP blinds. Everything else (cards dealt, joker effects, shop contents) remains locally deterministic.
MP.Gamemode (gamemodes/_gamemodes.lua) extends SMODS.GameObject, stored in MP.Gamemodes[] and G.P_CENTER_POOLS.Gamemode. Each gamemode defines get_blinds_by_ante(ante) → (small, big, boss) override keys (or nil for vanilla), its own ban lists, and a create_info_menu() for UI.
The standard head-to-head mode. Normal blinds until pvp_start_round, then boss blind becomes bl_mp_nemesis (the PvP blind). Bans SP-specific jokers, ante-manipulation vouchers, the boss tag, and SP boss blinds.
Intensive PvP variant. Normal blinds until showdown_starting_antes, then all three blind slots become bl_mp_nemesis. Same bans as Attrition.
Solo endurance — 1 life, no PvP blinds at all (all vanilla). Bans MP jokers and consumables. Forces starting_lives = 1 and disable_live_and_timer_hud = true.
MP.Ruleset (rulesets/_rulesets.lua) extends SMODS.GameObject, stored in MP.Rulesets[]. Rulesets are now composed from layers — reusable bundles of ban lists, rework lists, scalars, and runtime hooks. A ruleset definition is typically 3–5 lines pointing at its layers plus any ruleset-specific overrides.
MP.Layer(name, definition) (layers/_layers.lua) registers a named bundle in MP.Layers. Definitions live in layers/*.lua.
Base layers (composed into rulesets via layers = { ... }):
| Layer | Purpose |
|---|---|
standard |
MP jokers enabled, standard balance bans + reworks |
experimental |
Standard-shaped rebalance playtest layer with additional reworks |
sandbox |
Parallel joker pool, idol selection, extra credit gating, vanilla-counterpart bans |
smallworld |
Random ban cull, showman override, tag/voucher/joker replacement logic |
speedlatro_timer |
Per-round countdown timer replacing the normal PvP timer |
ranked |
Version-gated, lobby locked |
classic |
Pre-MP-joker-era card pool |
Modifier layers (picked at runtime via MP.MODIFIERS, not baked into rulesets — see "Active context" below):
| Layer | Purpose |
|---|---|
no_animation_timer |
Faster base timer, no anim |
pressure_timer |
Calculate-button costs timer time; timer accelerates while opponent plays |
MP.resolve_layers(init) runs before SMODS construction (because SMODS validates required_params in __call, not inject()). Left-to-right:
- Array fields (
banned_*,reworked_*): concatenated (union of all layers + ruleset additions) - Scalar fields: last layer wins; ruleset-level always overrides
- Missing
banned_*/reworked_*arrays default to{}
The runtime view of "what's active right now" is bigger than a single ruleset — modifier layers compose on top. Two abstractions:
MP.current_ruleset() — a metatable proxy that resolves any field as (ruleset + active modifiers). Arrays union; scalars last-wins; modifiers beat the ruleset's own scalars. This is the canonical read site for ban lists, timer scalars, preview flags, etc. — ApplyBans, LoadReworks, lobby code all go through it. Safe with no active ruleset (arrays read as {}, scalars as nil).
MP.active_layer_chain(target?) — single source of truth for the deduped, ordered list of active layer names: ruleset's _layer_order → ruleset's self-name → modifiers. Powers is_layer_active, RunLayerHooks, and LoadReworks resolution. Dedup matters because not every hook is idempotent (smallworld's 75% cull would re-cull survivors if the ruleset's self-name re-fired the layer hook).
MP.MODIFIERS — runtime-only ordered list of modifier-layer names. Picked from the Modifiers overlay in lobby (host) or in practice mode (player). Reset to {} on lobby leave / practice exit. Helpers: MP.has_modifier, MP.add_modifier, MP.remove_modifier, MP.modifiers_serialize, MP.modifiers_parse. Modifiers are not materialized onto the ruleset — they're queried at read time via current_ruleset().
Returns true if name appears in MP.active_layer_chain() — i.e. the active ruleset composes that layer, the ruleset's own short name matches, or it's an active modifier. Use this to gate runtime behavior. Replaces the old is_standard_ruleset() and most is_ruleset_active() usage.
Minimal (layer-only):
MP.Ruleset({
key = "blitz",
layers = { "standard" },
}):inject()With overrides:
MP.Ruleset({
key = "traditional",
layers = { "standard" },
banned_jokers = { "j_mp_speedrun", "j_mp_conjoined_joker" }, -- merged into standard's bans
force_lobby_options = function(self)
MP.LOBBY.config.timer = false
return false -- false = soft defaults, host can override
end,
}):inject()Layerless (standalone):
MP.Ruleset({
key = "vanilla",
multiplayer_content = false,
banned_jokers = {}, banned_consumables = {}, banned_vouchers = {},
banned_enhancements = {}, banned_tags = {}, banned_blinds = {},
reworked_jokers = {}, reworked_consumables = {}, reworked_vouchers = {},
reworked_enhancements = {}, reworked_tags = {}, reworked_blinds = {},
}):inject()Fields that live on the ruleset (not in layers): forced_gamemode, force_lobby_options (when ruleset-specific), forced_lobby_options (when not from a layer like ranked).
Fields that live in layers (shared behavior): ban/rework lists, multiplayer_content, on_apply_bans hooks, is_disabled, standard flag.
MP.ApplyBans() merges bans from three sources into G.GAME.banned_keys at game start:
- Ruleset — read via
MP.current_ruleset(), which folds in active modifier layers - Gamemode —
gamemode["banned_" .. category] - Deck —
MP.DECK["BANNED_" .. category](deck-specific compat bans)
Then MP.RunLayerHooks("on_apply_bans") fires each layer's hook in active_layer_chain order. Used by sandbox (idol selection, extra credit gating) and smallworld (75% random cull).
banned_silent adds hidden bans not shown in UI (used to hide vanilla counterparts of reworked cards).
There are two paths for reworking a card. They serve different purposes.
You write a brand-new card with its own key, logic, and loc_txt. The vanilla card is silently banned; yours takes its place in the pool.
This is what we use for joker reworks. ReworkCenter mutates center properties on the existing card, which can desync the shop queue — Balatro's RNG pool system has already indexed the original center by the time reworks load, so mutating joker config mid-run risks desyncing the pseudorandom joker ordering between clients. Reimplementing as a new card with a fresh key avoids this entirely.
Steps:
- Create the new card via
SMODS.Joker({ key = "hanging_chad", ... }) - Add the vanilla key to the layer's
banned_silent(hides it from pool) - Add your new key to the layer's
reworked_jokers— this both shows it in the info panel and auto-attaches anmp_includethat returns true iff any owning layer is active
Auto-gating is driven by reverse indices MP._JOKER_LAYERS / MP._CONSUMABLE_LAYERS built in MP.Layer(). SMODS.Joker:register and SMODS.Consumable:register are grafted to consult them and stitch a default mp_include onto cards whose key is in the index — but only when the card doesn't already define one. Override mp_include only for bespoke logic (e.g. sandbox joker_mappings, top-level MP jokers gated on multiplayer_content, error/magnet special-cases).
Overrides config, loc text, and/or logic on an existing center without creating a new key. Cleaner API, less boilerplate — good for enhancements, consumables, tags, stakes, blinds, and poker hands.
MP.ReworkCenter("m_glass", {
layers = "standard", -- string or array of strings
config = { Xmult = 1.5, extra = 4 },
})Registration stores properties as mp_<layer>_<prop> on the center. MP.LoadReworks(ruleset) resolves in MP.active_layer_chain(ruleset) order: vanilla → composed layers → ruleset self-name → modifiers. Later entries override earlier ones.
Why not for jokers: ReworkCenter mutates G.P_CENTERS[key] properties. Balatro's shop pool generation reads center config during pseudorandom selection. If you change a joker's rarity or config after pool generation has already used the original values, the two clients can diverge. Enhancements/consumables/tags don't go through the same shop queue machinery, so they're safe.
- Path A jokers / consumables (
reworked_jokers,reworked_consumables): the layer entry does drive runtime gating — auto-mp_include kicks in. You still write theSMODS.Joker/SMODS.Consumabledefinition and thebanned_silententry for the vanilla version, but no manualmp_includeis needed for layer-only gates. - Path B centers (
reworked_enhancements,reworked_vouchers,reworked_tags,reworked_blinds): the layer entry is display metadata only. Runtime patching needs a separateMP.ReworkCenter(key, { layers = "..." })call. Auto-gating doesn't apply because Path B doesn't go throughregister.
| Ruleset | Layers | forced_gamemode |
Lobby locked | Distinct behavior |
|---|---|---|---|---|
| Ranked | standard, ranked | Attrition | yes | Version-gated competitive ruleset |
| Blitz | standard | — | no | Default ruleset for new lobbies |
| Traditional | standard | — | no | Timer disabled |
| SmallWorld | standard, smallworld | — | no | Pseudorandomly bans a chunk of the pool per seed |
| Speedlatro | standard, speedlatro_timer | Attrition | no | Per-round countdown timer |
| Chaos | standard, sandbox, smallworld, speedlatro_timer | — | no | Everything composed together |
| Sandbox | sandbox | — | no (soft defaults) | Parallel joker pool, idol selection; seeds preview/order/lives, host can override |
| Experimental | experimental, ranked, pressure_timer | Attrition | yes | Rebalance playtest — ranked-shaped + pressure-timer modifier |
| Legacy Ranked | classic, ranked | Attrition | yes | Pre-MP-joker card pool, version-gated |
| Vanilla | (none) | — | no | No bans, no reworks, no MP jokers |
| Badlatro | (none) | — | no | Heavy joker bans |
| MajorLeague | (none) | Attrition | yes | Longer timer with forgiveness |
| MinorLeague | (none) | Attrition | yes | Even longer timer with forgiveness |
When a ruleset has forced_gamemode, the "Next" button in ruleset selection becomes "Create Lobby" and directly sets MP.LOBBY.config.gamemode, skipping the gamemode selection screen. Rulesets without it show the gamemode picker.
G.FUNCS.start_lobby calls ruleset.force_lobby_options(). Returning true = fully locked (host can't change settings). Returning false = soft defaults applied, host can still override. Result is stored in MP.LOBBY.config.forced_config. multiplayer_content is also set here to gate the j_mp_* pool.
The most complex layer. MP.SANDBOX (defined in layers/sandbox.lua) manages a parallel joker pool:
joker_mappingslinks sandbox joker keys (j_mp_*_sandbox) to vanilla counterparts (ornilfor originals). Tracks active/out-of-rotation status.get_vanilla_bans()silently bans vanilla versions of active sandbox jokers.is_joker_allowed(key)gates card pools — checksis_layer_active("sandbox")internally.on_apply_banshook: idol selection (select_random_idolpseudorandomly picks one of two idol variants seeded on lobby code) + extra credit gating (bans sandbox EC jokers ifextracreditmod is loaded).- Reworked joker list is Fisher-Yates shuffled at load time for randomized UI panel order.
- Register optional art via
SMODS.Atlas, then callSMODS.Jokerwith metadata (rarity, cost, compat flags) plusconfig.extrato seed per-card state. loc_txtholds name/description templates;loc_varsreturns dynamic numbers and color tags injected into that text.- Runtime behavior via
calculate(context)— inspects context table (context.joker_main,context.individual,context.end_of_round, etc.) and returns chip/mult/xmult values or UI messages. Other hooks:add_to_deck,remove_from_deck,mp_include(pool gating). - Pool gating layers: layer-membership cards get
mp_includeauto-attached at register time (see "Wiring" above). Top-level MP jokers (not tied to a single layer) still hand-rollmp_includeto checkMP.LOBBY.code+MP.LOBBY.multiplayer_jokers. Sandbox variants gate viaMP.SANDBOX.is_joker_allowed. - Balanced sticker: Lovely patches auto-apply sticker to any card flagged as reworked for the active ruleset (or with
mp_sticker_balancedin config) duringCardinitialization. - Sandbox rotation:
joker_mappingslinks sandbox keys to vanilla ancestors, controls active status, silently bans vanilla when sandbox is live.
core.lua hard-bans incompatible mods via MP.BANNED_MODS and exposes integrations (e.g., Preview) through MP.INTEGRATIONS for opt-in/out without hard dependencies.
The compatibility/ tree contains targeted shims for popular mods (Pokermon, StrangePencil, TooManyJokers, AntePreview, etc.). Each shim can push additional bans through MP.DECK.ban_* helpers or inject UI/logic so shared content cooperates.
config.lua — User-local settings (username, server URL/port, integrations, match history). Loaded by SMODS.