10 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) |
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.
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:
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.