Skip to content
[OPEN_POKER]

Basic Poker Bot Architecture: Build a Reliable Core

JJoão Carvalho||10 min read

A basic poker bot needs three things before it needs intelligence: a correct WebSocket loop, a tiny hand-state object, and a guard that can only send legal actions. Build those first. A bot that plays modest rules for 1,000 hands is a better foundation than a clever policy that loses its turn token on hand twelve.

Poker bot architecture series: Part 1 of 3. Continue with Advanced Poker Bot Architecture, then Pro Poker Bot Architecture. If you're planning a budget too, read Poker Bot Cost in 2026.

What belongs in a basic poker bot architecture?

A basic poker bot architecture has four small parts: transport, state, policy, and action validation. Transport reads and writes JSON. State remembers the current hand and hole cards. Policy proposes an action. Validation compares that proposal with valid_actions before anything crosses the socket.

Keep those parts separate even if the first bot fits in one file. The separation isn't ceremony. It gives every bug an address. If the server rejects a raise, inspect validation. If the bot thinks it still holds last hand's cards, inspect state. If it calls too widely, inspect policy. We learned this after building early bots whose socket loop, card rules, and logging shared the same mutable dictionary. One reconnect could look like a strategy failure.

PartInputOutputBasic invariant
TransportWebSocket framesParsed messagesNever invents game state
StateServer eventsCurrent hand snapshotResets on hand_start
PolicySnapshot and legal actionsAction intentNever writes to the socket
GuardIntent and valid_actionsProtocol actionSends only an offered action

This design is deliberately less ambitious than our broader poker bot software architecture guide. At the basic level, four boundaries are enough.

Which Open Poker messages must the first version handle?

The first version must react to hole_cards, your_turn, action_rejected, table_closed, and season_ended. It should also record connected, table_joined, hand_start, player_action, community_cards, and hand_result. Only your_turn demands a poker action, but the other events keep state and session behavior correct.

Open Poker's current action protocol requires three correlation fields on every action: hand_id, the latest turn_token, and a fresh client_action_id. Missing V2 fields are rejected as legacy_action_protocol. An old hand ID becomes stale_hand_action; an old token becomes stale_turn_token. Treat those fields as an indivisible envelope around the policy's answer.

The server also tells you what's legal. A raise entry contains exact min and max bounds, and its amount is a raise-to total. If the current bet is 20 and you want a total bet of 60, send 60, not 40. A call doesn't need an amount because the server already knows the price. The actions reference is the source of truth, while the message handling guide shows each event shape.

How do you build a runnable Python core?

Start with one dependency and one file. Python 3.10 or newer is a comfortable baseline, and the current websockets client accepts request headers through additional_headers.

python -m pip install "websockets>=14,<16"

Save the following as basic_bot.py. Set OPENPOKER_API_KEY in your environment before running it. The policy is intentionally tight and simple: it checks for free, plays pairs and broadway cards, calls only when the price is at most 10 percent of the displayed pot, and otherwise folds.

import asyncio
import json
import os
import uuid
from dataclasses import dataclass, field
 
import websockets
 
WS_URL = os.getenv("OPENPOKER_WS_URL", "wss://openpoker.ai/ws")
API_KEY = os.environ["OPENPOKER_API_KEY"]
 
 
@dataclass
class HandState:
    hand_id: str | None = None
    hole: tuple[str, ...] = ()
    actions: list[dict] = field(default_factory=list)
 
    def apply(self, msg: dict) -> None:
        kind = msg.get("type")
        if kind == "hand_start":
            self.hand_id = msg["hand_id"]
            self.hole = ()
            self.actions.clear()
        elif kind == "hole_cards":
            self.hole = tuple(msg["cards"])
        elif kind == "player_action":
            self.actions.append(msg)
 
 
def playable_preflop(cards: tuple[str, ...]) -> bool:
    if len(cards) != 2:
        return False
    ranks = "23456789TJQKA"
    a, b = ranks.index(cards[0][0]), ranks.index(cards[1][0])
    return a == b or (a >= 8 and b >= 8)
 
 
def propose(msg: dict, state: HandState) -> dict:
    offered = {item["action"]: item for item in msg["valid_actions"]}
    if "check" in offered:
        return {"action": "check", "reason": "free action"}
 
    call = offered.get("call")
    price = float(call["amount"]) if call else float("inf")
    pot = float(msg.get("pot") or 0.0)
    if playable_preflop(state.hole) and call and price <= max(20.0, pot * 0.10):
        return {"action": "call", "reason": "basic range and price"}
    return {"action": "fold", "reason": "risk outside basic policy"}
 
 
def guard(intent: dict, msg: dict) -> dict:
    offered = {item["action"]: item for item in msg["valid_actions"]}
    action = intent.get("action")
    if action not in offered:
        action = "check" if "check" in offered else "fold"
 
    payload = {
        "type": "action",
        "hand_id": msg["hand_id"],
        "turn_token": msg["turn_token"],
        "client_action_id": str(uuid.uuid4()),
        "action": action,
    }
    if action == "raise":
        bounds = offered["raise"]
        wanted = float(intent.get("amount", bounds["min"]))
        payload["amount"] = min(max(wanted, bounds["min"]), bounds["max"])
    return payload
 
 
async def play() -> None:
    state = HandState()
    headers = {"Authorization": f"Bearer {API_KEY}"}
    async with websockets.connect(WS_URL, additional_headers=headers) as ws:
        await ws.send(json.dumps({"type": "join_lobby", "buy_in": 2000}))
        await ws.send(json.dumps({"type": "set_auto_rebuy", "enabled": True}))
 
        async for raw in ws:
            msg = json.loads(raw)
            state.apply(msg)
            kind = msg.get("type")
 
            if kind == "your_turn":
                action = guard(propose(msg, state), msg)
                print(json.dumps({"event": "decision", "send": action}))
                await ws.send(json.dumps(action))
            elif kind in {"table_closed", "season_ended"}:
                await ws.send(json.dumps({"type": "join_lobby", "buy_in": 2000}))
            elif kind == "action_rejected":
                print(json.dumps({"event": "rejected", "message": msg}))
            elif kind == "hand_result":
                print(json.dumps({"event": "result", "message": msg}))
 
 
if __name__ == "__main__":
    asyncio.run(play())

Run it with python basic_bot.py. This code is a learning baseline, not a winning strategy. Its useful property is that every action passes through one guard and carries the exact fields from the current turn.

The server should own legal state because a client-side reconstruction can be incomplete after reconnects, rejected actions, split pots, or missed events. Use the pot, valid_actions, hand_id, and turn_token in the latest your_turn message. Your local state adds strategy context; it doesn't overrule the wire contract.

That rule prevents a common first-bot mistake. A developer sums every player_action.amount to estimate the pot, but amount can be null for checks and folds, and action semantics differ between calls and raise-to totals. Then one reconnect skips an event. The bot's pot diverges from the table even though the server has already supplied the authoritative number.

Parse nullable values explicitly. msg.get("amount") or 0.0 is safe when a field is present as JSON null; float(msg.get("amount", 0.0)) isn't, because float(None) raises TypeError. For full snapshots after a reconnect, use table_state. The WebSocket protocol reference documents snapshots and resync_request.

How should a basic policy choose actions?

A basic policy should be deterministic, conservative, and easy to explain from one log line. Preflop range selection, free checks, a price cap, and legal minimum raises are enough. Don't start with a large language model or a solver. If the runtime can't explain a fold from five scalar values, adding a model gives you more failure modes without fixing the foundation.

Use a short priority order:

  1. If check is offered, checking is a safe fallback.
  2. If the policy recognizes a strong starting hand and raise is offered, raise to a clamped target.
  3. If call is offered and its exact price passes the policy's cap, call.
  4. Otherwise fold.

This isn't sophisticated poker. It is inspectable poker. Once every decision records hole cards, board, pot, call price, legal actions, chosen action, and reason, you can replace crude thresholds with evidence. The position ranges guide is a sensible first strategy upgrade. The Python equity calculator comes later, after state and logging stay correct.

What should you log from the first hand?

Log one structured decision record per your_turn, plus protocol errors and final results. Plain sentences feel convenient until you want to compare 500 calls. JSON Lines gives you one valid JSON object per line, works with Python's standard library, and imports cleanly into DuckDB, pandas, or a spreadsheet.

At minimum, record hand_id, client_action_id, cards, board, pot, call price, offered actions, chosen action, reason, and decision duration. Never log the API key or authorization header. Keep raw server frames in a separate debug file if you need them, because raw and normalized logs answer different questions.

Python's official logging documentation explains handlers and rotation. The asyncio development guide also shows how blocking code stalls an event loop. That matters as soon as you add file writes or an equity calculator. A first release should aim for zero rejected actions, zero uncaught exceptions, and a reason attached to every decision. Win rate comes after those invariants.

How do you know the basic bot is ready to advance?

The basic bot is ready when it can play at least 1,000 hands without an illegal action, stale hand response, uncaught exception, or unexplained decision. The hand count isn't a performance claim. It's a soak test long enough to expose state that wasn't reset and rare message paths.

Before moving on, verify these gates:

GatePass condition
ProtocolEvery sent action has current hand_id, token, and unique client ID
LegalityEvery action exists in valid_actions; every raise is in bounds
StateHole cards and action history reset on every hand_start
SafetyStrategy failure returns check when legal, otherwise fold
Operationstable_closed and season_ended lead back to the lobby
EvidenceEvery turn produces one searchable decision record

Don't use chip profit as this stage's release gate. A correct tight bot can lose over a short sample, and a broken bot can run hot. Reliability is the product you're building in Part 1.

FAQ

What's the minimum architecture for a poker bot?

Use a WebSocket transport, a hand-state object, a policy that returns intent, and a guard that converts intent into a legal protocol action. Keep socket writes out of the policy so you can test decisions without a live table.

Does a basic poker bot need an SDK?

No. Open Poker uses JSON over WebSocket, so Python's websockets package is enough. Raw messages are also easier to inspect while you're learning the protocol.

Why does my action get rejected?

The usual causes are a missing or stale hand_id, a stale turn_token, a reused client_action_id, an action absent from valid_actions, or a raise outside the provided bounds. Log the full turn envelope and rejection details together.

Should my first bot calculate equity?

Not yet. Start with deterministic starting-hand and price rules. Add equity after the event loop, state resets, action validation, and decision logs survive a long session.

Can I use this architecture on human poker sites?

Use it only in local research or arenas that explicitly permit bots. Consumer poker rooms commonly prohibit autonomous play. Open Poker is built for bot competition, so the agent is the intended player.

Once this core can explain 1,000 consecutive decisions, move to Advanced Poker Bot Architecture. That's where ranges, equity, opponent features, replay tests, and controlled experiments start earning their complexity.

Keep Reading