- 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
15 KiB
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:
-
Add
tournament_rake_percentage: u8toGameConfig -
Add
rake_amount: ChipCounttoPrizeStructure- Add
#[serde(default)]field - Update
custom()to setrake_amount: 0 - Add
with_rake()constructor - Add
total_entry_fees()accessor - Update
standard_sng()to acceptrake_percentage: u8 - Update
standard_mtt()to acceptrake_percentage: u8
- Add
-
Update
to_prize_structure()- Apply rake deduction to
prize_poolin the custom prizes path - Pass
tournament_rake_percentagetostandard_sng()/standard_mtt() - Return
PrizeStructure::with_rake(prizes, house_rake)when rake > 0
- Apply rake deduction to
-
Update
validate()- Warn when
tournament_rake_percentage > 0for Cash games - Warn when
tournament_rake_percentage > 50 - Warn when
tournament_rake_percentage == 100 - Warn when prize percentages sum outside 99–101%
- Warn when
-
Update stats tracking in
stats.rs- Add
total_tournament_rake: ChipCounttoSimulationStats - Add
total_house_rake: ChipCounttoSngSeriesResult/MttSeriesResult - Accumulate rake in the simulation loop
- Add
-
Update existing tests in
config.rs- Add
tournament_rake_percentage: 0to all existingGameConfigtest 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()
- Add
-
Update TOML example configs
- Update
sng_tournament.tomlto includetournament_rake_percentage = 10 - Update
mtt_10_tables.tomlto includetournament_rake_percentage = 5 - Add comments explaining the rake computation
- Update
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.