A complete technical deep-dive
A self-contained Rust server that simulates 378 live blackjack tables, computes exact card-counting expected value in real time, and serves an operator dashboard that is wire-identical to the original production system.
Part I — The Game
Before understanding what this codebase does, you need to understand the game it models. Blackjack is played between each player and a dealer (the house). The goal: get as close to 21 points as possible without going over — and beat the dealer's total.
Number cards = face value. Face cards (J, Q, K) = 10. Aces = 1 or 11, whichever helps.
Players place bets before any cards are dealt.
Each player gets 2 cards face-up. Dealer gets 1 face-up, 1 hidden.
Hit (draw a card), Stand (stop), Double (double bet, take exactly one more card).
Dealer flips hidden card. Must hit until reaching ≥17, then must stand.
Higher total without busting wins. Going over 21 = bust = automatic loss.
Part II — Card Counting
A blackjack shoe starts with 8 decks = 416 cards. As the game progresses, the composition of remaining cards shifts — and so do the probabilities. Card counting is simply tracking that shift.
The key insight: high cards (10s, Aces) favor the player. When the remaining shoe is rich in high cards, blackjacks (paying 3:2) are more frequent, doubles are more profitable, and the dealer busts more often because they must hit to 17. Low cards (2–6) favor the dealer.
EdgeCounter2 uses Wong Halves — one of the most accurate card counting systems ever developed. Each dealt card updates a running total. The higher the count, the richer the remaining shoe in high cards, and the better for the player.
The count values in count.rs are the single source of truth — the shoe, engine, and EV engine all import from here. Nothing duplicates these numbers.
// Every dealt card calls this pub fn wong_halves(rank: usize) -> f64 { match rank { 0 => 0.5, // 2 — low card out → good 1 => 1.0, // 3 2 => 1.0, // 4 3 => 1.5, // 5 — best low card 4 => 1.0, // 6 5 => 0.5, // 7 6 => 0.0, // 8 — neutral 7 => -0.5, // 9 8..=11 => -1.0, // T, J, Q, K 12 => -1.0, // A — ace out → bad } }
A running count of +10 means different things depending on how many cards remain. The true count normalizes for deck depth — it's the running count divided by decks remaining.
// Normalize for shoe depth pub fn true_count(rc: f64, cards: u32) -> f64 { // Guard: near-empty shoe = ¼ deck minimum // (avoids explosive values at end-of-shoe) let decks = (cards as f64 / 52.0).max(0.25); (rc / decks * 100.0).round() / 100.0 } // Example: // RC = +8, cards remaining = 156 (3 decks) // TC = 8 / 3 = +2.67
Part III — The EV Engine
Expected Value (EV) answers: "If I bet $1 on the next round, how much do I expect to win or lose?" A fresh 8-deck shoe gives roughly −0.534% — you lose about half a cent per dollar bet. The engine's job is to compute this precisely from the exact cards remaining, not from a rough formula.
EV ≈ −0.5% + TrueCount × 0.5%. The real engine enumerates every possible dealer upcard and player starting hand — 550 branches — using exact shoe composition without replacement.
The code lives in src/sim/ev_engine/. Each module is independently validated against published probability tables.
Given the dealer's upcard and the live shoe composition, compute the probability the dealer ends on each possible total: P(17), P(18), P(19), P(20), P(21), P(Blackjack), P(Bust). Uses without-replacement recursion over exact card counts. Validated against Wizard of Odds Appendix 2 oracles to 6 decimal places — e.g. P(bust | upcard 6) = 0.422922.
For every possible player hand total and dealer upcard, compute the EV of each action using the dealer distribution from stage 1. Handles Ace demotion (soft → hard), Pragmatic Play's rules: peek on Ace (kills insurance trap) but no peek on Ten, double-after-split allowed, no resplit, split aces draw one card only.
Enumerate every (dealer upcard × unordered player starting pair): 10 upcards × 55 pairs = 550 branches. For each branch, look up the optimal-play EV from stage 2. Weight each branch by the exact probability of that deal using without-replacement arithmetic from the live shoe. Sum all weighted EVs to get the headline roundEv. Run twice — with and without the Cashout option — to derive cashoutUplift.
struct RoundEv { round_ev: f64, // e.g. -0.00534 (−0.534%) round_ev_no_cashout: f64, cashout_uplift: f64, // bonus value of the cashout option p_player_bj: f64, // probability of player blackjack p_dealer_bust: f64, // probability dealer busts insurance_ev: f64, // EV of insurance side bet }
During a round, players can "cash out" — accept a fixed offer rather than play their hand to completion. A 303-entry lookup table (ported faithfully from the original Python script, 779/779 exact-match verified) provides the cashout multiplier for every possible hand state. cashoutUplift = roundEv(with) − roundEv(without).
The original frontend has classList.add(cashoutUplift > 1e-6 ? "pos" : ""). Passing an empty string to classList.add("") throws a DOMException. To avoid this crash, cashoutUplift is always floored at 1×10⁻⁴ — so the conditional is always true and the class is always set.
Part IV — Architecture
A single Rust binary built on Axum (async web framework on top of Tokio). The prime directive is wire fidelity — every JSON field name, type, array ordering, and value must exactly match what the captured original JavaScript frontend expects.
The original JavaScript frontend was captured verbatim and is served unchanged. The server must match every JSON field name, type, and array order those scripts expect.
camelCase via #[serde(rename_all)]serde_json::Value — no struct drift riskshoe_counts emitted as [A,2,3,4,5,6,7,8,9,10] — not internal rank order"hasEngine":true hardcoded because the UI reads themPages are served with raw string replacement — no Tera, Askama, or Handlebars. Each request reads the template file from disk and substitutes three placeholders:
// Three placeholders, replaced at serve time: __EC_ROLE__ → "admin" or "user" __EC_USER__ → username from session __EC_TABLE_ID__ → table ID from URL // Adding a new dynamic value means: // 1. Add placeholder to template HTML // 2. Add .replace() call in main.rs
Each of the 378 tables independently cycles through a fixed 8-phase round loop. Phases have randomized tick-durations to desynchronize the tables and make the lobby feel alive. Speed tables use half the normal duration.
One of the most interesting Rust-specific patterns in the codebase. The tick loop needs &mut self.rng while iterating over &mut self.tables. A for t in &mut self.tables loop takes a mutable borrow of all of self.tables which conflicts with borrowing self.rng. Indexed access lets the borrow checker see two distinct fields:
// ❌ Won't compile — two borrows of self: for t in &mut self.tables { t.advance(&mut self.rng); // ^^^ borrow conflict }
// ✅ Works — borrows two distinct fields: for idx in 0..self.tables.len() { self.tables[idx].advance( &mut self.rng ); // compiler tracks field paths }
Part V — The Codebase
src/gen/)Non-table data (users, proxies, bots, analytics) is generated once at boot from a random seed, then served verbatim on every request. The goal is internal coherence — e.g., insights totals always sum correctly across all partitions.
| File | What it generates | Strategy |
|---|---|---|
gen/users.rs | 8 user accounts with roles | Seed-once Plaintext passwords |
gen/proxies.rs | 32 proxy shards | Seed-once Fake IPs, masked creds |
gen/sessions.rs | 66 Pragmatic sessions | Seed-once Fake JSESSIONIDs |
gen/bots.rs | 15 player bot configs | Seed-once Wired to top live table |
gen/insights.rs | Player analytics rollups | Seed-once Partitions sum correctly |
gen/misc.rs | Settings, metrics, sniffer | Per-request Resource stats always random |
| Component | Status | Notes |
|---|---|---|
| Wong Halves count math | Faithful | Exact values, single source of truth in count.rs |
| Round EV (headline) | Faithful | Combinatorial engine, validated vs. Wizard of Odds to 6 dp |
| Cashout table (303 entries) | Faithful | Ported from real Python script; 779/779 exact-match verified |
| API + WebSocket wire shapes | Faithful | Byte-compatible JSON, camelCase external contract |
| Per-hand action EVs | Modelled | Heuristic + jitter; real CA engine exists but not yet wired into rendered hands |
| Dealer final distribution | Faithful | Exact without-replacement recursion; used by round EV |
| Live card feed | Simulated | Random draws weighted by remaining shoe composition |
| Users, proxies, bots | Mocked | Invented data; no persistence — lost on server restart |
| Table names & IDs | Mocked | Random on every boot; deep links break across restarts |
| Admin mutations | Partial | Mutate in-memory state only; operator actions are no-op acks |