Skip to content

Instantly share code, notes, and snippets.

@adilw3nomad
Last active February 22, 2026 05:51
Show Gist options
  • Select an option

  • Save adilw3nomad/d1a6925cefbf5871be6ff4e61978338a to your computer and use it in GitHub Desktop.

Select an option

Save adilw3nomad/d1a6925cefbf5871be6ff4e61978338a to your computer and use it in GitHub Desktop.
balatro multiplayer stats script
#!/usr/bin/env python3
"""Parse Balatro multiplayer logs to analyze joker win rates, grouped by stake.
Outputs:
- joker_stats.csv: Win/loss per joker per stake
- joker_winrates.csv: Overall win rate per joker (sorted best to worst)
- idol_analysis.csv: Idol-specific analysis (with vs without)
- joker_winrates.png: Stacked bar chart with stake breakdown
"""
import csv
import glob
import re
import sys
from collections import defaultdict
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import matplotlib.patches as mpatches
import numpy as np
STAKE_NAMES = {
1: "White",
2: "Red",
3: "Green",
4: "Black",
5: "Blue",
6: "Purple",
7: "Orange",
8: "Gold",
}
STAKE_COLORS = {
1: "#c8ccd0", # White
2: "#e74c3c", # Red
3: "#2ecc71", # Green
4: "#6c7a89", # Black (lightened so it's visible on dark bg)
5: "#3498db", # Blue
6: "#9b59b6", # Purple
7: "#e67e22", # Orange
8: "#f1c40f", # Gold
}
LOG_DIR = "log"
MIN_GAMES = 3
JOKER_DISPLAY_OVERRIDES = {
"j_mp_hanging_chad": "Hanging Chad (MP)",
"j_mp_bloodstone": "Bloodstone (MP)",
"j_mp_penny_pincher": "Penny Pincher (MP)",
"j_mp_lets_go_gambling": "Let's Go Gambling (MP)",
"j_mp_pacifist": "Pacifist (MP)",
"j_mp_pizza": "Pizza (MP)",
}
def joker_display_name(joker_key):
if joker_key in JOKER_DISPLAY_OVERRIDES:
return JOKER_DISPLAY_OVERRIDES[joker_key]
name = joker_key
if name.startswith("j_"):
name = name[2:]
if name.startswith("mp_"):
name = name[3:] + " (MP)"
return name.replace("_", " ").title()
def parse_end_game_jokers(line):
match = re.search(r"Sending end game jokers:\s*(.*)", line)
if not match:
return set()
raw = match.group(1).strip()
jokers = set()
for entry in raw.split(";"):
entry = entry.strip()
if not entry:
continue
parts = entry.rsplit("-", 3)
jokers.add(parts[0] if len(parts) >= 4 else entry)
return jokers
def parse_logs():
games = []
for filepath in sorted(glob.glob(f"{LOG_DIR}/*.log")):
current_stake = None
in_game = False
game_outcome = None
awaiting_jokers = False
with open(filepath, "r", errors="replace") as f:
for line in f:
stake_match = re.search(
r'"stake"\s*:\s*(\d+).*"action"\s*:\s*"lobbyOptions"', line
)
if not stake_match:
stake_match = re.search(
r'"action"\s*:\s*"lobbyOptions".*"stake"\s*:\s*(\d+)', line
)
if stake_match:
current_stake = int(stake_match.group(1))
if "startGame" in line and '"action"' in line:
if re.search(r'"action"\s*:\s*"startGame"', line):
in_game = True
game_outcome = None
awaiting_jokers = False
if in_game and ("winGame" in line or "loseGame" in line):
if "winGame" in line:
game_outcome = "win"
elif "loseGame" in line:
game_outcome = "loss"
awaiting_jokers = True
if awaiting_jokers and "Sending end game jokers:" in line:
jokers = parse_end_game_jokers(line)
if current_stake is not None and game_outcome is not None:
games.append({
"stake": current_stake,
"won": game_outcome == "win",
"jokers": jokers,
})
in_game = False
game_outcome = None
awaiting_jokers = False
return games
# ── CSV outputs ──────────────────────────────────────────────────────────────
def write_joker_stats_by_stake(games):
stats = defaultdict(lambda: defaultdict(lambda: {"wins": 0, "losses": 0}))
for game in games:
for joker in game["jokers"]:
s = stats[joker][game["stake"]]
if game["won"]:
s["wins"] += 1
else:
s["losses"] += 1
all_stakes = sorted({g["stake"] for g in games})
with open("joker_stats.csv", "w", newline="") as f:
writer = csv.writer(f)
header = ["joker_key", "joker_name"]
for stake in all_stakes:
sname = STAKE_NAMES.get(stake, f"Stake {stake}")
header.extend([f"{sname} W", f"{sname} L", f"{sname} WR%"])
header.extend(["total_wins", "total_losses", "total_games", "overall_wr%"])
writer.writerow(header)
for joker_key in sorted(stats.keys()):
row = [joker_key, joker_display_name(joker_key)]
total_w, total_l = 0, 0
for stake in all_stakes:
s = stats[joker_key][stake]
w, l = s["wins"], s["losses"]
total_w += w
total_l += l
total = w + l
wr = f"{w / total * 100:.1f}" if total > 0 else ""
row.extend([w, l, wr])
total = total_w + total_l
wr = f"{total_w / total * 100:.1f}" if total > 0 else ""
row.extend([total_w, total_l, total, wr])
writer.writerow(row)
print(" joker_stats.csv")
def write_joker_winrates(games):
stats = defaultdict(lambda: {"wins": 0, "losses": 0})
for game in games:
for joker in game["jokers"]:
if game["won"]:
stats[joker]["wins"] += 1
else:
stats[joker]["losses"] += 1
ranked = []
for joker_key, s in stats.items():
total = s["wins"] + s["losses"]
wr = s["wins"] / total if total > 0 else 0
ranked.append((joker_key, s["wins"], s["losses"], total, wr))
ranked.sort(key=lambda x: (-x[4], -x[3]))
with open("joker_winrates.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["rank", "joker_key", "joker_name", "wins", "losses", "total", "winrate%"])
for i, (jk, w, l, t, wr) in enumerate(ranked, 1):
writer.writerow([i, jk, joker_display_name(jk), w, l, t, f"{wr * 100:.1f}"])
print(" joker_winrates.csv")
qualified = [r for r in ranked if r[3] >= MIN_GAMES]
print(f"\n TOP 15 JOKERS (min {MIN_GAMES} games):")
print(f" {'#':<4} {'Joker':<28} {'W':>4} {'L':>4} {'Tot':>4} {'WR%':>7}")
print(f" {'-'*55}")
for i, (jk, w, l, t, wr) in enumerate(qualified[:15], 1):
print(f" {i:<4} {joker_display_name(jk):<28} {w:>4} {l:>4} {t:>4} {wr*100:>6.1f}%")
print()
print(f" BOTTOM 15 JOKERS (min {MIN_GAMES} games):")
print(f" {'#':<4} {'Joker':<28} {'W':>4} {'L':>4} {'Tot':>4} {'WR%':>7}")
print(f" {'-'*55}")
bottom = qualified[-15:]
for i, (jk, w, l, t, wr) in enumerate(bottom, len(qualified) - len(bottom) + 1):
print(f" {i:<4} {joker_display_name(jk):<28} {w:>4} {l:>4} {t:>4} {wr*100:>6.1f}%")
def write_idol_analysis(games):
with_idol = {"wins": 0, "losses": 0}
without_idol = {"wins": 0, "losses": 0}
by_stake_with = defaultdict(lambda: {"wins": 0, "losses": 0})
by_stake_without = defaultdict(lambda: {"wins": 0, "losses": 0})
for game in games:
has_idol = "j_idol" in game["jokers"]
bucket = with_idol if has_idol else without_idol
stake_bucket = (by_stake_with if has_idol else by_stake_without)[game["stake"]]
if game["won"]:
bucket["wins"] += 1
stake_bucket["wins"] += 1
else:
bucket["losses"] += 1
stake_bucket["losses"] += 1
all_stakes = sorted({g["stake"] for g in games})
with open("idol_analysis.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"stake", "stake_name",
"with_idol_wins", "with_idol_losses", "with_idol_total", "with_idol_wr%",
"without_idol_wins", "without_idol_losses", "without_idol_total", "without_idol_wr%",
"wr_diff%",
])
for stake in all_stakes:
wi, wo = by_stake_with[stake], by_stake_without[stake]
wi_t, wo_t = wi["wins"] + wi["losses"], wo["wins"] + wo["losses"]
wi_wr = wi["wins"] / wi_t * 100 if wi_t else 0
wo_wr = wo["wins"] / wo_t * 100 if wo_t else 0
diff = wi_wr - wo_wr
writer.writerow([
stake, STAKE_NAMES.get(stake, f"Stake {stake}"),
wi["wins"], wi["losses"], wi_t, f"{wi_wr:.1f}" if wi_t else "",
wo["wins"], wo["losses"], wo_t, f"{wo_wr:.1f}" if wo_t else "",
f"{diff:+.1f}" if wi_t and wo_t else "",
])
wi_t = with_idol["wins"] + with_idol["losses"]
wo_t = without_idol["wins"] + without_idol["losses"]
wi_wr = with_idol["wins"] / wi_t * 100 if wi_t else 0
wo_wr = without_idol["wins"] / wo_t * 100 if wo_t else 0
diff = wi_wr - wo_wr
writer.writerow([
"", "TOTAL",
with_idol["wins"], with_idol["losses"], wi_t, f"{wi_wr:.1f}",
without_idol["wins"], without_idol["losses"], wo_t, f"{wo_wr:.1f}",
f"{diff:+.1f}",
])
print(" idol_analysis.csv")
print(f"\n {'':>16} {'WITH IDOL':>24} {'WITHOUT IDOL':>24} {'DIFF':>7}")
print(f" {'Stake':<16} {'W':>4} {'L':>4} {'Tot':>4} {'WR%':>7} {'W':>4} {'L':>4} {'Tot':>4} {'WR%':>7} {'':>7}")
print(f" {'-'*80}")
for stake in all_stakes:
wi, wo = by_stake_with[stake], by_stake_without[stake]
wi_t, wo_t = wi["wins"] + wi["losses"], wo["wins"] + wo["losses"]
wi_wr = wi["wins"] / wi_t * 100 if wi_t else 0
wo_wr = wo["wins"] / wo_t * 100 if wo_t else 0
d = wi_wr - wo_wr
sn = STAKE_NAMES.get(stake, f"Stake {stake}")
print(f" {sn:<16} {wi['wins']:>4} {wi['losses']:>4} {wi_t:>4} {f'{wi_wr:.1f}%' if wi_t else 'n/a':>7} {wo['wins']:>4} {wo['losses']:>4} {wo_t:>4} {f'{wo_wr:.1f}%' if wo_t else 'n/a':>7} {f'{d:+.1f}%' if wi_t and wo_t else 'n/a':>7}")
print(f" {'-'*80}")
print(f" {'TOTAL':<16} {with_idol['wins']:>4} {with_idol['losses']:>4} {wi_t:>4} {wi_wr:.1f}%{'':>3} {without_idol['wins']:>4} {without_idol['losses']:>4} {wo_t:>4} {wo_wr:.1f}%{'':>3} {diff:+.1f}%")
if diff > 0:
print(f"\n Idol HELPS: +{diff:.1f}% win rate when you have it")
elif diff < 0:
print(f"\n Idol HURTS: {diff:.1f}% win rate when you have it")
else:
print(f"\n Idol has NO measurable impact on win rate")
# ── Chart output ─────────────────────────────────────────────────────────────
def generate_chart(games):
"""Generate a horizontal stacked bar chart with stake segments."""
# Build per-joker per-stake win rate data
# {joker_key: {stake: {wins, losses}}}
per_stake = defaultdict(lambda: defaultdict(lambda: {"wins": 0, "losses": 0}))
totals = defaultdict(lambda: {"wins": 0, "losses": 0})
for game in games:
for joker in game["jokers"]:
per_stake[joker][game["stake"]]["wins" if game["won"] else "losses"] += 1
totals[joker]["wins" if game["won"] else "losses"] += 1
# Filter and sort
jokers = []
for jk, s in totals.items():
t = s["wins"] + s["losses"]
if t >= MIN_GAMES:
jokers.append({"key": jk, "name": joker_display_name(jk), "wins": s["wins"], "losses": s["losses"], "total": t, "wr": s["wins"] / t * 100})
jokers.sort(key=lambda x: x["wr"]) # worst at bottom, best at top
all_stakes = sorted({g["stake"] for g in games})
names = [j["name"] for j in jokers]
n = len(names)
# For each joker, compute segment widths: each stake's contribution scaled to overall WR
# The full bar width = overall WR%. Each stake segment width = (games at stake / total games) * WR%
# This way the bar totals to the overall WR, and segments show stake proportions.
# Separately, color each segment by its own stake WR (bright = high WR, dim = low WR at that stake).
fig_height = max(8, n * 0.46)
fig, ax = plt.subplots(figsize=(13, fig_height))
bg = "#2b2d31"
fig.patch.set_facecolor(bg)
ax.set_facecolor(bg)
y_pos = np.arange(n)
for i, j in enumerate(jokers):
jk = j["key"]
total = j["total"]
overall_wr = j["wr"]
left = 0.0
for stake in all_stakes:
s = per_stake[jk][stake]
games_at_stake = s["wins"] + s["losses"]
if games_at_stake == 0:
continue
# Segment width proportional to how many games at this stake
seg_width = (games_at_stake / total) * overall_wr
ax.barh(
i, seg_width, left=left, height=0.7,
color=STAKE_COLORS[stake], edgecolor="#1e1f22", linewidth=0.5,
)
left += seg_width
# WR% label
if overall_wr > 15:
ax.text(
overall_wr - 1, i, f"{overall_wr:.0f}%",
ha="right", va="center", fontsize=8.5, fontweight="bold",
color="white", fontfamily="monospace",
)
else:
ax.text(
overall_wr + 1, i, f"{overall_wr:.0f}%",
ha="left", va="center", fontsize=8.5, fontweight="bold",
color="white", fontfamily="monospace",
)
# W-L (total) to the right
ax.text(
102, i, f"{j['wins']}W {j['losses']}L ({total})",
ha="left", va="center", fontsize=7.5,
color="#b5bac1", fontfamily="monospace",
)
# Y axis
ax.set_yticks(y_pos)
ax.set_yticklabels(names, fontsize=8.5, color="#f2f3f5", fontfamily="monospace")
# X axis
ax.set_xlim(0, 130)
ax.set_xticks([0, 25, 50, 75, 100])
ax.set_xticklabels(["0%", "25%", "50%", "75%", "100%"], color="#b5bac1", fontsize=9)
ax.axvline(x=50, color="#5865f2", linewidth=1, linestyle="--", alpha=0.4)
# Grid
ax.xaxis.grid(True, color="#40444b", linewidth=0.5, alpha=0.5)
ax.set_axisbelow(True)
for spine in ax.spines.values():
spine.set_visible(False)
ax.tick_params(axis="y", length=0, pad=8)
ax.tick_params(axis="x", length=0, colors="#b5bac1")
# Legend for stakes
legend_patches = []
for stake in all_stakes:
sname = STAKE_NAMES.get(stake, f"S{stake}")
legend_patches.append(mpatches.Patch(color=STAKE_COLORS[stake], label=sname))
legend = ax.legend(
handles=legend_patches, loc="lower right",
fontsize=8, frameon=True, fancybox=True,
facecolor="#383a40", edgecolor="#40444b",
labelcolor="#f2f3f5", title="Stake", title_fontsize=9,
)
legend.get_title().set_color("#f2f3f5")
# Title
total_games = len(games)
total_wins = sum(1 for g in games if g["won"])
ax.set_title(
f"Balatro MP \u2014 Joker Win Rates by Stake ({total_wins}W/{total_games - total_wins}L, {total_wins/total_games*100:.0f}% overall, min {MIN_GAMES} games)",
fontsize=13, fontweight="bold", color="#f2f3f5", pad=16,
)
ax.set_xlabel("Win Rate %", fontsize=10, color="#b5bac1", labelpad=10)
# Subtitle note
ax.text(
0.5, -0.03,
"Bar width = win rate | Segments = proportion of games at each stake",
transform=ax.transAxes, ha="center", fontsize=7.5, color="#80848e", style="italic",
)
plt.tight_layout()
plt.savefig("joker_winrates.png", dpi=200, facecolor=fig.get_facecolor(), bbox_inches="tight")
plt.close()
print(" joker_winrates.png")
# ── Main ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
games = parse_logs()
if not games:
print("No completed games found with end-game joker data.")
sys.exit(0)
total_games = len(games)
total_wins = sum(1 for g in games if g["won"])
print(f"Parsed {total_games} games ({total_wins}W / {total_games - total_wins}L, {total_wins/total_games*100:.1f}% WR)\n")
print("Output files:")
write_joker_stats_by_stake(games)
write_joker_winrates(games)
print()
write_idol_analysis(games)
print()
generate_chart(games)
print(f"\nDone.")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment