We replaced two third-party JavaScript libraries with our own hand evaluator, pi-poker-eval, written in Rust. It is faster, it treats Omaha correctly by design, and it is the first piece of the Hand Evaluator we will share between our game servers and, later, our clients. It also adds something neither old library had: winning probability.
Every poker Hand Evaluator has to answer three questions about cards: how strong is this hand, who wins the pot, and what do we call the winning hand (“Flush, A High”). Get the first two wrong and someone is paid the wrong amount. Get the third wrong and the client shows a label that does not match the payout. This post explains what we used before, where it fell short, and why we chose to build and open-source our own.
What we used before
Our Hand Evaluator is a TypeScript library that holds the rules of the game. It deals, takes bets, builds side pots and settles showdowns. For hand judging it relied on two open-source npm packages.
Neither library supports Omaha’s hand-selection rule. Both rank a set of cards as given, which works for Hold’em, where you use any five of your seven cards. Omaha requires exactly two of your hole cards and exactly three from the board. We enforced that rule in our own wrapper code by generating every 2+3 combination and asking pokersolver to rank them: 60 combinations for PLO, 100 for PLO5 and 150 for PLO6.
| Library | What it gave us | How we used it |
|---|---|---|
| pokersolver | Hand names, descriptions, the best five cards, and Hand.winners to compare hands | Every showdown, every hand-strength label, every WinnerDeclared event |
| poker-evaluator | A fast comparable number per hand, from the Two Plus Two lookup table | Listed as a dependency and used in tests; candidate for fast hand comparison |
The challenges
1. Omaha correctness lived outside the library
Because the 2+3 rule sat in our wrapper, so did the risk. pokersolver’s rank field is only the hand category (flush, straight, and so on), not the full strength. Code that keeps the combination with the highest rank keeps the first flush it finds, not the best one: a jack-high flush can beat an ace-high flush. We found exactly this in our Omaha path and fixed it by comparing full strengths with Hand.winners. The lesson was that a rule this important belongs inside the evaluator, tested once, not in every caller.
2. Speed, especially for Omaha
pokersolver parses strings and builds objects for every hand. That is fine for a single Hold’em hand. For Omaha it multiplies by up to 150 combinations per player. A six-player PLO showdown took about 4.7 ms in pokersolver. Performance was a key requirement for us: hand judging runs at every showdown and on every street for hand-strength labels, and anything that calculates winning probability calls it thousands of times.
3. Quirks we had to patch around
pokersolver ranks hands correctly, but some of what it returns about the winning hand is wrong or inconsistent. Each of these came back from Hand.solve on a 7-card Hold’em hand:
| Hand | What pokersolver returns | The problem |
|---|---|---|
| Wheel straight: | Best cards 5h 4s 3c 2d 1h | It turns the ace into a card of value 1 so it can sit low in the straight. 1 is not a real rank, so any code that maps ranks back to 2–A loses the ace: our clients received a card with no rank. |
| Two sets of trips: | “Full House, A’s over K’s” with best cards Ah Ad Ac Kh Kd Ks | The description is right, but a full house is five cards. It returns all six, so the client highlights a third king that is not part of the hand. |
| Six or seven cards of one suit: | “Flush, Ah High” with best cards Ah Kh 9h 7h 4h 2h | Same problem: a flush is the top five suited cards, but it returns every suited card. |
| Any flush or straight flush | “Flush, Ah High”, “Straight Flush, 9h High” | Only these two categories put the card’s suit letter into the description. Every other one reads “Straight, 5 High” or “Pair, 10’s”. We stripped the letter with a regular expression before sending the text to clients. |
4. Two libraries, two blind spots
poker-evaluator is fast but returns only a number: no name, no description, no best five cards, and no Omaha. It also loads a 130 MB lookup table, which every service that installed our Hand Evaluator pulled in. So we needed pokersolver for the words and poker-evaluator for the speed, and still had neither in one place.
5. No winning probability
Players increasingly expect to see winning probability (equity): how often each hand wins from here. Our Run It Multiple Times feature needs it too. Neither library computes it. Built on pokersolver, 10,000 Monte Carlo run-outs took about 277 ms.
Why we wrote our own
We wanted one library that does all of the following, and we could not get it by combining what was available:
- Exact answers for Hold’em, PLO, PLO5 and PLO6, with the Omaha 2+3 rule enforced inside the library, so no caller can get it wrong.
- The same names and descriptions our clients already show. We wanted to switch evaluators without any client change, so the output had to match pokersolver’s text, minus its quirks.
- Performance as a first-class requirement, for Hold’em and above all for Omaha.
- Winning probability (equity) built in: exact over every run-out, or seeded Monte Carlo.
- One build that runs everywhere we need it: on the server and, if we choose, in the browser. That rules out a 130 MB table and a native Node addon.
- A base for the rest of the Hand Evaluator. We are moving our game rules to Rust step by step. The evaluator is the natural first piece because it has no betting or table logic.
We wrote it from scratch rather than building on an existing crate, so its output is exactly what our Hand Evaluator needs and every line is ours to maintain.
How it works
- Rust core, no lookup tables. Each hand is held as bit masks: one 13-bit rank mask per suit, plus rank counts. Flushes, straights (including the wheel) and pairs fall out of a few bit operations. Ranking a 7-card hand on its own takes about 35 ns.
- Variant rules as a strategy. Hold’em (“any five cards”) and Omaha (“exactly two plus three”) are two implementations of one internal trait. Adding a variant later, such as Short Deck or Hi-Lo, does not touch the ranking code.
- One comparable strength. Every evaluated hand carries a single number: higher wins, equal ties. Comparing hands, splitting pots and ordering showdowns become integer comparisons.
- Winning probability. The library calculates how often each hand wins, either exactly over every remaining run-out or by seeded Monte Carlo, against known hands or random opponents. On a flop of , against is computed exactly over all 990 turn and river cards. The same seed always gives the same answer.
- Errors are values. An invalid card, a duplicate card, the wrong number of hole cards or a bad board size returns a clear error in Rust and throws a readable
Errorin JavaScript. pokersolver accepted a 10-card board without complaint; pi-poker-eval refuses it. - WebAssembly for JavaScript. The npm package is the same Rust code compiled to a WebAssembly file of about 130 KB, with full TypeScript types. Cards cross the boundary in a shared buffer, so a Hold’em call from Node costs about 0.66 µs.
In our Hand Evaluator, hand judging now sits behind an IHandEvaluator interface that is injected into the Hand Evaluator. pokersolver and pi-poker-eval are two implementations of it, so we could run them side by side before switching.
How we proved it
A hand evaluator that is fast but wrong is worse than useless, so we checked correctness before we looked at speed.
| Check | Result |
|---|---|
| Every possible 7-card hand (133,784,560) | Hand counts per category match the published totals |
| Every possible 5-card hand (2,598,960) | Exactly 7,462 distinct strengths, in the same order as poker-evaluator; same descriptions and best five cards as pokersolver, apart from the four quirk fixes |
| About 390,000 random hands and 59,000 random showdowns, all variants | Zero unexpected differences; the same winners in every showdown |
| Our full Hand Evaluator test suite run with pi-poker-eval as the evaluator | 931 of 932 tests pass, the same result as with pokersolver (the one failure is unrelated) |
| 12,000 seeded games, 2–6 players, all variants, side pots and split pots, played once per evaluator | Chips, winners, amounts and descriptions identical in every hand |
| Exact equity, vs over all 1,712,304 boards | Matches an independent count to 7 decimal places |
The only differences in the 12,000-game run were the four pokersolver quirks listed above, which we fixed on purpose. They change only which cards are reported as the best hand, never who wins or how much.
Speed
Measured from Node on an Apple M1, on the same pre-generated hands for all three libraries:
| Call | pokersolver | poker-evaluator | pi-poker-eval |
|---|---|---|---|
| Hold’em, one hand | 14.4 µs | 0.85 µs | 0.66 µs |
| PLO, one hand | 778 µs | 72.6 µs | 4.27 µs |
| PLO6, one hand | 1,958 µs | 174 µs | 9.27 µs |
| Hold’em showdown, 6 players | 87.6 µs | 5.72 µs | 3.56 µs |
| PLO showdown, 6 players | 4,708 µs | 426 µs | 24.4 µs |
That is 22× faster than pokersolver for Hold’em and close to 200× for Omaha. Against poker-evaluator, which gives no descriptions, it is 1.3× faster for Hold’em and about 17× for Omaha, from a 130 KB file instead of a 130 MB table. Equity for against one random hand, 10,000 run-outs, takes 2.9 ms from JavaScript, against 277 ms on pokersolver. Full numbers and how to reproduce them are in the README.
Why MIT
We released pi-poker-eval under the MIT License because we want it used as widely as possible.
- No barrier to adoption. MIT is short and well understood. A developer, a studio or an operator’s legal team can read it in a minute and say yes.
- Works inside closed products. MIT places no copyleft obligation on the code around it. Operators who license our Hand Evaluator’s source code can ship it in their own products without extra conditions, and so can anyone else building a poker product.
- Easy to contribute to. We do not ask contributors to sign a contributor licence agreement. A pull request is enough.
- The library is not where our value lies. A hand evaluator is shared infrastructure, like a JSON parser. Our value is in the full platform around it. Keeping the evaluator open costs us nothing and lets others find and fix problems we have not seen.
- Trust through openness. Hand judging decides who gets paid. Anyone can read the code, run the exhaustive checks and confirm the results for themselves.
What it gives us
- Correct payouts by construction. The Omaha rule and the full-strength comparison live in one tested place. The label a player sees and the hand they are paid on come from the same evaluation.
- Cleaner client output. The wheel ace has a rank, every hand has exactly five best cards, and descriptions need no clean-up.
- Lighter installs. Services that use our Hand Evaluator no longer need a 130 MB data file.
- Winning probability on demand. Exact or Monte Carlo winning probability for any spot, in milliseconds, ready for all-in displays and other player-facing features.
- Real equity for Run It Multiple Times. The equity shown to players when they choose how many boards to run can come from an exact or Monte Carlo calculation in the same library.
- A first step to a shared Rust Hand Evaluator. The same code runs on the server and, if we choose, in the browser. It is the foundation for moving the rest of the rules to Rust.
- Ownership. When we need a new variant, a new output field or a fix, we change it ourselves and release it the same day.
Performance runs through all of this. A six-player PLO showdown went from about 4.7 ms to 24 µs. Showdowns, hand-strength labels and winning probability now cost a fraction of what they did, leaving more headroom on every server.
Try it
pi-poker-eval 0.1.0 is on crates.io and npm, and the source is on GitHub.
cargo add pi-poker-eval # Rust
npm install pi-poker-eval # JavaScript / TypeScriptNext we plan winning probability against ranges of possible opponent hands. Issues and pull requests are welcome.