A complete technical deep-dive

EdgeCounter²

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.

Rust · Axum Tokio async Wong Halves Combinatorial EV WebSocket · JSON
378simulated tables
700mstick interval
550EV branches
0.8msper EV compute
Scroll to explore

Part I — The Game

Blackjack, from scratch

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.

Card Values

Number cards = face value. Face cards (J, Q, K) = 10. Aces = 1 or 11, whichever helps.

7
= 7 pts
K
= 10 pts
A
= 1 or 11
5
= 5 pts
Blackjack = an Ace + any 10-value card on the first two cards. Pays 3:2 — a $10 bet wins $15.

A Round, Step by Step

① Betting

Players place bets before any cards are dealt.

② Deal

Each player gets 2 cards face-up. Dealer gets 1 face-up, 1 hidden.

③ Players Act

Hit (draw a card), Stand (stop), Double (double bet, take exactly one more card).

④ Dealer Reveals

Dealer flips hidden card. Must hit until reaching ≥17, then must stand.

⑤ Settlement

Higher total without busting wins. Going over 21 = bust = automatic loss.

The house edge: In a freshly shuffled shoe, the dealer wins roughly 0.5% more than the player over many hands. That's ~50 cents lost per $100 wagered. Card counting exploits shifts away from this baseline.

Part II — Card Counting

Why remaining cards change everything

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.

The Wong Halves System

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.

Running Count

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
  }
}

True Count

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
Rule of thumb: Each +1 true count adds roughly +0.5% to player EV. At TC +2, player edge is near zero. At TC +4, the player has an advantage.

Part III — The EV Engine

Computing exact expected value

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.

Before vs. After: The original approach used a linear proxy: 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 Four-Stage Pipeline

The code lives in src/sim/ev_engine/. Each module is independently validated against published probability tables.

01
dealer.rs
Dealer Final-Total Distribution

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.

02
hand.rs
Player Hand EVs — Stand / Hit / Double / Split

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.

03
round.rs
Round EV Enumeration — 550 Branches

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.

RoundEv output
The result stored in each TableState
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
}
Cashout — Pragmatic Play's Special Option

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).

A Subtle JS Bug — Floored at 0.0001

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

How the server is built

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.

System Overview
Browser JS / WebSocket HTTP / WS Axum Router main.rs Auth Gate (cookie) Page templates (string-replace) API routes WebSocket handlers src/api/mod.rs AppState state.rs — Arc<RwLock> sim: Arc<RwLock<Sim>> sessions: HashMap users, proxies bots, settings insights, pragmatic tables_tx: broadcast → /ws/tables clients started_at: f64 Sim (378 tables) sim/mod.rs · tick every 700 ms TableState (×378) shoe · phase · hands · ev · trackers count.rs — Wong Halves (source of truth) cards.rs — 8-deck shoe, weighted deal engine.rs — play_round, basic strategy ev_engine/ — real CA (dealer→hand→round) cashout.rs — 303-entry offer table Per-shoe trackers (reset on reshuffle): rounds · BJ counts · avg/peak TC ev_history (last 60 rounds, sparkline) snapshot() → TableSnapshot → /api/tables, /ws/tables, /ws/tables/{id} bot_candidates() → top-EV eligible tables tokio tick loop 700ms · spawned task

Wire Fidelity — the Prime Directive

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.

  • All serialized fields are camelCase via #[serde(rename_all)]
  • Most data stored as raw serde_json::Value — no struct drift risk
  • shoe_counts emitted as [A,2,3,4,5,6,7,8,9,10]not internal rank order
  • Constants like "hasEngine":true hardcoded because the UI reads them

No Template Engine

Pages 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

The Phase State Machine

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.

Betting 6–11 ticks BettingClose 2–3 ticks BetsClosed 1 tick Dealing ★ new round Players 3–6 ticks Dealer 2–4 ticks RoundEnd 2–3 ticks Between 1–2 ticks
Dealing is the only phase where a new round is dealt, the EV engine re-runs against the current shoe (≈0.8 ms), and — if the shoe reached the cut card — the shoe reshuffles and all per-shoe statistics reset to zero.

The Split-Borrow Trick

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

Every file, explained

Data Generators (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.

FileWhat it generatesStrategy
gen/users.rs8 user accounts with rolesSeed-once Plaintext passwords
gen/proxies.rs32 proxy shardsSeed-once Fake IPs, masked creds
gen/sessions.rs66 Pragmatic sessionsSeed-once Fake JSESSIONIDs
gen/bots.rs15 player bot configsSeed-once Wired to top live table
gen/insights.rsPlayer analytics rollupsSeed-once Partitions sum correctly
gen/misc.rsSettings, metrics, snifferPer-request Resource stats always random

What's Real vs. Simulated

ComponentStatusNotes
Wong Halves count mathFaithfulExact values, single source of truth in count.rs
Round EV (headline)FaithfulCombinatorial engine, validated vs. Wizard of Odds to 6 dp
Cashout table (303 entries)FaithfulPorted from real Python script; 779/779 exact-match verified
API + WebSocket wire shapesFaithfulByte-compatible JSON, camelCase external contract
Per-hand action EVsModelledHeuristic + jitter; real CA engine exists but not yet wired into rendered hands
Dealer final distributionFaithfulExact without-replacement recursion; used by round EV
Live card feedSimulatedRandom draws weighted by remaining shoe composition
Users, proxies, botsMockedInvented data; no persistence — lost on server restart
Table names & IDsMockedRandom on every boot; deep links break across restarts
Admin mutationsPartialMutate in-memory state only; operator actions are no-op acks