src/lib/poker/ is a pure, framework-free, deterministic, unit-tested Texas
Hold'em engine. No React, no I/O, no globals. It is the source of truth for the rules.
Everything here is portable to a server unchanged.
- Types:
Suit(c d h s),Rank(2–9 T J Q K A),Card { rank, suit }. RANK_VALUE,isRed(suit).- Seeded RNG:
mulberry32(seed): Rng.Rng = () => numberin[0,1). createDeck(),shuffle(items, rng)(Fisher–Yates),shuffledDeck(rng).- String codec:
cardToString,cardsToStrings,cardFromString(e.g."Ah","Td").T= Ten (matches pokersolver notation; the UI displays it as "10").
Determinism is the whole game: pass a seeded
Rngand shuffles/equity are reproducible, which is what makes the engine testable. Production passesMath.random.
Thin typed wrapper over pokersolver (a CommonJS module; see
src/types/pokersolver.d.ts).
evaluateHand(hole, community): EvaluatedHand— best 5-from-7;{ name, description, categoryRank, solved }.determineWinners(contenders, community): { winners, evaluations }— handles ties (multiple winners share) and kickers. This is what showdown uses.
Main + side pot construction — a classic bug source, isolated and heavily tested.
buildPots(contributions): Pot[]whereContribution { id, committed, folded }andPot { amount, eligible[] }.- Folded players' chips stay in the pots but they're not eligible to win.
- Adjacent layers with identical eligibility are merged.
totalPot(pots)sums.
The heart. Operates on an immutable-ish HandState value via pure transitions.
Key types:
Street = preflop | flop | turn | river | showdown | completePlayerStatus = active | folded | allin | outAction { type: fold|check|call|bet|raise, amount? }— for bet/raise,amountis the total to commit this street (the "raise to" amount), not the delta.Player,HandState,SeatConfig.
Key functions:
startHand(opts): HandState— deals hole cards, posts blinds (heads-up: button = SB and acts first preflop; multiway: SB left of button, action starts left of BB). Accepts a seededrngor a presetdeck(drawn from the end viapop()).legalActions(state): LegalActions | null— what the player to act may do, withcallAmount,minRaiseTo,maxRaiseTo(all-in), and can-flags.applyAction(prev, action): HandState— validates, applies, advances. Enforces min-raise (a short all-in does not re-open the action), advances streets, deals the board, runs it out when betting is closed, resolves showdown, splits pots (odd chips go to earliest seats left of the button).- Helpers:
potSize(state),isHandComplete(state).
Results live on state.result: HandResult when a hand ends:
{ showdown, payouts, potsAwarded, evaluations? }.
Monte-Carlo equity — how often a hand wins at showdown vs N opponents.
estimateEquity({ hole, community?, opponents, iterations?, rng?, opponentSelectivity? }): EquityResult→{ win, tie, equity, iterations }.equity= win share incl. tie splits, in[0,1].opponentSelectivity(per-opponent,[0,1]) weights each opponent's range toward stronger hands instead of two random cards — omit it for classic raw equity.- Powers both the AI and the human's ambient "win %" readout. ~800–1800 iters is plenty.
There is no drop-in poker bot library worth using in JS, so the AI is ours: equity + pot odds + a personality.
AiProfile { tightness, aggression, bluff, iterations, skill? }—skill(default 1) degrades play quality with genuine mistakes: noisy self-equity reads and folding under pressure. Used by the Kitchen Table freeroll so it stays beatable heads-up.decideAction(state, profile, rng?): Action— always returns a legal action. Logic: estimate equity vs live opponents — ranging each by how much they've backed the hand (opponentSelectivity, so it doesn't over-call into aggression) → compare to pot odds (tightness, plus a little more when players are still to act behind it) → value-bet/raise strong hands, check/call medium, fold weak, occasionally bluff (less so out of position). Bet sizing is a jittered fraction of the pot, clamped to legal bounds. Preflop,tightnessis the looseness dial: it sets a starting-hand-quality cutoff (holeStrength) below which a holding won't open-bluff and folds to any bet, and it scales how far the continue decision discounts (loose, station-y) or demands a premium over (nit) the pot odds. Raw equity vs random cards flatters junk — 2-3o still wins ~⅓ heads-up — so without this an equity-only bot limps and cheap-peels hands a real player mucks. Net effect, measured six-handed intests/ai.test.ts: the Garage plays ~35% of hands, the Main Event ~19%, and the fall is monotone in between — roughly real VPIP ranges. Never overrides checking for free — a limped big blind still sees the flop with anything.opponentSelectivity(state, opp)is exported and shared with the store's hero "win %" read, so both sides model opponent ranges identically.- Difficulty scales per venue via the profile (see
config/venues.ts).
- Chip conservation: total chips are constant across a hand (verified over 40
random AI-vs-AI 6-handed hands in
tests/ai.test.ts). - AI only ever returns legal actions (else
applyActionthrows — asserted). - AI never folds when it can check for free.
- Every table in
ALL_VENUESsits inside a real preflop band — PFR above 5% and below 40%, and never calling more than 8x as often as it raises. The bands are measured against the profiles imported fromconfig/venues.ts, so a table added or retuned there is covered without touching the test. Onlyiterationsis overridden (down to 90, for suite time); read the note aboveMEASURED_ITERATIONSbefore changing that or the hand count, because both move the numbers more than they look. - The ladder's difficulty curve: VPIP falls rung by rung from the Garage to the Main Event, and the top of the ladder raises a far greater share of the pots it enters than the bottom. A public claim rides on the second one — relaxing it is a copy change first.
- Blinds/first-to-act correct heads-up and multiway; min-raise rejection; all-in
run-outs; side pots; kickers; ties. See
tests/*.test.ts.
AVA, run with pnpm test. Specs: cards, handEval, pots, engine, equity, ai.
tests/helpers.ts has makeDeck(popOrder) to build a deck whose pop() order yields
exactly the cards you want — the key to deterministic scenario tests.
// deterministic hand: hero AA, villain KK, brick board
const deck = makeDeck(['Ah','Kh','Ad','Kd','2c','7s','Ts','Jc','3d'])
let s = startHand({ seats, buttonIndex: 0, smallBlind: 5, bigBlind: 10, deck })
s = applyAction(s, { type: 'call' }) // ...- Never import React, stores, or browser APIs into
lib/poker/. - Any rules change ships with tests.
- Keep transitions pure:
applyActionclones state (structuredClone) and returns a new value; callers treatHandStateas immutable.