Skip to main content
Version: 0.4.0 β€” Wine Answers

Architecture

Containers view​

Repository layout​

/
β”œβ”€β”€ back/ ← PartyKit backend (Durable Object)
β”‚ β”œβ”€β”€ game.ts ← thin dispatcher β€” routes messages to handlers
β”‚ β”œβ”€β”€ game-context.ts ← GameContext interface
β”‚ β”œβ”€β”€ types.ts ← exported types & interfaces
β”‚ β”œβ”€β”€ constants.ts ← word lists (25Γ—25), category prompts
β”‚ β”œβ”€β”€ utils.ts ← pure utilities: generateIdentity(), shuffle()
β”‚ β”œβ”€β”€ scoring.ts ← pure scoring functions
β”‚ β”œβ”€β”€ question.ts ← buildQuestionPayload, broadcast helpers
β”‚ β”œβ”€β”€ persistence.ts ← saveState, upsertKvSession, endGame
β”‚ └── handlers/
β”‚ β”œβ”€β”€ session.ts ← create_session, rejoin_host, join_session, rejoin_session
β”‚ β”œβ”€β”€ game-flow.ts ← host control handlers + submit_answer
β”‚ └── timer.ts ← startTimer, handleTimerExpiry
β”œβ”€β”€ front/ ← Astro + React frontend
β”‚ └── src/
β”‚ β”œβ”€β”€ lib/
β”‚ β”‚ β”œβ”€β”€ socket.ts ← PartySocket factory (createSocket(room))
β”‚ β”‚ └── rejoin.ts ← shared localStorage credential helpers
β”‚ β”œβ”€β”€ types/events.ts ← shared event payload types
β”‚ β”œβ”€β”€ stores/
β”‚ β”‚ β”œβ”€β”€ hostStore.ts
β”‚ β”‚ └── participantStore.ts
β”‚ β”œβ”€β”€ hooks/
β”‚ β”‚ β”œβ”€β”€ useHostSocket.ts
β”‚ β”‚ β”œβ”€β”€ useParticipantSocket.ts
β”‚ β”‚ └── useUrlSync.ts ← URL ↔ store sync for host & participant
β”‚ └── components/
β”‚ β”œβ”€β”€ host/
β”‚ └── participant/
β”œβ”€β”€ docs-site/ ← Docusaurus docs
β”œβ”€β”€ proxy-worker/
β”‚ └── index.ts ← Cloudflare Worker: routes /docs/* to docs Pages
β”œβ”€β”€ wine-answers-worker/ ← Cloudflare Worker: curated wine answer suggestions (KV-backed)
β”‚ └── index.ts ← GET/POST/DELETE endpoints for answer collections
β”œβ”€β”€ partykit.json ← PartyKit config (no KV binding β€” see data-persistence.md)
β”œβ”€β”€ package.json ← root: partykit + partysocket
└── docker-compose.yml ← Mode B: full-stack with PartyKit in Docker

⚠️ After any backend change (files under back/), run npx partykit deploy (from repo root) or cd back && npm run deploy to push to Cloudflare. The proxy-worker does not need redeployment for backend-only changes.

Runtime communication​

Browser (Host)          Browser (Participant)
β”‚ β”‚
β”‚ PartySocket ws β”‚ PartySocket ws
β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ PartyKit Durable Object β”‚
β”‚ room = 4-digit session code β”‚
β”‚ β”‚
β”‚ this.room.storage ← game state β”‚
β”‚ this.room.broadcast ← fan-out msgs β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Session history: the DO does not write to Cloudflare KV β€” the HOSTS_KV binding has been removed (CF free-plan incompatibility). Session history is localStorage-only. See Data Persistence.

Durable Object lifecycle​

HookCalled whenWhat we do
onStart()DO wakes from evictionRestore SavedState from storage
onConnect(ws)Client opens WebSocketRegister connection (host or participant)
onMessage(ws, msg)Message arrivesParse { type }, dispatch to handler
alarm()Timer alarm firesAuto-reveal current question
onClose(ws)Client disconnectsMark participant offline; start 1-hour host disconnect grace period if host drops

Host vs participant socket flow​

Host joins β†’ sends rejoin_host { hostId } on socket open β†’ server validates hostId against stored value β†’ sends host:state_snapshot

Participant joins β†’ sends join_session {} β†’ server creates participant with a unique ADJECTIVE-NOUN pseudonym, broadcasts lobby:updated (the session code is the WebSocket room ID, not a message field)

Participant rejoins β†’ localStorage { id, code } detected on mount β†’ sends rejoin_session { pseudonym } β†’ server sends participant:state_snapshot

Static Frontend, Dynamic Experience​

Astro generates a fully static site β€” plain HTML, CSS, and JS served from a CDN. Yet Sommelier Arena delivers real-time multiplayer gameplay, live leaderboards, and instant answer reveals. There is no application server behind the pages. Three runtime protocols bridge the gap:

WebSocket (PartyKit) β€” real-time game state​

The PartyKit Durable Object (back/game.ts) holds all session state in memory and on Durable Object storage. Host and participant browsers open a PartySocket connection to the room (keyed by the 4-digit session code). Every game event β€” question broadcast, answer submission, timer expiry, leaderboard update β€” flows over this single WebSocket. No REST endpoints are needed for gameplay.

React Islands (Zustand) β€” client-side interactivity​

Astro pages (/, /host, /play, /admin) are static shells with zero JavaScript by default. Interactive components mount with client:only="react" and manage all UI state through two Zustand stores (hostStore, participantStore). These stores mirror the backend phase machine (waiting β†’ question_open β†’ … β†’ ended), so the frontend is always a projection of server state received over the WebSocket.

HTTP REST (Wine Answers Worker) β€” reference data​

The wine-answers-worker/ Cloudflare Worker serves curated answer collections via simple GET/POST/DELETE endpoints. This data is read-heavy, rarely written, and not time-sensitive β€” a classic fit for REST over HTTP rather than WebSocket.

Why this works​

Static hosting means zero server cost for serving pages and instant page loads from a CDN. The "server" lives at the edge β€” Cloudflare Durable Objects for real-time state, Workers for reference data. The static shell loads first, then runtime connections bring it to life.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Static Astro Shell β”‚
β”‚ (HTML/CSS/JS β€” served from CDN) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ React Islands + Zustand Stores β”‚
β”‚ (hydrated client-side) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ WebSocketβ”‚ REST β”‚ localStorage β”‚
β”‚ PartyKit β”‚ Worker β”‚ (rejoin data) β”‚
β”‚ (game) β”‚ (data) β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Docker vs PartyKit dev​

Mode A (partykit dev)Mode B (docker)
Backendnpx partykit dev on :1999partykit Docker container on :1999
Frontendnpm run dev on :4321nginx on :4321
Docscd docs-site && npm startDocker container on :3002
Wine AnswersNot running (or npx wrangler dev on :1998)wine-answers Docker container on :1998

All services run on Cloudflare's infrastructure: zero VMs, zero servers to maintain. The frontend is a static CDN deployment; game state lives in a Durable Object; answer suggestions come from a KV-backed Worker. See Deployment Guide for step-by-step deploy commands.