Multiplayer API (mod id MultiplayerAPI, global MPAPI) is a Steamodded library mod that gives other Balatro mods online multiplayer. It handles the account and server connection, lobbies, matchmaking, actions between players, chat, replays and moderation. The mods built on it only describe their own game rules.
It is part of the Balatro Multiplayer Platform:
| Repository | Mod id | What it is |
|---|---|---|
| BalatroMultiplayerAPI | MultiplayerAPI |
This library |
| BalatroMultiplayerPvP | MultiplayerPvP |
Head-to-head PvP gamemodes |
| BalatroMultiplayerSpeedrunning | MultiplayerSPDRN |
Speedrun gamemodes |
| BalatroMultiplayerServer | n/a | The server: Nakama runtime, website and admin tools |
MPAPI on its own adds an account button and the multiplayer menu. You need at least one gamemode mod (PvP or Speedrun) to actually play.
The older MQTT-based version of the platform is on the
legacybranch. It is frozen and no longer works against the live server.
The easiest route is the Balatro Multiplayer launcher. It installs the mods for you, and it is required for ranked play. To install by hand:
- Install Lovely 0.8 or newer and Steamodded 1.0.0~BETA-1221a or newer.
- Put this mod's folder (or its release zip) in your Balatro
Modsdirectory:- Windows:
%AppData%\Balatro\Mods\ - Linux (Proton):
~/.steam/steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/ - macOS:
~/Library/Application Support/Balatro/Mods/
- Windows:
- Install the gamemode mods you want to play the same way.
- Launch Balatro through Steam. Login uses your Steam session ticket, so the multiplayer menu stays offline without the Steam client.
Keep only one copy of each mod in Mods/. Two folders with the same mod id stop either one from loading.
MPAPI connects to the server over TLS. It does this by loading OpenSSL through LuaJIT FFI (networking/openssl_ffi.lua). The Windows and macOS libraries ship in this mod's networking/ folder (see networking/NOTICE-OpenSSL.txt), so a normal install needs nothing extra. Where MPAPI looks, in order:
| Platform | Files | Where MPAPI looks |
|---|---|---|
| Windows, Linux via Proton | libssl-3-x64.dll, libcrypto-3-x64.dll (OpenSSL 1.1 names also accepted) |
This mod's networking/ folder, then the default DLL search path, the folder containing Balatro.exe, the save directory and the working directory. |
| macOS | libssl.3.dylib, libcrypto.3.dylib (universal x86_64 + arm64) |
This mod's networking/ folder, then the directory in BET_OPENSSL_DYLIB_DIR (set by the launcher). Apple's system libssl is LibreSSL and is never loaded. |
| Linux (native) | libssl.so.3 / libcrypto.so.3 (or 1.1) |
The system OpenSSL from the default library path and the standard lib dirs. Install your distribution's openssl package. |
The bundled copy is only loadable when the mod is an extracted folder. A mod left as a zip falls back to the other locations. If OpenSSL can't be found, the game still starts, but MPAPI stays offline. The Lovely log shows every path it tried, with lines prefixed [OpenSSL].
Clone the repository straight into Mods/, or symlink it there. The folder name does not matter.
git clone https://github.com/Balatro-Multiplayer/BalatroMultiplayerAPI.gitWhen dev/init.lua is present, the mod loads it. It switches the client to a local server stack (Nakama on 127.0.0.1:7350, plain HTTP) and turns on a few developer switches, all read from environment variables:
| Variable | Effect |
|---|---|
BMP_SERVER_HOST, BMP_SERVER_PORT |
Use this server instead of the local stack |
BMP_IMPERSONATE_ID / BMP_IMPERSONATE_NAME |
Log in as another account with a Nakama device login (the server must allow device auth). This lets you run several game instances on one Steam account. |
BMP_DEV_SKIP_RANKED_ANTICHEAT |
Treat the launcher's ranked supervision as present so ranked queues can be tested |
Follow BalatroMultiplayerServer's README to run the server stack locally. Players never get dev/: release builds strip it.
config.lua is the Steamodded mod config. Players can toggle chat_enabled in-game. You can also set the server endpoints there. Each one falls back to the production default when it is unset:
| Key | Default |
|---|---|
server_host |
nakama.balatromp.com |
server_port |
443 |
server_secure |
true |
nakama_server_key |
defaultkey |
web_url |
https://new.balatromp.com (match-log and report links) |
Options passed to MPAPI.connect{ host, port, secure, server_key, web_url } take precedence over the config. On Windows, write 127.0.0.1 rather than localhost. Windows tries IPv6 first, and the connection then stalls for about 20 seconds before it falls back to IPv4.
Declare the dependency in your mod's JSON:
"dependencies": ["Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", "MultiplayerAPI"]The best reference is the gamemode mods. BalatroMultiplayerSpeedrunning's core.lua shows a complete registration.
Do your registration inside MPAPI.on_loaded(fn). MPAPI runs these callbacks once Steam is available and the connection has started. While a callback runs, MPAPI sets SMODS.current_mod back to the mod that registered it. That way the action types and gamemodes you create there belong to your mod, which matters for lobby routing (see below).
Each screen is an MPAPI.Page. The page that is showing replaces the main menu. Register your mod's menu and lobby screens as pages, then register the mod with their keys:
MPAPI.on_loaded(function()
MPAPI.Page({
key = 'mymod_main_menu', -- used verbatim; no Steamodded prefix is added
category = 'mod_menu', -- 'mod_menu' | 'lobby_menu' | 'game_select' | ...
build = function(self) return MyMod.build_menu() end,
})
MPAPI.Page({ key = 'mymod_lobby', category = 'lobby_menu', build = function(self) return MyMod.build_lobby() end })
MPAPI.register_mod({
id = 'MyMod',
name = 'My Mod',
colour = G.C.BLUE,
main_menu_ui = 'mymod_main_menu', -- page key (required)
lobby_ui = 'mymod_lobby', -- page key
prevent_pause = true, -- optional
options_builder = MyMod.options, -- optional run-options UI
})
end)Use MPAPI.pages.show(key, params) to switch pages. Pages can also define on_enter, cleanup (an exit animation) and dispose hooks; see api/page/.
Hooks into the base game should check MPAPI.is_active('MyMod') (or MPAPI.get_active_mod()). Otherwise your hook also runs during other mods' games.
MPAPI.GameMode({
key = 'mymod_duel',
min_players = 2, -- or { public = 2, private = 2, ranked = 2 }
max_players = 2,
has_ranked_mode = false,
start_run = function(self, ...) --[[ start the Balatro run ]] end,
init = function(self) --[[ per-game instance state ]] end,
})MPAPI.GameModes[key] holds the definitions. lobby:get_gamemode_instance() returns the instance for the current game. Optional flags: seed_change_allowed, draw_vote_allowed, has_duration_cap.
local lobby = MPAPI.create_lobby('MyMod', { max_players = 4 }) -- nil when offline
local lobby = MPAPI.join_lobby('MyMod', 'GWLPR')
local lobby = MPAPI.create_local_lobby('MyMod') -- in-process only, for solo/practice
MPAPI.get_current_lobby()
lobby:on('connected', function() ... end) -- event names are in MPAPI.LobbyEvent
lobby.code, lobby.is_host, lobby.player_id
lobby:get_players() -- { { id, displayName, is_away, ... }, ... }
lobby:set_metadata({ gamemode = 'mymod_duel' }) -- host only; the server merges and broadcasts it
lobby:get_metadata()
lobby:set_player_state(tbl) -- your own state; the server keeps it for reconnects
lobby:leave()Every online lobby is a server-authoritative Nakama match. The server records who sent each message, enforces that only the host can set metadata, and holds a player's seat for a grace period (120 seconds by default) when they drop. A player who reconnects in time is put back into the same lobby.
The server also referees the ready/start/result flow for every gamemode:
lobby:set_ready(true)
lobby:start_game({ stake = 1, options = {...} }) -- host; fires 'game_started' { seed, stake, options, players }
lobby:report_result({ ...placements... }) -- every player; the server fires 'game_ended'
lobby:abandon_game() lobby:restart_game()Useful events: player_joined, player_left, player_disconnected, player_reconnected, host_changed, metadata_changed, player_ready_changed, game_started, game_ended, server_restarting, lobby_closed, error, disconnected.
Actions are how your mod sends game events between players. Define a type once:
MyMod.actions.swap_joker = MPAPI.ActionType({
key = 'mymod_swap_joker',
parameters = { { key = 'joker', type = 'string', required = true } }, -- types: MPAPI.ActionParamType
on_receive = function(self, from, params)
-- return a table to reply, or true to just acknowledge
return { joker = 'j_blueprint' }
end,
on_response = function(self, response, err) end, -- default reply handler
})Then send instances of it through the lobby:
lobby:action(MyMod.actions.swap_joker):send(player_id, { joker = 'j_joker' })
lobby:action(MyMod.actions.swap_joker):callback(function(self, response, err) ... end):send(player_id, params)
lobby:action(MyMod.actions.swap_joker):broadcast({ joker = 'j_joker' })Parameters are checked against the schema before anything is sent. A lobby only carries its own mod's action types plus MPAPI's built-in ones. If you send another mod's action into it, the action is dropped with a warning. When the server refuses an action, the callback receives err.kind == MPAPI.ErrorKind.ACTION_REJECTED.
local handle = MPAPI.matchmaking.queue({ mod_id = 'MyMod', game_mode = 'mymod_duel', ranked = false })
handle:on('lobby_ready', function(lobby) ... end) -- events: MPAPI.MatchmakingEvent
handle:on('error', function(err) ... end)To queue for ranked, pass ranked = true explicitly. MPAPI does not work out ranked from the gamemode name. Every queue checks the account's standing (which mods are installed), and a ranked queue also needs a launcher-supervised session. If a check fails, the handle fires error and the err.kind says why. MPAPI.matchmaking also provides get_rating, get_leaderboard, get_queue_counts and group queues.
| API | Purpose |
|---|---|
MPAPI.Layer, MPAPI.Ruleset |
Bundles of reworked or banned content, and pool gating for multiplayer runs |
MPAPI.Blind / MPAPI.Joker / MPAPI.Consumable |
Synced Steamodded objects that can message their copies on other clients |
MPAPI.BanPick, MPAPI.ReadyTracker, MPAPI.VoteTracker |
Host-authoritative draft and voting helpers |
MPAPI.replay |
Reading stored match logs |
MPAPI.register_card_info_provider, MPAPI.on_winner_declared, MPAPI.register_lobby_reconnect |
Extension points for lobby UI, results and rejoin |
MPAPI.Result, MPAPI.make_error, MPAPI.ErrorKind |
Errors as values: callbacks receive { kind, message } |
Each one has its documentation in the header comment of the file that defines it, under api/.
| Path | Contents |
|---|---|
core.lua |
Entry point: load order, the per-frame update, and startup |
domain/ |
Enums and pure value types (MPAPI.LobbyEvent, MPAPI.ErrorKind, MPAPI.Result, ...) |
networking/ |
Steam ticket, OpenSSL FFI and the bundled OpenSSL libraries, Nakama transport (nakama_lobby, nakama_notify) and the I/O worker threads |
lib/nakama/ |
Vendored nakama-defold SDK, plus engine/love.lua, a LÖVE engine adapter |
lib/ |
Shared helpers, the thread preloader, and the vendored DebugPlus console (MPL-2.0) |
api/ |
The public API, one folder per concept (lobby/, action/, matchmaking/, gamemode/, page/, layers/, ruleset/, replay/, ...) |
ui/ |
Built-in screens and overlays (lobby, account, moderation notices, ...) |
anticheat/ |
Ranked-mode supervision channel to the launcher; inactive unless the launcher started a ranked session |
lovely/ |
Lovely patches to the base game (The Order, pool gating, HUD, ban timing, crash guards) |
localization/ |
UI strings |
compatibility/ |
Integrations with other mods (DebugPlus) |
dev/ |
Developer-only overrides; stripped from release builds |
GPL-3.0; see LICENSE.md. Vendored code keeps its own license: lib/nakama/ is Apache-2.0 and lib/debugplus/ is MPL-2.0.