Last active
February 22, 2026 05:51
-
-
Save adilw3nomad/d1a6925cefbf5871be6ff4e61978338a to your computer and use it in GitHub Desktop.
balatro multiplayer stats script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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