- New docs/09-limits-and-performance.md: principles, all protection layers, what players see, configuration reference, measurements, tuning guide, monitoring and how to test - Server chapter summarises the limits and links to chapter 9; circuit breaker description and AI_MAX_INFLIGHT default corrected - Architecture: new files in the layout, design decisions for load handling; docs index lists chapter 9 - Protocol, AI and operations chapters link to chapter 9; test counts - Root and component READMEs describe the limits and settings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9.7 KiB
9.7 KiB
1. Architecture overview
Components
| Component | Folder | Language / stack | Runs as | Responsibility |
|---|---|---|---|---|
| Website | wizard_website/ |
React 19, TypeScript, Vite, CSS modules | static files served by nginx (container port 8082) | UI: start page, lobby, game table |
| Game server | wizard_server/app/ |
Python 3.13, FastAPI, uvicorn | one process (container port 8083) | sessions, lobbies, running games, asking the AI for moves, limits and load control |
| Game engine | wizard_server/game_logic/ (package wizard_game) |
pure Python, no dependencies | library, installed into server and AI images | the rules of Wizard |
| AI service | wizard_ai/ |
Python, websockets |
one process, no listening port | chooses moves for AI seats; training tools |
The game engine is deliberately a separate installable package. The server uses it to run games, and the AI uses the same code to train, so a model learns exactly the rules it will later play by.
Network layout (production)
flowchart TB
subgraph internet
U[Browser]
end
subgraph net1 ["network wizard_website"]
CADDY1((Caddy))
WEB["wizard_website :8082"]
end
subgraph net2 ["network wizard_api"]
CADDY2((Caddy))
SRV["wizard_server :8083"]
end
subgraph net3 ["network wizard_internal (no internet)"]
SRV2[wizard_server]
AI[wizard_ai]
end
U -->|"https://game.marvinkrausser.com/wizard/website/"| CADDY1 --> WEB
U -->|"wss://game.marvinkrausser.com/wizard/api/ws"| CADDY2 --> SRV
AI -->|"ws://wizard_server:8083/internal/ai"| SRV2
- Caddy is one existing container attached to the external networks
wizard_websiteandwizard_api; it is drawn twice only for readability. wizard_serveris attached to two networks (wizard_apiandwizard_internal);SRVandSRV2are the same container.- Caddy strips the path prefix: the website container sees
/assets/…, the server sees/wsand/health. - The AI service connects to the server. It has no open port, and its
network (
wizard_internal) has no internet access.
How one move travels through the system
A human plays a card in a game that also has an AI player:
sequenceDiagram
participant B as Browser (useWizard)
participant S as Server (WizardService / Lobby)
participant E as WizardGame (engine)
participant A as AI service
B->>S: {"type":"action","action":{"type":"play","card":17}}
S->>E: game.apply(seat, PlayCard(card 17))
E-->>S: events (e.g. TrickCompleted)
S-->>B: {"type":"state", lobby, game (personal observation), events}
Note over S: next seat is an AI → Lobby._ai_loop
S->>A: {"type":"decide","request_id":"…","observation":{…}}
A->>A: agent.act(observation)
A-->>S: {"type":"action","request_id":"…","action":{…}}
Note over S: waits at least AI_MOVE_DELAY<br/>(and TRICK_PAUSE after a trick)
S->>E: game.apply(ai_seat, action)
S-->>B: {"type":"state", …}
Important properties of this flow:
- The server is authoritative. Browsers and the AI only propose
actions; the engine validates every action (
WizardGame.applyraisesIllegalActionErrorfor anything illegal). - Every state message is complete. The server does not send diffs. Each
statemessage contains the full lobby and the full personal view of the game. The frontend simply replaces its state, which makes reconnects and reloads trivial. - Each player receives their own view. The server builds an
Observationper seat, which never contains other players' hands (and hides bids while "hidden bids" is active). - The AI sees exactly what a human sees. The AI service receives the same
ObservationJSON a browser gets for that seat.
Repository layout
.
├── compose.yaml # all three containers, networks, CPU/memory caps
├── .env.example # template for .env (secret, resource limits, capacity)
├── README.md # short project readme
├── docs/ # this documentation
├── instructions.txt # original task description
├── wizard_instructions/ # original rules (saved web page)
│
├── wizard_server/
│ ├── Dockerfile
│ ├── requirements.txt # fastapi, uvicorn
│ ├── requirements-dev.txt # + pytest, httpx, editable game_logic
│ ├── app/ # the FastAPI server
│ │ ├── main.py # app factory + routes
│ │ ├── config.py # Settings from environment variables
│ │ ├── protocol.py # browser → server message schema (pydantic)
│ │ ├── service.py # WizardService: dispatches messages, sessions, cleanup
│ │ ├── sessions.py # Session, Connection (ordered send queue), SessionRegistry
│ │ ├── lobby.py # Lobby, Seat, LobbyManager, AI turn loop
│ │ ├── ai_bridge.py # request/response with the AI service, fallback, circuit breaker
│ │ ├── limits.py # TokenBucket, rate and connection limiters
│ │ └── load.py # LoadMonitor: detects overload (lag, CPU, cgroup throttling)
│ ├── tests/ # conftest.py/helpers.py (fixtures), test_server.py,
│ │ # test_protection.py (limits/overload), test_limits.py (units)
│ └── game_logic/ # installable package "wizard-game"
│ ├── pyproject.toml
│ ├── wizard_game/
│ │ ├── cards.py # Card, Suit, deck, card ids
│ │ ├── actions.py # Bid, PlayCard, JSON (de)serialization
│ │ ├── rules.py # stateless rule functions
│ │ ├── state.py # Phase, GameOptions, results, events, RoundState
│ │ ├── observation.py # Observation (a player's view) + JSON
│ │ └── game.py # WizardGame state machine
│ └── tests/ # rules + full random games
│
├── wizard_ai/
│ ├── Dockerfile # needs game_logic as extra build context
│ ├── requirements.txt # websockets
│ ├── models/ # trained checkpoints (mounted read-only)
│ ├── wizard_ai/
│ │ ├── __main__.py # CLI: serve | train | evaluate
│ │ ├── config.py
│ │ ├── agents/ # Agent interface, random, heuristic, model agent
│ │ ├── model/ # encoding.py (features/actions), policy.py (model interface)
│ │ ├── training/ # env.py, self_play.py, evaluate.py
│ │ └── service/client.py # production websocket client (workers, queue, degradation)
│ └── tests/test_ai.py
│
└── wizard_website/
├── Dockerfile # node build → nginx
├── nginx.conf # listens on 8082, SPA fallback
├── vite.config.ts # base './', dev proxy for /api
└── src/
├── main.tsx, App.tsx, App.module.css
├── api/ # types.ts (protocol types), useWizard.ts (connection hook)
├── components/ # Button, PlayingCard, Modal, Toasts
├── screens/ # HomeScreen, LobbyScreen, GameScreen (+ its parts)
└── util/cards.ts # sorting, labels, legal-action helpers
Key design decisions (and why)
| Decision | Reason |
|---|---|
| Game rules in a dependency-free package | The AI trains on the exact same rules the server enforces; the engine is fast and easy to test in isolation. |
| Engine works with seat indices only | Keeps it independent of users, names and networking. The server maps indices ↔ players. |
One Observation type for browser and AI |
A single, well-tested definition of "what a player may know"; no risk of the AI accidentally seeing hidden information. |
| Full-state messages instead of diffs | Simple client, trivial reconnects, no sync bugs. Messages are small (a few KB). |
| Server asks the AI (request/response), AI seats live in the server | The AI service is stateless per request, can be scaled to several instances (round robin), and a missing AI never blocks a game (random fallback). |
| All state in memory, single process | No database needed for a casual game. Consequence: a server restart ends all games, and the server must run with one worker. |
Session token in sessionStorage |
Survives page reloads (reconnect to the same seat) but is per tab, so you can test several players in several tabs. |
| Per-connection send queue | Websocket messages are always sent in order and never concurrently (see sessions.Connection). |
| Relative asset paths + relative API URL in the frontend | The app works under any path prefix (/wizard/website/ in production, / in development) without rebuilding. |
| Refuse new games under load, never interrupt running ones | Players in a game are never punished for load; creating/starting games is what adds load, so that is what gets refused ("server busy"). Refusing sheds load by itself. See chapter 9. |
| Hard Docker caps plus software limits | The caps guarantee the host is protected even if a software limit fails; the software limits make overload visible and graceful instead of just slow. |
| Health check stays "ok" under load | Docker must never restart an overloaded server — that would end every running game. |