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

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_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/):

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.