Building a Fast Poker Game Simulator with Rust and WebAssembly


Have you ever wondered what your exact odds are in a poker hand? Not estimates or approximations, but the precise probability calculated by evaluating every possible outcome? I built a poker game simulator that does exactly that, and in this post, I'll share the technical journey of creating a solver that can crunch through over a million game scenarios in seconds.

The Challenge

Building a poker simulator sounds straightforward until you consider the combinatorial explosion. In Texas Hold'em:

  • A deck has 52 cards
  • Each player gets 2 hole cards
  • 5 community cards are dealt
  • With partial information, you need to evaluate all possible opponent hands and remaining community cards

Here's how the possibilities scale based on known information:

Known CardsYour HandOpponent's HandCommunity CardsRemaining CardsPossible Scenarios
Pre-flop2 cards0 cards0 cards50 cards1,070,190
Pre-flop2 cards2 cards0 cards48 cards17,296
Flop2 cards0 cards3 cards47 cards178,365
Flop2 cards2 cards3 cards45 cards990
Turn2 cards0 cards4 cards46 cards15,180
Turn2 cards2 cards4 cards44 cards44
River2 cards0 cards5 cards45 cards990
River2 cards2 cards5 cards43 cards1

The worst case—when you only know your own 2 cards—requires evaluating over 1 million possible game states. The challenge was to build something that could handle this exhaustive computation while remaining responsive and user-friendly.

Architecture Overview

The project consists of two main components:

  1. poker-solver: A Rust library that handles all game logic and probability calculations
  2. poker-simulator: A SvelteKit frontend that provides an intuitive interface

Bit-mask Card Representation

I use 64-bit integer to represent a hand, actually we only need 52 bits:

pub struct Hand {
    pub mask: i64,
}

// Each card gets a unique bit position (0-51)
// Position = rank * 4 + suit
// Example: Ace of Spades = bit 48

Why is this brilliant? It transforms expensive operations into simple bitwise operations:

  • Combining hands: hand1.mask | hand2.mask
  • Checking overlap: (hand1.mask & hand2.mask) != 0
  • Counting cards: hand.mask.count_ones()

This representation allows us to perform hand operations in O(1) time instead of iterating through card arrays.

Pattern Matching for Hand Evaluation

Instead of checking each hand type with complex logic, I pre-computed all possible patterns: For example I use these masks to quickly test if a hand containing a triple (222, 333, 444, ...)

// Pattern for three of a kind combinations
const SAME_RANK_3X: [i64; 4] = [
    0b1110, // ♠♣♦
    0b1101, // ♠♣♥
    0b1011, // ♠♦♥
    0b0111, // ♣♦♥
];

The evaluator checks these patterns in order of hand strength (straight flush → high card). When it finds a match, it uses bit manipulation to extract the exact cards forming that hand. This approach is not only fast but also cache-friendly, a 64bit integer is tiny we can fit so many of that in CPU cache.

The Simulation Algorithm

The solver uses a breadth-first search approach to evaluate all possible game states:

pub fn compute(&mut self) -> GameResult {
    let mut queue = VecDeque::new();
    queue.push_back(initial_state);

    while let Some(state) = queue.pop_front() {
        if state.is_complete() {
            // Evaluate and update win/lose/tie counts
        } else {
            // Generate next possible states
            for card in available_cards {
                queue.push_back(state.add_card(card));
            }
        }
    }
}

On top of the BFS search, I use a hash map to avoid computing the same game over and over.

WebAssembly Integration

Compiling to WASM was surprisingly straightforward with wasm-bindgen:

#[wasm_bindgen]
pub fn solve(hand_a: String, hand_b: String, community: String) -> GameResult {
    static GAME_INSTANCE: OnceLock<Mutex<Game>> = OnceLock::new();
    let game = GAME_INSTANCE.get_or_init(|| Mutex::new(Game::new()));
    // ... parse and compute
}

Heavy computations run in a Web Worker, keeping the UI responsive:

// Main thread
const worker = new Worker('poker-solver.js');
worker.postMessage({ handA, handB, community });

// Worker thread
import init, { solve } from './poker_solver_bg.wasm';
self.onmessage = async (e) => {
    const result = solve(e.data.handA, e.data.handB, e.data.community);
    self.postMessage(result);
};

Performance Results

The optimizations paid off dramatically:

  • Worst-case scenario (1,070,190 possibilities): ~4 seconds native, ~16 seconds WASM
  • Typical late-game scenario: 50-290ms
  • Memory usage: Minimal due to bit-packed representation

Future Improvements

While the current implementation is fast, there's room for enhancement:

  • Parallelization: Use Rayon to evaluate hands across multiple cores
  • SIMD instructions: Batch evaluate multiple hands simultaneously
  • Approximate mode: Monte Carlo sampling for instant estimates
  • Other variants: Extend to Omaha or Seven-Card Stud

Try It Yourself

The entire project is open source:

I hope my humble works inspires you to push the boundaries of what's possible in web applications. Modern web are not slow as they used to be :)