Welcome to the production-grade, real-time Texas Hold'em Poker Platform monorepo. This repository contains the complete Texas Hold'em engine and real-time multiplayer network sync layer, organized as an npm workspaces monorepo.
This workspace is divided into three distinct, isolated packages under packages/:
poker-core: A pure, framework-agnostic library containing all card representations, deterministic deck shufflers, standard 5-to-7 card lexicographical evaluation algorithms, linear hand state transitions, dynamic side-pots calculations, and pure table orchestration logic (seat management, dead button rules, etc.).poker-server: A real-time synchronization server running on Node.js and Fastify. It integrates the core engine logic with a PostgreSQL database for transaction-safe balance adjustments, a Redis cache/pub-sub cluster for state coordination and cross-node sync, WebSockets (Socket.io) for multi-player client communication, and Pino for structured JSON/human-readable logging.poker-client: A premium, real-time single-page web app built with React, TypeScript, and Vite. It links directly to the real-time server via WebSockets, utilizing split Zustand state stores (session, table state, and high-frequency timer ticks) to optimize layout rendering.
graph TD
Client[poker-client React/Vite] <-->|HTTP / WebSockets| Nginx[Nginx Reverse Proxy]
Nginx <-->|Proxy to Port 3000| Server[poker-server Node.js]
Server <-->|Caching & Pub/Sub| Redis[(Redis Cluster)]
Server <-->|Balances & Hand History| Postgres[(PostgreSQL DB)]
.
├── packages/
│ ├── poker-core/ ← Pure functional engine and table orchestrator
│ ├── poker-server/ ← Real-time server layer (sockets, DB, Redis, timers)
│ └── poker-client/ ← Premium React frontend web app (Zustand, WebSockets)
├── package.json ← Root npm workspace definitions and tasks
├── tsconfig.base.json ← Shared compiler configuration rules
└── project_context.md ← Single source of truth design specifications
The Texas Hold'em game engine is built as a pure, side-effect-free state reducer. It accepts a table state and a game action, and deterministically returns the next state. There are no databases, timers, or network concerns inside poker-core. This enables:
- 100% Test Coverage: The game engine can be tested exhaustively via automated unit and property-based test suites.
- Easy Replayability: Any hand can be replayed from any state by feeding it the sequence of actions.
To prevent client-side hacks (like memory inspection or network packet decoding), opponent hole cards are masked. The server maintains a 3-Tier Card Visibility state:
- Own Cards: Only visible to the player holding them.
- Tabled Showdown Cards: Visible to all players once the hand enters showdown.
- Hidden Opponent Cards: Masked as null prior to showdown. Sanitization is computed authoritatively on the server before serializing and transmitting state payloads, ensuring clients never receive sensitive information about opponents' hands.
State updates and active hand timers are synchronized across server processes via Redis Pub/Sub. When an action occurs on any server node, the updated table state is published to Redis. Connected backend nodes subscribe to the channel, receive the state change, and immediately broadcast it to their local socket connections.
To prevent Time-of-Check to Time-of-Use (TOCTOU) race conditions—such as a player checking a fraction of a second after their turn times out and auto-folds—each state update carries an incrementing stateVersion (sequence count). Inbound game actions must supply the handActionSeq matching the version they acted upon. Stale actions are automatically rejected.
Although the backend utilizes TypeScript types (such as TableAction) to declare compile-time schema bounds, incoming Socket.IO network payloads are untrusted JSON structures at runtime. To maintain type boundary integrity and block clients from invoking restricted internal operations (like injecting "timeout" events to auto-fold opponent hands), the server casts raw payloads to unknown and runs safe runtime property guards before handling them. This prevents malicious clients from bypassing network protocol boundaries.
- Node.js: v20+ recommended
- Redis: An active instance for state persistence and Pub/Sub
- PostgreSQL: A database instance containing the schema specified in
packages/poker-server/src/db/schema.sql
Run all commands from the root directory:
# Install dependencies for all workspace packages
npm install
# Build all packages (generates dist outputs)
npm run buildExecute the comprehensive test suites:
# Run all tests across the monorepo workspace (poker-core and poker-server)
npm run testStart the real-time server in development mode:
npm run dev --workspace=packages/poker-serverYou can build and spin up the entire multi-service architecture locally using a single command:
Warning
Database Schema Migration (Phase 8): Since the database schema was updated to introduce password hashing support via a mandatory password_hash column on the players table, any existing Docker PostgreSQL data volumes from previous runs must be dropped to ensure the correct schema is generated. Execute:
docker compose down -vdocker compose up --buildThis command orchestrates:
postgres(5432): Database initialization and automatic schema execution.redis(6379): Cache and broker.poker-server(3000): Real-time Fastify server running Pino structured logs. It automatically waits for postgres and redis healthchecks before starting.poker-client(8080): The React web application served by Nginx, routing WebSocket upgrades and HTTP requests back to the server.
Navigate to http://localhost:8080 to start playing.
The server uses the following environment variables (configured via .env in packages/poker-server/):
| Variable | Description | Default |
|---|---|---|
PORT |
HTTP Server port | 3000 |
REDIS_URL |
Redis server connection URI | redis://localhost:6379 |
DATABASE_URL |
PostgreSQL connection string | postgresql://postgres:postgres@localhost:5432/poker |
ACTION_TIMEOUT_SECONDS |
Standard action window per player | 15 |
TIME_BANK_DEFAULT_SECONDS |
Default extra time-bank allocated per player | 30 |
DISCONNECT_GRACE_PAUSE_SECONDS |
Delay before acting on a disconnected player | 5 |
AUTH_SECRET |
HMAC signature key for player tokens | poker-server-secret-key-12345 |
Vite compiles these variables into the static SPA bundle at build-time. They are declared as stage-scoped ARG variables inside packages/poker-client/Dockerfile and passed via docker-compose.yml build configurations:
| Variable | Description | Default / Fallback |
|---|---|---|
VITE_SOCKET_URL |
Socket.io server connection origin URL | window.location.origin (proxied via Nginx) |
VITE_API_URL |
API auth server base path / origin prefix | "" (relative prefix proxied via Nginx) |
To connect to the poker server, a player must obtain a signed HMAC-SHA256 token.
- Endpoint:
POST /api/auth - Content-Type:
application/json - Request Payload:
{ "playerId": "player_123", "name": "Jane Doe" } - Success Response (200 OK):
The token format is
{ "token": "player_123.3a89045bfae7e..." }{playerId}.{signature}.
All client actions must be authorized using the token generated during REST authentication.
subscribe_table: Join a table lobby room and begin observing state.{ "tableId": "1", "token": "player_123.3a89045bfae7e..." }join_table: Register for an active seat at the table.{ "tableId": "1", "token": "player_123.3a89045bfae7e...", "name": "Jane Doe", "buyIn": 1000, "seatIndex": 2, "handActionSeq": 0 }game_action: Dispatch a poker decision (or seat/top-up management request).{ "tableId": "1", "playerId": "player_123", "handActionSeq": 12, "action": { "type": "dispatchHandAction", "action": { "type": "raise", "playerId": "player_123", "amount": 200 } } }
table_state: Broadcasts the current table state.- Hidden information (remaining deck, opponents' hole cards) is automatically masked unless the hand is in the
Showdownround and the player has not folded. - Includes
stateVersion(mirroringhandActionSeq) to track active updates. - Includes
legalActions(populated withfold,check,call,raise, including exact call amounts and min raises) only for the active actor's sanitized view.
- Hidden information (remaining deck, opponents' hole cards) is automatically masked unless the hand is in the
timer_tick: Emitted once per second during an active decision:{ "playerId": "player_123", "timeLeft": 12, "timeBankLeft": 30, "isTimeBank": false, "isPaused": false, "maxTimeLeft": 15 }error: Emitted if an action is rejected or invalid (e.g.,{ "message": "Out of sync action sequence" }).
To host the platform online within free tier constraints (Fly.io + Upstash Redis), the following production architecture is configured:
- Scale Count: The server must run on exactly 1 machine to prevent concurrent active-hand timers (
setInterval) from executing on multiple nodes. This also keeps Redis reads/writes strictly within Upstash's free quota of 10,000 requests/day.fly scale count 1 --app <poker-server-app-name>
- Host Binding: Set to listen on
0.0.0.0in server.ts to accept routing from the Fly proxy. - Database & Redis Secrets: Connection URIs (
DATABASE_URL,REDIS_URL) andAUTH_SECRETare managed as secure Fly secrets.
- Nginx Configuration: Simplified nginx.conf to serve purely static assets. Direct connections to the server are used instead of local reverse proxying, preventing Nginx from crashing at startup due to unresolvable internal hostnames.
- Build Arguments: Envs are injected during Vite compilation using
[build.args]in fly.toml:[build.args] VITE_SOCKET_URL = "https://<poker-server-app-name>.fly.dev" VITE_API_URL = "https://<poker-server-app-name>.fly.dev"
- WebSockets Optimization: Force-enabled WebSocket-only connections (
transports: ["websocket"]in socket.ts) to bypass HTTP polling and resolve routing handshake delays on Fly.io's proxy. - Resource Optimization: Reduced client VM memory allocation to
256mbin fly.toml to stay within the Fly.io free tier resource envelope.