Event-sourced No-Limit Texas Hold’em engine and server: a deterministic, test-heavy core (engine/) with FastAPI adapters (server/), PostgreSQL (or SQLite) persistence, JWT auth, WebSockets for live tables, and a small static web UI.
- Overview
- Features
- Technology stack
- Repository layout
- Core concepts
- Prerequisites
- Installation
- Configuration
- Running locally
- Docker
- Testing & quality
- REST API
- WebSocket protocol
- Web UI (
web/) - CLI tools
- Determinism & replay
- Security
- Continuous integration
- Troubleshooting
- Documentation
- License
DEAL-R implements a play-money poker table where:
- All meaningful state changes flow through a pure reducer:
next_state(state, command) → (new_state, events[]). - Events are append-only in the persistence layer with optimistic concurrency (
expected_version). - Clients send commands (sit, stand, act, start hand); the server validates, persists events, and broadcasts derived public state.
- Hole cards are never leaked to non-owners in the intended production posture (see threat model docs).
| Area | Capability |
|---|---|
| Engine | NLHE rules: legality, side pots, auto-advance, showdown evaluation, hand strength ranking |
| Event sourcing | Command idempotency, versioned streams, deterministic replay via apply_event |
| Server | FastAPI REST + JWT auth + WebSockets + Prometheus-friendly metrics hooks |
| Persistence | SQLAlchemy models, event store abstraction (PostgreSQL in CI/production path; SQLite default for dev) |
| Quality | Unit tests, integration tests with Postgres, property-based invariant tests (Hypothesis), mypy on engine + server |
| UX | Static web/ assets: login, home, poker table, daily roulette |
- Python 3.11+ (CI tests 3.11 and 3.12)
- FastAPI, Pydantic v2, Uvicorn
- SQLAlchemy 2.x, PostgreSQL (recommended) / SQLite (default URL)
- python-jose + bcrypt for JWT and password hashing (see
server/services/auth.py) - Hypothesis for property testing
- Ruff, Black, mypy (with
sqlalchemy.ext.mypy.plugin), pre-commit
DEAL-R/
├── engine/ # Pure game logic — no IO, no networking
│ ├── domain/ # GameState, commands, events, Card, Deck, types
│ ├── rules/ # Legality, side pots, invariants
│ ├── reducer/ # next_state, apply_event, auto-advance
│ └── eval/ # Hand evaluation / pot splitting helpers
├── server/ # FastAPI application
│ ├── api/ # REST routers, WebSocket router, schemas
│ ├── persistence/ # Event store, ORM models
│ ├── services/ # Table service, auth, analytics, etc.
│ ├── middleware/ # Logging, rate limiting
│ ├── config.py # Pydantic settings / env
│ └── main.py # App factory, static mounts, health
├── web/ # Static HTML/JS/CSS client (session.js, table.js, …)
├── tools/ # replay_cli, hand history export
├── tests/
│ ├── unit/ # Engine + server units
│ ├── integration/ # DB + API + websocket tests
│ └── property/ # Hypothesis invariant tests
├── docs/ # Architecture, threat model, ADRs
├── Dockerfile
├── docker-compose.yml
├── pyproject.toml
└── README.md # (this file)
- Commands (
engine/domain/commands.py) express intent:SitDown,StandUp,StartHand,Act,RevealSeed. - Events (
engine/domain/events.py) capture facts that happened:PlayerSatDown,ActionApplied,StreetDealt,ShowdownResolved,HandEnded, etc. - The reducer validates commands against
GameStateand emits zero or more events;apply_eventfolds events into state for replay.
- Auditability: the event log is the authoritative history.
- Deterministic replay: same initial state + same events ⇒ same final state (modulo deliberate non-determinism boundaries, which this project avoids in the engine).
- Concurrency story: optimistic locking on
(hand_id/stream_id, version)detects conflicting writers.
Runtime code uses identifiers such as:
table-{table_id}for table-level lifecycle (sit/stand outside a specific hand persistence path in some flows)- Individual
hand_idvalues once a hand is started (StartHand)
The exact composition is implemented in TableService; when extending persistence, preserve version monotonicity per stream.
- Python 3.11 or newer
- PostgreSQL 15+ (optional locally; Docker Compose supplies one)
- Docker / Docker Compose (recommended for Postgres and image builds)
git clone <your-fork-url> DEAL-R
cd DEAL-R
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install --upgrade pip
pip install -e ".[dev]"
pre-commit install # optional but recommendedDevelopment dependencies ([dev]) include: pytest, hypothesis, ruff, black, mypy, types-python-jose, pre-commit.
Environment variables are read via pydantic-settings (server/config.py). Typical .env:
| Variable | Default | Meaning |
|---|---|---|
DATABASE_URL |
sqlite:///./poker.db |
SQLAlchemy URL (PostgreSQL DSN for real deployments) |
JWT_SECRET_KEY |
(dev placeholder) | Must be rotated in production |
JWT_ALGORITHM |
HS256 |
JWT alg |
JWT_ACCESS_TOKEN_EXPIRE_MINUTES |
1440 |
Session lifetime |
RATE_LIMIT_ENABLED |
true |
Toggle simple rate limiting |
RATE_LIMIT_PER_MINUTE |
60 |
Requests/min per IP/key |
LOG_LEVEL |
INFO |
Structlog threshold |
LOG_FORMAT |
json |
json or text |
SNAPSHOT_INTERVAL |
100 |
Snapshot cadence hook (see event store) |
docker compose up -d
export DATABASE_URL=postgresql://user:pass@localhost:5432/dbname # match your compose fileuvicorn server.main:app --reload --host 0.0.0.0 --port 8000Useful URLs:
- OpenAPI UI:
http://localhost:8000/docs - ReDoc:
http://localhost:8000/redoc - Health:
http://localhost:8000/health - Static UI entry:
http://localhost:8000/web/home.html(if static mount is enabled; seemain.py)
The Dockerfile installs runtime dependencies only (pip install -e ., no [dev]). Health checks use urllib against /health (no extra requests dependency).
Build:
docker build -t deal-r:latest .Run (example):
docker run --rm -p 8000:8000 \
-e DATABASE_URL=postgresql://… \
-e JWT_SECRET_KEY=… \
deal-r:latest# Full suite (includes integration tests; some require DATABASE_URL pointing at Postgres)
pytest
pytest --cov=engine --cov=server --cov-report=term-missing
# Focused subsets
pytest tests/unit/
pytest tests/integration/
pytest tests/property/
# Static checks
ruff check .
black --check .
mypy engine serverPre-commit mirrors CI style checks locally:
pre-commit run --all-filesAll JSON routes live under prefixes defined in routers (auth: /api/v1/auth, game REST: /api/v1). OpenAPI (/docs) is the authoritative list. Common capabilities include:
| Concern | Notes |
|---|---|
| Auth | Register/login returns JWT access_token; use Authorization: Bearer <token> |
| Player profile | /api/v1/players/me style endpoints (see router definitions) |
| Table snapshots | Read models for spectators/refresh paths |
| Roulette (demo) | Daily spin endpoint persists chips into users.json (dev-oriented) |
Consult server/api/rest.py and server/api/auth.py for exact paths and payloads.
Connect to /ws/tables/{table_id} (see server/api/ws.py). Pass JWT as ?token=<jwt> query parameter (browser WebSockets cannot set custom headers easily).
Outbound messages commonly include:
type: "state"— table snapshot wrapper with seats, pots, street, versiontype: "command_accepted"— echo of successful command application withnew_versiontype: "error"— human-readable failures (validation, capacity, etc.)
Inbound commands mirror domain commands roughly as:
{ "type": "sit_down", "data": { "seat_id": 0, "stack": 1000, "player_id": "player_alice" }, "idempotency_key": "…", "expected_version": 0 }Similar shapes exist for act, start_hand, and stand_up. Always send fresh idempotency keys per logical user action.
Static assets demonstrate end-to-end flows:
session.js— shared auth/session helpers (fetchWithAuth, login redirect).table.js— WebSocket-driven poker UI.home.js,roulette.js— profile and daily bonus demo.
See web/DESIGN.md for UX notes.
python -m tools.replay_cli <hand-id> [--hash-only]
python -m tools.hh_export <hand-id> --output hand.txtThese assume database connectivity consistent with DATABASE_URL.
- Deck:
Deck.create_shuffled(seed)derives order deterministically from an integer seed. - Hand start: Clients supply a
seed_commitstring; replay paths derive integer seeds compatibly with runtime dealing (seeTableService._replay_eventsand reducer start-hand flows). - Replay:
apply_eventin a loop reconstructs table state machine positions from persisted events alone.
Extended discussion: docs/architecture.md.
DEAL-R is designed as server authoritative poker with JWT auth and optimistic concurrency. Threat assumptions and residual risks are documented in docs/threat-model.md. Rotate secrets, tighten CORS, and harden persistence before any real-money adjacent deployment.
GitHub Actions (.github/workflows/ci.yml):
- Lint — Python 3.12,
pip install -e ".[dev]",pre-commit run --all-files,mypy engine server. - Test — Matrix 3.11 / 3.12 against PostgreSQL 15,
pytest+ coverage XML. - Build — Docker image build with GH Actions cache backends.
Codecov upload runs once (Python 3.12 job) to avoid duplicate reports.
| Symptom | Things to check |
|---|---|
| Version mismatch errors | Client expected_version stale; reload snapshot or websocket state.version. |
| 401 on REST | Expired JWT; re-login. Ensure Authorization: Bearer … header present. |
| WebSocket instant disconnect | Missing/invalid token query param; server logs in server/api/ws.py print decode failures during dev. |
| Integration tests fail | Export DATABASE_URL to a reachable Postgres with created DB/user (CI uses poker_test). |
| SQLite locked | Multiple writers; prefer Postgres for concurrent local dev. |
| mypy plugin errors | Ensure sqlalchemy 2.x installed; pyproject.toml enables sqlalchemy.ext.mypy.plugin. |
| Doc | Purpose |
|---|---|
docs/architecture.md |
System design, boundaries between engine and server |
docs/state-machine.md |
Streets, transitions, table lifecycle |
docs/invariants.md |
Chip conservation, pot correctness, etc. |
docs/threat-model.md |
Security assumptions |
docs/adr/0001-event-sourcing.md |
ADR: why event sourcing |
web/DESIGN.md |
Static client design notes |
MIT
# Dev install
pip install -e ".[dev]"
# Database
docker compose up -d
# Run API
uvicorn server.main:app --reload
# Tests + types
pytest && mypy engine server
# Hooks
pre-commit run --all-filesFor questions about extending the engine (new streets, tournament mode, different game variants), start from engine/domain and engine/reducer/reducer.py, then thread changes through server/services/table_service.py and API schemas.