- Add RakeConfig with percentage-based rake and per-pot cap - Add bankroll tracking (reset per hand or auto-refill threshold) - Add seat randomization with seeded RNG for reproducibility - Add FlockBot (tight-aggressive) and ChumpBot (loose-passive) bots - Refactor HandRunner: extract finalize_result, compute_chip_movements, credit_winnings - Add num_raises to GameInfo for authoritative raise counting - Record SB/BB as Raise actions (Meerkat convention) - Update all existing bot tests for new GameInfo fields - Add ARCHITECTURE_RAKE_BANKROLL_SEATS.md documentation
27 KiB
Architecture: Rake, Bankroll Management & Randomized Seats
Overview
This document describes the architecture for three new features in the holdem_core poker engine:
- Rake — Configurable percentage deducted from pots at showdown
- Bankroll Management — Per-hand stack reset and automatic short-stack refill
- Randomized Seats — Shuffle player seat assignments before each hand or tournament
All three features are backward-compatible: existing TOML configs without these fields continue to work with sensible defaults (no rake, no reset, no refill, no randomization).
Feature 1: Rake
1.1 Config Changes
Add two fields to GameConfig:
// In GameConfig struct:
/// Rake percentage taken from each pot at showdown (e.g., 0.05 = 5%).
/// Rake is deducted proportionally from each pot (main + side pots).
/// Set to 0.0 (default) to disable rake.
#[serde(default)]
pub rake_percentage: f64,
/// Maximum rake in chips per pot. 0 means no cap.
#[serde(default)]
pub rake_cap: ChipCount,
Defaults: rake_percentage: 0.0, rake_cap: 0 (no rake, no cap). Since both use #[serde(default)], existing TOML files without these fields deserialize correctly.
Validation: Add a validation method or #[serde(deserialize_with = ...)] that ensures rake_percentage is in [0.0, 1.0].
1.2 Data Model Changes
New struct: RakeConfig
/// Runtime rake configuration, derived from GameConfig.
#[derive(Debug, Clone, Copy)]
pub struct RakeConfig {
/// Fraction of each pot taken as rake (e.g., 0.05).
pub percentage: f64,
/// Maximum rake per pot in chips; 0 = uncapped.
pub cap: ChipCount,
}
impl RakeConfig {
pub fn no_rake() -> Self {
Self { percentage: 0.0, cap: 0 }
}
/// Returns true if rake is effectively disabled.
pub fn is_zero(&self) -> bool {
self.percentage == 0.0
}
/// Calculate rake for a pot of the given size.
/// Returns the rake amount (floored to whole chips).
pub fn calculate_rake(&self, pot_amount: ChipCount) -> ChipCount {
if self.percentage == 0.0 || pot_amount == 0 {
return 0;
}
let rake = (pot_amount as f64 * self.percentage).floor() as ChipCount;
if self.cap > 0 {
rake.min(self.cap)
} else {
rake
}
}
}
Changes to HandResult
Add a field to HandResult:
pub struct HandResult {
pub pot_winners: Vec<Vec<PotWinner>>,
pub hand_values: HashMap<PlayerId, HandValue>,
pub board: Board,
pub chip_movements: HashMap<PlayerId, i64>,
/// Total rake deducted from all pots this hand.
pub total_rake: ChipCount,
/// Rake deducted per pot, parallel to pot_winners.
pub rake_per_pot: Vec<ChipCount>,
}
1.3 Behavior Flow
The rake is applied after PotManager::distribute() returns its results, but before chip movements are calculated and stacks are credited. This keeps the pot manager's core logic clean and testable.
flowchart TD
A[pot_manager.distribute - hand_values] --> B[For each pot: calculate rake]
B --> C[For each winner in pot: reduce award by proportional rake]
C --> D[Accumulate total_rake and rake_per_pot]
D --> E[Calculate chip_movements with raked amounts]
E --> F[Credit raked winnings to stacks]
F --> G[Build HandResult with rake info]
Hook point: Both HandRunner::showdown() and HandRunner::resolve_last_player_wins() need rake logic. The cleanest approach is:
- Add
rake_config: RakeConfigfield toHandRunner - Create a private helper method:
impl HandRunner {
/// Apply rake to pot distribution results.
/// Returns (raked_pot_winners, rake_per_pot, total_rake).
fn apply_rake(
&self,
pot_winners: Vec<Vec<PotWinner>>,
) -> (Vec<Vec<PotWinner>>, Vec<ChipCount>, ChipCount) {
if self.rake_config.is_zero() {
let total: ChipCount = 0;
let per_pot = vec![0; pot_winners.len()];
return (pot_winners, per_pot, total);
}
let mut total_rake: ChipCount = 0;
let mut rake_per_pot = Vec::with_capacity(pot_winners.len());
let raked_winners: Vec<Vec<PotWinner>> = pot_winners
.into_iter()
.map(|mut pot_result| {
// Sum the pot from winner amounts (they haven't been raked yet).
let pot_total: ChipCount = pot_result.iter().map(|w| w.amount).sum();
let rake = self.rake_config.calculate_rake(pot_total);
rake_per_pot.push(rake);
total_rake += rake;
// Deduct rake proportionally from each winner.
if rake == 0 || pot_result.is_empty() {
return pot_result;
}
let num_winners = pot_result.len() as ChipCount;
let rake_per_winner = rake / num_winners;
let rake_remainder = rake % num_winners;
for (i, winner) in pot_result.iter_mut().enumerate() {
let extra = if (i as ChipCount) < rake_remainder { 1 } else { 0 };
winner.amount = winner.amount.saturating_sub(rake_per_winner + extra);
}
pot_result
})
.collect();
(raked_winners, rake_per_pot, total_rake)
}
}
-
In
showdown(), after line 611 (let pot_winners = self.pot_manager.distribute(&hand_values);), callapply_rake()before computing chip movements. -
In
resolve_last_player_wins(), same pattern after line 677. -
Pass
RakeConfigthroughHandRunner::new()— add it as a parameter. -
Thread
RakeConfigfromGameConfig→Table→HandRunner:- Add
rake_config: RakeConfigtoTableConfig - Store it in
Table - Pass it when constructing
HandRunnerinTable::start_hand()
- Add
1.4 Stats Impact
Add to PlayerStats:
/// Total rake paid by this player across all hands.
/// Rake is attributed proportionally based on the player's contribution to each pot.
pub total_rake_paid: ChipCount,
Add to SimulationStats:
/// Total rake collected across the entire simulation.
pub total_rake: ChipCount,
Update SimulationStats::record_hand_result() to accumulate total_rake from result.total_rake.
Rake attribution: To attribute rake to individual players, we need to know each player's contribution to each pot. The PotManager already tracks this via wagers. We can add a method to compute per-player rake:
impl PotManager {
/// Returns each player's contribution to each finalized pot.
/// Useful for proportional rake attribution.
pub fn player_pot_contributions(&self) -> Vec<HashMap<PlayerId, ChipCount>> {
// For each pot, compute what each player contributed
// based on the side pot algorithm thresholds
}
}
Alternatively, a simpler approach: track rake only at the simulation level (total_rake) and don't attribute per-player. This is simpler and sufficient for most use cases. Per-player rake attribution can be added later.
1.5 Edge Cases
| Scenario | Behavior |
|---|---|
| Pot of 0 chips | Rake = 0 (guard in calculate_rake) |
| Rake > winner's share | Use saturating_sub — winner gets 0, rake is capped at pot total |
| Split pot with uneven shares | Remainder rake chips distributed round-robin to winners in order |
| No showdown (all fold) | Rake still applies — the winner's pot is raked |
| Rake cap reached | rake.min(self.cap) applied per pot |
| Multiple side pots | Rake calculated independently per pot |
1.6 TOML Config Example
[game]
game_type = "Cash"
num_seats = 6
small_blind = 10
big_blind = 20
starting_chips = 10000
num_hands = 1000
rake_percentage = 0.05 # 5% rake
rake_cap = 50 # Max 50 chips rake per pot
[game.bet_limit]
type = "no_limit"
min_raise = 20
Feature 2: Bankroll Management
2.1 Config Changes
Add two fields to GameConfig:
/// If true, all players' stacks are reset to `starting_chips` before every hand.
/// Their overall profit/loss balance is tracked separately via `bankroll`.
#[serde(default)]
pub reset_bankroll_each_hand: bool,
/// When a player's stack drops to or below this threshold (and reset is NOT enabled),
/// they automatically buy back up to `starting_chips`. Their overall balance
/// decreases by the buy-in amount.
#[serde(default)]
pub bankroll_refill_threshold: ChipCount,
Defaults: reset_bankroll_each_hand: false, bankroll_refill_threshold: 0 (disabled). Both use #[serde(default)] for backward compatibility.
2.2 Data Model Changes
New field on Seat
Add a bankroll field to Seat:
pub struct Seat {
// ... existing fields ...
/// The player's overall balance (profit/loss) across all hands.
/// Starts at 0. Increases when the player wins, decreases when they lose.
/// When reset_bankroll_each_hand is enabled, this tracks the cumulative
/// profit/loss while the stack is reset each hand.
/// When bankroll_refill_threshold triggers a reload, the buy-in amount
/// is deducted from bankroll.
pub bankroll: i64,
}
Update Seat::new() to initialize bankroll: 0.
Changes to Table
Add bankroll config fields to Table:
pub struct Table {
// ... existing fields ...
/// If true, reset all stacks to starting_chips before each hand.
reset_bankroll_each_hand: bool,
/// Auto-refill threshold. 0 = disabled.
bankroll_refill_threshold: ChipCount,
/// Starting chips (for resets and refills).
starting_chips: ChipCount,
}
Add these to TableConfig as well so they flow from config:
pub struct TableConfig {
// ... existing fields ...
pub reset_bankroll_each_hand: bool,
pub bankroll_refill_threshold: ChipCount,
pub starting_chips: ChipCount,
}
Changes to PlayerInfo
Add to PlayerInfo:
/// The player's overall bankroll balance across all hands.
/// Positive = profit, negative = loss.
pub bankroll: i64,
Changes to GameInfo
Add to GameInfo:
/// Whether bankroll reset mode is enabled.
pub reset_bankroll_each_hand: bool,
2.3 Behavior Flow
Reset Mode (reset_bankroll_each_hand = true)
flowchart TD
A[Before start_hand] --> B{reset_bankroll_each_hand?}
B -- Yes --> C[For each seated player]
C --> D[bankroll += stack as i64 - starting_chips as i64]
D --> E[stack = starting_chips]
E --> F[state = Active]
B -- No --> G{refill_threshold > 0?}
G -- Yes --> H[For each seated player]
H --> I{stack <= threshold AND state != SittingOut?}
I -- Yes --> J[refill_amount = starting_chips - stack]
J --> K[bankroll -= refill_amount as i64]
K --> L[stack = starting_chips]
I -- No --> M[Skip]
G -- No --> N[Proceed normally]
F --> N
L --> N
M --> N
Hook point: In Table::start_hand(), insert the bankroll logic before step 3 (advance button). The new step 2.5:
// In Table::start_hand(), after the active_count check:
// 2.5. Bankroll management.
if self.reset_bankroll_each_hand {
self.reset_stacks_to_starting();
} else if self.bankroll_refill_threshold > 0 {
self.refill_short_stacks();
}
New private methods on Table:
impl Table {
/// Reset all seated players' stacks to starting_chips.
/// Track the change in their bankroll.
fn reset_stacks_to_starting(&mut self) {
let starting = self.starting_chips;
for slot in &mut self.seats {
if let Some(seat) = slot.as_mut() {
// The "cost" of resetting is the difference between
// starting and current stack.
// If they have more than starting, they profit.
// If they have less, they lost.
seat.bankroll += seat.stack as i64 - starting as i64;
seat.stack = starting;
// Re-activate sitting-out players (they have chips again).
if seat.state == PlayerState::SittingOut {
seat.state = PlayerState::Active;
}
}
}
}
/// Refill short-stacked players up to starting_chips.
fn refill_short_stacks(&mut self) {
let threshold = self.bankroll_refill_threshold;
let starting = self.starting_chips;
for slot in &mut self.seats {
if let Some(seat) = slot.as_mut() {
if seat.state == PlayerState::SittingOut {
continue;
}
if seat.stack <= threshold {
let refill = starting.saturating_sub(seat.stack);
if refill > 0 {
seat.bankroll -= refill as i64;
seat.stack = starting;
}
}
}
}
}
}
Important: When reset_bankroll_each_hand = true, busted players (stack = 0, state = SittingOut) are re-activated with a full stack. This means all players play every hand — the simulation tracks their cumulative profit/loss via bankroll.
When bankroll_refill_threshold > 0 (without reset), only players below the threshold get refilled. Busted players (SittingOut) are not refilled — they stay out. This matches real poker behavior where a bust-out is different from being short-stacked. If you want busted players to also reload, set reset_bankroll_each_hand = true instead.
After Hand Completion
In Table::complete_hand(), after restoring seats, the bankroll needs to be updated with the hand results. The chip movements from HandResult already reflect the net change per hand. However, with reset_bankroll_each_hand, the bankroll is updated at the start of the next hand (via reset_stacks_to_starting), not at the end of the current one. This is simpler and avoids double-counting.
For bankroll_refill_threshold mode (without reset), the bankroll is only affected by refill purchases. The stack changes from winning/losing are already reflected in seat.stack. The bankroll field tracks the additional money spent on refills.
2.4 Stats Impact
The existing SimulationStats::record_final_stacks() computes total_profit = final_stack - starting_stack. With bankroll management, this is no longer accurate.
Solution: Add bankroll tracking to stats:
// In PlayerStats:
/// Total bankroll balance (includes refill costs and reset adjustments).
pub bankroll: i64,
Update SimulationStats::record_final_stacks() to also record bankroll:
pub fn record_final_stacks(
&mut self,
stacks: &HashMap<PlayerId, ChipCount>,
starting_stacks: &HashMap<PlayerId, ChipCount>,
bankrolls: &HashMap<PlayerId, i64>, // NEW parameter
) {
for (&player_id, &final_stack) in stacks {
if let Some(stats) = self.players.get_mut(&player_id) {
stats.final_stack = final_stack;
let starting = starting_stacks.get(&player_id).copied().unwrap_or(0);
stats.bankroll = *bankrolls.get(&player_id).unwrap_or(&0);
// With bankroll tracking, total_profit = bankroll
// (bankroll already captures all net flows)
if stats.bankroll != 0 {
stats.total_profit = stats.bankroll;
} else {
// Fallback for non-bankroll mode
stats.total_profit = final_stack as i64 - starting as i64;
}
}
}
}
In run_cash_game(), collect bankrolls alongside final stacks:
let bankrolls: HashMap<PlayerId, i64> = table
.seats()
.iter()
.filter_map(|slot| {
slot.as_ref().map(|seat| (seat.player_id, seat.bankroll))
})
.collect();
stats.record_final_stacks(&final_stacks, &starting_stacks, &bankrolls);
2.5 Edge Cases
| Scenario | Behavior |
|---|---|
| Player wins big, stack > starting_chips, reset mode | bankroll increases by the surplus, stack reset to starting_chips |
| Player busts, reset mode | Re-activated with starting_chips, bankroll decreases by starting_chips |
| Refill threshold = 0 | No refills (disabled) |
| Refill threshold >= starting_chips | Refill every hand (unusual but valid) |
| Both reset and refill enabled | Reset takes precedence (refill is skipped) |
| Tournament mode | Bankroll management is cash-game only — ignored for tournaments |
| Bankroll goes very negative | i64 provides ~9 quintillion range — no practical overflow concern |
2.6 TOML Config Example
# Example: Cash game with bankroll reset (tournament-style tracking)
[game]
game_type = "Cash"
num_seats = 6
small_blind = 10
big_blind = 20
starting_chips = 10000
num_hands = 1000
reset_bankroll_each_hand = true
[game.bet_limit]
type = "no_limit"
min_raise = 20
# Example: Cash game with auto-refill
[game]
game_type = "Cash"
num_seats = 6
small_blind = 10
big_blind = 20
starting_chips = 10000
num_hands = 1000
bankroll_refill_threshold = 1000 # Refill when stack <= 1000
[game.bet_limit]
type = "no_limit"
min_raise = 20
Feature 3: Randomized Seats
3.1 Config Changes
Add one field to GameConfig:
/// If true, player seat assignments are randomized before each hand
/// (cash game) or before tournament start.
#[serde(default)]
pub randomize_seats: bool,
Default: false. Uses #[serde(default)] for backward compatibility.
3.2 Data Model Changes
Add to Table:
pub struct Table {
// ... existing fields ...
/// If true, shuffle seat assignments before each hand.
randomize_seats: bool,
}
Add to TableConfig:
pub struct TableConfig {
// ... existing fields ...
pub randomize_seats: bool,
}
3.3 Behavior Flow
flowchart TD
A[Table::start_hand] --> B{randomize_seats?}
B -- Yes --> C[Collect all seated players into a Vec]
C --> D[Shuffle the Vec using seeded RNG]
D --> E[Clear all seats]
E --> F[Re-seat players in shuffled order to positions 0..N]
B -- No --> G[Proceed with existing seat assignments]
F --> G
G --> H[Advance button and continue hand start]
Hook point: In Table::start_hand(), insert randomization before step 3 (advance button) and after bankroll management. The new step 2.7:
// In Table::start_hand():
// 2.7. Randomize seats if configured.
if self.randomize_seats {
self.shuffle_seats()?;
}
New method on Table:
impl Table {
/// Randomize seat assignments by collecting all seated players,
/// shuffling them, and re-seating at positions 0..N.
fn shuffle_seats(&mut self) -> Result<(), GameError> {
// 1. Collect all occupied seats.
let mut occupied: Vec<Seat> = self
.seats
.iter_mut()
.filter_map(|slot| slot.take())
.collect();
if occupied.len() < 2 {
// Put them back.
for (i, seat) in occupied.into_iter().enumerate() {
self.seats[i] = Some(seat);
}
return Ok(());
}
// 2. Shuffle using seeded RNG for reproducibility.
// Use a seed derived from the table seed + hand number.
let rng_seed = self.seed
.map(|s| s.wrapping_add(self.hand_number))
.unwrap_or_else(|| {
// Use a simple hash of hand_number as fallback.
self.hand_number
});
let mut rng = rand::rngs::StdRng::seed_from_u64(rng_seed);
use rand::seq::SliceRandom;
occupied.shuffle(&mut rng);
// 3. Clear all seats (already empty from take).
// 4. Re-seat at positions 0, 1, 2, ...
for (i, mut seat) in occupied.into_iter().enumerate() {
seat.index = i as SeatIndex;
self.seats[i] = Some(seat);
}
// 5. Reset button to 0 since seat assignments changed.
self.button = 0;
Ok(())
}
}
For tournaments: Randomization happens once at tournament start, not per hand. The tournament code in tournament.rs would call a Table::shuffle_seats() method during initialization. This is a separate call site but uses the same method.
3.4 Stats Impact
No new stats fields needed. Seat randomization is transparent to stats tracking, which uses PlayerId (not seat index) as the key.
3.5 Edge Cases
| Scenario | Behavior |
|---|---|
| Only 1 player seated | No shuffle (not enough players) |
| No seed configured | Use hand_number as a fallback seed |
| Seed configured | Use seed + hand_number for per-hand variation |
| During a hand | No-op (randomization only happens between hands) |
| Tournament | Shuffle once at start, not per hand |
| Button position | Reset to 0 after shuffle to avoid invalid button references |
3.6 TOML Config Example
[game]
game_type = "Cash"
num_seats = 6
small_blind = 10
big_blind = 20
starting_chips = 10000
num_hands = 1000
randomize_seats = true
[game.bet_limit]
type = "no_limit"
min_raise = 20
Cross-Feature Interactions
Rake + Bankroll Reset
When both are enabled:
- Rake is deducted from pot winnings during the hand (inside
HandRunner) - Bankroll reset happens between hands (inside
Table) - The
chip_movementsinHandResultalready reflect rake deductions - Bankroll tracking picks up the post-rake stack values
The interaction is clean because rake operates within a hand and bankroll operates between hands.
Rake + Stats
With rake enabled, total_profit across all players will sum to -(total_rake as i64) instead of 0. This is correct — the rake is money leaving the system.
Bankroll Reset + Randomized Seats
Both operate between hands in Table::start_hand(). The execution order is:
- Bankroll management (reset/refill) — step 2.5
- Seat randomization — step 2.7
- Advance button — step 3
This order ensures bankroll adjustments happen before seats are shuffled.
All Three Together
flowchart TD
A[Table::start_hand] --> B[Validate: enough players?]
B -- No --> ERR[Return NotEnoughPlayers]
B -- Yes --> C{reset_bankroll?}
C -- Yes --> D[Reset all stacks to starting_chips]
C -- No --> E{refill_threshold > 0?}
E -- Yes --> F[Refill short stacks]
E -- No --> G[Skip]
D --> H{randomize_seats?}
F --> H
G --> H
H -- Yes --> I[Shuffle seat assignments]
H -- No --> J[Skip]
I --> K[Advance button]
J --> K
K --> L[Collect active seats]
L --> M[Create HandRunner with RakeConfig]
M --> N[Start hand: post blinds, deal]
Implementation Todo List
The following is the recommended implementation order, broken into independent units:
Phase 1: Rake
- Add
RakeConfigstruct topot.rs(or a newrake.rsmodule) - Add
rake_percentageandrake_capfields toGameConfigwith#[serde(default)] - Add
RakeConfig::from_game_config()conversion - Add
rake_config: RakeConfigtoTableConfigandTable - Add
rake_config: RakeConfigtoHandRunner - Implement
HandRunner::apply_rake()helper method - Update
HandRunner::showdown()to callapply_rake() - Update
HandRunner::resolve_last_player_wins()to callapply_rake() - Add
total_rakeandrake_per_pottoHandResult - Add
total_raketoSimulationStatsand updaterecord_hand_result() - Update
GameConfig::to_table_config()to pass rake config - Add unit tests for
RakeConfig::calculate_rake() - Add integration tests for raked hands
Phase 2: Bankroll Management
- Add
bankroll: i64field toSeat(default 0) - Add
reset_bankroll_each_handandbankroll_refill_thresholdtoGameConfig - Add these fields to
TableConfigandTable - Implement
Table::reset_stacks_to_starting() - Implement
Table::refill_short_stacks() - Update
Table::start_hand()to call bankroll management - Add
bankroll: i64toPlayerInfoand updateSeat::to_player_info() - Add
reset_bankroll_each_hand: booltoGameInfo - Update
SimulationStats::record_final_stacks()to accept bankrolls - Update
run_cash_game()to collect bankrolls - Add
bankroll: i64toPlayerStats - Add tests for reset mode, refill mode, and edge cases
Phase 3: Randomized Seats
- Add
randomize_seats: booltoGameConfig,TableConfig, andTable - Implement
Table::shuffle_seats()using seeded RNG - Update
Table::start_hand()to call shuffle when enabled - Ensure
randcrate is available as a dependency - Add tests for seat randomization with and without seed
- Add tournament integration (shuffle at start)
Phase 4: Cross-Cutting
- Update existing TOML config examples with commented-out new fields
- Update
GameConfig::default()to include new fields - Update
SimulationStats::summary()to display rake and bankroll info - Verify backward compatibility: run existing tests unchanged
- Update
docs/README with new config options
Summary of All Struct Changes
| Struct | File | New Fields |
|---|---|---|
GameConfig |
config.rs |
rake_percentage: f64, rake_cap: ChipCount, reset_bankroll_each_hand: bool, bankroll_refill_threshold: ChipCount, randomize_seats: bool |
TableConfig |
table.rs |
rake_config: RakeConfig, reset_bankroll_each_hand: bool, bankroll_refill_threshold: ChipCount, starting_chips: ChipCount, randomize_seats: bool |
Table |
table.rs |
rake_config: RakeConfig, reset_bankroll_each_hand: bool, bankroll_refill_threshold: ChipCount, starting_chips: ChipCount, randomize_seats: bool |
HandRunner |
hand_runner.rs |
rake_config: RakeConfig |
HandResult |
hand_runner.rs |
total_rake: ChipCount, rake_per_pot: Vec<ChipCount> |
Seat |
player.rs |
bankroll: i64 |
PlayerInfo |
player_info.rs |
bankroll: i64 |
GameInfo |
game_info.rs |
reset_bankroll_each_hand: bool |
PlayerStats |
stats.rs |
total_rake_paid: ChipCount, bankroll: i64 |
SimulationStats |
stats.rs |
total_rake: ChipCount |
RakeConfig |
NEW | percentage: f64, cap: ChipCount |