A heads-up post-flop Texas Hold'em solver written in Zig. Computes approximate Nash-equilibrium strategies on your own laptop — no subscription, no cloud, no bloat. A config file and a terminal are all you need.
$ zolver solve spot.toml --summary
Strategy summary — flop As Kd 7h
OOP to act:
hand class combos check bet 5 all-in
two pair 2 94.6% 4.7% 0.7%
pair 6 97.5% 1.8% 0.7%
IP vs check:
hand class combos check bet 5 all-in
pair 6 9.3% 73.2% 17.5%
high card 4 5.8% 48.9% 45.3%
Zolver is a CFR (Counterfactual Regret Minimization) solver for heads-up Texas Hold'em. Give it two players' ranges, a flop, stack sizes, and a betting structure, and it finds the game-theoretically optimal way to play the rest of the hand — then tells you, per hand, exactly how often to check, bet, call, or fold.
It's a serious study tool for players who want to understand GTO (game theory optimal) strategy. It is not a poker bot or a real-time assistant.
- 🆓 Free and open source. Commercial solvers cost hundreds of dollars. This costs nothing.
- 💻 Runs on consumer hardware. Designed for laptops and desktops, not server racks.
- 🎯 Exact, no abstraction. Solves the full game tree — no card bucketing that blurs the answer.
- ⚡ Fast. Multi-threaded and SIMD-accelerated, with complete physical runout traversal.
- 🔁 Deterministic. Byte-for-byte identical results regardless of thread count.
- 📊 Actually usable output. Human-readable terminal summaries and machine-readable JSON.
- Post-flop solving from any flop, turn, or river state
- Discounted CFR (DCFR) and CFR+ algorithms
- Interactive web UI — config builder (range grid, card picker, Equilab-style paste, TOML import/export) and strategy viewer (color grid, suit-averaged cells, combo drill-down, tree navigation)
- Standard range formats —
QQ+,ATs+,A5s-A2s,JTs-87s, weights, just like Equilab/Flopzilla - Human-readable summaries (
--summary) — see your strategy by hand class at a glance - JSON output — per-street strategy grids + per-hand EVs for any runout
- Helpful error messages —
spot.toml:3: expected integer for 'game.initial_pot', got 'abc' - Exploitability measurement — know exactly how close to Nash equilibrium you are
- Convergence stopping — halts at your exploitability target or iteration limit, with optional early stopping on lack of progress
- Physical runouts — evaluates every turn/river runout with private-card-aware blocking
First build the binary (see Installation), then alias it for convenience:
zig build -Doptimize=ReleaseFast
alias zolver=./zig-out/bin/zolverThe fast path — no text editor needed:
# 1. Build your spot in the browser (or import an existing spot.toml)
zolver config # range grid, card picker, paste ranges, TOML import/export
# → download spot.toml when done
# 2. Solve it, then explore the result visually
zolver solve spot.toml -o results.json --summary
# stderr ends with: next: zolver view results.json
zolver view results.json # interactive strategy viewer in the browserPrefer the terminal? Start from a documented example and edit it by hand:
zolver example --output spot.toml # writes a fully-commented config
$EDITOR spot.toml
zolver solve spot.toml --summaryRunning zolver with no arguments opens the config builder automatically.
| Config Builder | Strategy Viewer |
|---|---|
![]() |
![]() |
Requires Zig 0.16.0 on Linux (see Limitations — the thread pool uses a Linux futex, so macOS/Windows are not supported).
git clone https://github.com/phagmaier/zolver.git
cd zolver
zig build -Doptimize=ReleaseFast
./zig-out/bin/zolver examplePrebuilt Linux binaries (zolver-linux-x86_64 and zolver-linux-aarch64)
are attached to each GitHub release.
Releases are published automatically when a version tag v* is pushed (see
.github/workflows/release.yml).
Loads a config, runs the solver until convergence or max_iterations, and
reports results. Flags:
| Flag | Effect |
|---|---|
--summary |
Print a human-readable flop strategy overview to the terminal. |
--output <path> / -o <path> |
Write the full strategy tree as JSON. |
--turn <card> |
Also include the turn subtree for that runout, e.g. --turn 2c. |
--river <card> |
Also include the river subtree (requires --turn), e.g. --river Ah. |
--all-runouts |
Dump every canonical turn/river runout (large; per-hand EVs omitted). |
Progress is printed to stderr as it solves:
loaded 'spot.toml'
ranges: 150/180 combos tree: 142 actions, 198 terminals runouts: 49 turns, 2352 rivers
memory: 45.2 MB threads: 4
solving...
start exploitability: 18.234% (18.234 chips)
iter 64 exploitability: 1.853% (1.853 chips) 4.7s
iter 128 exploitability: 0.487% (0.487 chips) 9.5s
solve complete: 128 iterations, 0.487% exploitability, 9.5s elapsed
A compact run summary is printed to stdout:
iterations: 128
exploitability_pct: 0.487
exploitability_chips: 0.487
avg_ev_oop: 48.32
avg_ev_ip: 51.68
initial_pot: 100
elapsed_s: 9.52
converged: true
When you pass -o / --output, stderr also prints a one-line next step:
output written to 'results.json'
next: zolver view results.json
Note on EVs: after normalization by compatible range mass,
avg_ev_oopandavg_ev_ipsum toinitial_pot, including when a line ends all-in before the river. Turn and river chance is conditioned on the four dealt private cards: 45 cards to a flop turn, then 44 to a river.
Per-hand JSON ev values are conditional net EVs in chips, measured from the
solve root and normalized by compatible opponent reach at that history. They
are null when that hand has no compatible opponent reach. Library consumers
can use extract.nodeCFVs for raw reach-weighted counterfactual values.
Opens an interactive strategy viewer in your browser.
How to use it:
- The 13×13 grid shows the acting player's range. Each cell is color-coded by what the strategy does with that hand — green = check, orange = bet, red = fold, purple = raise, pink = all-in. Brighter = higher probability.
- Click any cell to see the strategy breakdown and EV in the detail panel. Cells with multiple combos show a suit-averaged strategy; expand the combo list in the detail panel to drill into a specific combo.
- Click the action buttons below the grid ("bet 66", "raise 110", etc.) to navigate deeper into the game tree and see how the opponent responds.
- Use the player toggle (OOP / IP / Acting) to view the other player's perspective at the same decision point.
- If you solved with
--turnor--all-runouts, use the street dropdown to switch between flop, turn, and river. - Color legend under the meta strip maps check / call / bet / raise / all-in / fold.
- Keyboard shortcuts:
Backspacegoes back up the tree;Escapesteps out of a concrete combo, then clears the selection; arrow keys switch streets. Terminal actions (fold / lines with no child) are disabled rather than silent no-ops. - The viewer also works standalone — open the HTML file directly and
drag-and-drop any
results.jsononto it.
Opens an interactive config builder in your browser. Build a complete spot without touching a text editor.
How to build a spot, step by step:
-
Pick the flop — click three cards from the 52-card deck at the top of the right panel. The selected cards appear in the board slots. The range grid automatically grays out hands that share cards with the board.
-
Set OOP's range — in the left panel ("OOP Range" tab):
- Paste a range string (Equilab/Flopzilla style) into the box under the
grid — e.g.
QQ+, AKs, A5s-A2s:0.5, T9s+— and hit Apply (or Enter). This replaces the active tab's grid. Invalid tokens toast an error and leave the previous range alone. - Left-click a cell to include that hand in the range (blue highlight). Click again to remove it.
- Right-click a cell to open a weight slider — set the hand to 75%, 50%, or 25% frequency. Partially-weighted hands show a yellow tint with the percentage in the cell. Click Apply to confirm or Cancel to discard.
- Click and drag across multiple cells to select or deselect a region of hands at once.
- Use the preset buttons ("All Pairs", "Suited Aces", "Broadway", etc.) to quickly populate common ranges. Presets replace the current selection. ("Top N%" buttons are coarse matrix shortcuts, not true equity percentiles.)
- Paste a range string (Equilab/Flopzilla style) into the box under the
grid — e.g.
-
Set IP's range — switch to the "IP Range" tab and repeat. The two ranges are independent.
-
Configure the game — in the right panel:
- Set Initial Pot, Effective Stack, and Min Bet.
- Add or remove bet sizings per street (as percentages of the pot). Click + Add to add a sizing, the × button to remove one.
- Set raise caps per street ("none" = unlimited raising).
-
Configure the solver — algorithm, max iterations, target exploitability, thread count, SIMD, prune options, and DCFR parameters (hidden when CFR+ is selected).
-
Export / Import — the TOML Preview panel updates live as you edit. Click Copy to Clipboard or Download spot.toml, then run:
zolver solve spot.toml -o results.json --summary zolver view results.json
Incomplete spots (missing flop or empty ranges) are blocked from export. Use Import TOML (header) or Import file… / Import preview under the preview to reload an existing
spot.tomlback into the builder.
Prints a fully-documented example config to stdout (or to a file).
Prints usage.
A range-weighted breakdown of the strategy at the key flop decision points —
OOP's opening action and IP's responses — grouped by made-hand class on the
flop (set+, two pair, pair, high card). Perfect for a quick read of
"what should I do with this kind of hand here?"
The full per-hand strategy tree, ready to feed into a script, notebook, or your
own viewer. The configured root street is always included; later-street
subtrees are added on demand with --turn/--river, or exhaustively with
--all-runouts.
strategy lines up positionally with actions; line is the action path from
the root to that node. Bet/raise amounts are in chips.
The config uses a TOML-like format. Sections and keys are required unless marked
optional. Bad configs report the exact line and reason, e.g.
spot.toml:3: expected integer for 'game.initial_pot', got 'abc'.
| Key | Type | Description |
|---|---|---|
flop |
string | Three flop cards, space-separated. Format: rank + suit (As Kd 7h). Ranks: 2-9, T, J, Q, K, A. Suits: s, h, d, c. |
turn |
string | Optional known turn card. Starts a turn solve unless river is also set. |
river |
string | Optional known river card; requires turn. Starts a river solve. |
initial_pot |
integer | Pot size (in chips) at the start of the configured street. |
effective_stack |
integer | The smaller of the two remaining postflop stacks. |
min_bet |
integer | Minimum bet/raise increment in chips. Optional, default: 1. |
max_budget_bytes |
integer | Total retained solver-memory limit before a solve starts: tables, storage, and thread-dependent working arenas. Optional, default: 8 GB. Increase for large trees with many sizings/raises; lower to avoid excessive swap on low-memory machines. |
compress_suits |
boolean | Solve using canonical suit-isomorphic turn/river runouts while exactly remapping private-hand reaches and values. Optional, default: true. Set false only to use the full physical-runout correctness oracle. |
Bet size fractions as percentages of the pot, per street. An empty list []
means only check and all-in are available. Values must be strictly increasing
per street.
| Key | Type | Description |
|---|---|---|
flop |
integer array | Bet sizes on the flop (e.g., [25, 50, 75] for 25%, 50%, 75% of pot). |
turn |
integer array | Bet sizes on the turn. |
river |
integer array | Bet sizes on the river. |
Maximum number of raises per street. Use none or unlimited (or omit the key)
for no cap. Use 0 to disallow raises (check/call/fold only).
| Key | Type | Description |
|---|---|---|
flop |
integer or none |
Max raises on the flop. |
turn |
integer or none |
Max raises on the turn. |
river |
integer or none |
Max raises on the river. |
Player hand ranges with frequencies. Format: HAND[SUFFIX][:WEIGHT],
comma-separated. The same notation Equilab and Flopzilla export.
Single hands:
AK— all 16 combos (suited + offsuit)AKs— suited only (4 combos)AKo— offsuit only (12 combos)88— pocket pair (6 combos; suffix ignored for pairs)
Plus ranges:
QQ+→ QQ, KK, AAATs+→ ATs, AJs, AQs, AKs (ace fixed, kicker climbs)T9s+→ T9s, JTs, QJs, KQs, AKs (gap preserved, climbs to ace)
Dash ranges:
99-66→ 99, 88, 77, 66A5s-A2s→ A5s, A4s, A3s, A2sJTs-87s→ JTs, T9s, 98s, 87s
Weights: append :VALUE (0.0–1.0) to play a hand a fraction of the time.
Default is 1.0. Applies to plus/dash ranges too (QQ+:0.5).
| Key | Type | Description |
|---|---|---|
oop |
string | Out-of-position player's preflop range. |
ip |
string | In-position player's preflop range. |
Examples:
oop = "QQ+, AKs, AQs+, AJo+, T9s+, 88:0.5"
ip = "JJ+, AKs, KQs, A5s-A2s:0.5"| Key | Type | Default | Description |
|---|---|---|---|
algorithm |
string | — | "dcfr" (default recommendation), "cfr_plus", "dcfr_plus", or "pdcfr_plus". Benchmark alternatives on your game. |
max_iterations |
integer | 1000 |
Hard cap on solve iterations. |
target_exploitability_pct |
float | 0.5 |
Stop when exploitability reaches this % of the initial pot. |
num_threads |
integer | 0 |
Worker threads. 0 = serial. 4 = 3 workers + main thread. |
prune_zero_reach |
boolean | false |
Skip subtrees where the opponent has zero probability mass. Safe to enable. |
use_simd |
boolean | true |
Use SIMD vectorized kernels (8-wide f32). Recommended. |
check_interval |
integer | 64 |
Exploitability re-check cadence after iterations 32, 64, 128. |
stall_patience |
integer | 0 |
Optional early stopping after this many checks without sufficient improvement. A plateau does not prove convergence. 0 disables. |
stall_rel_improvement |
float | 0.01 |
Minimum fractional drop in exploitability that counts as progress for stall_patience. |
allin_cache_max_bytes |
integer | 16777216 |
Budget for optional all-in equity matrices, including construction scratch. 0 disables. Selection also considers the iteration cap, matchup count, and remaining solver memory. |
verify_final |
boolean | false |
CLI: independently verify the final profile with physical runouts and f64 arithmetic. Also enabled by --verify. Can be much slower than training. |
debug_invariants |
boolean | true (Debug) |
Run NaN/Inf scans of regret arrays after every pass. |
Parameters for dcfr and dcfr_plus (beta is unused by the plus variant).
For the paper’s DCFR+ settings, use alpha = 1.5, gamma = 4.
| Key | Type | Default | Description |
|---|---|---|---|
alpha |
float | 1.5 |
Discount exponent for positive regrets. |
beta |
float | 0.0 |
Discount exponent for negative regrets. |
gamma |
float | 2.0 |
Strategy averaging weight exponent. |
Optional PDCFR+ parameters: alpha = 2.3, gamma = 5 by default. Prediction
uses the previous instantaneous regret. PDCFR+ retains one additional f32
array the size of the regret storage; this is included in the memory budget.
The algorithm is experimental here and is not consistently faster than DCFR.
[game]
flop = "As Kd 7h"
initial_pot = 100
effective_stack = 200
min_bet = 1
max_budget_bytes = 8589934592 # 8 GB — increase for large trees
compress_suits = true # Set false only for physical-runout oracle checks
[game.sizings]
flop = [25, 50, 75]
turn = [25, 50]
river = [50, 100]
[game.raise_cap]
flop = 1
turn = none
river = 1
[ranges]
oop = "QQ+, AKs, AQs+, AJo+, T9s+, 88:0.5"
ip = "JJ+, AKs, KQs, A5s-A2s:0.5"
[solver]
algorithm = "dcfr"
max_iterations = 1000
target_exploitability_pct = 0.5
num_threads = 4
prune_zero_reach = true
stall_patience = 0 # optional plateau heuristic; 0 = off
stall_rel_improvement = 0.01
[solver.dcfr]
alpha = 1.5
beta = 0.0
gamma = 2.0Zolver uses Discounted CFR (DCFR) with parameters α=1.5, β=0, γ=2 — the configuration recommended by Brown & Sandholm (2019) for fastest convergence in large games. Each iteration, one player updates their regrets while the opponent plays their current strategy, then the roles swap. Strategies are extracted from accumulated positive regrets via regret matching.
The tree is built from the betting structure you specify. Action nodes branch on every legal action (check, fold, call, bet, raise, all-in); chance nodes deal the turn and river; terminal nodes are folds or showdowns.
By default (compress_suits = true) the solver collapses suit-symmetric
turn/river runouts into canonical representatives, exactly remapping each
player's private-hand reaches and returned values for every orbit member — so
board-blocking semantics are preserved and the result matches the full physical
traversal. This is where the memory savings on symmetric boards come from
(monotone flops shrink the runout tables ~71%). Set compress_suits = false to
evaluate the complete physical 49×48 space directly as a correctness oracle;
rainbow flops have no board symmetry, so the two modes coincide there.
Exploitability measures how far a strategy is from Nash equilibrium, in chips per
hand and as a percentage of the initial pot. Lower is stronger. The solver stops
automatically once it drops below target_exploitability_pct.
Set known cards in [game], for example turn = "2c" and river = "Ah".
initial_pot is the pot at that street and effective_stack is the remaining
stack. Each solve starts at a new betting round with OOP acting first. It does
not reconstruct prior actions; supply the ranges conditional on reaching this
board. Permanently blocked hands are removed before allocating storage.
CLI --turn/--river select output runouts; they do not change the solve root.
zig-out/bin/zolver solve spot.toml --verify --all-runouts -o complete.json
zig-out/bin/zolver verify spot.toml complete.json--verify checks the in-memory average profile independently of the CFR walk,
terminal sweep, all-in cache, and suit-orbit value reuse. It expands physical
runouts with f64 reaches and pairwise terminal payoffs; it shares the card
strength evaluator and game tree. This is a numerical cross-check, not a formal
proof. It may cost substantially more than solving a wide flop range.
Verification scratch is checked against the remaining solver memory budget;
JSON parsing/output and native thread stacks are outside that budget.
The verify subcommand reloads a complete export into fresh storage, checks
node/action/hand/runout coverage and probabilities, and remeasures the rounded
exported policy. Supply the original config (including range weights). Partial
flop-only dumps are rejected. The command exits unsuccessfully when the
exported profile misses the config's accuracy target. meta.root_board,
meta.root_street, and meta.independently_verified describe new exports.
In the library, call verify.exploitability, verify.verifyExport, or
best_response.solveVerified; best_response.solve remains the fast API.
All reported exploitability is relative to the configured betting tree.
A small gap does not bound losses to bet sizes omitted from that tree. Use
bench/run_study.py to compare algorithms and add candidate sizes individually;
see the study instructions.
The end-to-end baseline below was measured on Linux with 8 solver threads,
ReleaseFast, DCFR with α=1.5, β=0, γ=2, at commit 669702c. Each result is
the median of three 128-iteration runs after one warm-up. Every spot traverses
all 49 turns and 2,352 ordered turn-river runouts; memory is total retained
solver memory plus thread-dependent working arenas.
| Kernel | Scalar | SIMD | Speedup |
|---|---|---|---|
| Regret matching | 1,005 Mslots/s | 11,504 Mslots/s | 11.5× |
| DCFR regret update | 2,757 Mslots/s | 12,939 Mslots/s | 4.7× |
| Strategy accumulation | 1,727 Mslots/s | 10,570 Mslots/s | 6.1× |
| Showdown sweep | — | — | 319 Mhands/s |
| Spot | Tree | Total memory | ms/iter | Exploitability @128 |
|---|---|---|---|---|
| SRP dry (rainbow) | 288A / 389T | 764.6 MB | 271 | 1.2902% |
| SRP two-tone | 288A / 389T | 764.6 MB | 296 | 0.7528% |
| SRP monotone | 288A / 389T | 764.6 MB | 295 | 0.9446% |
| 3-bet dry | 204A / 269T | 346.9 MB | 127 | 0.7857% |
| SRP, three sizings | 1,108A / 1,613T | 3,611.4 MB | 1,267 | 2.3985% |
| SRP, raise cap 2/1/1 | 372A / 493T | 920.1 MB | 357 | 1.6058% |
These are the physical-oracle numbers (compress_suits = false), so texture
does not change runout-table size or total memory — the matched rainbow,
two-tone, and monotone spots all traverse the complete physical chance space, and
their small runtime difference is board-specific evaluation work. With the
default compress_suits = true, symmetric boards shrink dramatically (two-tone
466.2 MB, monotone 221.9 MB) while rainbow is unchanged. Extra bet sizes remain
the dominant capacity lever. Memory and exploitability match the pre
spin-then-park baseline; wall ms/iter is at least as fast.
A separate thread-pool characterization (wall + CPU time per phase at
1/2/4/8 threads) lives in bench/README.md: the solve
iteration still pins ~7.9 cores and scales ~6× on 8 threads, while the adaptive
spin-then-park pool drops exploit/output cores_busy from ~8.0 toward
~1–2 (workers park during serial work instead of burning cores).
- Linux only. The thread pool parks idle workers on a raw Linux futex (a
deliberate trade-off to keep the pool allocation-only — see
src/threading.zig), so the code does not build on macOS or Windows. - Heads-up only. Multi-way pots are not supported.
- Post-flop only. The solver always begins at the flop; preflop solving is out of scope.
- No abstraction. It solves the full game tree with no card bucketing — exact, but the tree can grow large with many bet sizes.
- Finite numerical precision. Regret/strategy storage is
f32; achievable accuracy depends on the game and numerical conditioning. There is no established universal 0.2% floor. Earlier plateau measurements were affected by incorrect pruning updates. See convergence notes.
The solver is complete and tested (238 tests) — tree construction,
threaded DCFR, best response, exploitability, SIMD kernels, suit compression,
JSON export, terminal summaries, and the browser config builder / strategy
viewer. Convergence is cross-validated against TexasSolver (see
bench/).
Requires Zig 0.16.0. The project is a single Zig package (build.zig)
exposing several steps, plus a few standalone measurement binaries and helper
scripts.
| Step | Command | Purpose |
|---|---|---|
| (default) | zig build |
Debug build → zig-out/bin/zolver (assertions + debug_invariants NaN/Inf sweeps enabled). |
| (default, release) | zig build -Doptimize=ReleaseFast |
Optimized build — use this for anything you actually run or time. |
run |
zig build run -- solve spot.toml --summary |
Build and launch the CLI; everything after -- is forwarded to zolver. |
test |
zig build test |
Full test suite (238 tests: unit, suit-compression parity, serial-vs-threaded determinism, spin-then-park pool). Runs in ReleaseSafe (all asserts/bounds checks kept, just optimized — the solver tests iterate real CFR, so this is ~6× faster than Debug with identical results). Add -Dtest-filter=<substr> to run a subset, or -Dtest-optimize=Debug for the fully unoptimized run (also enables the Debug-only invariant sweeps). |
bench-threads |
zig build bench-threads -- <spot.toml> [flags] |
Thread-pool benchmark: wall and CPU time for the solve / exploitability / output passes at 1/2/4/8 threads. Always compiles ReleaseFast. Flags: --iters N --warmup N --exploit-reps N --output-reps N. Prints JSON to stdout, a table to stderr. |
These time work rather than assert; they're intentionally excluded from zig build test.
| Command | Purpose |
|---|---|
zig run -OReleaseFast src/bench.zig |
Kernel microbenchmarks (regret matching, DCFR update, strategy accumulation, showdown sweep) plus one full CFR iteration with a memory-bandwidth figure. |
PERF_ITERS=40 PERF_THREADS=8 zig run -OReleaseFast src/perf_profile.zig |
Runs only the solve hot path in a tight loop, for profiling under perf record --call-graph dwarf. |
| Command | Purpose |
|---|---|
python3 bench/run_bench.py [spot.toml ...] |
End-to-end solve benchmark over bench/spots/ → bench/out/results.md + JSON. One warm-up + three median samples; asserts the full 49-turn / 2,352-runout space. |
bench/run_thread_bench.sh [spot.toml ...] |
Runs bench-threads across the texture spots → bench/out/threads/ (+ a combined summary.md). |
TEXASSOLVER_DIR=/path/to/TexasSolver bench/run_validation.sh v1 v1b v2 |
Cross-validates flop strategies against TexasSolver v0.2.0. |
See bench/README.md for methodology, results, and how the
harness has caught real bugs.
MIT — see LICENSE for details.
This project draws on the academic literature on CFR and its variants:
- Zinkevich et al. (2008) — Regret minimization in games
- Brown & Sandholm (2019) — Discounted CFR
- Tammelin (2014) — CFR+
- Johanson et al. (2012) — Suit isomorphism for poker



{ "meta": { "flop": "As Kd 7h", "initial_pot": 100, "effective_stack": 200, "iterations": 128, "exploitability_pct": 0.487, "ev_oop": 48.32, "ev_ip": 51.68, "converged": true }, "streets": [ { "street": "flop", "board": "As Kd 7h", "nodes": [ { "id": 87, "player": "oop", "line": [], "actions": ["check", "bet 50", "all-in"], "hands": [ { "combo": "AhKh", "strategy": [0.05, 0.40, 0.55], "ev": 142.3 } ] } ] } ] }