Files
Wizard/docs/01-architecture.md
T
2026-09-27 11:26:10 +02:00

8.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
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_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:

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:

  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.