Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e1abcac
0.4.1 (#465)
Fantom-Balatro Jun 9, 2026
719cfed
remove ~DEV for release
Fantom-Balatro Jun 9, 2026
42a1c18
Revert ruleset changes (0.4.2) (#466)
Fantom-Balatro Jun 9, 2026
611c5a7
Casjb/full replay (#473)
Casjb Jun 26, 2026
be18f2a
0.5.0 Merge (#476)
V-rtualized Jul 1, 2026
d563f19
Add PvP validation to ante timer actions (#477)
V-rtualized Jul 1, 2026
fbd46a2
Update version from 0.5.0 to 0.5.1
V-rtualized Jul 1, 2026
1183a68
Pause timer when opponent disconnects (#478)
V-rtualized Jul 1, 2026
182ba5d
Fix PvP timer toggle when it shouldn't
SleepyG11 Jul 1, 2026
74bba66
Merge branch 'Balatro-Multiplayer:main' into main
SleepyG11 Jul 1, 2026
950b8b5
just in case
SleepyG11 Jul 1, 2026
7777702
Merge branch 'main' of https://github.com/SleepyG11/BalatroMultiplaye…
SleepyG11 Jul 1, 2026
77b5615
PvP + hidden score fixes (#479)
SleepyG11 Jul 1, 2026
b1e7372
Merge branch 'main' of https://github.com/Balatro-Multiplayer/Balatro…
SleepyG11 Jul 5, 2026
70ed3b9
Return blind select sound
SleepyG11 Jul 5, 2026
9b9b9a9
More strict timer checks
SleepyG11 Jul 5, 2026
091499f
Get Logs and Open Parser buttons in game over screen
SleepyG11 Jul 5, 2026
f58c903
Add Log Parser and Open Logs in mod config
SleepyG11 Jul 5, 2026
1e70448
Add "mods' and "collection' button to lobby
SleepyG11 Jul 5, 2026
0929296
always display remaining hands
SleepyG11 Jul 9, 2026
04342d9
server-side pvp timer
SleepyG11 Jul 9, 2026
7da6baa
automatic pvp timer
SleepyG11 Jul 9, 2026
391bdff
PvP timer 60+10
SleepyG11 Jul 9, 2026
1957fed
Prevent asteroid go into negative levels
SleepyG11 Jul 9, 2026
18b0f1d
Merge branch 'dev' of https://github.com/Balatro-Multiplayer/BalatroM…
SleepyG11 Jul 9, 2026
eadd77e
update version, I guess
SleepyG11 Jul 9, 2026
14ea741
prevent timer during game over/win
SleepyG11 Jul 9, 2026
813604f
use real timer for updating opponent's score
SleepyG11 Jul 13, 2026
4e2875c
Merge branch 'dev' of https://github.com/Balatro-Multiplayer/BalatroM…
SleepyG11 Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Multiplayer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"priority": 10000000,
"badge_colour": "AC3232",
"badge_text_colour": "FFFFFF",
"version": "0.5.0~DEV",
"version": "0.5.2~DEV",
"dependencies": [
"Steamodded (>=1.0.0~BETA-1620a)",
"Lovely (>=0.9)",
Expand Down
6 changes: 6 additions & 0 deletions core.lua
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ function MP.reset_game_states()
end_pvp = false,
enemy = {
score = MP.INSANE_INT.empty(),
real_score = MP.INSANE_INT.empty(),
score_text = "0",
hands = 4,
hands_text = "4",
Expand Down Expand Up @@ -244,8 +245,11 @@ function MP.reset_game_states()
highest_score = MP.INSANE_INT.empty(),
timer = MP.UTILS.timer_base(),
timer_started = false,
nemesis_timer_started = false,
nemesis_timer_was_started = false,
timer_consumed = false,
pvp_reached = false,
pvp_reached_first = false,
pvp_countdown = 0,
real_money = 0,
ce_cache = false,
Expand All @@ -262,6 +266,8 @@ function MP.reset_game_states()
reroll_cost_total = 0,
-- Add more stats here in the future
},
pvp_timer_order = nil,
pvp_timer_activated = false,
}
end
MP.reset_game_states()
Expand Down
4 changes: 2 additions & 2 deletions layers/pvp_timer.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
MP.Layer("pvp_timer", {
pvp_timer_base_seconds = 90,
pvp_timer_hand_played_increment_seconds = 15,
pvp_timer_base_seconds = 60,
pvp_timer_hand_played_increment_seconds = 10,
})
2 changes: 2 additions & 0 deletions lib/ghost_replay.lua
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function MP.GHOST.init_playback(ante)
MP.GHOST._hand_idx = 1
local score = MP.INSANE_INT.from_string(hands[1].score)
MP.GAME.enemy.score = score
MP.GAME.enemy.real_score = score
MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(score)
MP.GAME.enemy.hands = hands[1].hands_left or 0
MP.GAME.enemy.info_received = true
Expand Down Expand Up @@ -97,6 +98,7 @@ function MP.GHOST.advance_hand()
func = function(t) return math.floor(t) end,
}))

MP.GAME.enemy.real_score = score
MP.GAME.enemy.hands = entry.hands_left or 0
MP.GAME.enemy.info_received = true
if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end
Expand Down
7 changes: 7 additions & 0 deletions localization/en-us.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,11 @@ return {
k_ghost = "Ghost",
k_hide_mp_content = "Hide Multiplayer content*",
k_applies_singleplayer_vanilla_rulesets = "*Applies in singleplayer and vanilla rulesets",
k_automatic_pvp_timer = "Automatic PvP timer",
k_automatic_pvp_timer_description = {
"Reactivate PvP timer when became available",
"(Must be activated manually first)"
},
k_timer_sfx = "Timer Sound Effects",
ml_mp_kofi_message = {
"This mod and game server is",
Expand Down Expand Up @@ -1447,6 +1452,8 @@ return {
loc_selecting = "Selecting",
loc_shop = "Shopping before",
loc_playing = "Playing",
b_get_log_file = "Get Logs",
b_open_log_parser = "Log Parser",
},
v_dictionary = {
a_mp_art = {
Expand Down
10 changes: 10 additions & 0 deletions lovely/pause.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ payload = '''
local unstuck_button = nil
local return_to_lobby = nil
local leave_lobby = nil

if MP.LOBBY.code and G.STATE == G.STATES.MENU then
your_collection = UIBox_button({
label = { localize("b_collection") },
button = "your_collection",
minw = 5,
id = "your_collection",
})
mods = UIBox_button{ id = "mods_button", label = {localize('b_mods')}, button = "mods_button", minw = 5}
end
'''
match_indent = true
times = 1
Expand Down
187 changes: 111 additions & 76 deletions networking/action_handlers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ local function action_rejoinedLobby(p)
reconnectToken = token
lastLobbyCode = code
MP.self_reconnect_countdown = nil
MP.GAME.timer_started = false
MP.GAME.nemesis_timer_started = false
MP.ACTIONS.sync_client()
MP.ACTIONS.lobby_info()
MP.UI.update_connection_status()
Expand Down Expand Up @@ -152,6 +154,9 @@ local function action_enemyDisconnected(p)
local timeout = p.timeout or 60
sendWarnMessage("Opponent disconnected, waiting for reconnection...", "MULTIPLAYER")

MP.GAME.timer_started = false
MP.GAME.nemesis_timer_started = false

MP.enemy_disconnect_countdown = {
end_time = love.timer.getTime() + timeout,
display = timeout .. "s remaining",
Expand Down Expand Up @@ -249,6 +254,8 @@ local function action_reconnecting()
-- Only show if we were in a lobby and don't already have a countdown running
if reconnectToken and lastLobbyCode and not MP.self_reconnect_countdown then
MP.LOBBY.connected = false
MP.GAME.timer_started = false
MP.GAME.nemesis_timer_started = false
MP.UI.update_connection_status()
sendWarnMessage("Connection lost, attempting to reconnect...", "MULTIPLAYER")

Expand Down Expand Up @@ -312,6 +319,11 @@ end
local function begin_pvp_blind()
if MP.GAME.next_blind_context then
G.FUNCS.select_blind(MP.GAME.next_blind_context)
MP.GAME.timer_started = false
MP.GAME.nemesis_timer_started = false
MP.GAME.nemesis_timer_was_started = false
MP.GAME.timer_consumed = false
MP.GAME.timer = MP.UTILS.pvp_timer_base()
else
sendErrorMessage("No next blind context", "MULTIPLAYER")
end
Expand All @@ -322,22 +334,20 @@ local function action_start_blind(p)
-- Reset the stored opponent score each blind so the first frame after we
-- play (which lifts the "???" mask) shows 0, not last blind's stale score.
MP.GAME.enemy.score = MP.INSANE_INT.empty()
MP.GAME.enemy.real_score = MP.INSANE_INT.empty()
MP.GAME.enemy.score_text = "0"
-- Re-mask the opponent's hands until the first enemyInfo of the new blind.
MP.GAME.enemy.info_received = false
MP.GAME.ready_blind = false
MP.GAME.pvp_reached = false
MP.GAME.timer_started = false
MP.GAME.nemesis_timer_started = false
MP.GAME.timer_consumed = false
MP.GAME.timer = MP.UTILS.pvp_timer_base()
MP.GAME.pvp_timer_order = nil
MP.GAME.pvp_timer_activated = false
MP.GAME.pvp_reached_first = (MP.LOBBY.is_host and "host" or "guest") == first_player
MP.UI.start_pvp_countdown(begin_pvp_blind)
end

local function action_enemy_info(p)
local score = MP.INSANE_INT.from_string(p.score)

local score
local hands_left = tonumber(p.handsLeft)
local skips = tonumber(p.skips)
local lives = tonumber(p.lives)
Expand All @@ -360,64 +370,78 @@ local function action_enemy_info(p)
end
end

if score == nil or hands_left == nil then
sendDebugMessage("Invalid score or hands_left", "MULTIPLAYER")
return
end
if not p.noScore then
score = MP.INSANE_INT.from_string(p.score)
end

if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then MP.GAME.enemy.highest_score = score end
if score then
if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then MP.GAME.enemy.highest_score = score end

G.E_MANAGER:add_event(Event({
blockable = false,
blocking = false,
trigger = "ease",
delay = 0.75,
timer = "REAL",
ref_table = MP.GAME.enemy.score,
ref_value = "e_count",
ease_to = score.e_count,
func = function(t)
return math.floor(t)
end,
}))

G.E_MANAGER:add_event(Event({
blockable = false,
blocking = false,
trigger = "ease",
delay = 0.75,
timer = "REAL",
ref_table = MP.GAME.enemy.score,
ref_value = "coeffiocient", -- why is this misspelled
ease_to = score.coeffiocient,
func = function(t)
local mult = 1
if score.exponent > 0 then mult = 100 end
return math.floor(t * mult) / mult
end,
}))

G.E_MANAGER:add_event(Event({
blockable = false,
blocking = false,
trigger = "ease",
delay = 0.75,
timer = "REAL",
ref_table = MP.GAME.enemy.score,
ref_value = "exponent",
ease_to = score.exponent,
func = function(t)
return math.floor(t)
end,
}))

MP.GAME.enemy.real_score = score
MP.GAME.enemy.info_received = true
end

-- PvP timer: stop timer according to score
if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then
if MP.INSANE_INT.greater_than(MP.GAME.score, score) then
MP.GAME.nemesis_timer_started = false
elseif MP.INSANE_INT.equal(MP.GAME.score, score) and MP.GAME.pvp_reached_first then
-- PvP timer: server determines who and when can activate pvp timer
if p.pvpTimerOrder ~= nil and MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then
MP.GAME.pvp_timer_order = p.pvpTimerOrder
if (MP.LOBBY.is_host and "host" or "guest") == MP.GAME.pvp_timer_order then
MP.GAME.nemesis_timer_started = false
if
not MP.GAME.timer_started
and MP.UI.can_timer_opponent()
and MP.GAME.pvp_timer_activated
and SMODS.Mods["Multiplayer"].config.automatic_pvp_timer
then
MP.ACTIONS.start_ante_timer()
end
else
MP.GAME.timer_started = false
end
end

G.E_MANAGER:add_event(Event({
blockable = false,
blocking = false,
trigger = "ease",
delay = 3,
ref_table = MP.GAME.enemy.score,
ref_value = "e_count",
ease_to = score.e_count,
func = function(t)
return math.floor(t)
end,
}))

G.E_MANAGER:add_event(Event({
blockable = false,
blocking = false,
trigger = "ease",
delay = 3,
ref_table = MP.GAME.enemy.score,
ref_value = "coeffiocient", -- why is this misspelled
ease_to = score.coeffiocient,
func = function(t)
local mult = 1
if score.exponent > 0 then mult = 100 end
return math.floor(t * mult) / mult
end,
}))

G.E_MANAGER:add_event(Event({
blockable = false,
blocking = false,
trigger = "ease",
delay = 3,
ref_table = MP.GAME.enemy.score,
ref_value = "exponent",
ease_to = score.exponent,
func = function(t)
return math.floor(t)
end,
}))
MP.GAME.timer_started = false
end
end

if MP.GAME.enemy.lives > lives then
play_sound("holo1", 0.865, 0.9)
Expand All @@ -428,11 +452,9 @@ local function action_enemy_info(p)
play_sound("gong", 0.765, 0.4)
end

MP.GAME.enemy.hands = hands_left
MP.GAME.enemy.skips = skips
MP.GAME.enemy.lives = lives
-- We've now heard from the opponent this blind: unmask their hands count.
MP.GAME.enemy.info_received = true
MP.GAME.enemy.hands = hands_left or 0
MP.GAME.enemy.skips = skips or 0
MP.GAME.enemy.lives = lives or 0
if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end
end

Expand Down Expand Up @@ -460,10 +482,29 @@ local function action_end_pvp(p)
MP.GAME.timer_consumed = false
MP.GAME.timer_started = false
MP.GAME.nemesis_timer_started = false
MP.GAME.nemesis_timer_was_started = false
MP.GAME.ready_blind = false
MP.GAME.pvp_reached = false
MP.GAME.pvp_reached_first = false
MP.GAME.pvp_timer_activated = false
MP.GAME.score = nil

-- Sanity check
G.E_MANAGER:add_event(Event({
func = function()
G.E_MANAGER:add_event(Event({
func = function()
MP.GAME.timer = MP.UTILS.timer_base()
MP.GAME.timer_started = false
MP.GAME.nemesis_timer_started = false
MP.GAME.nemesis_timer_was_started = false
MP.GAME.pvp_timer_activated = false
return true
end,
}))
return true
end,
}))
end

local function action_player_info(p)
Expand Down Expand Up @@ -970,6 +1011,8 @@ end
-- Dual-call: dispatched from network (fromNemesis defaults to true) or self-triggered
-- by MP.ACTIONS.start_ante_timer (passes fromNemesis = false explicitly).
local function action_start_ante_timer(p)
if p.isPvP and (MP.GAME.end_pvp or not MP.is_pvp_boss() or G.GAME.current_round.hands_left <= 0) then return end

local time = p.time
local from_nemesis = p.fromNemesis
if from_nemesis == nil then from_nemesis = true end
Expand Down Expand Up @@ -1001,6 +1044,7 @@ local function action_start_ante_timer(p)
end
if from_nemesis then
MP.GAME.nemesis_timer_started = true
MP.GAME.nemesis_timer_was_started = true
else
MP.GAME.timer_started = true
end
Expand Down Expand Up @@ -1160,17 +1204,6 @@ function MP.ACTIONS.play_hand(score, hands_left)
MP.GAME.highest_score = insane_int_score
end

-- Stop PvP timers according to score
if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then
if MP.INSANE_INT.greater_than(insane_int_score, MP.GAME.enemy.score) then
MP.GAME.nemesis_timer_started = false
elseif MP.INSANE_INT.equal(insane_int_score, MP.GAME.enemy.score) and MP.GAME.pvp_reached_first then
MP.GAME.nemesis_timer_started = false
else
MP.GAME.timer_started = false
end
end

Client.send({
action = "playHand",
score = fixed_score,
Expand Down Expand Up @@ -1303,9 +1336,11 @@ function MP.ACTIONS.request_nemesis_stats()
end

function MP.ACTIONS.start_ante_timer()
local is_pvp = MP.is_pvp_boss() and MP.is_layer_active("pvp_timer")
Client.send({
action = "startAnteTimer",
time = MP.GAME.timer,
isPvP = is_pvp or nil,
})
action_start_ante_timer({ time = MP.GAME.timer, fromNemesis = false })
end
Expand Down
Loading