This guide covers the internals of the poker bot system — how bots are created, managed, how they make decisions, and how to add new strategy types. It is intended for developers extending or integrating the bot functionality.
Architecture Overview
BotScheduler — periodic table analysis, adds/removes bots
└── BotManager — manages a pool of BotPlayer instances
└── BotPlayer — a single bot: joins table, acts on turn
├── ParameterizedStrategy — admin-configured strategies
├── Decision Engine — modular strategy + hand analysis
└── BotDelayManager — realistic timing simulation
Bot Identity Pool — pool of personas (name/avatar) assigned per session
Bot Strategy Profiles — admin-configurable parameter sets stored in DB
Bot Session Service — tracks per-session state (P/L, identity, table)
Bot Persistence Service — syncs bot state to DB across restarts
Source files:
| File | Purpose |
|---|---|
src/game/bot/bot-player.ts | Core bot actor class |
src/game/bot/bot-manager/ | BotManager pool management |
src/game/bot/bot-policy.ts | botsMayPlay() and refreshBotPolicy(): gate bot play on the bots feature toggle and FEATURE_BOTS |
src/game/bot/bot-scheduler.ts | Automated table-fill scheduler |
src/game/bot/decision-engine.ts | Modular hand analysis + strategies |
src/game/bot/parameterized-strategy.ts | Admin-configurable strategy |
src/game/bot/delay-config.ts | Realistic action timing |
src/game/bot/strategies/ | Variant-specific strategy wrappers |
src/services/bot-identity.service.ts | Identity pool assignment |
src/services/bot-strategy-profile.service.ts | DB-backed strategy profiles |
src/services/bot-scheduler-config.service.ts | Scheduler settings |
src/services/bot-session.service.ts | Per-session lifecycle |
src/services/bot-persistence.service.ts | DB persistence |
BotPlayer Lifecycle
1. Creation
import { createBotPlayer, BotStrategy } from './bot-player.js';
const bot = createBotPlayer({
id: 'bot_abc123', // Must match a user record in DB
name: 'Ace McGee',
buyIn: 1000,
strategy: BotStrategy.TIGHT_AGGRESSIVE,
minActionDelay: 500,
maxActionDelay: 3000,
delayProfile: DelayProfile.NORMAL,
});
Factory shortcuts exist: createTightBot(), createLooseBot(), createCallingStationBot(), createRandomBot().
2. Joining a table
const success = bot.joinTable(table, seatIndex);
// Internally calls table.join(bot.id, seatIndex, bot.getBuyIn(), { isBot: true, ... })
3. Acting on turn
The game engine calls bot.onActionRequired(tableState) when it is the bot’s turn. The bot:
- Builds a
BotGameContextfromtableState. - Calls
makeDecision(context)immediately (before the delay). - Schedules
executePrecomputedAction(decision)after a calculated delay.
Pre-computing the decision before the delay allows near-timeout bluff timing to work correctly.
4. Session identity
A bot can be assigned a temporary persona from the identity pool:
bot.setSessionIdentity('John Smith', 'https://cdn.example.com/avatar.jpg');
// All table-visible data uses the session identity
bot.clearSessionIdentity(); // Reverts to real name/avatar
5. Leaving a table
const result = bot.leaveTable(); // Only if not mid-hand
const result = bot.leaveTable(true); // Force-leave (folds hand first)
Strategy System
Built-in strategies (legacy enum-based)
| Enum | Behaviour |
|---|---|
TIGHT_PASSIVE | Only strong hands, rarely raises. |
TIGHT_AGGRESSIVE | Default. Fewer hands but bets/raises aggressively. |
LOOSE_AGGRESSIVE | Plays most hands, bluffs with weak holdings. |
LOOSE_PASSIVE | Calling station. Rarely folds, rarely raises. |
RANDOM | Random actions for testing. |
HEADS_UP | Auto-applied when only 2 players. Very loose with bluffing. |
Auto heads-up switch: when a table drops to 2 players, a bot on a legacy enum strategy temporarily switches to HEADS_UP. The original strategy is stored in originalStrategy and restored when more players join. Bots using a strategy profile do not switch.
Parameterized strategy (admin-configurable)
ParameterizedStrategy derives all thresholds from 6 tunable parameters, so admins can create new strategy profiles without code changes:
interface StrategyParameters {
tightness: number; // 0 (loose) to 1 (tight)
aggression: number; // 0 (passive) to 1 (aggressive)
betSizingMultiplier: number; // 0.1 to 3.0 — scales pot-fraction bets
bluffFrequency: number; // 0 to 1
positionAwareness: number; // 0 to 1 — tightens range in early position
preflopRangeWidth: number; // 0 (premiums only) to 1 (any two cards)
}
When strategyParameters is set on a BotPlayer, makeDecision() routes to makeParameterizedDecision() instead of the inline strategy methods.
Decision engine (modular)
BotDecisionEngine separates hand analysis from strategy:
const engine = createDecisionEngine('TAG'); // TAG | TP | LAG | LP | RANDOM
const analysis: HandAnalysis = engine.analyzeHand(context);
const decision: BotDecision = engine.decide(context);
// BotDecision includes { action, amount, confidence, reasoning }
Implementing a custom strategy
import { type IDecisionStrategy, type GameContext, type HandAnalysis } from './decision-engine.js';
class MyCustomStrategy implements IDecisionStrategy {
readonly name = 'My Custom';
decide(context: GameContext, analysis: HandAnalysis): BotDecision {
// Use analysis.strength, analysis.draws, analysis.preflopCategory, etc.
if (analysis.strength > 0.7) {
return {
action: 'bet',
amount: Math.floor(context.potSize * 0.75),
confidence: 0.8,
reasoning: 'Value bet with strong hand',
};
}
return { action: 'check', amount: 0, confidence: 0.6, reasoning: 'Check weak hand' };
}
}
// Register with an engine
const engine = new BotDecisionEngine({ defaultStrategy: new MyCustomStrategy() });
Variant-specific strategies
getStrategyForVariant(gameType, personality) returns variant-aware strategies. RANDOM always returns the plain random strategy:
| Game Type | Strategy |
|---|---|
| OMAHA / OMAHA_5 / OMAHA_HI_LO | src/game/bot/strategies/omaha-strategy.ts (for example OmahaTightAggressiveStrategy) — wider preflop ranges, nut-seeking, strong draws |
| SHORT_DECK | src/game/bot/strategies/short-deck-strategy.ts (for example ShortDeckTightAggressiveStrategy) — more aggressive, flush > full house, higher pair values |
| TEXAS_HOLDEM / others | Standard strategies |
The hand analyzer is also variant-aware via getAnalyzerForVariant(gameType). Omaha uses OmahaPostflopAnalyzer which enforces the 2+3 rule (exactly 2 hole cards + 3 board cards) and detects wrap straight draws.
Hand Strength Evaluation
BotPlayer.evaluateHandStrength(context) returns a float 0–1:
- Preflop: looks up hand quality (pairs, high cards, suitedness, connectivity).
- 2 hole cards →
evaluateHoldemPreflopStrength(). - 4 hole cards →
evaluateOmahaPreflopStrength()(values double-suitedness and rundowns). - 3–7 hole cards →
evaluateStudPreflopStrength()(trips/pairs/flush draws).
- 2 hole cards →
- Postflop: evaluates the best 5-card hand from all available cards using
evaluate5CardHand()/evaluate7CardHand().
Hand rank → strength mapping:
| Hand Rank | Strength |
|---|---|
| High Card | 0.10 |
| One Pair | 0.30 |
| Two Pair | 0.50 |
| Three of a Kind | 0.60 |
| Straight | 0.70 |
| Flush | 0.75 |
| Full House | 0.85 |
| Four of a Kind | 0.95 |
| Straight Flush | 0.98 |
| Royal Flush | 1.00 |
A small kicker bonus (rank / 14 * 0.05) is added on top.
Short-handed adjustment
At tables with fewer than 6 players, a shortHandedBonus() is added to effective strength so bots don’t over-fold in short-handed and heads-up situations:
| Players | Bonus |
|---|---|
| 6+ | 0 |
| 5 | +0.04 |
| 4 | +0.08 |
| 3 | +0.13 |
| 2 | +0.18 |
Action Timing (Delay System)
BotDelayManager in delay-config.ts produces realistic delays so bots don’t act instantly.
Delay profiles
| Profile | Range | Description |
|---|---|---|
INSTANT | 0 | No delay, for testing. |
FAST | 100–500 ms | Quick testing. |
NORMAL | 500–3000 ms | Default realistic speed. |
SLOW | 1000–5000 ms | Deliberate player style. |
ADAPTIVE | 300–4000 ms | Delay varies strongly with decision complexity. |
CUSTOM | 500–3000 ms | Base values for custom settings. |
Decision complexity → delay
assessDecisionComplexity(context) categorises decisions, checked in this order. Adaptive profiles scale their delay with a per-profile multiplier:
| Complexity | Trigger |
|---|---|
CRITICAL | Pot > 50% of stack |
COMPLEX | Facing a bet on the river, or strength 0.4–0.6 |
TRIVIAL | Nothing to call |
EASY | Strength < 0.3 or > 0.8 |
MODERATE | Default |
Bluff near-timeout
When a decision is classified as a bluff (betting/raising with strength < 0.35), there is a 15% chance the bot will wait until near the action timeout before acting — mimicking the human "tank-and-bluff" behaviour.
Emotional state
After winning or losing a large pot (> 20 big blinds), the bot’s delay profile shifts:
- Win →
CONFIDENT(slightly faster, smoother timing). - Loss →
TILTED(faster, more erratic timing).
The state decays to NEUTRAL after 8 hands.
Bot-only table pacing
When all opponents at a table are bots (detected by id.startsWith('bot_')), a minimum delay of 3–8 seconds is enforced so bot-vs-bot tables don’t play at unrealistic speeds.
Bot Manager
BotManager maintains a pool of BotPlayer instances. The global instance is initialized once at server startup via initializeGlobalBotManager().
// Add a bot to a table (seat and buy-in are optional)
const result = await botManager.addBotToTable(table, {
seatIndex: 3,
strategyProfileId: 'profile_id',
});
// { success, botId, seatIndex, error }
// Take a bot off a table (it stays managed)
botManager.removeBotFromTable(botId);
// Remove a bot from the manager entirely
await botManager.removeBot(botId);
// Get pool stats
const stats = botManager.getStats();
// { totalBots, botsAtTables, botsInPool, tablesWithBots, botsByStrategy }
addBotToTable returns { success: false, error: 'Bots are disabled' } when the bot policy is off. autoRebuyBots(tableId, minStack, rebuyAmount) tops up bots that have fallen below a threshold. State is synced to the database every 30 seconds by startPeriodicSync(30000).
Bot Scheduler
BotScheduler runs a periodic check loop (checkIntervalSeconds, default 30) that:
- Loads the global scheduler config from the database.
- Iterates all active in-memory tables that are eligible, skipping tables whose template has
botsEnabled === false. - For each table, calculates effective config (global defaults merged with per-template overrides).
- Counts real players (excludes bots via
isBot(id)). - Removes all bots if real players fall below
minHumanPlayers(default 1). - Removes one bot per cycle if
realPlayers >= autoDepartThreshold(default 6). - Adds a bot if
realPlayers < autoJoinThreshold(default 3), the table holds fewer thanmaxBotsPerTable(default 3) bots, and the time is within active hours. - Ends sessions where the stop-loss or win-limit has been reached.
The scheduler is off by default (enabled defaults to false). When autoFillEnabled is on, tables with a BotTableAutoFill record or a template botTargetPlayers value are managed by auto-fill instead, which adds or removes bots to reach the target.
Identity assignment
When adding a bot, the scheduler calls acquireIdentity() which picks an available persona from the identity pool and assigns it as the bot’s session identity. The identity is released when the bot leaves via releaseIdentity(name, cooldownMinutes).
Cooldown
After a bot leaves a table, it cannot rejoin the same table for cooldownMinutes (default 5), tracked in the in-memory cooldownMap. The released identity also goes on cooldown (cooldownUntil).
Active hours
The scheduler respects activeHoursStart / activeHoursEnd from the global config. If both are null, it is always active. Outside these hours, bots are not added, but removals still run.
Bot Identity Pool
The identity pool (src/services/bot-identity.service.ts, model BotIdentityPool) stores a list of personas (unique name, optional avatar). When a bot session starts:
- An available identity is atomically acquired (marked
inUse: true). - The bot’s session identity is set so it appears at the table with that name/avatar.
- On session end, the identity is released back to the pool.
This prevents two bots from appearing with the same persona simultaneously.
Bot Strategy Profiles (Database-backed)
Admins can create named strategy profiles via the Admin Panel or API. Each BotStrategyProfile row stores the six StrategyParameters values as columns, plus name, displayName and description.
// Loads the profile, applies its parameters to the bot and persists the assignment
await botManager.setStrategyProfile(botId, profileId);
Creating a Custom Strategy (Step by Step)
- Implement
IDecisionStrategyin a new file undersrc/game/bot/strategies/:
// src/game/bot/strategies/my-strategy.ts
import { type IDecisionStrategy, type GameContext, type HandAnalysis, type BotDecision } from '../decision-engine.js';
export class MyStrategy implements IDecisionStrategy {
readonly name = 'My Strategy';
decide(context: GameContext, analysis: HandAnalysis): BotDecision {
// Your logic here
return { action: 'fold', amount: 0, confidence: 0.5 };
}
}
- Register with the decision engine (optional — only needed for
createDecisionEngine()factory):
Add a case to getStrategy() and createDecisionEngine() in decision-engine.ts.
- Or use a parameterized profile — create a profile on the Strategy Profiles page of the Admin Panel (
/admin/bot-strategy-profiles). No code required. - Test your strategy:
import { BotDecisionEngine } from '../decision-engine.js';
import { MyStrategy } from '../strategies/my-strategy.js';
const engine = new BotDecisionEngine({ defaultStrategy: new MyStrategy() });
const decision = engine.decide({
holeCards: ['AH', 'KD'],
communityCards: ['TH', 'JD', 'QC'],
potSize: 500,
amountToCall: 100,
stack: 2000,
bigBlind: 50,
activePlayers: 3,
position: 2,
totalPlayers: 4,
round: 'flop',
});
Statistics and Persistence
BotPlayer.recordHandPlayed(won, potSize, bigBlind) updates in-memory win/loss counters and triggers emotional state changes for large pots.
bot-persistence.service.ts syncs bot state to the database, so bot continuity survives server restarts. BotManager.startPeriodicSync() drives the sync, and the server starts it with a 30-second interval.
bot-session.service.ts manages session records — each time a bot joins a table, a new session is created. Sessions track entry/exit chips for P/L calculation against stop-loss and win-limit thresholds.