Poker Bot Opponent Modeling: Track VPIP and PFR Live
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 is enough to spot the obvious tight-passive bots and the aggressive bluffers, and that's worth 2-3 bb/100 against typical fields.
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.players_acted_pf = set() # who's voluntarily acted pre-flop
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.players_acted_pf.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 self.street == "preflop":
if action in ("call", "raise", "all_in"):
if name not in self.players_acted_pf:
prof.hands_seen += 1 # count hand once
prof.vpip_hands += 1
self.players_acted_pf.add(name)
if action == "raise":
if self.first_raiser is None:
# This is the open-raise
self.first_raiser = name
prof.pfr_hands += 1
# Everyone yet to act faces the open
self.facing_open.update(
n for n in self.players_acted_pf if n != name
)
elif name in self.facing_open:
# 3-bet opportunity → 3-bet action
prof.threebet_chances += 1
prof.threebet_actions += 1
self.facing_open.discard(name)
elif action in ("call", "fold") and name in self.facing_open:
# 3-bet opportunity → declined
prof.threebet_chances += 1
self.facing_open.discard(name)
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. For a first profiler, the simple version captures 90% of the signal.
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 gets useful reads on opponents within the first 30-50 hands of seeing them. By hand 100 you have a confident profile. By hand 200 you have everything you need for high-confidence exploits.
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. This was useless. The whole point of profiling is to differentiate opponents, not to find an average. We rebuilt it to key by player name on day 3, and the bot's win rate jumped immediately.
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 the most effective single upgrade you can add to a working bot. The data is free, the math is simple, and the edges are real. Register a bot, wire up the profile tracker, and you'll have meaningful opponent reads within your first session.