diff --git a/README.md b/README.md
index 85b826f..256fdeb 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
An online version of the card game Wizard (rules: `wizard_instructions/`).
+**Detailed documentation of the whole codebase: [`docs/`](docs/README.md).**
+
| Component | Path | Tech |
|-----------|------|------|
| Frontend | `wizard_website/` | React + TypeScript + Vite, CSS modules, served by nginx |
@@ -18,7 +20,8 @@ browser ──wss /wizard/api/ws──────▶ Caddy ──/ws──▶ w
1. A player opens the website, enters a name and creates a lobby (and joins it).
2. The lobby shows a code (and an invite link, `?lobby=CODE`) others use to join.
-3. The host can add AI players, toggle the *not equal* / *hidden bids* rules, and start the game (3–6 players).
+3. The host can add AI players, toggle the *hidden bids* option, and start the game (3–6 players).
+ The sum of all bids may never equal the number of cards in hand (always on).
4. The server runs the game with `wizard_game` and sends every player a personal view
(`Observation`) after each move. Reloading the page reconnects to the same seat.
A player who leaves a running game is replaced by an AI player.
diff --git a/docs/01-architecture.md b/docs/01-architecture.md
new file mode 100644
index 0000000..7d12981
--- /dev/null
+++ b/docs/01-architecture.md
@@ -0,0 +1,160 @@
+# 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 |
+| 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)
+
+```mermaid
+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_website` and `wizard_api`; it is drawn twice only for readability.
+- `wizard_server` is attached to two networks (`wizard_api` and
+ `wizard_internal`); `SRV` and `SRV2` are the same container.
+- Caddy **strips the path prefix**: the website container sees `/assets/…`,
+ the server sees `/ws` and `/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:
+
+```mermaid
+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
(and TRICK_PAUSE after a trick)
+ S->>E: game.apply(ai_seat, action)
+ S-->>B: {"type":"state", …}
+```
+
+Important properties of this flow:
+
+1. **The server is authoritative.** Browsers and the AI only *propose*
+ actions; the engine validates every action (`WizardGame.apply` raises
+ `IllegalActionError` for anything illegal).
+2. **Every state message is complete.** The server does not send diffs. Each
+ `state` message contains the full lobby and the full personal view of the
+ game. The frontend simply replaces its state, which makes reconnects and
+ reloads trivial.
+3. **Each player receives their own view.** The server builds an
+ `Observation` per seat, which never contains other players' hands (and hides
+ bids while "hidden bids" is active).
+4. **The AI sees exactly what a human sees.** The AI service receives the same
+ `Observation` JSON a browser gets for that seat.
+
+## Repository layout
+
+```
+.
+├── compose.yaml # all three containers + networks
+├── .env.example # template for .env (AI_SHARED_SECRET, …)
+├── 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
+│ ├── tests/test_server.py # websocket-level tests of the server
+│ └── 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
+│ └── 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. |
diff --git a/docs/02-game-rules.md b/docs/02-game-rules.md
new file mode 100644
index 0000000..37dea05
--- /dev/null
+++ b/docs/02-game-rules.md
@@ -0,0 +1,140 @@
+# 2. Game rules as implemented
+
+This chapter lists every rule the code enforces and where. The original rules
+are in `wizard_instructions/`; where this implementation deliberately differs
+from them, it is marked **(house rule)**.
+
+All rule code lives in the game engine: stateless helpers in
+`wizard_server/game_logic/wizard_game/rules.py`, flow in `game.py`.
+
+## Cards
+
+60 cards (`cards.py`):
+
+| Cards | Count | Notes |
+|-------|-------|-------|
+| Number cards 1–13 in red, yellow, green, blue | 52 | the only cards with a suit |
+| Wizards | 4 | always highest |
+| Jesters | 4 | always lowest |
+
+Every card has a fixed **id 0–59**, used in JSON and in the AI encoding:
+
+| Ids | Cards |
+|-----|-------|
+| 0–12 | red 1–13 |
+| 13–25 | yellow 1–13 |
+| 26–38 | green 1–13 |
+| 39–51 | blue 1–13 |
+| 52–55 | wizards |
+| 56–59 | jesters |
+
+Formula for number cards: `id = suit_index * 13 + (rank - 1)` with suit order
+red, yellow, green, blue.
+
+## Players and rounds
+
+- 3 to 6 players (`rules.MIN_PLAYERS`, `rules.MAX_PLAYERS`).
+- Number of rounds = `60 // players` (`rules.total_rounds`): 3 → 20, 4 → 15,
+ 5 → 12, 6 → 10.
+- In round *n* every player gets *n* cards, so round *n* has *n* tricks.
+
+## Dealing, dealer and starting player
+
+`WizardGame._deal`:
+
+1. A fresh 60-card stack is shuffled every round.
+2. Cards are dealt one at a time from the top of the stack, starting with the
+ player left of the dealer.
+3. The **next card of the stack is turned up as the trump card**. It is taken
+ off the stack: it is in nobody's hand and can never be played.
+4. In the last round all 60 cards are dealt, so there is **no trump card**.
+
+The first dealer is random (or `first_dealer=` in tests). The dealer moves one
+seat to the left every round. The player left of the dealer (the *starter*)
+bids first and leads the first trick.
+
+## Trump
+
+`rules.trump_suit(trump_card)`:
+
+| Trump card | Trump suit |
+|------------|------------|
+| number card | its suit |
+| wizard | **no trump** (house rule — the original rules let the dealer choose) |
+| jester | no trump |
+| none (last round) | no trump |
+
+## Bidding
+
+`WizardGame._allowed_bids` and `_bid`:
+
+- Players bid in turn, starting with the starter; the dealer bids last.
+- A bid is any number from 0 to the number of cards in hand.
+- **The sum of all bids may never equal the number of cards in hand**
+ (house rule — in the original rules this is the optional "notequal"
+ variant; here it is always on). Only the last bidder is restricted: the one
+ value that would make the sum equal the round number is removed from their
+ legal bids. Example: round 5, the others bid 1, 2 and 0 → the last player may
+ bid anything except 2.
+- Option **hidden bids** ("hiddentip", `GameOptions.hidden_bids`): while
+ bidding is in progress, each player only sees their own bid; everyone can
+ see *who* has already bid (`Observation.has_bid`). All bids are revealed as
+ soon as the last bid is placed (`WizardGame.observation`).
+ Note: because of the sum rule, the last bidder can deduce the sum of the
+ hidden bids from the forbidden value.
+
+## Playing a trick
+
+The starter leads the first trick; afterwards the winner of the previous
+trick leads.
+
+### Which suit must be followed — `rules.lead_suit(trick)`
+
+Look at the cards of the trick in playing order:
+
+- Jesters are skipped.
+- If the first non-jester card is a **wizard**, there is no suit to follow for
+ the whole trick.
+- If the first non-jester card is a **number card**, its suit must be
+ followed.
+- If only jesters were played so far, there is no suit yet (the next number
+ card will set it).
+
+### Which cards may be played — `rules.legal_cards(hand, trick)`
+
+- Wizards and jesters may always be played.
+- If there is a lead suit and the player holds at least one number card of
+ that suit, they must play a card of that suit (or a wizard/jester).
+- Otherwise any card may be played.
+
+### Who wins the trick — `rules.trick_winner(trick, trump)`
+
+In this order:
+
+1. The **first wizard** played wins.
+2. Otherwise the **highest trump** wins.
+3. Otherwise the **highest card of the lead suit** wins.
+4. If only jesters were played, the **first jester** wins.
+
+## Scoring
+
+`rules.round_score(bid, tricks)` at the end of each round:
+
+| Result | Points |
+|--------|--------|
+| tricks == bid | 20 + 10 × tricks |
+| tricks ≠ bid | −10 × \|tricks − bid\| |
+
+Examples: bid 0, took 0 → +20. Bid 3, took 3 → +50. Bid 2, took 0 → −20.
+
+## End of the game
+
+After the last round (`WizardGame._complete_round`) the phase becomes
+`finished`. All players with the highest total score win (ties are possible,
+`WizardGame.winners`).
+
+## Rule variants from the original that are not implemented
+
+- `dmmixed` and `wiztournament` (tournament card distributions).
+- The dealer choosing trump when a wizard is turned up (replaced by the house
+ rule "wizard = no trump").
diff --git a/docs/03-game-engine.md b/docs/03-game-engine.md
new file mode 100644
index 0000000..5e3121f
--- /dev/null
+++ b/docs/03-game-engine.md
@@ -0,0 +1,238 @@
+# 3. Game engine (`wizard_game`)
+
+Location: `wizard_server/game_logic/` (distribution name `wizard-game`,
+import name `wizard_game`). Pure Python ≥ 3.11, **no dependencies**.
+
+It is used by
+
+- the server, to run games (`wizard_server/app/lobby.py`), and
+- the AI, to train and to decode observations (`wizard_ai/`).
+
+Install it on its own with `pip install ./wizard_server/game_logic`
+(or `pip install -e …` while developing — see
+[troubleshooting](08-operations.md#troubleshooting)).
+
+## Modules
+
+| Module | Contents |
+|--------|----------|
+| `cards.py` | `Suit` (enum), `CardKind` (number/wizard/jester), `Card` (frozen dataclass with `id`, `kind`, `suit`, `rank`), `ALL_CARDS`, `card_by_id`, `new_shuffled_deck` |
+| `actions.py` | `Bid(value)`, `PlayCard(card)`, `Action = Bid \| PlayCard`, `action_to_dict`, `action_from_dict` |
+| `rules.py` | stateless rule functions: `total_rounds`, `trump_suit`, `lead_suit`, `legal_cards`, `trick_winner`, `round_score`, scoring constants |
+| `state.py` | `Phase`, `GameOptions`, `PlayedCard`, `TrickResult`, `RoundResult`, the event classes, and the internal mutable `RoundState` |
+| `observation.py` | `Observation`: the view of one player, with lossless `to_dict` / `from_dict` |
+| `game.py` | `WizardGame` (the state machine) and `IllegalActionError` |
+| `__init__.py` | re-exports everything public, so `from wizard_game import WizardGame, Bid, …` works |
+
+## Using it
+
+```python
+import random
+from wizard_game import WizardGame, GameOptions
+
+game = WizardGame(num_players=4, options=GameOptions(hidden_bids=False), seed=1)
+rng = random.Random(1)
+
+while not game.is_finished:
+ player = game.current_player # whose turn it is (seat index)
+ obs = game.observation(player) # what that player may know
+ action = rng.choice(obs.legal_actions) # a Bid or PlayCard
+ events = game.apply(player, action) # advances the game
+
+print(game.scores, game.winners())
+```
+
+The loop is the whole API: *ask whose turn it is → look at the observation →
+apply one of the legal actions*. Rounds advance automatically.
+
+## `WizardGame` public API
+
+| Member | Description |
+|--------|-------------|
+| `WizardGame(num_players, options=None, *, seed=None, rng=None, first_dealer=None)` | Creates a game and immediately deals round 1. Use `seed` or pass your own `random.Random` as `rng` for reproducibility. |
+| `num_players`, `options`, `total_rounds` | constants of this game |
+| `phase` | `Phase.BIDDING`, `Phase.PLAYING` or `Phase.FINISHED` |
+| `current_player` | seat index whose turn it is (`None` when finished) |
+| `round_number`, `dealer` | current round (1-based) and its dealer |
+| `scores` | list of total scores per seat |
+| `round_history` | list of `RoundResult`, one per finished round |
+| `last_trick` | `TrickResult` of the most recently completed trick |
+| `hand(player)` | copy of a player's hand (server/training use only — it is private information) |
+| `legal_actions(player=None)` | legal actions of `player` (default: current player); empty if it is not their turn |
+| `is_legal(player, action)` | shortcut for `action in legal_actions(player)` |
+| `apply(player, action) -> list[Event]` | performs the action; raises `IllegalActionError` if it is not that player's turn, the game is over, or the action is illegal |
+| `observation(player) -> Observation` | the player's view (see below) |
+| `winners()` | seats with the highest score |
+| `clone()` | deep copy, e.g. for search-based AIs that simulate moves |
+
+## State machine
+
+```mermaid
+stateDiagram-v2
+ [*] --> BIDDING: __init__ deals round 1
+ BIDDING --> BIDDING: Bid (not everyone has bid yet)
+ BIDDING --> PLAYING: last Bid
+ PLAYING --> PLAYING: PlayCard (trick not full / trick full but round not over)
+ PLAYING --> BIDDING: PlayCard completes the last trick\n(round scored, next round dealt)
+ PLAYING --> FINISHED: PlayCard completes the last trick of the last round
+ FINISHED --> [*]
+```
+
+What happens inside `apply` (`game.py`):
+
+- `_bid` stores the bid. When all bids are in, the phase becomes `PLAYING` and
+ the starter (`round.trick_leader`) is to move; otherwise the next seat.
+- `_play` removes the card from the hand, appends it to `current_trick` and to
+ `played_cards`. When every player has played, `_complete_trick` runs:
+ - determines the winner (`rules.trick_winner`), increments `tricks_won`,
+ - stores a `TrickResult` in `last_trick` and emits `TrickCompleted`,
+ - if tricks remain: the winner leads the next trick,
+ - otherwise `_complete_round`: computes score changes, appends a
+ `RoundResult` to `round_history`, emits `RoundCompleted`, and then either
+ finishes the game (`GameFinished`) or deals the next round (`RoundStarted`)
+ with the next dealer.
+
+So a single `PlayCard` can produce up to three events, e.g.
+`[TrickCompleted, RoundCompleted, RoundStarted]`.
+
+### Internal per-round state (`state.RoundState`)
+
+Mutable, owned by `WizardGame.round`; not part of the public API.
+
+| Field | Meaning |
+|-------|---------|
+| `round_number`, `dealer` | |
+| `hands` | list of lists of `Card` per seat |
+| `trump_card`, `trump_suit` | the turned-up card and the resulting suit (or `None`) |
+| `bids` | `int` or `None` (not yet bid) per seat |
+| `tricks_won` | per seat |
+| `current_trick` | list of `PlayedCard(player, card)` in play order |
+| `trick_leader`, `trick_number` | who leads / which trick (1-based) |
+| `played_cards` | every card played this round, including the current trick |
+
+## `Observation` — what a player may know
+
+`observation.py`. Built by `WizardGame.observation(player)`. Immutable
+(frozen dataclass). This is **the** data structure of the project: the
+browser renders it and the AI decides on it.
+
+| Field | Type | Meaning |
+|-------|------|---------|
+| `player` | int | whose view this is |
+| `num_players`, `options` | | |
+| `phase` | `Phase` | |
+| `round_number`, `total_rounds`, `dealer` | int | |
+| `current_player` | int \| None | |
+| `hand` | tuple[Card] | own cards, sorted by id |
+| `hand_sizes` | tuple[int] | number of cards each seat still holds |
+| `trump_card` | Card \| None | turned-up card of this round |
+| `trump_suit` | Suit \| None | `None` for wizard/jester/last round |
+| `bids` | tuple[int \| None] | `None` = not yet bid **or** hidden (hidden bids option) |
+| `has_bid` | tuple[bool] | who has already bid (visible even when bids are hidden) |
+| `tricks_won` | tuple[int] | this round |
+| `scores` | tuple[int] | totals |
+| `trick_number`, `trick_leader` | int | |
+| `current_trick` | tuple[PlayedCard] | cards on the table |
+| `lead_suit` | Suit \| None | suit that must be followed right now |
+| `played_cards` | tuple[Card] | all cards played this round (for card counting) |
+| `last_trick` | TrickResult \| None | most recent completed trick |
+| `round_history` | tuple[RoundResult] | all finished rounds |
+| `legal_actions` | tuple[Action] | **empty unless it is this player's turn** |
+| `winners` | tuple[int] | filled when finished |
+
+Property `is_my_turn` is `current_player == player`.
+
+Privacy guarantees (tested in `game_logic/tests/test_game.py`):
+
+- Only the observer's own hand is included.
+- With hidden bids, other players' bids are `None` until all bids are placed.
+- Legal actions are only present for the player to move.
+
+## Results and events (`state.py`)
+
+| Type | Fields |
+|------|--------|
+| `PlayedCard` | `player`, `card` |
+| `TrickResult` | `round_number`, `trick_number`, `cards` (tuple of `PlayedCard`), `winner`, `trump_card` (trump card of that trick's round) |
+| `RoundResult` | `round_number`, `bids`, `tricks`, `score_changes`, `scores` (totals after the round) — all tuples indexed by seat |
+| `RoundStarted` (event) | `round_number`, `dealer`, `trump_card` |
+| `TrickCompleted` (event) | `trick: TrickResult` |
+| `RoundCompleted` (event) | `result: RoundResult` |
+| `GameFinished` (event) | `scores`, `winners` |
+
+Events are returned by `apply` and forwarded to browsers so the UI can show,
+e.g., the completed trick for a moment.
+
+## JSON format
+
+Every type above has `to_dict()`; `Observation`, `TrickResult`, `RoundResult`,
+`PlayedCard`, `GameOptions` also have `from_dict()`. The JSON is what goes over
+the websockets. Examples:
+
+```json
+// Card
+{"id": 17, "kind": "number", "suit": "yellow", "rank": 5}
+{"id": 53, "kind": "wizard", "suit": null, "rank": 0}
+
+// Actions
+{"type": "bid", "value": 2}
+{"type": "play", "card": 17}
+
+// Event
+{"type": "trick_completed", "trick": {"round_number": 3, "trick_number": 2,
+ "cards": [{"player": 1, "card": {…}}, …], "winner": 2, "trump_card": {…}}}
+```
+
+A full observation looks like this (shortened):
+
+```json
+{
+ "player": 0, "num_players": 3, "options": {"hidden_bids": false},
+ "phase": "playing", "round_number": 3, "total_rounds": 20, "dealer": 2,
+ "current_player": 0,
+ "hand": [{"id": 4, "kind": "number", "suit": "red", "rank": 5}, …],
+ "hand_sizes": [2, 2, 2],
+ "trump_card": {"id": 44, "kind": "number", "suit": "blue", "rank": 6},
+ "trump_suit": "blue",
+ "bids": [1, 0, 1], "has_bid": [true, true, true],
+ "tricks_won": [1, 0, 0], "scores": [40, 30, -10],
+ "trick_number": 2, "trick_leader": 0, "current_trick": [],
+ "lead_suit": null, "played_cards": […], "last_trick": {…},
+ "round_history": [{"round_number": 1, …}, {"round_number": 2, …}],
+ "legal_actions": [{"type": "play", "card": 4}, …],
+ "winners": []
+}
+```
+
+`action_from_dict` validates strictly: unknown types, missing fields,
+non-integer values (booleans are rejected too) and card ids outside 0–59
+raise `ValueError`.
+
+## Randomness and reproducibility
+
+All randomness (first dealer, shuffling) comes from one `random.Random`
+(`seed=` or `rng=`). The same seed and the same sequence of actions always
+produce the same game — useful for tests and for comparing AI agents on
+identical deals.
+
+## Tests
+
+`wizard_server/game_logic/tests/`:
+
+- `test_rules.py` — each rule function (lead suit, legal cards, trick winner,
+ trump suit, scoring, round counts).
+- `test_game.py` — full random games for 3–6 players with and without hidden
+ bids; invariants checked on every round (tricks add up, bids never add up to
+ the cards in hand, the trump card is never in a hand, completed tricks keep
+ their trump card); JSON round trips; illegal actions; hidden bids.
+
+## Extending the engine
+
+- **A new rule option**: add a field to `GameOptions` (plus `to_dict` /
+ `from_dict`), read `self.options.` in `game.py`, add it to the
+ server's `SetOptions` message and the lobby UI, and consider adding it to the
+ AI feature encoding. See [recipes](08-operations.md#recipes).
+- **A new action type**: add a dataclass in `actions.py`, handle it in
+ `action_from_dict`, `WizardGame.legal_actions` and `apply`, and extend
+ `ActionSpace` in `wizard_ai/wizard_ai/model/encoding.py` and the TypeScript
+ `Action` type.
diff --git a/docs/04-server.md b/docs/04-server.md
new file mode 100644
index 0000000..000a129
--- /dev/null
+++ b/docs/04-server.md
@@ -0,0 +1,291 @@
+# 4. Game server (`wizard_server/app`)
+
+A FastAPI application running in a single uvicorn process. It keeps
+**everything in memory**: sessions, lobbies and running games.
+
+## Module overview
+
+```mermaid
+flowchart TB
+ main["main.py
routes, auth"] --> service["service.py
WizardService"]
+ service --> sessions["sessions.py
Session, Connection, SessionRegistry"]
+ service --> lobby["lobby.py
LobbyManager, Lobby, Seat"]
+ service --> protocol["protocol.py
message schema"]
+ service --> bridge["ai_bridge.py
AIBridge"]
+ lobby --> bridge
+ lobby --> engine[("wizard_game")]
+ bridge --> engine
+ main --> config["config.py
Settings"]
+```
+
+| Module | Main types | Role |
+|--------|-----------|------|
+| `main.py` | `create_app()` | builds the FastAPI app, defines the three routes, starts the cleanup task |
+| `config.py` | `Settings`, `settings` | configuration from environment variables |
+| `protocol.py` | pydantic models, `client_message_adapter` | validates messages from browsers |
+| `service.py` | `WizardService` | the "controller": handles a browser connection, dispatches messages, manages sessions and cleanup |
+| `sessions.py` | `Session`, `Connection`, `SessionRegistry` | player identity across reconnects; ordered sending |
+| `lobby.py` | `LobbyManager`, `Lobby`, `Seat`, `LobbyError` | lobby membership, host actions, running the game, AI turns, broadcasting |
+| `ai_bridge.py` | `AIBridge`, `AIConnection` | talks to the AI service; random fallback |
+
+## Routes (`main.py`)
+
+Caddy strips `/wizard/api`, so these are the paths the server sees:
+
+| Route | Used by | Purpose |
+|-------|---------|---------|
+| `GET /health` | Docker health check, you | `{"status":"ok","lobbies":…,"sessions":…,"ai_available":…}` |
+| `WS /ws` | browsers | the game protocol (see [protocol](05-protocol.md)) |
+| `WS /internal/ai` | AI service | decision requests |
+
+`/internal/ai` is protected twice:
+
+1. `_via_proxy()` rejects the connection if it carries forwarding headers
+ (`X-Forwarded-For`, `Forwarded`, `Via`). Caddy always adds them, so
+ requests from the internet via `…/wizard/api/internal/ai` are refused. The
+ AI service connects directly over the internal network and has none.
+2. `_ai_authorized()` requires `Authorization: Bearer `
+ (compared with `secrets.compare_digest`). If the secret is empty,
+ authentication is disabled — only for development; a warning is logged at
+ startup.
+
+`create_app(config)` takes a `Settings` object so tests can build apps with
+different configuration (e.g. zero delays).
+
+## Core concepts
+
+### Session — "who is this browser tab?"
+
+`sessions.Session`:
+
+| Field | Meaning |
+|-------|---------|
+| `player_id` | public id (12 hex chars), shown to other players, used as seat id |
+| `token` | secret (`secrets.token_urlsafe(24)`), stored by the browser in `sessionStorage`, used to reconnect |
+| `name` | display name, set when creating/joining a lobby |
+| `lobby_code` | lobby the player is in, or `None` |
+| `connection` | the current `Connection`, or `None` while disconnected |
+| `last_seen` | monotonic time of the last disconnect/connect, for expiry |
+
+`SessionRegistry` maps token → session. Sessions without a connection for
+`SESSION_TTL` seconds (default 30 min) are removed by the cleanup task; if they
+were in a lobby they leave it (see below).
+
+### Connection — ordered sending
+
+`sessions.Connection` wraps one websocket and owns an `asyncio.Queue`. A
+background writer task sends queued messages one by one. Therefore:
+
+- `session.send(msg)` is **synchronous** (just enqueues), so game logic never
+ awaits while mutating state;
+- messages to one client are always in order and never sent concurrently.
+
+### Lobby and seats
+
+`lobby.Seat` = one place at the table:
+
+| Field | Meaning |
+|-------|---------|
+| `player_id` | session's player id, or `ai-xxxxxxxx` for AI seats |
+| `name` | unique within the lobby (`"Ann"`, `"Ann (2)"`, …) |
+| `is_ai` | AI seats are played via the `AIBridge` |
+| `session` | the human's session (or `None`) |
+
+The **seat index in `lobby.seats` is the player index in the game**. Seats are
+never removed or reordered while a game runs, so indices stay valid.
+
+`lobby.Lobby` fields: `code` (6 chars from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, no
+0/O/1/I), `seats`, `host_id`, `options` (`GameOptions`), `status`
+(`waiting` → `playing` → `finished` → back to `waiting`), `game` (`WizardGame`
+or `None`).
+
+`LobbyManager` creates lobbies (up to `MAX_LOBBIES`), finds them by code
+(case-insensitive) and deletes a lobby as soon as no human seat is left
+(`discard_if_abandoned`).
+
+## Lifecycle of a browser connection (`WizardService.handle_client`)
+
+```mermaid
+sequenceDiagram
+ participant B as Browser
+ participant S as WizardService
+ B->>S: websocket connect /ws
+ Note over S: Origin check (ALLOWED_ORIGINS), accept,
start Connection writer
+ B->>S: {"type":"hello","token": }
+ alt token known
+ S->>S: reuse session (kick an older tab with close code 4000)
+ else
+ S->>S: create new session
+ end
+ S-->>B: {"type":"welcome","player_id","token","name"}
+ S-->>B: {"type":"state", …} (lobby + game if still seated, else nulls)
+ loop every message
+ B->>S: create_lobby / join_lobby / action / …
+ S->>S: validate (pydantic) → _dispatch → Lobby method
+ S-->>B: state broadcast, or {"type":"error"}
+ end
+ B--xS: disconnect
+ Note over S: session.connection = None, lobby broadcast
(seat shows "offline"), the seat is kept
+```
+
+Rules applied in `_hello` and `_dispatch`:
+
+- Every message except `hello` requires a completed `hello` on *this*
+ connection.
+- Invalid JSON/fields → `error` message with the pydantic reason; the
+ connection stays open.
+- `LobbyError` (rule violations like "Only the host can do that") → `error`
+ message to that client only.
+- Opening the same session in a second tab takes it over: the old tab gets an
+ `error` and is closed with code **4000**; the frontend then stops
+ reconnecting and shows "opened in another window".
+
+### Message handling (`_dispatch`)
+
+| Message | What happens |
+|---------|-------------|
+| `create_lobby {name}` | leave current lobby, set name, create lobby, join it (creator becomes host) |
+| `join_lobby {code, name}` | lobby must exist; leave another lobby first; join. Joining is only possible while the lobby is `waiting` and has < 6 seats — **except** re-joining your own seat |
+| `leave_lobby` | leave (see below), receive empty state |
+| `add_ai` | host only, lobby waiting and not full; adds an AI seat named after a famous wizard (Merlin, Morgana, …) |
+| `remove_player {player_id}` | host only, lobby waiting; the removed human gets an error + empty state |
+| `set_options {hidden_bids}` | host only, lobby waiting |
+| `start_game` | host only, 3–6 seats; creates `WizardGame(len(seats), options)` |
+| `action {action}` | lobby must be `playing`; the engine validates the action |
+| `return_to_lobby` | host only, game must be finished; status back to `waiting`, game discarded |
+
+### Leaving
+
+`Lobby.leave(session)`:
+
+- In a **waiting** lobby the seat is removed.
+- During a **playing or finished** game the seat is kept and **converted to an
+ AI seat** (new `ai-…` id, same name), so the game continues.
+- If the host leaves, the next human seat becomes host.
+- If no human seat remains, the lobby is deleted (its AI task is cancelled).
+
+Disconnecting (closing the tab, network loss) is *not* leaving: the seat stays
+human and the game waits for that player. After `SESSION_TTL` without
+reconnect, the cleanup task makes the session leave (→ AI takes over).
+
+## Running a game
+
+### Broadcasting state
+
+`Lobby.broadcast(events)` sends every seated human their own message built by
+`Lobby.state_for(session, events)`:
+
+```json
+{
+ "type": "state",
+ "lobby": { "code": "…", "status": "playing", "host_id": "…", "options": {…},
+ "players": [{"id", "name", "is_ai", "connected"}, …],
+ "min_players": 3, "max_players": 6, "ai_available": true },
+ "game": { "you": 1, "observation": { … personal Observation … } },
+ "events": [ … events caused by the last action … ]
+}
+```
+
+It is called after every change (join, leave, options, start, every move, AI
+service connecting/disconnecting, a player going offline).
+
+### Human moves
+
+`Lobby.apply_action(session, action_dict)` → `action_from_dict` →
+`game.apply(seat, action)`. Errors become `LobbyError` → error toast in the
+browser. On success `_after_action(events)` runs.
+
+### `_after_action` and the AI loop
+
+```python
+def _after_action(self, events):
+ if TrickCompleted in events: self._pause_until = now + TRICK_PAUSE
+ if GameFinished in events: self.status = FINISHED
+ self.broadcast(events)
+ self._schedule_ai()
+```
+
+`_schedule_ai()` starts `_ai_loop()` as an asyncio task if the current player
+is an AI seat and no loop is running. The loop:
+
+```mermaid
+flowchart TD
+ A{"current player
is an AI seat?"} -- no --> Z[stop]
+ A -- yes --> B["obs = game.observation(seat)"]
+ B --> C["action = await bridge.decide(obs)"]
+ C --> D["wait until max(start + AI_MOVE_DELAY, pause_until)"]
+ D --> E{"same game, same seat,
action still legal?"}
+ E -- no --> A
+ E -- yes --> F["game.apply → _after_action"]
+ F --> A
+```
+
+- The AI "thinks" in parallel with the minimum delay, so a slow AI does not add
+ to the delay.
+- `TRICK_PAUSE` gives humans time to see a completed trick before AI players
+ continue.
+- The re-validation step makes the loop safe against state changes while it was
+ awaiting (e.g. the host returned to the lobby).
+- Several consecutive AI seats are handled by the same loop.
+
+### Concurrency model
+
+Everything runs on one asyncio event loop. State changes (join, apply action,
+…) are **synchronous** — there is no `await` between checking and mutating —
+so no locks are needed. The only awaits are in the AI loop (decide, sleep), and
+it re-validates afterwards. This is also why the server must run with **one
+worker process**: a second process would have its own, separate lobbies.
+
+## AI bridge (`ai_bridge.py`)
+
+The AI service keeps a websocket open to `/internal/ai`. `AIBridge`:
+
+- keeps a list of connected AI services (`available` = at least one);
+- `decide(observation)`: picks a connection round robin, sends
+ `{"type":"decide","request_id","observation"}`, and awaits a future that
+ `serve()` resolves when the matching reply arrives;
+- falls back to a **random legal action** if no service is connected, the reply
+ does not arrive within `AI_DECISION_TIMEOUT`, the service reports an error,
+ the action is malformed or illegal (logged as warning, except the
+ "no service connected" case which is logged at debug level);
+- calls `on_change` when a service connects/disconnects, and the service then
+ re-broadcasts all lobbies so the lobby screen shows whether the AI is online.
+
+## Configuration (`config.py`)
+
+All values are read from environment variables when the module is imported.
+
+| Variable | Default | Meaning |
+|----------|---------|---------|
+| `AI_SHARED_SECRET` | empty | secret the AI service must send; empty = no auth (dev only) |
+| `AI_DECISION_TIMEOUT` | 5.0 s | wait for the AI before playing a random move |
+| `AI_MOVE_DELAY` | 0.8 s | minimum time an AI move takes, so humans can follow |
+| `TRICK_PAUSE` | 1.8 s | pause after a completed trick before AIs continue |
+| `SESSION_TTL` | 1800 s | disconnected sessions are dropped after this |
+| `MAX_LOBBIES` | 1000 | limit of simultaneously open lobbies |
+| `ALLOWED_ORIGINS` | empty (= all) | comma separated allowed `Origin` headers for `/ws` |
+| `LOG_LEVEL` | INFO | Python logging level |
+
+## Housekeeping
+
+`WizardService.cleanup_loop()` runs every 30 s (started in the app lifespan)
+and removes expired sessions (leaving their lobby first).
+
+## Tests (`wizard_server/tests/test_server.py`)
+
+Uses FastAPI's `TestClient` websockets with zero delays. Covers: health,
+hello requirement, validation errors, the full lobby flow including a complete
+20-round game between two humans and an AI, reconnect by token, leaving
+mid-game (seat becomes AI, host moves), the AI endpoint's secret and proxy
+checks, and a fake AI service answering decide requests.
+
+Note for writing new tests: enter websocket sessions with
+`client.sockets.enter_context(client.websocket_connect(...))` (see the fixture),
+otherwise the test client hangs on shutdown.
+
+## Limitations to be aware of
+
+- In-memory state: restarting the server ends all lobbies and games.
+- Must run as a single process (no `--workers`).
+- No spectators: only seated players receive game state.
+- No rate limiting on websocket messages.
diff --git a/docs/05-protocol.md b/docs/05-protocol.md
new file mode 100644
index 0000000..9b0860e
--- /dev/null
+++ b/docs/05-protocol.md
@@ -0,0 +1,168 @@
+# 5. Protocol reference
+
+All messages are JSON objects with a `type` field, sent as websocket text
+frames. There are two protocols:
+
+- **Client protocol** — browser ↔ server on `/ws`
+ (public URL `wss://game.marvinkrausser.com/wizard/api/ws`).
+ Schema: `wizard_server/app/protocol.py` (Python) and
+ `wizard_website/src/api/types.ts` (TypeScript). Keep both in sync.
+- **AI protocol** — AI service ↔ server on `/internal/ai`.
+ Implemented in `wizard_server/app/ai_bridge.py` and
+ `wizard_ai/wizard_ai/service/client.py`.
+
+Game objects inside messages (cards, actions, observations, events) use the
+engine's JSON format described in [the engine chapter](03-game-engine.md#json-format).
+
+---
+
+## Client protocol
+
+### Browser → server
+
+| `type` | Fields | Allowed when | Effect |
+|--------|--------|--------------|--------|
+| `hello` | `token: string \| null` | always; must be the first message | starts or resumes a session |
+| `create_lobby` | `name` | after hello | creates a lobby and joins it as host |
+| `join_lobby` | `code`, `name` | after hello | joins an existing lobby |
+| `leave_lobby` | – | in a lobby | leaves (during a game an AI takes the seat) |
+| `add_ai` | – | host, lobby waiting, < 6 seats | adds an AI seat |
+| `remove_player` | `player_id` | host, lobby waiting | removes a seat (human or AI) |
+| `set_options` | `hidden_bids: bool` | host, lobby waiting | changes game options |
+| `start_game` | – | host, lobby waiting, 3–6 seats | starts the game |
+| `action` | `action: {"type":"bid","value":n}` or `{"type":"play","card":id}` | your turn in a running game | performs a move |
+| `return_to_lobby` | – | host, game finished | back to the waiting lobby |
+
+Validation (`protocol.py`):
+
+- `name`: whitespace stripped, 1–20 characters.
+- `code`: whitespace stripped, upper-cased, 4–12 characters.
+- Unknown `type` or wrong field types → `error` message, connection stays open.
+
+### Server → browser
+
+#### `welcome`
+
+Sent after every `hello`.
+
+```json
+{"type": "welcome", "player_id": "3f9c0a1b2d4e", "token": "Zq…", "name": "Marvin"}
+```
+
+The browser stores `token` (in `sessionStorage`) and sends it in the next
+`hello` to resume the same session. `player_id` identifies you in
+`lobby.players` and `lobby.host_id`.
+
+#### `state`
+
+Sent after `hello` and whenever anything in your lobby changes. It always
+contains the **complete** current state.
+
+```json
+{
+ "type": "state",
+ "lobby": {
+ "code": "7C7TAJ",
+ "status": "playing",
+ "host_id": "3f9c0a1b2d4e",
+ "options": {"hidden_bids": false},
+ "players": [
+ {"id": "3f9c0a1b2d4e", "name": "Marvin", "is_ai": false, "connected": true},
+ {"id": "ai-1a2b3c4d", "name": "Merlin", "is_ai": true, "connected": true},
+ {"id": "ai-5e6f7a8b", "name": "Morgana", "is_ai": true, "connected": true}
+ ],
+ "min_players": 3,
+ "max_players": 6,
+ "ai_available": true
+ },
+ "game": {
+ "you": 0,
+ "observation": { "…": "the personal Observation of seat 0" }
+ },
+ "events": [
+ {"type": "trick_completed", "trick": {"…": "…"}}
+ ]
+}
+```
+
+- `lobby: null, game: null` → you are not in a lobby (start page).
+- `game: null` → lobby is waiting (no game running).
+- `players[i]` is seat `i` in the game (`observation` uses the same indices).
+- `events` lists what the last action caused: `trick_completed`,
+ `round_completed`, `round_started`, `game_finished`. Usually empty.
+- `ai_available` is `false` when no AI service is connected; AI seats then play
+ random moves.
+
+#### `error`
+
+```json
+{"type": "error", "message": "Only the host can do that"}
+```
+
+Sent only to the client that caused it. Typical messages: `Send 'hello' first`,
+`Invalid message: …` (followed by the pydantic reason),
+`Lobby ABC123 does not exist`, `The game in this lobby has already started`,
+`This lobby is full`, `Only the host can do that`,
+`Wizard needs 3 to 6 players`, `Illegal action Bid(value=2) for player 0`,
+`It is not player 1's turn`, `You were removed from the lobby`,
+`This session was opened in another window`.
+
+### Close codes
+
+| Code | Meaning | Frontend reaction |
+|------|---------|-------------------|
+| (handshake rejected) | origin not in `ALLOWED_ORIGINS`; the server closes with 1008 before accepting, so the browser sees a failed connection | reconnects with backoff |
+| 4000 | the session was opened in another tab | stops reconnecting, shows a banner |
+| other | network problem, server restart | reconnects (1 s, 2 s, 4 s … max 10 s) |
+
+### Example conversation
+
+```mermaid
+sequenceDiagram
+ participant H as Host browser
+ participant S as Server
+ participant G as Guest browser
+ H->>S: hello {token:null}
+ S-->>H: welcome, state {lobby:null}
+ H->>S: create_lobby {name:"Ann"}
+ S-->>H: state {lobby: code "K7M2QX", players:[Ann]}
+ G->>S: hello {token:null}
+ S-->>G: welcome, state {lobby:null}
+ G->>S: join_lobby {code:"k7m2qx", name:"Ben"}
+ S-->>H: state {players:[Ann, Ben]}
+ S-->>G: state {players:[Ann, Ben]}
+ H->>S: add_ai
+ S-->>H: state {players:[Ann, Ben, Merlin]}
+ S-->>G: state {…}
+ H->>S: start_game
+ S-->>H: state {game:{you:0, observation{phase:"bidding", current_player:1}}}
+ S-->>G: state {game:{you:1, …}}
+ G->>S: action {type:"bid", value:0}
+ S-->>H: state {…}
+ S-->>G: state {…}
+```
+
+---
+
+## AI protocol
+
+The AI service connects to `ws://wizard_server:8083/internal/ai` with the
+header `Authorization: Bearer ` and without forwarding
+headers (see "Routes" in [the server chapter](04-server.md)).
+
+| Direction | Message | Meaning |
+|-----------|---------|---------|
+| AI → server | `{"type":"hello","agent":"heuristic"}` | informational, logged |
+| server → AI | `{"type":"decide","request_id":"","observation":{…}}` | choose an action for `observation.player` |
+| AI → server | `{"type":"action","request_id":"","action":{"type":"play","card":17}}` | the chosen action |
+| AI → server | `{"type":"error","request_id":"","message":"…"}` | the AI failed; the server plays a random move |
+
+Rules:
+
+- Requests are independent; the AI service may answer them in any order
+ (matched by `request_id`) and may handle several concurrently.
+- The answer must be one of `observation.legal_actions`; otherwise the server
+ plays a random legal move.
+- No answer within `AI_DECISION_TIMEOUT` (5 s) → random move.
+- Several AI service instances may connect; the server distributes requests
+ round robin.
diff --git a/docs/06-frontend.md b/docs/06-frontend.md
new file mode 100644
index 0000000..86bbd29
--- /dev/null
+++ b/docs/06-frontend.md
@@ -0,0 +1,199 @@
+# 6. Frontend (`wizard_website`)
+
+React 19 + TypeScript, built with Vite, styled with CSS modules, served by
+nginx. No router and no state library: the server state is the app state.
+
+## Structure
+
+```
+src/
+├── main.tsx # mounts in StrictMode
+├── App.tsx # picks the screen, connection banner, toasts, URL sync
+├── App.module.css # design tokens (CSS variables) + global resets
+├── api/
+│ ├── types.ts # TypeScript mirror of the protocol and engine JSON
+│ └── useWizard.ts # THE connection hook: websocket, state, actions
+├── components/ # reusable, game-agnostic-ish UI
+│ ├── Button/ # variants primary | secondary | ghost | danger, sizes sm | md | lg
+│ ├── PlayingCard/ # PlayingCard (face) and CardBack
+│ ├── Modal/ # dialog with backdrop, Escape to close
+│ └── Toasts/ # error messages bottom right
+├── screens/
+│ ├── HomeScreen/ # name, create lobby, join by code
+│ ├── LobbyScreen/ # code, players, AI, options, start
+│ └── GameScreen/ # the table and its parts (see below)
+└── util/cards.ts # suit names, card labels, hand sorting, legal-action helpers
+```
+
+Every component has its own `*.module.css` next to it.
+
+## Data flow
+
+```mermaid
+flowchart LR
+ WS[(WebSocket)] -- state / welcome / error --> H[useWizard]
+ H -- lobby, game, completedTrick, toasts --> App
+ App --> Home[HomeScreen]
+ App --> Lobby[LobbyScreen]
+ App --> Game[GameScreen]
+ Home -- actions.createLobby / joinLobby --> H
+ Lobby -- actions.addAI / startGame / … --> H
+ Game -- actions.play({type:'bid'|'play'}) --> H
+ H -- JSON messages --> WS
+```
+
+Components never talk to the websocket directly. They receive data as props
+and call functions from `actions`.
+
+## `useWizard` (`src/api/useWizard.ts`)
+
+The only stateful piece of the app.
+
+**State it exposes**
+
+| Value | Meaning |
+|-------|---------|
+| `status` | `connecting` \| `open` \| `closed` (will retry) \| `replaced` (opened in another tab, no retry) |
+| `playerId` | from `welcome` |
+| `lobby`, `game` | exactly what the last `state` message contained |
+| `completedTrick` | the last `trick_completed` event, kept for 2.2 s so the trick stays visible |
+| `toasts`, `dismissToast` | error messages, auto-removed after 4 s |
+| `actions` | `createLobby`, `joinLobby`, `leaveLobby`, `addAI`, `removePlayer`, `setOptions`, `startGame`, `play(action)`, `returnToLobby` |
+
+**Connection behaviour**
+
+- URL: `VITE_WS_URL` if set at build time, otherwise `new URL('../api/ws',
+ location.href)` with `ws:`/`wss:`. On
+ `https://game.marvinkrausser.com/wizard/website/` this gives
+ `wss://game.marvinkrausser.com/wizard/api/ws`; in development
+ (`http://localhost:5173/`) it gives `ws://localhost:5173/api/ws`.
+- On open it sends `hello` with the token from `sessionStorage`
+ (key `wizard.token`); the `welcome` reply stores the (possibly new) token.
+ Because `sessionStorage` is per tab, a reload resumes the same seat, while a
+ second tab is a different player.
+- On close it reconnects with exponential backoff (1, 2, 4, 8, 10, 10 … s),
+ except after close code 4000.
+- `send()` shows a toast if the socket is not open.
+
+## Screen selection (`App.tsx`)
+
+| Condition | Screen |
+|-----------|--------|
+| no lobby (or no player id yet) | `HomeScreen` |
+| lobby `waiting` or no game | `LobbyScreen` |
+| otherwise | `GameScreen` |
+
+`App` also keeps `?lobby=CODE` in the address bar while you are in a lobby
+(so the URL is an invite link) and removes it when you are not. A red banner
+shows the connection status when it is not `open`.
+
+## Screens
+
+### HomeScreen
+
+- Name input (max 20 chars), remembered in `localStorage` (`wizard.name`).
+- "Create lobby" → `actions.createLobby(name)`.
+- Code input + "Join" → `actions.joinLobby(code, name)`. The code is prefilled
+ from `?lobby=` so invite links work.
+- Buttons are disabled while disconnected or without a name.
+
+### LobbyScreen
+
+- Big lobby code with "Copy code" / "Copy invite link" (clipboard API).
+- Player list with badges (Host, AI, offline, "(you)") and empty "Free seat"
+ rows up to 6.
+- Host only: "+ Add AI player", "Remove" per player, the "Hidden bids"
+ checkbox, and "Start game" (disabled with "Need N more players" until 3
+ seats are filled). Others see the options read-only and "Waiting for the
+ host…".
+- Shows a warning if AI players are present but the AI service is offline.
+- Explains the fixed bidding rule (bids may not add up to the cards in hand).
+
+### GameScreen (`screens/GameScreen/`)
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Round 3 / 20 Lobby 7C7TAJ [Scoreboard] [Leave]│ top bar
+│ ┌ Round 2 complete ┐ │ RoundSummary (while bidding)
+│ [PlayerSeat] [PlayerSeat] … │ opponents in play order
+│ ┌────────────────────────────────────────────────────────┐ │
+│ │ [trump card] [TrickArea: cards + owner names] │ │ table
+│ │ Trump: Blue │ │
+│ └────────────────────────────────────────────────────────┘ │
+│ Your turn to bid │ status line
+│ [BidPanel 0 1 2 3] │ only when you bid
+│ [your PlayerSeat] [Hand: clickable cards] │
+└──────────────────────────────────────────────────────────────┘
+```
+
+| Part | File | Details |
+|------|------|---------|
+| `GameScreen` | `GameScreen.tsx` | layout, status text, trump display, which panels/modals to show |
+| `PlayerSeat` | `PlayerSeat.tsx` | name, AI/dealer(D)/offline chips, score, "tricks / bid" (green when on target, red when over), card backs for the hand size; gold glow when it is that player's turn |
+| `TrickArea` | `TrickArea.tsx` | cards of the current trick with owner names; right after a trick is complete it shows the completed trick with the winner highlighted and "X wins the trick" |
+| `Hand` | `Hand.tsx` | your cards sorted (wizards, trump suit, other suits, jesters); clickable only on your turn in the playing phase; illegal cards are dimmed and disabled |
+| `BidPanel` | `BidPanel.tsx` | buttons 0…round number; illegal bids disabled; shows the running sum of bids and explains a forbidden value |
+| `RoundSummary` | `RoundSummary.tsx` | result of the previous round (tricks/bid and score change per player) |
+| `Scoreboard` | `Scoreboard.tsx` | modal "block of truth": every round with total, change and tricks/bid, plus the current round |
+| `GameOver` | `GameOver.tsx` | ranking, winner, "Back to lobby" (host), "Leave", "Scoreboard" |
+
+Derived display logic worth knowing (all in `GameScreen.tsx`):
+
+- **Completed trick**: `TrickArea` shows `completedTrick` while the table is
+ empty and the 2.2 s timer has not expired.
+- **Trump display**: normally `observation.trump_card`. While the last trick of
+ the *previous* round is still displayed (`completedTrick.round_number !==
+ round_number`), the trick's own `trump_card` is shown instead, so the table
+ never shows a trick next to the wrong trump. The trump suit is derived with
+ the same rule as the engine (only number cards define a suit), and the label
+ says "Wizard turned up — no trump", "Jester turned up — no trump" or
+ "Last round — no trump" when there is none.
+- **Round summary**: shown while `phase === 'bidding'` and the last entry of
+ `round_history` is the previous round — i.e. from the start of a round until
+ the last bid is placed. Derived from server state, so it survives reloads.
+- **Leaving** asks for confirmation (an AI takes the seat mid-game).
+
+## Styling
+
+- **CSS modules only.** Each component imports `styles` from its
+ `*.module.css`; class names are scoped automatically.
+- **Design tokens** are CSS variables on the `.app` root element in
+ `App.module.css` (`--bg`, `--surface`, `--accent`, `--gold`, `--suit-red`, …).
+ All components use them, so the theme can be changed in one place.
+- The only global rules (`box-sizing`, `body { margin: 0 }`) are written with
+ `:global(...)` inside `App.module.css`.
+- Responsive breakpoint: 720 px (cards overlap in the hand, the round summary
+ moves into the page flow, the table stacks vertically).
+- Card corners are **not** rotated, so 6 and 9 cannot be confused.
+
+## Build and configuration
+
+| File | Purpose |
+|------|---------|
+| `vite.config.ts` | `base: './'` → relative asset URLs, so the build works under any path prefix; dev server proxies `/api/*` to `WIZARD_SERVER_URL` (default `http://localhost:8000`) and strips `/api`, like Caddy does in production |
+| `tsconfig.app.json` | strict TypeScript for `src/` (no unused locals/params, erasable syntax only → no enums) |
+| `eslint.config.js` | typescript-eslint + react-hooks + react-refresh |
+| `Dockerfile` | `node:22-alpine` runs `npm ci && npm run build` (optional build arg `VITE_WS_URL`), then `nginx:1.27-alpine` serves `dist/` |
+| `nginx.conf` | listens on **8082**; `/assets/` cached forever (hashed names), everything else falls back to `index.html` with `no-cache` |
+
+Commands (in `wizard_website/`):
+
+```bash
+npm install
+npm run dev # http://localhost:5173
+npm run build # type-check (tsc -b) + production build into dist/
+npm run lint
+```
+
+## Keeping types in sync
+
+`src/api/types.ts` mirrors:
+
+- `wizard_server/app/protocol.py` (client messages),
+- `Lobby.view()` / `Lobby.state_for()` in `wizard_server/app/lobby.py` (server
+ messages),
+- the `to_dict()` methods in `wizard_game` (cards, actions, observation,
+ events).
+
+When you change one of those, update `types.ts`; `npm run build` then shows
+every place in the UI that needs to follow.
diff --git a/docs/07-ai.md b/docs/07-ai.md
new file mode 100644
index 0000000..cc6e4fd
--- /dev/null
+++ b/docs/07-ai.md
@@ -0,0 +1,280 @@
+# 7. AI player (`wizard_ai`)
+
+The AI package contains everything around a reinforcement-learning player:
+the agent interface, a feature encoding, a pluggable policy (model) interface,
+a training environment with a self-play loop, an evaluation tool, and the
+production websocket service. **The actual learning model is not implemented
+yet**: `UniformPolicy` is a random placeholder, and until a trained checkpoint
+exists production uses the rule-based `HeuristicAgent`.
+
+## Package layout
+
+```
+wizard_ai/wizard_ai/
+├── __main__.py # CLI: serve | train | evaluate
+├── config.py # env vars: SERVER_URL, AI_SHARED_SECRET, AGENT, MODEL_PATH, LOG_LEVEL
+├── agents/
+│ ├── base.py # Agent interface
+│ ├── random_agent.py # RandomAgent
+│ ├── heuristic_agent.py # HeuristicAgent (baseline + production fallback)
+│ ├── model_agent.py # ModelAgent: Agent backed by a Policy
+│ └── __init__.py # make_agent(name, model_path)
+├── model/
+│ ├── encoding.py # encode_observation(), FEATURE_SIZE, ActionSpace
+│ └── policy.py # Policy interface, Transition, checkpoints, UniformPolicy
+├── training/
+│ ├── env.py # WizardEnv (multi-agent environment)
+│ ├── self_play.py # TrainingConfig, SelfPlayTrainer
+│ └── evaluate.py # evaluate(agents, games)
+└── service/client.py # AIService: production websocket client
+```
+
+## How the pieces fit together
+
+```mermaid
+flowchart TB
+ subgraph production
+ SRV[game server] -- "decide (observation JSON)" --> SVC[AIService]
+ SVC -- "Observation.from_dict" --> AG["Agent.act"]
+ AG -- Action --> SVC -- "action JSON" --> SRV
+ end
+ subgraph training
+ ENV["WizardEnv
wraps WizardGame"] -- Observation --> MA[ModelAgent]
+ MA -- "encode_observation + ActionSpace.mask" --> POL["Policy.select"]
+ POL -- "action index" --> MA
+ MA -- Action --> ENV
+ MA -- "records Transition" --> TR[SelfPlayTrainer]
+ ENV -- rewards --> TR
+ TR -- trajectories --> UPD["Policy.update"]
+ TR -- save --> CK[("models/latest/")]
+ end
+ CK -- load_policy --> AG
+```
+
+The key idea: **an `Agent` maps an `Observation` to an `Action`**, and the
+observation is identical in training (straight from `WizardGame`) and in
+production (JSON from the server, decoded with `Observation.from_dict`). A
+trained agent can be deployed without any change.
+
+## Agents (`agents/`)
+
+```python
+class Agent(ABC):
+ name: str
+ def act(self, observation: Observation) -> Action: ... # must return one of observation.legal_actions
+ def reset(self) -> None: ... # optional, called before each game in evaluation
+```
+
+| Agent | Behaviour |
+|-------|-----------|
+| `RandomAgent` | uniformly random legal action |
+| `HeuristicAgent` | **Bidding:** estimates expected tricks (wizard ≈ 0.95, trump ≈ 0.35 + 0.05·rank, high side cards 12–13 count less with more opponents), bids the closest legal value. **Playing:** if it still needs tricks, it wins with the cheapest winning card when last to play, otherwise with the strongest (and plays its weakest card if it cannot win); if it needs no more tricks, it throws the strongest card that still loses, else its weakest card. |
+| `ModelAgent(policy, explore=False, recorder=None)` | encodes the observation, asks the policy for an action index, converts it back to an `Action`; optionally records every decision (used by the trainer) |
+
+`make_agent(name, model_path)`: `"random"`, `"heuristic"` or `"model"`. For
+`"model"` it loads the checkpoint at `model_path` if it exists, otherwise it
+logs a warning and returns a `HeuristicAgent`.
+
+Baseline strength (100 games, `evaluate`): heuristic vs. three random agents →
+heuristic ≈ +220 average score, 100 % win rate; random agents ≈ −350.
+
+## Encoding (`model/encoding.py`)
+
+### Features — `encode_observation(obs) -> list[float]` (length `FEATURE_SIZE = 312`)
+
+Plain Python floats (convert to numpy/torch in the model). Opponents are
+ordered relative to the observer (next player first) and zero-padded to 5, so
+**one model works for 3–6 players**.
+
+| Index | Size | Feature |
+|-------|------|---------|
+| 0–3 | 4 | number of players one-hot (3, 4, 5, 6) |
+| 4–5 | 2 | phase one-hot (bidding, playing) |
+| 6 | 1 | round number / 20 |
+| 7 | 1 | round number / total rounds (game progress) |
+| 8 | 1 | hidden bids option |
+| 9–14 | 6 | own seat relative to the dealer, one-hot |
+| 15–20 | 6 | own position in the current trick (0 = leading), one-hot |
+| 21–80 | 60 | own hand (card id one-hot) |
+| 81–140 | 60 | all cards played this round |
+| 141–200 | 60 | cards in the current trick |
+| 201–205 | 5 | trump suit one-hot (4 suits + none) |
+| 206–210 | 5 | lead suit one-hot (4 suits + none) |
+| 211–270 | 60 | trump card one-hot (none = all zero) |
+| 271–275 | 5 | own: has bid, bid/20, tricks/20, (bid − tricks)/20, score/100 |
+| 276–310 | 35 | 5 opponent slots × 7: present, has bid, bid visible, bid/20, tricks/20, (their score − my score)/100, already played in this trick |
+| 311 | 1 | (sum of known bids − round number)/20 |
+
+If you change the encoding, `FEATURE_SIZE` must be updated (a test checks the
+length for all player counts) and existing checkpoints become incompatible.
+
+### Actions — `ActionSpace` (size 81)
+
+| Index | Action |
+|-------|--------|
+| 0–20 | `Bid(0)` … `Bid(20)` |
+| 21–80 | `PlayCard(card id 0…59)` |
+
+`ActionSpace.index(action)`, `ActionSpace.action(index)` and
+`ActionSpace.mask(observation)` (list of 81 booleans, `True` = legal). A model
+should give illegal actions zero probability using the mask.
+
+## Policy interface (`model/policy.py`) — where the model goes
+
+```python
+@dataclass
+class Transition:
+ features: list[float] # encode_observation(obs)
+ mask: list[bool] # ActionSpace.mask(obs)
+ action: int # chosen action index
+ reward: float = 0.0 # scaled score change, attached to the player's last decision of a round
+ done: bool = False # True for the player's last decision of the game
+
+class Policy(ABC):
+ type_name: ClassVar[str] # key in the checkpoint metadata
+ def select(self, features, mask, *, explore=False) -> int: ...
+ def update(self, episodes: list[list[Transition]]) -> dict[str, float]: ...
+ def config(self) -> dict: ... # constructor kwargs stored in the checkpoint
+ def save_weights(self, directory: Path): ... # write parameters
+ @classmethod
+ def load(cls, directory: Path, config: dict) -> Policy: ...
+```
+
+Checkpoints are **directories**: `policy.save(dir)` writes
+`dir/policy.json` = `{"type": type_name, "config": {...}}` and calls
+`save_weights(dir)`. `load_policy(dir)` reads the type, finds the class
+registered with `@register_policy`, and calls its `load`.
+
+### Implementing the real model — step by step
+
+1. Add `torch` (or another library) to `wizard_ai/requirements.txt`.
+2. Create e.g. `wizard_ai/wizard_ai/model/ppo_policy.py`:
+
+ ```python
+ @register_policy
+ class PPOPolicy(Policy):
+ type_name = "ppo"
+
+ def __init__(self, hidden: int = 256, lr: float = 3e-4):
+ self.hidden, self.lr = hidden, lr
+ self.net = ... # FEATURE_SIZE -> hidden -> (ActionSpace.SIZE logits, value)
+
+ def select(self, features, mask, *, explore=False):
+ logits = self.net(torch.tensor(features))[0]
+ logits[~torch.tensor(mask)] = -inf # only legal actions
+ dist = Categorical(logits=logits)
+ return int(dist.sample() if explore else logits.argmax())
+
+ def update(self, episodes):
+ # compute returns/advantages from Transition.reward/done, run PPO epochs
+ return {"loss": ..., "mean_episode_reward": ...}
+
+ def config(self):
+ return {"hidden": self.hidden, "lr": self.lr}
+
+ def save_weights(self, directory):
+ torch.save(self.net.state_dict(), directory / "weights.pt")
+
+ @classmethod
+ def load(cls, directory, config):
+ policy = cls(**config)
+ policy.net.load_state_dict(torch.load(directory / "weights.pt"))
+ return policy
+ ```
+
+ For PPO you will likely want to store log-probabilities and value estimates
+ per decision; extend `Transition` (or keep them inside the policy keyed by
+ decision order) as needed.
+3. Import the module in `model/__init__.py` so `@register_policy` runs.
+4. In `__main__.py` (`train` command) replace `UniformPolicy(args.seed)` with
+ your policy (the line is marked `TODO`).
+5. Train: `python -m wizard_ai train --episodes 50000 --out models/latest`.
+6. Compare: `python -m wizard_ai evaluate --agents model,heuristic,heuristic,heuristic`.
+7. Deploy: the checkpoint directory `wizard_ai/models/latest/` is mounted into
+ the container (`compose.yaml`); restart `wizard_ai`. With `AGENT=model`
+ (default) it is loaded automatically.
+
+Model size matters: the service answers every AI move in a worker thread and
+the server waits at most `AI_DECISION_TIMEOUT` (5 s).
+
+## Training (`training/`)
+
+### `WizardEnv`
+
+A turn-based multi-agent environment around `WizardGame`:
+
+```python
+env = WizardEnv(num_players=(3, 4, 5, 6), options=GameOptions(), seed=1)
+obs = env.reset() # observation of the player to act; samples a player count
+while obs is not None:
+ result = env.step(action) # action for obs.player
+ result.rewards # per seat: score change / 100 when a round ended, else 0
+ result.done, result.events
+ obs = result.observation # next player to act (None when the game is over)
+```
+
+The sum of all rewards of a seat over a game equals its final score / 100
+(tested).
+
+### `SelfPlayTrainer`
+
+`TrainingConfig` fields: `episodes`, `player_counts`, `options`,
+`opponents` (`"self"` = every seat is the learning policy, `"heuristic"` /
+`"random"` = one learning seat against those), `episodes_per_update`,
+`checkpoint_dir`, `checkpoint_every`, `log_every`, `seed`.
+
+Per episode (= one full game) it:
+
+1. resets the env (random player count),
+2. lets a `ModelAgent(policy, explore=True, recorder=…)` play the learning
+ seats, recording one `Transition` per decision,
+3. adds each round's reward to that seat's most recent transition, and marks
+ each seat's last transition `done`,
+4. collects the trajectories (one list per learning seat and game).
+
+Every `episodes_per_update` episodes it calls `policy.update(batch)`, logs the
+returned metrics, and saves a checkpoint every `checkpoint_every` episodes and
+at the end.
+
+### `evaluate(agents, games, options=None, seed=None)`
+
+Plays `games` games with `len(agents)` players, rotating seats so every agent
+starts in every position, and returns mean score and win rate per agent (ties
+count as a win for everyone tied). Seeded for fair comparisons.
+
+## Production service (`service/client.py`)
+
+`AIService(server_url, agent, secret).run_forever()`:
+
+- connects to `SERVER_URL` with `Authorization: Bearer `,
+ sends `hello`, then answers `decide` requests (protocol in
+ [chapter 5](05-protocol.md#ai-protocol));
+- each request runs `agent.act` in a worker thread (`asyncio.to_thread`) behind
+ a lock, so a slow model does not block the websocket and non-thread-safe
+ agents stay consistent;
+- errors while deciding are reported back as `error` (the server then plays a
+ random move) instead of crashing;
+- reconnects forever with exponential backoff (1 s … 30 s); logs a hint if the
+ server rejects the secret.
+
+## Command line
+
+```bash
+cd wizard_ai
+python -m wizard_ai serve [--server-url …] [--agent model|heuristic|random] [--model-path …]
+python -m wizard_ai train --episodes 5000 [--players 3,4,5,6] [--opponents self|heuristic|random]
+ [--hidden-bids] [--out models/latest] [--resume] [--seed N]
+python -m wizard_ai evaluate --agents model,heuristic,random,random --games 200 [--seed N]
+```
+
+Environment variables (`config.py`): `SERVER_URL`
+(default `ws://localhost:8000/internal/ai`; in Docker
+`ws://wizard_server:8083/internal/ai`), `AI_SHARED_SECRET`, `AGENT`
+(default `model`), `MODEL_PATH` (default `models/latest`, in Docker
+`/srv/models/latest`), `LOG_LEVEL`.
+
+## Tests (`wizard_ai/tests/test_ai.py`)
+
+Feature length and masks for 3–6 players, action-space round trip, all agents
+play legal full games, env rewards add up to scores, trainer runs and writes a
+loadable checkpoint, `make_agent` fallback, heuristic beats random.
diff --git a/docs/08-operations.md b/docs/08-operations.md
new file mode 100644
index 0000000..1ac3327
--- /dev/null
+++ b/docs/08-operations.md
@@ -0,0 +1,209 @@
+# 8. Deployment, development & operations
+
+## Docker
+
+### Images
+
+| Service | Dockerfile | Build context | Listens on | Notes |
+|---------|-----------|---------------|------------|-------|
+| `wizard_website` | `wizard_website/Dockerfile` | `wizard_website/` | 8082 (nginx) | multi-stage: node build → nginx; build arg `VITE_WS_URL` (optional) |
+| `wizard_server` | `wizard_server/Dockerfile` | `wizard_server/` | 8083 (uvicorn) | installs `requirements.txt`, then `game_logic/`; runs as non-root; health check on `/health` |
+| `wizard_ai` | `wizard_ai/Dockerfile` | `wizard_ai/` **plus** named context `game_logic` | – | installs the game engine from the extra context; runs `python -m wizard_ai serve` |
+
+The AI image needs the game engine, which lives in the server folder. Compose
+passes it as an additional build context (requires docker compose ≥ 2.17):
+
+```yaml
+build:
+ context: ./wizard_ai
+ additional_contexts:
+ game_logic: ./wizard_server/game_logic
+```
+
+Building it by hand: `docker build --build-context
+game_logic=wizard_server/game_logic wizard_ai`.
+
+### Networks
+
+| Network | Type | Members |
+|---------|------|---------|
+| `wizard_website` | external (shared with Caddy) | `wizard_website` |
+| `wizard_api` | external (shared with Caddy) | `wizard_server` |
+| `wizard_internal` | created by compose, `internal: true` (no internet) | `wizard_server`, `wizard_ai` |
+
+No ports are published to the host; only Caddy reaches the containers.
+
+### Environment (`.env` next to `compose.yaml`)
+
+Compose reads `.env` automatically; `.env.example` is only a template and is
+not read by anything.
+
+| Variable | Used by | Required | Meaning |
+|----------|---------|----------|---------|
+| `AI_SHARED_SECRET` | server + AI | **yes** (compose refuses to start without it) | shared secret for `/internal/ai` |
+| `ALLOWED_ORIGINS` | server | no | e.g. `https://game.marvinkrausser.com` |
+| `AI_AGENT` | AI | no (default `model`) | `model`, `heuristic` or `random` |
+| `LOG_LEVEL` | server + AI | no | `INFO`, `DEBUG`, … |
+
+Further server settings (`AI_MOVE_DELAY`, `TRICK_PAUSE`, …, see
+"Configuration" in [the server chapter](04-server.md)) can be added to
+the `environment:` section of `wizard_server` in `compose.yaml`.
+
+### AI models
+
+`./wizard_ai/models` is mounted read-only to `/srv/models`; the service loads
+`/srv/models/latest/` (a policy checkpoint directory). Put a new checkpoint
+there and restart `wizard_ai` — no rebuild needed. Without a checkpoint the
+heuristic agent is used.
+
+### Deploying
+
+```bash
+cp .env.example .env # once; set AI_SHARED_SECRET
+docker compose up -d --build # build and (re)start everything
+docker compose up -d --build wizard_website # only one service
+docker compose logs -f wizard_server
+docker compose ps # server shows "healthy" when /health answers
+```
+
+## Reverse proxy (Caddy)
+
+The existing Caddy container serves the game under `game.marvinkrausser.com`:
+
+```caddy
+game.marvinkrausser.com {
+ import common
+
+ redir /wizard /wizard/website/ permanent
+ redir /wizard/ /wizard/website/ permanent
+ redir /wizard/website /wizard/website/ permanent
+
+ @wizard_api path /wizard/api /wizard/api/*
+ handle @wizard_api {
+ uri strip_prefix /wizard/api
+ reverse_proxy wizard_server:8083
+ }
+
+ handle_path /wizard/website/* {
+ reverse_proxy wizard_website:8082
+ }
+
+ handle {
+ respond 404
+ }
+}
+```
+
+What each part means for the app:
+
+- The website must be opened with a trailing slash (`/wizard/website/`) so
+ relative asset paths resolve — the redirects take care of that.
+- Both prefixes are stripped, so the containers do not know about `/wizard/…`.
+ The frontend finds the API through the relative URL `../api/ws`.
+- Websockets are proxied automatically by Caddy.
+- `/wizard/api/internal/ai` is reachable through this block, but the server
+ refuses it because the request carries Caddy's forwarding headers.
+
+URLs: game `https://game.marvinkrausser.com/wizard/website/`, health check
+`https://game.marvinkrausser.com/wizard/api/health`.
+
+## Local development
+
+One Python virtual environment for server and AI:
+
+```bash
+python -m venv .venv && . .venv/bin/activate
+pip install -r wizard_server/requirements-dev.txt -r wizard_ai/requirements-dev.txt
+```
+
+`requirements-dev.txt` installs the game engine **editable** (`-e`), so changes
+to `wizard_game` are picked up immediately.
+
+Run the three parts in three terminals:
+
+```bash
+# 1. server on :8000 (add AI_MOVE_DELAY=0 TRICK_PAUSE=0 to make AI players instant)
+cd wizard_server && AI_SHARED_SECRET=dev uvicorn app.main:app --reload
+
+# 2. AI service
+cd wizard_ai && AI_SHARED_SECRET=dev SERVER_URL=ws://localhost:8000/internal/ai python -m wizard_ai serve
+
+# 3. website on :5173 (proxies /api → localhost:8000 without the /api prefix)
+cd wizard_website && npm install && npm run dev
+```
+
+Open . To play as several humans, use several browser
+tabs (each tab is its own session). Without step 2 the game still works; AI
+seats then play random moves.
+
+## Tests and checks
+
+```bash
+cd wizard_server && pytest # game engine (game_logic/tests) + server (tests/)
+cd wizard_ai && pytest
+cd wizard_website && npm run lint && npm run build
+```
+
+| Suite | Count | Covers |
+|-------|-------|--------|
+| game engine | 52 | rules, full random games for 3–6 players, invariants, JSON |
+| server | 9 | websocket flows incl. a complete game, reconnect, AI endpoint security |
+| AI | 13 | encoding, agents, env, trainer, checkpoints |
+
+## Troubleshooting
+
+| Symptom | Cause / fix |
+|---------|-------------|
+| Browser console: *Refused to apply style … MIME type ('')*, 404 for `/assets/…` | The page loads assets from the wrong path. The build must use relative paths (`base: './'` in `vite.config.ts`) and the page must be opened with a trailing slash. Rebuild the website image after changing the Vite config. |
+| Red banner "Connection lost. Reconnecting…" forever | The websocket URL does not reach the server. Check `https://…/wizard/api/health`; check that the Caddy block strips `/wizard/api`; check `ALLOWED_ORIGINS` (must contain the exact page origin, e.g. `https://game.marvinkrausser.com`). |
+| Banner "This game was opened in another window" | The same session (token) was opened in a second tab; reload this tab to take it back. |
+| Lobby shows "The AI service is offline" | `wizard_ai` is not connected. `docker compose logs wizard_ai`: *Server rejected the connection, check AI_SHARED_SECRET* → secrets differ; connection refused → server not running or wrong `SERVER_URL`. |
+| AI players only make weird/random moves | Same as above (random fallback), or the AI answers too slowly (`AI_DECISION_TIMEOUT`), see server logs "Playing random move for player …". |
+| Game waits and nothing happens | It is a disconnected human's turn (seat shows "offline"). The game waits until they reconnect or their session expires (`SESSION_TTL`, then an AI takes over). |
+| All lobbies gone | The server restarted — state is in memory only. |
+| Tests pass but behaviour does not change after editing `wizard_game` | A non-editable copy of `wizard_game` is installed in your virtualenv and shadows the source. `pip uninstall wizard-game && pip install -e wizard_server/game_logic`. Check with `python -c "import wizard_game; print(wizard_game.__file__)"`. |
+| Server tests hang | A test opened a websocket without a context manager; use `client.sockets.enter_context(client.websocket_connect(...))`. |
+
+## Recipes
+
+### Add a new game option (e.g. a rule variant)
+
+1. `wizard_game/state.py`: add a field to `GameOptions` and to `to_dict` /
+ `from_dict`.
+2. `wizard_game/game.py`: implement the behaviour using
+ `self.options.`; add tests in `game_logic/tests/test_game.py`.
+3. `wizard_server/app/protocol.py`: add the field to `SetOptions`;
+ `service.py`: pass it into `GameOptions(...)`.
+4. `wizard_website/src/api/types.ts`: add it to `GameOptions` and the
+ `set_options` message; `LobbyScreen.tsx`: add a checkbox (copy the
+ "Hidden bids" one).
+5. `wizard_ai/wizard_ai/model/encoding.py`: add a feature (and update
+ `FEATURE_SIZE`) if the AI should know about it; `__main__.py`: add a
+ `--flag` to `train`.
+
+### Add a new client message
+
+1. `protocol.py`: new pydantic model, add it to the `ClientMessage` union.
+2. `service.py`: handle it in `_dispatch` (usually by calling a new `Lobby`
+ method and then `lobby.broadcast()`).
+3. `types.ts`: add it to `ClientMessage`; `useWizard.ts`: add a function to
+ `actions`.
+4. Add a test in `wizard_server/tests/test_server.py`.
+
+### Change how fast AI players move
+
+Set `AI_MOVE_DELAY` (per move) and `TRICK_PAUSE` (after a trick) for
+`wizard_server` in `compose.yaml`.
+
+### Add another AI agent
+
+Subclass `Agent` in `wizard_ai/wizard_ai/agents/`, add it to `AGENT_NAMES`
+and `make_agent` in `agents/__init__.py`, then use it via `AI_AGENT=` or
+in `evaluate --agents`.
+
+### Change container ports
+
+Website: `listen` in `wizard_website/nginx.conf` and `EXPOSE` in its
+Dockerfile. Server: `--port`, `EXPOSE` and the health check URL in
+`wizard_server/Dockerfile`, and `SERVER_URL` of `wizard_ai` in `compose.yaml`.
+Then update the Caddy block.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..ccf475b
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,47 @@
+# Wizard — developer documentation
+
+This folder explains the whole codebase: what each part does, how the parts
+talk to each other, and why things are built the way they are. It is written
+to be read top to bottom the first time and used as a reference afterwards.
+
+## Reading order
+
+| # | Document | What you learn |
+|---|----------|----------------|
+| 1 | [Architecture overview](01-architecture.md) | The four components, how a move travels through the system, repository layout, key design decisions |
+| 2 | [Game rules as implemented](02-game-rules.md) | Every rule of Wizard as the code implements it, with the exact function that enforces it |
+| 3 | [Game engine (`wizard_game`)](03-game-engine.md) | The rules package: cards, the `WizardGame` state machine, observations, actions, events |
+| 4 | [Game server (`wizard_server/app`)](04-server.md) | Sessions, reconnects, lobbies, running games, AI turns, broadcasting, configuration |
+| 5 | [Protocol reference](05-protocol.md) | Every websocket message between browser ↔ server and server ↔ AI, with examples |
+| 6 | [Frontend (`wizard_website`)](06-frontend.md) | The React app: connection hook, screens, components, styling, build |
+| 7 | [AI player (`wizard_ai`)](07-ai.md) | Agents, feature encoding, policy interface, training, evaluation, production service — and how to plug in a real model |
+| 8 | [Deployment, development & operations](08-operations.md) | Docker, networks, Caddy, environment variables, local setup, tests, troubleshooting, how-to recipes |
+
+## The system in one paragraph
+
+A **React website** lets players create or join a lobby and play. It talks to
+the **Python game server** over one websocket per browser tab. The server keeps
+all lobbies and games in memory and uses the **game engine** (`wizard_game`, a
+dependency-free Python package) to enforce the rules. For every AI seat, the
+server asks the **AI service** over an internal websocket which move to make;
+the AI service answers using an `Agent` (today a heuristic, later a
+reinforcement-learning model trained with the same game engine).
+
+```mermaid
+flowchart LR
+ B["Browser
React app"] -- "wss /wizard/api/ws" --> C[Caddy]
+ B -- "https /wizard/website/" --> C
+ C -- "/ws, /health" --> S["wizard_server
FastAPI :8083"]
+ C -- "/assets, /index.html" --> W["wizard_website
nginx :8082"]
+ A["wizard_ai
AI service"] -- "ws /internal/ai" --> S
+ S -. uses .-> G[("wizard_game
rules engine")]
+ A -. uses .-> G
+```
+
+## Conventions used in these docs
+
+- File references are relative to the repository root, e.g.
+ `wizard_server/app/lobby.py`.
+- "Seat" or "player index" means the position `0 … n-1` of a player at the
+ table. The game engine only knows indices; names and ids live in the server.
+- Code excerpts are shortened; the source files are the final truth.