Inspiration
Heads-up Short Deck 5-card Pot-Limit Omaha is a brutal game for a bot: a 36-card deck, five hole cards, huge equity swings, and only an 8 second thinking budget per 1,000-hand half. If a bot runs out of time, the judge plays check-or-fold for it, and a good opponent steals every hand from then on. We realized most teams would compute equity with the starter omaha_score function (about 15 µs per hand), so naive Monte Carlo would drain their time bank before the match ended. We wanted a bot that was both fast enough to think every hand and smart enough to read what its opponent's bets actually mean.
What it does
ShortStack plays 1,000-deal duplicate matches and adapts to each opponent within the half. It estimates its equity against the opponent's likely range rather than a random hand, learns that range from the opponent's bets and showdowns, prices calls by how often the opponent keeps betting, bluffs the river only when the opponent's measured fold rate pays for it, and steals every hand once it detects the opponent's time bank is empty. Every action is checked for legality, so it can't forfeit on an error. Coded with Cursor + GrokBot agents for math.
How we built it
1. A vectorized hand evaluator
In Omaha a hand must use exactly 2 hole cards and exactly 3 board cards, so each showdown is a max over $$\binom{5}{2}\binom{5}{3} = 10 \times 10 = 100$$ combinations. With a 36-card deck there are only \(\binom{36}{2} = 630\) card pairs and \(\binom{36}{3} = 7{,}140\) card triples, so we precomputed a 630 × 7,140 rank table where each entry is the best 5-card rank for that pair plus triple. We also precompute our own best rank for each board triple once per hand, which cuts each runout from 100 lookups to 10. A 1,000-sample Monte Carlo equity call takes about 1.3 ms on the flop and turn and 0.36 ms on the river, more than 10x faster than scoring each sample with the starter function. Preflop decisions read a precomputed equity table covering all \(\binom{36}{5} = 376{,}992\) starting hands.
2. Equity against a range, not a random hand
For \(n\) sampled opponent hands with outcome \(r_i \in {0, \tfrac{1}{2}, 1}\) (loss, tie, win), ShortStack computes a weighted equity $$\hat{E} = \frac{\sum_{i=1}^{n} w_i\, r_i}{\sum_{i=1}^{n} w_i}$$ where \(w_i\) is how well hand \(i\) explains the opponent's betting. If they bet, a hand whose made-hand strength percentile \(s_i\) sits below the implied level \(\ell\) becomes unlikely: $$w_i = \max!\left(\sigma!\left(\frac{s_i - \ell}{0.12}\right),\ 0.06\right), \qquad \sigma(x) = \frac{1}{1 + e^{-x}}$$ The 0.06 floor means a misread never makes a hand impossible. Weighting shrinks the effective sample size, so we track the estimate's uncertainty with $$n_{\text{eff}} = \frac{\left(\sum w_i\right)^2}{\sum w_i^2}, \qquad \text{SE}(\hat{E}) = \sqrt{\frac{\hat{E}(1-\hat{E})}{n_{\text{eff}}}}$$
3. Learning each opponent
A bot that bets a fraction \(a\) of the time is betting roughly its top \(a\) of hands, so the implied strength level moves from our prior \(\ell_0 = 0.40\) toward \(1 - a\) as evidence accumulates: $$\ell = (1 - \lambda)\,\ell_0 + \lambda\,(1 - a), \qquad \lambda = \frac{n}{n + 60}$$ where \(n\) is the number of the opponent's actions observed so far. The level is then refined, separately for each street, by the hands the opponent actually shows down. For bots that bet nearly every street, the extra strength we credit for earlier bets fades by $$c = \min!\big(1,\ \max(0,\ 2(1 - a))\big)$$ so a bot that bets everything is read as betting its whole range.
4. Pricing calls when more bets are coming
Pot odds alone undervalue the cost of calling a bot that keeps betting. With \(p\) the pot odds and \(q\) the all-in odds, $$p = \frac{\text{call}}{\text{pot} + \text{call}}, \qquad q = \frac{\text{our remaining stack}}{\text{pot} + \text{our stack} + \text{what they can match}}$$ the equity we require before the river is $$E_{\text{call}} = p + \min(1,\ a \cdot k \cdot \beta)\,\big(q - p\big)$$ where \(k\) is the number of streets left to bet and \(\beta = 0.6\). Against bots that bet often and whom we fold to more than 55% of the time, a minimum-defense term lowers that bar by $$\delta = \min!\big(0.10,\ 0.3\,\max(0,\ \text{FTB} - 0.55)\big)$$ where FTB is our fold rate to their first bet on that street. This keeps them from profiting by bluffing at our fold rate, and the term shrinks by itself as our fold rate comes down.
5. River bluffs that pay for themselves
Measured in pots, a bluff that risks \(r\) (0.5 for half-pot, 1 for pot) against an opponent who folds with frequency \(f\) gains $$\text{EV} = f \cdot 1 - (1 - f)\, r$$ ShortStack bluffs only when \(\text{EV} - \hat{E} > 0.05\), where \(\hat{E}\) is the showdown equity it gives up by bluffing. Break-even is \(f^* = \frac{r}{1 + r}\): 33% folds for a half-pot bluff and 50% for a pot bluff. Calling stations never clear the bar, so the rule switches itself off against them.
6. Spending the time bank
Monte Carlo error falls as \(1/\sqrt{n}\), so samples are worth a lot until the bank gets tight. Each decision gets $$n = \min!\left(1500,\ \frac{T_{\text{left}}}{H_{\text{left}} \cdot d \cdot c_{\text{sample}}}\right)$$ samples, where \(T_{\text{left}}\) is the remaining logic budget, \(H_{\text{left}}\) the hands remaining, \(d\) the measured decisions per hand and \(c_{\text{sample}}\) the measured cost per sample. At the start of each half, a timing probe benchmarks the machine and scales the assumed overhead by \(\max!\big(1,\ t_{\text{probe}} / t_{\text{ref}}\big)\), so a slower machine automatically thinks less instead of running out of time.
7. A simulator and a statistically rigorous A/B harness
Matches are duplicate: each deal is played twice with seats swapped, so card luck cancels. We built an in-process match runner scored exactly like the judge, plus a pool of 22 opponents built to look like the field: organizer baselines, the guide's example bot, Monte Carlo threshold bots, fast range-aware bots, bluff-heavy barrelers, adaptive exploiters and self-play variants.
Every change ran as a paired A/B test: candidate and baseline play the same opponents on the same seeds and deals, and we score the mean paired difference $$\bar{d} = \frac{1}{m}\sum_{j=1}^{m} \big(x_j^{\text{cand}} - x_j^{\text{base}}\big), \qquad z = \frac{\bar{d}}{s_d / \sqrt{m}}$$ Keep rule: \(\bar{d} \ge +0.05\) bb/deal, \(z > 2\), no opponent worse by more than 0.3 bb/deal, confirmed on 8 fresh seeds. Over 13 batches we rejected dozens of ideas that looked good on four seeds and vanished on eight.
Finally, we played matches through the real HTTP judge, then in Docker containers limited to 1 CPU and 4 GB, with fresh containers each half, exactly like the tournament.
Challenges we ran into
- Our own experiments were lying to us. Early A/B runs timed the bot with the wall clock, so two identical bots drifted apart by up to 1.44 bb/deal depending on CPU load, and a fake improvement showed up at \(z = 3.6\). We built a deterministic virtual clock for the simulator and added an A/A control arm (identical parameters, so \(\bar{d}\) should be exactly 0) to every batch, so we could see the noise floor before trusting any result.
- Time is a resource. Cutting our thinking budget from 45% to 35% cost 0.25 bb/deal, but raising it risks forfeits on a slower machine. With the timing probe, the bot now survives a machine 4x slower without emptying its bank.
- Always-bettors broke our range model. A bot that bets every street with random hands was being read as a top-44% hand by the river, and a third of our folds against it gave up the best hand. The fading term \(c\) above fixed it: +0.119 bb/deal at \(z = 7.0\).
- The strongest rival. A rival team's Bayesian EV-maximizing bot was the hardest opponent we found. We diffed duplicate matches deal by deal to find exactly where we lost chips, and that's where the per-street levels and the minimum-defense rule came from.
Accomplishments that we're proud of
- 66 wins, 0 chops, 0 losses against all 22 non-self pool bots over three seeds, at +3.71 bb/deal.
- Undefeated in tournament-style Docker matches against the strongest rival bot we found (2 wins, 6 chops, every margin zero or positive).
- 0 errors, and only 5.3 to 5.9 s of the 8 s bank used under a 1-CPU container limit.
- Five statistically confirmed improvements, among them learned postflop ranges (+0.144 bb/deal, \(z = 5.6\)) and per-street bet levels (+0.108, \(z = 3.3\)).
What we learned
Measurement beats intuition. Most of our "obviously good" ideas, like more samples, thinner value bets and an aggressive opening image, either did nothing or lost chips once we tested them properly. And in a time-banked game, speed is strategy: a fast equity engine lets you think every hand while your opponents run out of time.
What's next for ShortStack
Check-raising and bet sizing against frequent flop bettors, closing the gap against Bayesian bots that have more thinking time, and running more of our experiments in containers to match tournament hardware exactly.