Running games always continue; new work is refused when there is no
capacity; abuse is rejected immediately.
- Docker: CPU/memory/pids caps per container (overridable in .env);
uvicorn message size, queue and concurrency limits
- Server abuse limits: connections total/per IP (close 1013), message
rate per connection (token bucket, close 1008 on floods), lobby
creation rate per IP
- Load monitor: event loop lag, CPU of the own cgroup quota and
throttling, with hysteresis (normal/busy/overloaded, severe lag
overloads immediately)
- Admission control: create_lobby and start_game refused while
overloaded or MAX_ACTIVE_GAMES are running; server_status message
broadcast on changes; /health reports load but stays ok
- AI bridge: timeout sent with requests, in-flight cap, circuit breaker
- AI service: bounded workers and queue ("overloaded"), drops expired
requests, degrades to the heuristic agent when busy or too slow
- Website: busy notices, Create/Start disabled when not accepting
games, slow retry when the server is full
- Tests for limiters, load levels, admission control and AI service
limits; docs updated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 KiB
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 <App/> 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
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) | busy (server full, retries slowly) |
serverStatus |
last server_status message: { load, accepting_games } |
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_URLif set at build time, otherwisenew URL('../api/ws', location.href)withws:/wss:. Onhttps://game.marvinkrausser.com/wizard/website/this giveswss://game.marvinkrausser.com/wizard/api/ws; in development (http://localhost:5173/) it givesws://localhost:5173/api/ws. - On open it sends
hellowith the token fromsessionStorage(keywizard.token); thewelcomereply stores the (possibly new) token. BecausesessionStorageis 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.
- If the server refuses the connection because it is full (an
errorwith codebusybeforewelcome, then close code 1013), the status becomesbusyand it retries slowly (15 s, 30 s, 60 s, …) so waiting clients don't add load. 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.
- While
serverStatus.accepting_gamesis false, "Create lobby" is disabled and a notice explains that the server is busy (joining still works).
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.
- While
serverStatus.accepting_gamesis false, "Start game" shows "Server busy" (disabled) with a notice; the lobby stays open. - 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:
TrickAreashowscompletedTrickwhile 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 owntrump_cardis 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 ofround_historyis 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
stylesfrom its*.module.css; class names are scoped automatically. - Design tokens are CSS variables on the
.approot element inApp.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(...)insideApp.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/):
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()inwizard_server/app/lobby.py(server messages),- the
to_dict()methods inwizard_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.