High-performance 7-card poker hand evaluator achieving ~2.0ns per hand evaluation on MacBook Pro (M5).
🤖 Note: This project was "vibe engineered" with Amp and Claude Opus 4.5 and others as part of my ongoing effort to demonstrate that AI-assisted development can produce high-quality software when paired with rigorous design documentation, comprehensive tests, and careful human review.
- Ultra-fast evaluation: ~2.0ns per hand using CHD perfect hash tables and SIMD batch processing
- SIMD optimization: Batch evaluation of 32 hands simultaneously
- Equity calculations: Monte Carlo and exact enumeration
- Range parsing: Standard poker notation (AA, KK, AKs, etc.)
- Hand features: Draw detection, board texture, strength normalization for AI
- Card abstraction: K-means bucketing with EMD for CFR/MCCFR solvers
- Zero dependencies: Pure Zig implementation
Requires Zig 0.15.1 or later.
zig fetch --save "git+https://github.com/lox/zig-poker-eval?ref=v3.7.1"In your build.zig:
const poker = b.dependency("zig_poker_eval", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("poker", poker.module("poker"));const poker = @import("poker");
// Evaluate a hand
const hand = poker.parseHand("AsKsQsJsTs5h2d");
const rank = poker.evaluateHand(hand); // Returns 1 (royal flush)
// Calculate equity - Monte Carlo simulation
const aa = poker.parseHand("AhAs");
const kk = poker.parseHand("KdKc");
const result = try poker.monteCarlo(aa, kk, &.{}, 100000, rng, allocator);
// result.equity() ≈ 0.82 (AA wins ~82% vs KK)const poker = @import("poker");
// Parse and evaluate a hand
const hand = poker.parseHand("AsKsQsJsTs5h2d");
const rank = poker.evaluateHand(hand);
const category = poker.getHandCategory(rank);
// category == .straight_flush (royal flush is a straight flush)
// Batch evaluation (SIMD optimized)
const hands = [_]u64{
poker.parseHand("AsKsQsJsTs5h2d"),
poker.parseHand("AhAdAcKhKd2s3s"),
poker.parseHand("7c8c9cTcJcQdKd"),
};
const ranks = poker.evaluateBatch(3, hands);var prng = std.Random.DefaultPrng.init(42);
const rng = prng.random();
const aa = poker.parseHand("AhAs");
const kk = poker.parseHand("KdKc");
// Monte Carlo simulation (fast, approximate)
const result = try poker.monteCarlo(aa, kk, &.{}, 100000, rng, allocator);
std.debug.print("AA vs KK: {d:.1}%\n", .{result.equity() * 100});
// Output: AA vs KK: 82.1%
// With board cards
const flop = [_]u64{
poker.parseCard("Qh"),
poker.parseCard("Jd"),
poker.parseCard("Tc"),
};
const result_flop = try poker.monteCarlo(aa, kk, &flop, 50000, rng, allocator);// Track how often each hand makes pairs, trips, flushes, etc.
const detailed = try poker.monteCarloWithCategories(aa, kk, &.{}, 100000, rng, allocator);
const hero_cats = detailed.hand1_categories.?;
std.debug.print("Hero makes:\n", .{});
std.debug.print(" Pair: {d:.1}%\n", .{hero_cats.percentage(hero_cats.pair)});
std.debug.print(" Two pair: {d:.1}%\n", .{hero_cats.percentage(hero_cats.two_pair)});
std.debug.print(" Set: {d:.1}%\n", .{hero_cats.percentage(hero_cats.three_of_a_kind)});
std.debug.print(" Flush: {d:.1}%\n", .{hero_cats.percentage(hero_cats.flush)});
// Get confidence interval
const ci = detailed.confidenceInterval().?;
std.debug.print("95% CI: [{d:.1}%, {d:.1}%]\n", .{ci.lower * 100, ci.upper * 100});// Enumerates all possible board runouts - slower but exact
const exact_result = try poker.exact(aa, kk, &.{}, allocator);
std.debug.print("Exact equity: {d:.4}\n", .{exact_result.equity()});
// Output: Exact equity: 0.8217 (no sampling variance)
// Fast on the turn (only 44 river cards)
const turn = [_]u64{
poker.parseCard("Qh"),
poker.parseCard("Jd"),
poker.parseCard("Tc"),
poker.parseCard("2s"),
};
const turn_exact = try poker.exact(aa, kk, &turn, allocator);
std.debug.print("Turn exact: {d:.2}%\n", .{turn_exact.equity() * 100});// Parse range notation
var hero_range = try poker.parseRange("AA,KK,AKs", allocator);
defer hero_range.deinit();
var villain_range = try poker.parseRange("22+,A2s+,K9s+,QTs+", allocator);
defer villain_range.deinit();
// Calculate range vs range equity
const range_result = try poker.calculateRangeEquityMonteCarlo(
&hero_range,
&villain_range,
&.{},
50000,
rng,
allocator,
);
defer range_result.deinit(allocator);
std.debug.print("Range equity: {d:.1}%\n", .{range_result.hero_equity * 100});
std.debug.print("Combinations evaluated: {}\n", .{range_result.total_combos});// Use pre-computed heads-up tables for instant results
const equity_table = poker.HeadsUpEquity{};
// Get exact equity for any starting hand matchup
const aa_vs_kk = equity_table.lookup(
poker.StartingHand.fromString("AA").?,
poker.StartingHand.fromString("KK").?,
);
std.debug.print("AA vs KK (instant): {d:.2}%\n", .{aa_vs_kk * 100});
// Output: AA vs KK (instant): 82.17%
// Check equity vs random for any hand
const ak_equity = poker.PREFLOP_VS_RANDOM[poker.StartingHand.fromString("AKs").?.index()];
std.debug.print("AKs vs random: {d:.1}%\n", .{ak_equity * 100});// 3+ player equity calculations
const hands = [_]u64{
poker.parseHand("AhAs"), // Hero
poker.parseHand("KdKc"), // Villain 1
poker.parseHand("QhQs"), // Villain 2
};
const multiway_results = try poker.multiway(&hands, &.{}, 50000, rng, allocator);
defer allocator.free(multiway_results);
for (multiway_results, 0..) |result, i| {
std.debug.print("Player {}: {d:.1}%\n", .{ i + 1, result.equity() * 100 });
}
// Output:
// Player 1: 49.3% (AA)
// Player 2: 28.1% (KK)
// Player 3: 22.6% (QQ)// Compute the winning seats in one pass
const board = poker.parseHand("AsKsQsJs2h");
const ctx = poker.initBoardContext(board);
const seats = [_]u64{
poker.parseHand("Th3c"), // Royal flush
poker.parseHand("AhAd"), // Trips
poker.parseHand("KcQc"), // Two pair
};
const showdown = poker.evaluateShowdownMultiway(&ctx, &seats);
std.debug.print("Best rank: {}\n", .{showdown.best_rank});
std.debug.print("Winner mask: 0b{b:0>3}\n", .{showdown.winner_mask});
std.debug.print("Tie count: {}\n", .{showdown.tie_count});
// Winner mask 0b001 → seat 0 wins outright// Normalized equity shares (1/tie_count for winners)
var equities = [_]f64{ 0, 0, 0 };
const weights = poker.evaluateEquityWeights(&ctx, &seats, &equities);
std.debug.print("Winner mask: 0b{b:0>3}\n", .{weights.winner_mask});
std.debug.print("Equities: {d:.2} {d:.2} {d:.2}\n", .{
equities[0], equities[1], equities[2],
});
// Output:
// Winner mask: 0b111
// Equities: 0.33 0.33 0.33// Calculate hero's equity against multiple opponents
const hero = poker.parseHand("AhAs");
const villains = [_]u64{
poker.parseHand("KdKc"),
poker.parseHand("QhQs"),
poker.parseHand("JcJd"),
};
const hero_equity = try poker.heroVsFieldMonteCarlo(
hero,
&villains,
&.{},
50000,
rng,
allocator,
);
std.debug.print("AA vs 3 opponents: {d:.1}%\n", .{hero_equity * 100});const features = poker.features;
// Extract features for a hand on a board (no allocation)
const hero = poker.parseHand("AhKh");
const board = [_]u64{
poker.parseCard("Qh"),
poker.parseCard("Jh"),
poker.parseCard("5c"),
poker.parseCard("2d"),
poker.parseCard("8s"),
};
const f = features.HandFeatures.extract(hero, &board);
std.debug.print("Category: {s}\n", .{@tagName(f.made_category)});
std.debug.print("Strength: {d:.3}\n", .{f.strength});
std.debug.print("Outs: {}\n", .{f.outs});
std.debug.print("Has flush draw: {}\n", .{f.has_flush_draw});
std.debug.print("Has OESD: {}\n", .{f.has_oesd});
std.debug.print("Board texture: {s}\n", .{@tagName(f.board_texture)});
// Check draw categories
if (f.hasStrongDraw()) {
std.debug.print("Strong draw detected!\n", .{});
}
if (f.isComboDraw()) {
std.debug.print("Combo draw with {} outs\n", .{f.outs});
}// Extract with equity distribution (for CFR/MCCFR applications)
const f = features.HandFeatures.extractWithEquity(
hero,
&board,
100, // simulations per remaining card
rng,
);
// 16-bin equity histogram for opponent modeling
std.debug.print("Has histogram: {}\n", .{f.has_equity_histogram});
for (f.equity_histogram, 0..) |bin, i| {
if (bin > 0.01) {
std.debug.print("Bin {}: {d:.2}%\n", .{i, bin * 100});
}
}const bucketing = poker.bucketing;
// Create a bucketer with k=50 buckets
var bucketer = bucketing.Bucketer.init(.{
.k = 50,
.metric = .feature_based, // or .earth_movers, .hybrid
.max_iterations = 100,
}, allocator);
defer bucketer.deinit();
// Add training samples (e.g., from game tree traversal)
for (training_hands) |hand_data| {
const feat = features.HandFeatures.extract(hand_data.hole, &hand_data.board);
try bucketer.addSample(feat, hand_data.reach_probability);
}
// Fit k-means clustering
try bucketer.fit();
// Assign new hands to buckets (0 to k-1)
const bucket = bucketer.assign(new_hand_features);
std.debug.print("Assigned to bucket: {}\n", .{bucket});
// Batch assignment for efficiency
var bucket_ids: [1000]u32 = undefined;
bucketer.assignBatch(&hand_features_array, &bucket_ids);// Compare hand similarity (for clustering, nearest neighbor)
const dist = bucketing.handDistance(feat_a, feat_b, .feature_based);
std.debug.print("Feature distance: {d:.3}\n", .{dist});
// Earth Mover's Distance on equity histograms (O(n))
const emd = bucketing.earthMoversDistance(
&feat_a.equity_histogram,
&feat_b.equity_histogram,
);
std.debug.print("EMD: {d:.3}\n", .{emd});// Save bucketing table to file for fast loading
try bucketer.saveTable("flop_buckets.bin");
// Load pre-computed table (mmap-compatible format)
var table = try bucketing.Table.load("flop_buckets.bin", allocator);
defer table.deinit();
// Fast lookups by hand index
const bucket = table.lookup(hand_index);// What's my equity against a uniformly random hand?
const aa = poker.parseHand("AhAs");
const flop = [_]u64{
poker.parseCard("Kd"),
poker.parseCard("7c"),
poker.parseCard("2s"),
};
const result = try poker.equityVsRandom(aa, &flop, 10000, rng, allocator);
std.debug.print("AA vs random on Kd7c2s: {d:.1}%\n", .{result.equity() * 100});
// Output: AA vs random on Kd7c2s: ~89%// On the river: what % of possible opponent hands do we beat?
const hero = poker.parseHand("AhKh");
const board = [_]u64{
poker.parseCard("Qh"),
poker.parseCard("Jh"),
poker.parseCard("2h"),
poker.parseCard("7c"),
poker.parseCard("3d"),
};
const result = try poker.handStrength(hero, &board, allocator);
std.debug.print("Nut flush beats {d:.1}% of hands\n", .{result.winRate() * 100});
// Output: Nut flush beats 99.2% of hands (only loses to straight flush)// Use multiple CPU cores for large simulations
const result = try poker.threaded(
aa,
kk,
&.{},
10_000_000, // 10M simulations
42, // Random seed
allocator,
);
std.debug.print("Equity (10M sims): {d:.4}\n", .{result.equity()});
// Automatically uses optimal thread count// Swap-remove deck sampler (no rebuilding 52-card arrays)
var sampler = poker.DeckSampler.initWithMask(poker.parseHand("AhAs"));
const card1 = sampler.draw(rng);
const flop = sampler.drawMask(rng, 3); // Draw three cards as a bitmask
std.debug.print("Remaining cards: {}\n", .{sampler.remainingCards()});# Activate environment
source bin/activate-hermit
# Build
task build
# Test
task test
# Benchmark
task bench:eval
# Calculate equity
task run -- equity "AhAs" "KdKc"-
MacBook Pro (M5) results:
-
Single evaluation: ~4.9ns per hand (~205M hands/second)
-
Batch evaluation: 500M hands/second (~2.0ns per hand)
-
Speedup: 6.0× from baseline (11.95ns → 2.00ns)
-
Memory: ~395KB lookup tables (267KB CHD + 128KB flush patterns)
- docs/design.md - Architecture, algorithms, implementation
- docs/performance.md - Benchmarking and profiling
- docs/experiments.md - Optimization experiments log
MIT