Poker Bot Opponent Modeling: Track VPIP and PFR Live
Lesson 10 of 13: Measure improvements
Inspect observations without promising reliable reads from small samples. Complete lesson 9; use Python 3.11+ and install the download's requirements.txt.
The cheapest edge in 6-max bot poker is knowing who you're playing against. Open Poker broadcasts every player action over WebSocket, which means your bot can build an opponent profile in real time without any external data source. A 50-hand sample may suggest a broad style, but it is not enough to establish a reliable exploit or a fixed win-rate improvement. Treat every read as provisional until more observations support it.
Part of: The Complete Guide to Building an AI Poker Bot in 2026 - the full pillar covering frameworks, decision logic, equity, testing, and where to compete.
What stats actually matter for opponent modeling?
Four numbers cover 80% of the value: VPIP, PFR, AF, and 3-bet frequency. Skip everything else for a first-pass profiler.
VPIP (Voluntarily Put $ In Pot) measures how often a player puts chips in pre-flop without being forced to. Small blinds and big blinds don't count unless the player calls a raise. A tight player has a VPIP under 18%. A loose player is over 30%. Calling stations sit around 60%+. This is the first stat you should compute because it's the most reliable signal of opponent style.
PFR (Pre-Flop Raise) measures how often a player open-raises or 3-bets pre-flop. Combined with VPIP, it tells you the gap between "hands they play" and "hands they play aggressively." A balanced player has PFR within 5-8 points of VPIP. A passive player has a much wider gap (high VPIP, low PFR), which means they call a lot but rarely raise. Passive players are easy to bluff post-flop because they don't put pressure on you.
AF (Aggression Factor) is (bets + raises) / calls postflop. Higher means more aggressive. AF below 1 means the player calls more than they bet, which usually signals a passive style. AF above 3 means they bet or raise on most of their actions, which signals aggression that you can exploit by trapping with strong hands.
3-bet frequency is the percentage of facing-a-raise spots where the player re-raises instead of calling or folding. Low 3-bet (under 4%) means re-raises are credible: that bot only re-raises with premium hands. High 3-bet (over 12%) means re-raises are bluffs or merged ranges, and you can call lighter.
How do you compute these stats from Open Poker events?
Your bot receives player_action messages for every action by every player at the table. The message includes the player's seat, their action type (fold, check, call, raise, all_in), and the amount. You also know the current street from community_cards events. That's enough to compute every stat you need.
Here's the data model:
from dataclasses import dataclass, field
from collections import defaultdict
@dataclass
class OpponentProfile:
name: str = ""
hands_seen: int = 0
vpip_hands: int = 0 # hands they voluntarily put $ in
pfr_hands: int = 0 # hands they open-raised pre-flop
threebet_chances: int = 0
threebet_actions: int = 0
postflop_bets: int = 0 # bet + raise post-flop
postflop_calls: int = 0 # call post-flop
@property
def vpip(self) -> float:
return self.vpip_hands / max(self.hands_seen, 1)
@property
def pfr(self) -> float:
return self.pfr_hands / max(self.hands_seen, 1)
@property
def af(self) -> float:
return self.postflop_bets / max(self.postflop_calls, 1)
@property
def threebet_pct(self) -> float:
return self.threebet_actions / max(self.threebet_chances, 1)
profiles: dict[str, OpponentProfile] = defaultdict(OpponentProfile)The defaultdict lets you reference a profile by name without checking if it exists. Every new opponent gets a fresh OpponentProfile() automatically.
What does the event handler look like?
You need to track per-hand state because VPIP and PFR are "did this player do X this hand?" not "how many actions did they take." Reset hand state on every hand_start message.
class HandTracker:
def __init__(self):
self.street = "preflop"
self.seen_this_hand = set()
self.voluntary_this_hand = set()
self.raisers_this_hand = set()
self.first_raiser = None
self.facing_open = set() # who faced an open and could 3-bet
def on_hand_start(self):
self.street = "preflop"
self.seen_this_hand.clear()
self.voluntary_this_hand.clear()
self.raisers_this_hand.clear()
self.first_raiser = None
self.facing_open.clear()
def on_community_cards(self, msg):
self.street = msg["street"]
def on_player_action(self, msg, profiles):
seat = msg["seat"]
name = msg.get("name", f"Seat {seat}")
action = msg["action"]
prof = profiles[name]
if name not in self.seen_this_hand:
prof.hands_seen += 1
self.seen_this_hand.add(name)
if self.street == "preflop":
if action in ("call", "raise", "all_in") and name not in self.voluntary_this_hand:
prof.vpip_hands += 1
self.voluntary_this_hand.add(name)
if action == "raise":
if name not in self.raisers_this_hand:
prof.pfr_hands += 1
self.raisers_this_hand.add(name)
if self.first_raiser is None:
# This is the open-raise
self.first_raiser = name
# Everyone yet to act faces the open
# A complete 3-bet denominator requires knowing each
# player's facing-a-raise opportunity; track it only when
# the protocol exposes that state explicitly.
else:
# Post-flop tracking
if action in ("bet", "raise", "all_in"):
prof.postflop_bets += 1
elif action == "call":
prof.postflop_calls += 1This is a simplified version. A production tracker would handle edge cases like check-raises, re-raises beyond 3-bet, and players posting blinds out of position. Treat the VPIP and PFR values as observations from the actions your client can identify; do not label a 3-bet rate complete unless the protocol exposes every facing-a-raise opportunity.
How many hands until your sample is reliable?
Stat reliability scales roughly with sample size. Here's the rule of thumb most poker tracking software uses:
| Stat | Hands needed for rough read | Hands for high confidence |
|---|---|---|
| VPIP | 30 | 100 |
| PFR | 50 | 150 |
| AF (postflop) | 80 | 250 |
| 3-bet % | 200 | 600 |
VPIP stabilizes fastest because almost every hand counts toward the sample. 3-bet% is slowest because the opportunity itself is rare (you only get a 3-bet chance when someone else raises and you're still in the hand).
The implication: your bot may get a preliminary read within 30-50 observed hands, but confidence depends on the stat and the number of relevant opportunities. Keep conservative defaults when the sample is small.
The catch: opponents come and go. On Open Poker, table composition changes as bots bust, rejoin, or get matched to different tables. You won't always have 200 hands of history on every opponent at your current table. Build the profiler to handle "name seen 5 times" gracefully (fall back to default assumptions) and to remember opponents across sessions so the data accumulates over a season.
How do you actually exploit a profile?
Three concrete exploits, ordered by how easy they are to implement.
Steal more from passive bots. A bot with VPIP under 15% and PFR under 8% folds too much pre-flop. Open-raise wider against them from late position. Default opening range from the cutoff is roughly 25-30% of hands; against a tight bot in the blinds, expand to 40-45%. They're going to fold the bottom of their range, and you pick up the blinds without contest.
Bluff less against calling stations. A bot with VPIP over 50% and AF under 1 calls too much postflop. Don't bluff them. Value-bet thinner instead: a hand that's marginal against an average opponent (top pair weak kicker on a wet board) is a strong value bet against a calling station because they'll call with worse made hands and worse draws. Save your bluffs for opponents whose AF is high enough that they fold to pressure.
3-bet light against high-frequency openers. A bot with PFR over 30% is opening too wide. 3-bet them with hands you'd normally just call: suited connectors, broadway non-pairs, small pocket pairs. The math is favorable because their wide opening range has too many hands that can't continue against a 3-bet. They'll fold a high percentage of the time, and you pick up the dead money.
def adjust_pre_flop_range(my_hand_strength, opener_profile):
"""Tighten or loosen pre-flop play based on opener profile."""
if opener_profile.hands_seen < 30:
return my_hand_strength > 0.4 # default ~25% range
if opener_profile.pfr > 0.30:
# Loose opener: 3-bet light, call wider
return my_hand_strength > 0.30
elif opener_profile.pfr < 0.12:
# Tight opener: only premium hands continue
return my_hand_strength > 0.55
else:
return my_hand_strength > 0.40What we got wrong with our first profiler
Our first version computed stats per session, not per opponent. We aggregated everything into "average opponent at this table" and used that to adjust our strategy. The whole point of profiling is to differentiate opponents, not to find an average. Key profiles by player name and validate any adjustment against later hand history.
The other early mistake: trusting small samples too much. We were exploiting opponents after 5-10 hands, which led to ridiculous reads ("this bot is a calling station" after they happened to call twice in a row). Now we require a minimum of 30 hands before applying any exploit, and we use confidence intervals instead of point estimates for sample sizes between 30 and 100.
The third mistake was forgetting to clear the per-hand state properly. We tracked VPIP across multiple hands without resetting, which inflated the stat for any opponent who acted twice in a row. The bug took two days to find because the symptom was "our bot is too tight against people we've labeled as loose," and the root cause was upstream in the data pipeline. Always reset state on hand_start.
How does this interact with table churn?
Open Poker's matchmaker rotates players across tables as people join, bust, and rejoin. Your bot might see the same opponent at three different tables across a session. The profiler should accumulate across all of those encounters, not reset when you change tables.
Persist profiles to disk between table sessions. JSON works fine for a few hundred opponents:
import json
from pathlib import Path
PROFILE_FILE = Path("opponent_profiles.json")
def load_profiles() -> dict[str, OpponentProfile]:
if not PROFILE_FILE.exists():
return defaultdict(OpponentProfile)
data = json.loads(PROFILE_FILE.read_text())
profiles = defaultdict(OpponentProfile)
for name, fields in data.items():
profiles[name] = OpponentProfile(**fields)
return profiles
def save_profiles(profiles):
data = {name: vars(p) for name, p in profiles.items()}
PROFILE_FILE.write_text(json.dumps(data, indent=2))Save every 100 hands or on disconnect. Loading is cheap, saving is occasional, and you get persistent profiles that improve across an entire 14-day season. The full WebSocket message reference lists every event type your profiler needs to handle.
FAQ
Are opponent names visible on Open Poker?
Yes. Bot names are public and visible to all players via the player_joined and player_action messages. This is intentional: it lets bots build profiles and adjust to opponents, just like human poker tracking software does on commercial sites.
Can I share opponent profiles between bots? Yes, but you'll need to do it yourself. Open Poker doesn't expose a public profile database. Do not register extra independent production agents just to share data between strategies. Once Pro bot portfolios are available, portfolio bots can sync profiles through your own shared data store while each bot still builds its own profile from its own table observations.
What if the same player rejoins under a different name? The platform uses bot name as the public identifier, so a name change creates a new profile. Bots typically don't change names mid-season, but if you suspect collusion or evasion, file a report. Names are unique per agent registration, so creating a new identity requires registering a new bot.
Does my bot need to play against an opponent to profile them?
No. The player_action events broadcast across the table to all seated players, even ones not in the current hand. As long as your bot is seated at the table, it sees every action and can profile every opponent at that table.
How does this work with the LLM bot from the previous post? You can feed opponent profiles directly into the LLM prompt. Add a "recent opponent stats" block before the decision: "Opponent in seat 3: VPIP 18%, PFR 12%, AF 0.8, tight passive." The LLM will use that information naturally. Combined with the LLM bot tutorial, this is a powerful pairing.
Opponent modeling is a practical upgrade to a working bot. The data is available from observed actions, but the value depends on sample quality and correct denominators. Register a bot, wire up the profile tracker, and inspect the resulting reads before using them to change strategy.
Try the lesson 10 checkpoint
- What changes
- Record per-opponent observed preflop hands and voluntary actions.
- Expected output
- opponents: fixture-opponent with vpip: 0
- Check your work
- The folding fixture counts in the denominator. Partial observations and all-in raises require care; see the README.
Extract the lesson download, open that folder in a terminal, and run:
python -m pip install -r requirements.txt
python bot.py --lesson 10 --self-test
python bot.py --lesson 10 --hands 3 --report run.jsonThe self-test uses offline fixtures and prints checkpoint: passed. Live play requires OPEN_POKER_API_KEY in your environment and available opponents. Read the included README for setup and recovery limits.
All lessons in this course
- 1. Build a Poker Bot in Python
- 2. Basic Poker Bot Architecture
- 3. Debug Poker Bot WebSocket Errors
- 4. Why Poker Bots Time Out
- 5. Poker Math for Bots
- 6. Poker Bot Position Ranges
- 7. Poker Bot Betting Strategy
- 8. PokerKit Tutorial
- 9. Monte Carlo Equity Calculator
- 10. Opponent Modeling
- 11. Advanced Poker Bot Architecture
- 12. Leaderboard Scoring
- 13. Pro Poker Bot Architecture