The engine answers one question:
Given a player's exact two-card hand and one or more opponents playing from a weighted distribution of possible hands, what is each player's equity across all the ways the remaining board cards can be distributed?
The project has three layers:
- The C engine (
src/,include/) — hand evaluation and Monte Carlo simulation - The Python bridge (
src/python/) — a wrapper around the compiled library, and a FastAPI server - The frontend (
frontend/) — a React web app that calls the API
The engine is written in C and compiled into a shared library. It has two components: the hand evaluator, which scores any poker hand, and the Monte Carlo simulator, which uses that evaluator to calculate equity across thousands of simulated runouts.
The hand evaluator takes any 2–7 card hand and returns a score. A higher score always means a stronger hand, and any two scores can be compared directly to determine a winner.
Benchmarked at ~700 million hands per second — roughly 300× faster than a brute-force evaluation at runtime.
Rather than storing a list of cards, the evaluator maintains a compact summary of the hand that updates incrementally as each card is added. This tracks rank composition and suit distribution — enough to perform the flush check and route to the correct table, without iterating over cards at evaluation time.
How it works
Precomputed tables
Before the engine can be used, a standalone table-generation program (tools/generate_tables.c) runs once and produces the lookup tables compiled directly into the library. These tables never change and never need to be loaded from disk at runtime.
- Flush table — handles any hand where five or more cards share the same suit
- Main table — handles everything else
Together they cover all possible 7-card Texas Hold'em hands.
Evaluating a hand
- Check if a flush is present
- Route to the flush table or the main table
- Return the score — a single lookup, immediate result
The score encodes both hand category and rank within that category, so A♠A♥ scores higher than K♠K♥, a king-high flush beats a ten-high flush, and so on.
| Category | Score Range |
|---|---|
| High card | 4 096 – 8 191 |
| Pair | 8 192 – 12 287 |
| Two pair | 12 288 – 16 383 |
| Three of a kind | 16 384 – 20 479 |
| Straight | 20 480 – 24 575 |
| Flush | 24 576 – 28 671 |
| Full house | 28 672 – 32 767 |
| Four of a kind | 32 768 – 36 863 |
| Straight flush | 36 864 – 40 959 |
Monte Carlo simulation is a technique for solving problems too complex to calculate exactly — instead of trying every possible outcome, it runs thousands of random trials and averages the results. Every time the game state changes — a card dealt, a range updated, anything — 100,000 fresh trials run instantly in the background. Each trial accounts for the exact state of the board: known cards are locked in and never redrawn, and each opponent's hand is sampled randomly from within their weighted range, so more likely holdings get picked more often. 100,000 of these statistically valid snapshots converge on an equity figure accurate to within ±0.3 percentage points.
What a range is
In poker, a range is the set of hands a player could plausibly be holding, along with how likely each hand is. In this engine, a range is a weight between 0.0 and 1.0 assigned to each of the 1,326 possible two-card starting hands.
| Weight | Meaning |
|---|---|
1.0 |
Player always holds this hand when it's available |
0.5 |
Player holds it half the time |
0.0 |
Player never holds it |
Seat types
| Type | Description |
|---|---|
| Exact | The player's two cards are known (the hero) |
| Active range | Still in the hand with a weighted range of possible holdings |
| Folded range | Has folded but range is known — their cards are removed from the deck for other players |
| Absent | The seat is empty |
How a trial runs
The simulation runs 100,000 trials by default, producing results accurate to within ±0.3 percentage points at 95% confidence.
Each trial:
- Mark all known cards as unavailable (hero's hand, existing board cards)
- Deal each player with a range a hand — sampler filters unavailable cards, selects randomly according to remaining weights, marks dealt cards as unavailable before moving to the next player
- Draw any remaining community cards from the deck
- Evaluate every active player's best 5-card hand against the board
- Award equity — clean win gets full credit, ties are split equally
Equity is tracked as exact integers throughout — no floating-point accumulation errors. Percentages are only computed once at the very end.
If a player's range has no available hands due to card conflicts, the trial is discarded and retried.
The engine is accessible over HTTP via a FastAPI server. Send a POST request to /equity and get back a full equity breakdown for the hand.
API Reference
Request
{
"schema_version": "equity_request_v1",
"request_id": "abc123",
"trials": 100000,
"hero_cards": ["As", "Kh"],
"board_cards": ["2h", "7c", "Jd"],
"seats": [
{
"seat_index": 1,
"state": "ACTIVE",
"range": {
"encoding": "sparse_bp_v1",
"pairs": [
[combo_index, weight_in_basis_points]
]
}
},
{ "seat_index": 2, "state": "ABSENT" }
]
}Cards are two-character strings — rank then suit. Ranks: 2–9, T, J, Q, K, A. Suits: d, c, h, s.
Seats 1 through 8 must all be present. Each seat state is one of ACTIVE, ABSENT, PRE_FLOP_FOLD, or CUSTOM_FOLD. Non-absent seats require a range.
Range encodings
dense_bp_v1— an array of exactly 1,326 integers in basis points (0–10000, where 10000 = weight 1.0), one per combo in canonical ordersparse_bp_v1— an array of[combo_index, weight_bp]pairs covering only nonzero combos, sorted ascending by index
Response
{
"schema_version": "equity_response_v2",
"request_id": "abc123",
"status": "ok",
"result": {
"hero": {
"win_bp": 6840,
"loss_bp": 2510,
"tie_bp": 650,
"equity_bp": 7165,
"win_count": 68400,
"loss_count": 25100,
"tie_count": 6500
},
"villains": [
{
"seat_index": 1,
"win_bp": 2835,
"equity_bp": 2835
}
],
"diagnostics": {
"requested_trials": 100000,
"accepted_trials": 99997,
"retry_count": 3,
"deadlock_count": 0,
"compute_ms": 142,
"seed_in": "1311768467294899668",
"seed_after": "9823456712309876543"
},
"table": {
"street": "FLOP",
"board_count": 3,
"active_villain_count": 1,
"folded_seat_count": 0,
"absent_seat_count": 7
}
}
}All percentages are returned in basis points (1/100th of a percent). Divide by 100 to get a percentage — 6840 bp = 68.40%.
On error, the response has "status": "error" with an error object containing a code and message.
A React + Vite web app (frontend/) for building hands and ranges and calling the equity API. When built, the frontend is served statically by the FastAPI server — both API and UI from a single process.
