rusty_marvin/docs/ARCHITECTURE_TOURNAMENT_RAKE.md
Jan-Peter v. Hunnius 7c79ae4966 feat: tournament rake, percentage-based prizes, and finish position fixes
- Add tournament_rake_percentage field to GameConfig (house share of entry fees)
- Convert prize structure from absolute amounts to percentages of prize pool
- Fix finish position calculation for tournament eliminations (was inverted)
- Add batch bustout processing for multi-table tournaments (shared positions)
- Add max_rebuys field and rebuy_count tracking on Seat
- Add PrizeStructure::with_rake() and rake_amount field
- Accumulate stats across tournament series (hands, rake, profit)
- Add best_finish and prize_won fields to PlayerStats
- Replace floating-point prize math with integer arithmetic (chip conservation)
- Add comprehensive tests for prize distribution and finish positions
- Update config examples to use percentage-based prizes with rake
- Add ARCHITECTURE_TOURNAMENT_RAKE.md documentation
2026-05-22 14:32:24 +02:00

15 KiB
Raw Blame History

Tournament Rake + Prize Percentage Architecture

Overview

This document describes the architecture for adding a tournament rake (house share) to the prize distribution system. The tournament rake is a percentage of total entry fees withheld by the house before the remaining pool is distributed to players according to the prizes percentage schedule.

Key Concept

total_entry_fees = buy_in × num_players  (× num_tables for MTT)
prize_pool       = total_entry_fees × (100 - tournament_rake_percentage) / 100
house_rake       = total_entry_fees - prize_pool
payout_N         = prize_pool × prize_percentage_N / 100

Current State

The uncommitted changes on master already converted prizes from absolute chip amounts to percentages of the prize pool. The current to_prize_structure() computes:

prize_pool = buy_in * num_players   // or buy_in * num_players * num_tables for MTT
prizes     = percentages.map(|pct| (prize_pool * pct / 100.0).round())

There is no tournament rake concept. The entire entry fee pool is redistributed to players.

The existing rake_percentage and RakeConfig are for cash game hand rake — a percentage taken from each pot at showdown. This is a completely separate mechanism.


Data Flow

flowchart TD
    A[TOML Config] --> B[GameConfig]
    B --> C[to_prize_structure]
    C --> D[Compute total_entry_fees]
    D --> E[Apply tournament_rake_percentage]
    E --> F[prize_pool = total_entry_fees * 100 - rake / 100]
    E --> G[house_rake = total_entry_fees - prize_pool]
    F --> H[Distribute prize_pool by prizes percentages]
    H --> I[PrizeStructure with absolute chip amounts]
    G --> J[PrizeStructure.rake_amount for reporting]
    I --> K[Tournament uses prizes for payouts]
    J --> L[Stats tracking: house_rake collected]

Design Details

1. New GameConfig Field

File: holdem_core/src/game/config.rs

Add a new field to GameConfig:

/// Tournament rake percentage (0–100). The house share withheld from the
/// prize pool before distribution. For example, `10` means the house keeps
/// 10% of total entry fees and the remaining 90% forms the prize pool.
///
/// This is separate from `rake_percentage` which is per-hand cash game rake.
/// Default: 0 (no tournament rake).
#[serde(default)]
pub tournament_rake_percentage: u8,

Serde behavior: #[serde(default)] means the field defaults to 0 when absent from TOML. Existing configs without this field work identically to today — full backward compatibility.

Default impl: Add tournament_rake_percentage: 0 to the Default for GameConfig block.

Type choice u8: A u8 supports 0–100%, which is the valid range. Values above 50 will produce a validation warning. Values of exactly 100 would yield a zero prize pool, which validation should catch.

2. Updated to_prize_structure() Logic

File: holdem_core/src/game/config.rs

The current method at line 535 needs two changes:

a) Custom prizes path (line 536–550)

if !self.prizes.is_empty() {
    let num_players = self.bots.len().max(2);
    let total_entry_fees = match self.game_type {
        GameType::MultiTableTournament => {
            self.buy_in * num_players as ChipCount * self.num_tables as ChipCount
        }
        _ => self.buy_in * num_players as ChipCount,
    };

    // Apply tournament rake
    let rake_pct = self.tournament_rake_percentage as f64 / 100.0;
    let prize_pool = (total_entry_fees as f64 * (1.0 - rake_pct)).round() as ChipCount;
    let house_rake = total_entry_fees - prize_pool;

    let prizes: Vec<ChipCount> = self
        .prizes
        .iter()
        .map(|&pct| (prize_pool as f64 * pct as f64 / 100.0).round() as ChipCount)
        .collect();

    PrizeStructure::with_rake(prizes, house_rake)
}

b) Standard prize paths (line 551–561)

The standard_sng() and standard_mtt() methods also compute prize_pool = buy_in * num_players internally. They need to accept the rake percentage as a parameter:

PrizeStructure::standard_sng(self.buy_in, num_players, self.tournament_rake_percentage)
PrizeStructure::standard_mtt(self.buy_in, total_entries, self.tournament_rake_percentage)

Inside these methods, the prize pool computation becomes:

let total_entry_fees = buy_in * num_players as ChipCount;
let prize_pool = (total_entry_fees as f64 * (1.0 - rake_percentage as f64 / 100.0)).round() as ChipCount;
let house_rake = total_entry_fees - prize_pool;

3. PrizeStructure Changes

File: holdem_core/src/game/tournament.rs

Add an optional rake_amount field for reporting:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrizeStructure {
    /// Prize amounts ordered from 1st place to last paid position.
    pub prizes: Vec<ChipCount>,
    /// House rake withheld from the prize pool (0 when no tournament rake).
    #[serde(default)]
    pub rake_amount: ChipCount,
}

New constructor:

/// Creates a prize structure with rake information.
pub fn with_rake(prizes: Vec<ChipCount>, rake_amount: ChipCount) -> Self {
    Self { prizes, rake_amount }
}

Updated existing constructor:

pub fn custom(prizes: Vec<ChipCount>) -> Self {
    Self { prizes, rake_amount: 0 }
}

New accessor:

/// Returns the total entry fees (prize pool + rake).
pub fn total_entry_fees(&self) -> ChipCount {
    self.total_prize_pool() + self.rake_amount
}

4. Validation Rules

File: holdem_core/src/game/config.rs — validate() method

Add the following warnings:

Condition Warning Message
tournament_rake_percentage > 0 && game_type == Cash tournament_rake_percentage is set but game type is Cash. Tournament rake only applies to SNG and MTT formats.
tournament_rake_percentage > 50 tournament_rake_percentage is {value}%, which exceeds 50%. This is unusually high and may be a misconfiguration.
!prizes.is_empty() && prizes.iter().sum::<u64>() != 100 Custom prize percentages sum to {sum}%, not 100%. Prize distribution may not match expectations.
tournament_rake_percentage == 100 tournament_rake_percentage is 100%, meaning the entire prize pool goes to the house. No prizes will be paid.

Prize sum tolerance: Due to integer percentages, allow a ±1% tolerance (99–101%) for the sum check. A strict != 100 check would be too rigid.

let prize_sum: u64 = self.prizes.iter().map(|&p| p as u64).sum();
if !(99..=101).contains(&prize_sum) {
    warnings.push(format!(
        "Custom prize percentages sum to {}%, not 100%. ...",
        prize_sum
    ));
}

5. Stats Reporting Impact

File: holdem_core/src/game/stats.rs

a) SimulationStats

The existing total_rake field tracks cash game hand rake. Add a separate field for tournament rake:

pub struct SimulationStats {
    // ... existing fields ...
    pub total_rake: ChipCount,
    /// Total tournament rake collected across all tournaments in the series.
    #[serde(default)]
    pub total_tournament_rake: ChipCount,
}

b) SngPlayerSeriesResult

The existing record_finish() method tracks total_entry_fees as buy_in × tournaments_played. With tournament rake, the player still pays the full buy_in as entry fee — the rake is just not returned via prizes. So the existing ROI computation remains correct:

ROI = (total_prize_money - total_entry_fees) / total_entry_fees × 100

A player who never cashes in a 10% rake tournament will have a -100% ROI regardless of the rake, because their total_prize_money = 0 and total_entry_fees = buy_in × N.

However, for reporting purposes, the rake information should be available. Consider adding to SngSeriesResult:

/// Total rake collected by the house across all tournaments in this series.
pub total_house_rake: ChipCount,

c) Where to accumulate tournament rake

The rake is accumulated in the simulation loop when tournaments complete. The call site is in simulation.rs where to_prize_structure() is called. After each tournament, the prize_structure.rake_amount should be added to the running total_tournament_rake.


Example TOML Configs

SNG with 10% Tournament Rake

[game]
game_type = "SitAndGo"
num_seats = 10
starting_chips = 1500
buy_in = 11
randomize_seats = true
num_tournaments = 100

# House keeps 10% of entry fees
tournament_rake_percentage = 10

# Prize distribution of the remaining 90% pool: 1st=50%, 2nd=30%, 3rd=20%
prizes = [50, 30, 20]

# With 10 players: total_entry_fees = 110, house_rake = 11, prize_pool = 99
# 1st place: 49.5 → 50 chips, 2nd: 29.7 → 30 chips, 3rd: 19.8 → 20 chips

[game.bet_limit]
type = "no_limit"
min_raise = 20

[[game.blind_levels]]
small_blind = 10
big_blind = 20
ante = 0
hands = 10

[[game.blind_levels]]
small_blind = 15
big_blind = 30
ante = 0
hands = 10

[[game.blind_levels]]
small_blind = 25
big_blind = 50
ante = 5
hands = 10

# ... more blind levels ...

[[game.bots]]
bot_type = "flock_bot"
name = "FlockBot-1"

[[game.bots]]
bot_type = "chump_bot"
name = "ChumpBot-1"

# ... more bots ...

MTT with 5% Tournament Rake

[game]
game_type = "MultiTableTournament"
num_seats = 10
num_tables = 10
small_blind = 10
big_blind = 20
starting_chips = 1500
buy_in = 5
hands_per_level = 10
num_blind_levels = 20
randomize_seats = true
num_tournaments = 100

# House keeps 5% of entry fees
tournament_rake_percentage = 5

# Prize distribution of the remaining 95% pool
# With 100 players: total_entry_fees = 500, house_rake = 25, prize_pool = 475
prizes = [40, 20, 10, 6, 4, 2, 2, 2, 2, 2]
# Sum = 94% — validation will warn, but it still works

[game.bet_limit]
type = "no_limit"
min_raise = 20

# ... blind levels and bots ...

Cash Game — Tournament Rake Not Applicable

[game]
game_type = "Cash"
num_seats = 10
small_blind = 10
big_blind = 20
starting_chips = 1500
num_hands = 10000

# Cash game hand rake (existing feature)
rake_percentage = 0.05
rake_cap = 50

# tournament_rake_percentage would trigger a validation warning if set
# It has no effect on cash games

[game.bet_limit]
type = "no_limit"
min_raise = 20

# ... bots ...

Implementation Checklist

These are the steps to implement this design, in order:

  1. Add tournament_rake_percentage: u8 to GameConfig

    • Add #[serde(default)] field
    • Add tournament_rake_percentage: 0 to Default impl
    • Update doc comment on prizes to mention rake interaction
  2. Add rake_amount: ChipCount to PrizeStructure

    • Add #[serde(default)] field
    • Update custom() to set rake_amount: 0
    • Add with_rake() constructor
    • Add total_entry_fees() accessor
    • Update standard_sng() to accept rake_percentage: u8
    • Update standard_mtt() to accept rake_percentage: u8
  3. Update to_prize_structure()

    • Apply rake deduction to prize_pool in the custom prizes path
    • Pass tournament_rake_percentage to standard_sng() / standard_mtt()
    • Return PrizeStructure::with_rake(prizes, house_rake) when rake > 0
  4. Update validate()

    • Warn when tournament_rake_percentage > 0 for Cash games
    • Warn when tournament_rake_percentage > 50
    • Warn when tournament_rake_percentage == 100
    • Warn when prize percentages sum outside 99–101%
  5. Update stats tracking in stats.rs

    • Add total_tournament_rake: ChipCount to SimulationStats
    • Add total_house_rake: ChipCount to SngSeriesResult / MttSeriesResult
    • Accumulate rake in the simulation loop
  6. Update existing tests in config.rs

    • Add tournament_rake_percentage: 0 to all existing GameConfig test instances
    • Add new test: to_prize_structure_sng_with_rake()
    • Add new test: to_prize_structure_mtt_with_rake()
    • Add new test: validate_tournament_rake_warnings()
  7. Update TOML example configs


Rounding Behavior

The prize pool computation uses .round() for consistency with the existing approach:

let prize_pool = (total_entry_fees as f64 * (1.0 - rake_pct)).round() as ChipCount;

This means:

  • 110 * 0.90 = 99.0 → prize_pool = 99 ✓
  • 100 * 0.95 = 95.0 → prize_pool = 95 ✓
  • 107 * 0.90 = 96.3 → prize_pool = 96, rake = 11

The house_rake is computed as total_entry_fees - prize_pool (residual method) rather than independently, ensuring prize_pool + house_rake == total_entry_fees always holds exactly.

Individual prize amounts also use .round():

(prize_pool as f64 * pct as f64 / 100.0).round() as ChipCount

Due to rounding, the sum of individual prizes may differ from prize_pool by ±1 chip per prize position. This is acceptable for simulation purposes.


Relationship Between Rake Types

Property Cash Game Rake Tournament Rake
Config field rake_percentage tournament_rake_percentage (new)
Type f64 (0.0–1.0) u8 (0–100)
When applied Each hand at showdown Once at tournament start
Cap rake_cap per pot No cap (percentage of total)
Tracked in RakeConfig PrizeStructure.rake_amount
Stats field SimulationStats.total_rake SimulationStats.total_tournament_rake (new)
Applicable to Cash games only SNG and MTT only

The two rake types are completely independent. A cash game with rake_percentage = 0.05 takes 5% of each pot. A tournament with tournament_rake_percentage = 10 withholds 10% of entry fees from the prize pool. They never interact.