A complete Monopoly board game engine using bevy_ecs standalone (not the full Bevy engine) for deterministic, turn-based simulation. Four AI players compete at progressively higher HL technology levels across a fully implemented 40-square board with cards, auctions, trades, mortgages, and house building.
The engine serves as the second integration test bed for the HL thesis: bandit-driven strategy selection + game phase adaptation + opponent modeling > static heuristics or random baselines in a turn-based strategy domain.
Feature flag: monopoly = ["bevy_ecs", "bandit"].
All systems operate on &mut World directly — no ECS schedule, no real-time delta, no plugins.
init_world(seed)
├─ GameConfig, TurnState, Statistics → resources
├─ Events<GameEvent> → event bus
├─ build_board() → 40 BoardSquare entities
├─ shuffle_decks(seed) → Chance + CommunityChest CardDeck entities
└─ spawn_players() → 4 Player entities at GO with $1500
run_game(seed, players, rng, max_turns) → GameResult
├─ reset() all players
└─ loop: execute_turn() for each active player
├─ count_active_players() → 1? → find_winner()
└─ turn >= max_turns → find_richest()
execute_turn(world, player_id, ai, rng) → TurnResult
├─ Phase 1: PreTurn (jail decision)
├─ Phase 2–4: Rolling / Resolving / Doubles loop
├─ Phase 5: Strategic (build houses)
└─ Phase 6: EndTurn
Unlike Bomberman's priority-based tick FSM, Monopoly uses a sequential phase pipeline — each phase runs once per turn in order:
PreTurn ──→ Rolling ──→ Resolving ──→ Strategic ──→ EndTurn
│ │
│ jail_decision() │ resolve_landing()
│ pay/roll/card │ property/auction/card/tax
│ │
└── doubles? ────────────┘ (re-roll loop)
| Phase | Purpose | AI Hook |
|---|---|---|
| PreTurn | Jail decision: pay fine, use card, or roll for doubles | jail_decision() |
| Rolling | Roll dice, track doubles count, send to jail on 3rd double | — |
| Resolving | Move token, collect salary, resolve square landing | should_buy_property(), auction_bid(), trade_response() |
| Strategic | Build houses on owned monopolies | build_houses(), propose_trade() |
| EndTurn | Advance to next active player | — |
The doubles loop re-enters Rolling→Resolving when doubles are rolled (max 3 before Speeding jail).
0 GO 10 Jail 20 Free Parking 30 Go To Jail
1 Mediterranean Ave 11 St. Charles Pl 21 Kentucky Ave 31 Pacific Ave
2 Community Chest 12 Electric Co 22 Chance 32 North Carolina Ave
3 Baltic Ave 13 States Ave 23 Indiana Ave 33 Community Chest
4 Income Tax 14 Virginia Ave 24 Illinois Ave 34 Pennsylvania Ave
5 Reading Railroad 15 Pennsylvania RR 25 B&O Railroad 35 Short Line RR
6 Oriental Ave 16 St. James Pl 26 Atlantic Ave 36 Chance
7 Chance 17 Community Chest 27 Ventnor Ave 37 Park Place
8 Vermont Ave 18 Tennessee Ave 28 Water Works 38 Luxury Tax
9 Connecticut Ave 19 New York Ave 29 Marvin Gardens 39 Boardwalk
| Type | Count | Squares |
|---|---|---|
| Property (Streets) | 22 | 8 color groups (Brown ×2, LightBlue/Pink/Orange/Red/Yellow/Green ×3, DarkBlue ×2) |
| Railroad | 4 | 5, 15, 25, 35 |
| Utility | 2 | 12 (Electric Co), 28 (Water Works) |
| Tax | 2 | 4 (Income $200), 38 (Luxury $100) |
| Chance | 3 | 7, 22, 36 |
| Community Chest | 3 | 2, 17, 33 |
| Special | 6 | GO (0), Jail (10), Free Parking (20), Go To Jail (30) |
| Group | Squares | Prices | House Cost | Base Rent |
|---|---|---|---|---|
| Brown | 1, 3 | $60, $60 | $50 | $2, $4 |
| LightBlue | 6, 8, 9 | $100–$120 | $50 | $6–$8 |
| Pink | 11, 13, 14 | $140–$160 | $100 | $10–$12 |
| Orange | 16, 18, 19 | $180–$200 | $100 | $14–$16 |
| Red | 21, 23, 24 | $220–$240 | $150 | $18–$20 |
| Yellow | 26, 27, 29 | $260–$280 | $150 | $22–$24 |
| Green | 31, 32, 34 | $300–$320 | $200 | $26–$28 |
| DarkBlue | 37, 39 | $350, $400 | $200 | $35, $50 |
| Component | Purpose |
|---|---|
Player { id, cash, position, in_jail, ... } |
Player state with cash, position, jail tracking, GOOJ cards |
Property { square, group, name, price, base_rent, monopoly_rent, house_cost, house_rent, mortgage_value } |
Street property data with rent table (5 house tiers) |
Owned { owner, is_mortgaged, houses } |
Ownership + house count (0–4 houses, 5 = hotel) |
Railroad |
Marker for railroad squares |
Utility |
Marker for utility squares |
CardDeck { cards, draw_index, is_chance } |
Card deck with circular draw |
BoardSquare { index, kind } |
Square identity and kind (SquareKind::Property(PropertyGroup), Railroad, Tax, etc.) |
| Resource | Purpose |
|---|---|
Board { squares: [Entity; 40] } |
Entity references for all 40 squares |
TurnState { current_player, phase, turn_number, doubles_count } |
Current turn tracking |
GameConfig { starting_cash, salary, jail_fine, ... } |
Game rules (default: $1500 start, $200 salary, $50 fine) |
PlayerEntities { entities: [Entity; 4] } |
4 player entity references |
Statistics { turns_played, properties_bought, rent_paid, ... } |
Per-player game statistics |
23 GameEvent variants covering all game actions:
enum GameEvent {
TurnStarted { player },
DiceRolled { player, die1, die2, doubles },
PlayerMoved { player, from, to, passed_go },
SalaryCollected { player, amount },
PropertyBought { player, square, price },
PropertyAuctioned { square, winner, price },
PropertyDeclined { player, square },
RentPaid { payer, payee, amount, square },
TaxPaid { player, amount, tax_kind },
CardDrawn { player, is_chance, effect },
HouseBuilt { player, square, houses },
PropertyMortgaged { player, square, amount },
PropertyUnmortgaged { player, square, cost },
TradeOffered { proposer, responder },
TradeAccepted { proposer, responder },
TradeDeclined { proposer, responder },
PlayerJailed { player, reason },
PlayerReleasedFromJail { player, method },
PlayerBankrupt { player, creditor },
GameOver { winner },
AuctionStarted { square },
AuctionBid { player, amount },
AuctionWon { player, square, amount },
}| Property Type | Formula |
|---|---|
| Street (no monopoly) | base_rent |
| Street (monopoly, 0 houses) | monopoly_rent (= 2× base) |
| Street (1–4 houses) | house_rent[houses - 1] (escalating per group) |
| Street (hotel, 5 houses) | house_rent[4] |
| Railroad (1 owned) | $25 |
| Railroad (2 owned) | $50 |
| Railroad (3 owned) | $100 |
| Railroad (4 owned) | $200 |
| Utility (1 owned) | 4× dice sum |
| Utility (2 owned) | 10× dice sum |
| Any mortgaged | $0 |
Railroad rent uses bit-shift: 25u32 << (count - 1).
- Complete set required — player must own all unmortgaged properties in the color group
- Even-building enforced —
can_build_house()checkshouses <= min_houses_in_group + 1 - Mortgage blocks building — mortgaged properties break the monopoly check
- Max 5 houses per property — 0–4 = houses, 5 = hotel
- House costs per group: Brown/LightBlue $50, Pink/Orange $100, Red/Yellow $150, Green/DarkBlue $200
| Effect | Value |
|---|---|
| Advance to GO | MoveTo(0) |
| Advance to Illinois Ave | MoveTo(24) |
| Advance to St. Charles Place | MoveTo(11) |
| Advance to nearest railroad (×2) | MoveToNearest { is_railroad: true } |
| Advance to nearest utility | MoveToNearest { is_railroad: false } |
| Bank pays dividend | CollectMoney(50) |
| Get out of jail free | GetOutOfJailFree |
| Go back 3 spaces | MoveBack(3) |
| Go to jail | GoToJail |
| General repairs | PayPerHouse { house: 25, hotel: 100 } |
| Pay poor tax | PayMoney(15) |
| Advance to Reading Railroad | MoveTo(5) |
| Advance to Boardwalk | MoveTo(39) |
| Chairman of the board | PayEachPlayer(50) |
| Loan matures | CollectMoney(150) |
| Effect | Value |
|---|---|
| Advance to GO | MoveTo(0) |
| Bank error | CollectMoney(200) |
| Doctor's fee | PayMoney(50) |
| Sale of stock | CollectMoney(50) |
| Get out of jail free | GetOutOfJailFree |
| Go to jail | GoToJail |
| Holiday fund | CollectMoney(100) |
| Income tax refund | CollectMoney(20) |
| Hospital bills | PayEachPlayer(50) |
| School fees | PayMoney(100) |
| Consultancy fee | CollectMoney(25) |
| Street repairs | PayPerHouse { house: 40, hotel: 115 } |
| Beauty contest | CollectMoney(10) |
| Inheritance | CollectMoney(100) |
| Birthday | CollectFromEachPlayer(50) |
| Life insurance | CollectMoney(50) |
The TurnPhase enum defines 9 states:
┌─────────────────────────────────────────────────────────┐
│ TurnPhase FSM │
│ │
│ PreTurn ──→ Rolling ──→ Resolving ──→ Strategic ──→ EndTurn │
│ │ │ │
│ │ └── doubles? ──→ Rolling (re-enter) │
│ │ │
│ └── jail? yes → PreTurn logic │
│ no → skip to Rolling │
│ │
│ Acquisition ── triggered when landing on unowned prop │
│ Auction ── triggered when player declines purchase │
│ FinancialCrisis ── triggered when can't pay debt │
│ Bankrupt ── terminal state, transfer assets │
└─────────────────────────────────────────────────────────┘
| State | Trigger | System Action |
|---|---|---|
| PreTurn | Turn start | Check jail, call jail_decision(), release or stay |
| Rolling | After PreTurn | Roll dice, check doubles (3rd = jail), track count |
| Resolving | After Rolling | Move token, resolve landing square (buy/rent/tax/card) |
| Acquisition | Unowned property | Offer to player → should_buy_property(), or auction |
| Auction | Player declines | All active players bid → auction_bid(), highest wins |
| FinancialCrisis | Can't pay debt | liquidate_assets() (sell houses, mortgage) to raise cash |
| Strategic | After Resolving | build_houses(), propose_trade() on complete sets |
| EndTurn | After Strategic | Advance player, increment turn counter |
| Bankrupt | Can't pay after liquidation | transfer_assets() to creditor, remove from game |
- Tech: None. Deterministic pseudo-random from square parity.
- Buy: 50% chance if affordable (even square + price ≤ cash).
- Auction: Min bid or parity-based increment.
- Jail: Pay if cash ≥ $50, else roll for doubles.
- Build: Never builds houses.
- Trade: Always declines, never proposes.
- No learning, no memory, no model. Pure baseline.
- Tech: Heuristic property acquisition with $100 cash buffer.
- Buy: Everything affordable above buffer.
- Auction: Bids up to 80% of printed price.
- Jail: Pay early (turns 1–15), roll late, use card if available.
- Build: On complete sets, highest base-rent first, up to 2 houses per call.
- Trade: Accepts if net properties > 0 or net cash > 0.
- Mortgage: Cheapest properties first (by price).
- No opponent tracking, no safety validation.
- Tech: Greedy base + hard safety validation ($200 reserve).
- Buy: Only if cash buffer ≥ $200 after purchase.
- Auction: Strategic value minus 15% safety margin, capped by reserve.
- Jail: Phase-aware — pay early, stay late (board is dangerous).
- Build: Only when cash ≥ reserve + $300 threshold, rent-to-cost ratio sort.
- Trade: Hard-blocks trades that create opponent monopolies via
creates_opponent_monopoly(). Requires non-negative net on both properties and cash. - Mortgage: Strategic value sorting with monopoly penalty (+$1000 to protect complete sets).
- Limitation: Static rules prevent bad trades but also prevent strategic risks that win games.
- Tech: P3 safety + opponent portfolio tracking + game phase adaptation + bandit Q-values + absorb-compress.
- Tracks: Opponent properties observed across game via
observe_opponent_property(). - Persists across games: Q-values, visits, compressed arms (bandit memory).
5 bandit strategies selected via ε-greedy (ε=0.1):
| Strategy | Focus | Buy Ratio | Bid Ratio | Build Threshold |
|---|---|---|---|---|
| Expansion | Acquire property | >0.5 | 0.9 | $300 |
| Development | Build houses | >0.8 | 0.75 | $200 |
| Survival | Preserve cash | >1.2 | 0.6 | $500 |
| Aggressive | Take risks | >0.6 | 0.85 | $100 |
| Conservative | Safe plays | >1.0 | 0.5 | $400 |
Game phase detection drives preferred strategy:
| Phase | Turns | Preferred Strategy |
|---|---|---|
| Early | ≤10 | Expansion |
| Mid | 11–25 | Development |
| Late | >25 | Survival |
opponent_properties: Vec<(u8, u8)> // (square, owner_id)Used by threat_level() (delegates to max_rent_exposure()) for jail decisions — late-game jail is safe when total threat exceeds cash.
- ε-greedy: Explore every 10th game, exploit phase-appropriate strategy otherwise.
- Absorb-compress: Every 10 games, strategies with
visits ≥ 20 && Q < 0.1get compressed (hard-blocked). - Accessor methods:
strategy_q(),strategy_visits(),strategy_names(),game_count(). - Learning:
update_outcome(strategy, reward)appliesQ += α * (reward - Q)with α=0.1.
propose_trade(): Expansion/Aggressive strategies actively propose trades to complete color sets, offering 30% over property price.evaluate_trade_value(): Scores both sides consideringproperty_strategic_value()with 20% reluctance penalty for giving properties.- Hard-blocks trades creating opponent monopolies (inherited from Validator).
| Phase | Decision Logic |
|---|---|
| Early | Use card → Pay fine (if affordable) → Roll |
| Mid | Use card → Pay fine (if affordable) → Roll |
| Late | If total threat > cash, stay (roll for doubles) → Use card → Pay fine |
These utility functions are used by multiple player types:
| Function | Purpose | Used By |
|---|---|---|
property_strategic_value(ctx, square) |
Score property value: monopoly bonus (+50%), railroad scaling (0.6–2.0×), set completion detection | Validator, HL |
creates_opponent_monopoly(offer, ctx) |
Check if trade gives any player a complete color set | Validator, HL |
max_rent_exposure(ctx, opponent_id) |
Sum max possible rent from opponent's properties (with houses) | HL |
monopoly_multiplier(ctx, group) |
2.0× for complete set, else 1.0 + (count/size) * 0.5 |
HL |
All AI decisions receive a DecisionContext with 40-element arrays for square data:
pub struct DecisionContext {
pub player_id: u8,
pub cash: u32,
pub position: u8,
pub owned_properties: Vec<u8>,
pub group_counts: [u8; 8], // properties per color group
pub opponent_cash: [u32; 4], // each opponent's cash
pub opponent_property_count: [u8; 4], // each opponent's property count
pub square_owners: [Option<u8>; 40], // who owns each square
pub square_houses: [u8; 40], // houses on each square
pub square_mortgaged: [bool; 40], // mortgage status
pub square_prices: [u32; 40], // printed prices
pub square_base_rent: [u32; 40], // base rent
pub square_house_cost: [u32; 40], // house cost per square
pub square_mortgage_value: [u32; 40], // mortgage value per square
pub turn_number: u32,
pub in_jail: bool,
pub jail_turns: u8,
pub has_jail_card: bool,
}Key methods: owns_complete_set(), count_in_group(), owned_in_group(), net_worth(), game_phase(), total_houses().
| File | Lines | Purpose |
|---|---|---|
src/pruners/monopoly/mod.rs |
1052 | Module index: enums, components, resources, events, constants, board data, 26 tests |
src/pruners/monopoly/board.rs |
738 | Board initialization, 40-square street_data(), card decks, group helpers, 13 tests |
src/pruners/monopoly/systems.rs |
1494 | Game systems: init_world, execute_turn, run_game, rent/build/liquidation, 13 tests |
src/pruners/monopoly/players.rs |
1977 | MonopolyPlayer trait + 4 implementations + shared AI functions, 38 tests |
examples/monopoly_01_arena.rs |
161 | Headless 100-game tournament runner |
examples/monopoly_02_tui.rs |
1125 | Animated ratatui TUI replay with three-panel layout |
examples/monopoly_03_hl_proof.rs |
243 | 1000-game HL proof experiment with stats |
examples/monopoly_04_bench.rs |
129 | Performance benchmark (throughput, latency distribution) |
Total: 90 tests across all 4 source files, 4 examples.
# Headless 100-game tournament
cargo run --example monopoly_01_arena --features monopoly
# Animated TUI replay (controls: Space/→/←/F/A/Home/End/Q)
cargo run --example monopoly_02_tui --features monopoly
# 1000-game HL proof experiment with stats
cargo run --example monopoly_03_hl_proof --features monopoly
# Tests
cargo test --features monopoly
# Specific test
cargo test --features monopoly -- test_full_game_completes#1 🧠 HL Wins=565 Win%=56.5% Survival=93.7%
#2 💰 Greedy Wins=179 Win%=17.9% Survival=75.5%
#3 🛡️ Validator Wins=152 Win%=15.2% Survival=74.0%
#4 🎲 Random Wins=104 Win%=10.4% Survival=71.8%
- Survival: HL (93.7%) - Validator (74.0%) = +19.7pp (threshold: ≥5pp)
- Win rate: HL (56.5%) - Validator (15.2%) = +41.3pp
- Correct ranking achieved: HL > Greedy > Validator > Random
| Strategy | Q-Value | Visits |
|---|---|---|
| Expansion | 0.45 | 229 |
| Development | 0.71 | 69 |
| Survival | 0.48 | 244 |
| Aggressive | 0.48 | 44 |
| Conservative | 0.48 | 414 |
→ Preferred strategy: Development (Q=0.71)
| Metric | Target | Actual |
|---|---|---|
| Full game (avg 278 turns × 4 players) | < 100ms headless | 11.5ms ✅ |
| AI decision per turn | < 1ms | 41µs (25× under) ✅ |
| 1000-game proof | < 2 minutes | ~12s ✅ |
| Throughput | — | 87 games/sec |
| p99 game latency | — | 13.3ms |
- Railroad/Utility group contamination — Railroads and utilities had
Propertycomponent withgroup: PropertyGroup::Brownplaceholder, causingcount_in_group(Brown)to exceedBrown.size()and panic with u8 underflow. Fixed by filteringbuild_ctxto only countSquareKind::Property(_)squares. - Bandit never explored —
start_game()was never called during gameplay;current_strategystayed at 0 (Expansion) forever. Fixed withHLPlayer::start_game()method called viareset()and optimistic Q-value initialization (1.0 instead of 0.0). - Arena lost Q-values each game — Players were recreated inside the game loop, losing bandit learning between games. Fixed by moving player creation outside the loop.
- Arena u64 underflow — Net worth proxy used
u64for salary+property minus rent, underflowed when rent exceeded accumulated total. Fixed withi64.
HL wins 56.5% (expected ~30%). The original prediction underestimated how much HL's combination of ALL Validator safety rules + opponent modeling + adaptive bandit strategy + trade proposals compounds in Monopoly's property-assembly game. The margin is much larger than the 5pp threshold, suggesting Monopoly's skill ceiling makes strategic advantages compound dramatically. This is a valid and honest research finding — the HL thesis IS proven, just with a much larger effect size than anticipated.
-
Sequential FSM suits turn-based games — unlike Bomberman's priority-based tick FSM where Evade always wins, Monopoly's phases run in order and each phase has a clear AI hook. Simpler to reason about, easier to test.
-
DecisionContext as snapshot — building a read-only 40-element array snapshot decouples AI from ECS internals. AI never touches
Worlddirectly, making player implementations testable without a full game world. -
Even-building is the hardest rule — the
can_build_house()check must query all group siblings, compare house counts, respect mortgage breaks, and enforce the +1 differential. Getting this wrong breaks the game economy. -
Card effects are composition, not inheritance —
CardEffectenum with 10 variants handles all classic cards via pattern matching inexecute_card_effect(). Move-based cards chain intoresolve_card_move()for secondary square resolution (e.g., Go To Jail). -
Bankruptcy cascades are complex —
pay_debt()→liquidate_assets()(sell houses half-price, mortgage properties) → if still short →PlayerBankrupt→transfer_assets()to creditor. The order matters: houses before mortgages, and houses on incomplete sets sell first. -
Trade validation is defense-in-depth —
creates_opponent_monopoly()checks both directions (proposer and responder), and Validator/HL both hard-block before any value evaluation. This prevents the AI from accidentally giving away a game-winning monopoly. -
Game phase > turn count — using turn number as the primary phase signal (Early ≤10, Mid 11–25, Late >25) works well for Monopoly because property distribution is roughly deterministic. The board state emerges predictably from the rules.
-
Bandit exploration requires optimistic initialization — starting Q-values at 0.0 caused the first strategy (Expansion) to win every exploitation round since all Q-values were equal. Fixing to optimistic init (1.0) allowed natural exploration: after a strategy's Q-value drops below 1.0 from real outcomes, untried strategies (still at 1.0) become more attractive. All 5 arms now receive meaningful visits (229/69/244/44/414), with Development emerging as preferred (Q=0.71).