--- name: 'Wordle Clone Architecture Spine' type: architecture-spine purpose: build-substrate altitude: feature paradigm: 'SPA + REST API, stateless server' scope: 'Wordle Clone v1 — web application, solver engine, word bank' status: final created: '2026-07-07' updated: '2026-07-07' binds: ['FR-1'..'FR-19', 'UJ-1'..'UJ-5'] sources: ['prds/prd-wordle-2026-07-07/prd.md'] companions: [] --- # Architecture Spine — Wordle Clone ## Design Paradigm **SPA + stateless REST API.** The client is a single-page application loaded once; all subsequent interaction is REST calls against a stateless server. The server holds no session state — every request carries the context it needs (word ID, attempt number, skill metric). Deterministic computation replaces stored state wherever possible (daily word ID is a pure function of the date). ``` Browser (React SPA) ─── REST ─── Server (Express, stateless) │ ├── data/word-bank.json (static) └── solver output (pre-computed, static) ``` **Layer map:** `client/` (presentation + game logic) → `server/` (REST API, guess validation, word selection) → `data/` (word bank, solver rankings). `shared/` holds TypeScript types consumed by all layers. `solver/` is an offline script, not part of the runtime. ## Invariants & Rules ### AD-1 — Stateless server - **Binds:** server/ - **Prevents:** server-side session state, in-memory game tracking, per-player storage on the server - **Rule:** Every request carries all context needed to process it. The server stores nothing between requests. `[ADOPTED]` ### AD-2 — Deterministic daily word - **Binds:** GET /api/daily, server/src/daily.ts - **Prevents:** stored daily-word state, cron jobs for rotation, clock-drift between requests - **Rule:** Daily word ID = `(daysSinceEpoch(EST/EDT) mod targetWordCount) + 1`. Same date always maps to same word for every player. Timezone: America/New_York. `[ADOPTED]` ### AD-3 — TypeScript everywhere - **Binds:** all - **Prevents:** multi-language drift, duplicate type definitions, solver/API type mismatches - **Rule:** All code (client, server, solver) is TypeScript. Shared types in `shared/` are the single source of truth for API contracts, word bank shape, and game types. `[ADOPTED]` ### AD-4 — Word bank as static data - **Binds:** server/, solver/, data/ - **Prevents:** database dependency, migration overhead, runtime word-list mutation - **Rule:** The word bank is a single static JSON file (`data/word-bank.json`) loaded at server startup. Shape: `{ guessable: string[], targets: { id: number, word: string, difficulty: number }[] }`. The `difficulty` field is a single aggregated score (e.g., average attempts across all solver algorithms). The solver script produces this shape; the server consumes it. Per-algorithm attempt counts are solver-internal and not exposed in the bank. `[ADOPTED]` ### AD-5 — Guess validation server-side only - **Binds:** server/src/validate.ts, POST /api/guess - **Prevents:** word bank leaking to client, client-side validation drift from server - **Rule:** The guessable word list never leaves the server. Every guess is validated server-side against the full word bank. Invalid guesses return `{ valid: false }` without consuming an attempt. **Exception:** On game over (loss, `tryNo=6 && !correct`), the correct word string is returned in the guess response — this is a single-word reveal, not a list leak. `[ADOPTED]` ### AD-6 — Feedback scoring on server - **Binds:** server/src/feedback.ts, POST /api/guess - **Prevents:** client and server computing feedback differently, scoring rule divergence - **Rule:** The Feedback Scoring Rule (greens first, yellows to remaining count, excess gray) is implemented once on the server. The client only renders the colors it receives. `[ADOPTED]` ### AD-7 — Client owns game state - **Binds:** client/ - **Prevents:** server needing to track attempts, win/loss, or game progress - **Rule:** The client tracks: current attempt number, guess history, game-over detection, rules-seen cookie, lifetime statistics, and skill metric. All persist to browser storage. The server never knows what attempt the player is on except what `tryNo` the client sends. `[ADOPTED]` ### AD-8 — Word selection by Gaussian sampling - **Binds:** POST /api/play-again, server/src/play-again.ts - **Prevents:** fixed-difficulty buckets, deterministic word ordering in play-again mode - **Rule:** Play Again word selection is weighted random: words near the player's skill level are more likely, harder words become more likely as skill improves. Specific distribution (Gaussian or other) is an implementation detail. No repeat-word tracking — the target set is large enough that repeats are negligible. `[ADOPTED]` ### AD-9 — Monorepo with shared types - **Binds:** all - **Prevents:** type mismatches across package boundaries, duplicated interface definitions - **Rule:** Single repository with packages: `client/`, `server/`, `solver/`, `shared/`, `data/`. `shared/` exports TypeScript interfaces for API request/response shapes, word bank structure, and game types. Every package imports types from `shared/`; no package defines its own copy of a shared type. `[ADOPTED]` ### AD-10 — Typed API contracts before implementation - **Binds:** shared/src/api.ts, client/src/api.ts, server/src/routes/ - **Prevents:** field-name divergence (tryNo vs attemptNumber), type mismatches (wordId as string vs number), different response shapes for same endpoint - **Rule:** Every API endpoint has a concrete TypeScript interface in `shared/src/api.ts` defining request body, response body (success and error shapes), and HTTP method+path. No endpoint implementation begins before its interface is committed to `shared/`. `[ADOPTED]` ### AD-11 — Feedback wire format - **Binds:** shared/src/api.ts, server/src/feedback.ts, client/src/components/Grid.tsx - **Prevents:** client and server encoding colors differently (string literals vs numeric codes vs per-letter objects) - **Rule:** Feedback colors on the wire are single-character strings: `'g'` (green, correct position), `'y'` (yellow, wrong position), `'x'` (gray, absent or excess). The `colors` field in the guess response is a 5-element array of these characters. `[ADOPTED]` ### AD-12 — Single persistence key with typed schema - **Binds:** client/src/hooks/usePersistence.ts - **Prevents:** localStorage key collisions, schema drift between hooks, unversioned state corruption - **Rule:** All browser-persisted state lives under a single `localStorage` key (`wordle-state`). The value is a JSON-serialized `PersistedState` interface defined in `shared/src/game.ts`. One hook (`usePersistence`) owns all read/write access; `useStats` and `useGame` consume it, never touch storage directly. `[ADOPTED]` ## Consistency Conventions | Concern | Convention | | --- | --- | | Naming (files, functions, interfaces) | camelCase functions/variables, PascalCase types/interfaces/components, kebab-case files | | API contracts | Request/response shapes defined as TypeScript interfaces in `shared/src/api.ts` | | Error shapes | API errors return `{ error: string }` with appropriate HTTP status (400 invalid guess, 404 unknown wordId) | | Responsive design | CSS media queries, mobile-first. Target: usable at 375px width (small phone) through desktop | | State mutation | Client state is immutable-style — each guess produces a new state object; React renders from state | | Network errors | Client displays error message on request failure; player can retry. No offline mode for v1 | | Logging | Server logs requests to stdout (method, path, status, latency). No structured logging framework for v1 | ## Stack | Name | Version | | --- | --- | | TypeScript | ^6.x | | Node.js | ^24 LTS | | React | ^19.x | | Vite | ^8.x | | Express | ^5.x | | `shared/` types package | workspace | ## Structural Seed ### Context diagram ```mermaid graph LR B[Browser
React SPA] -->|REST| S[Express Server] S -->|reads at startup| WB[data/word-bank.json] SV[Solver script] -->|writes| WB S -->|serves static files| B B -->|stores| LS[Browser localStorage] ``` ### Source tree ```text wordle/ client/ # React + Vite SPA src/ screens/ # Screen1 (Rules), Screen2 (Game), Screen3 (Results) components/ # Grid, Keyboard, Stats, shared UI hooks/ # useGame, useStats, usePersistence api.ts # fetch wrappers for /api/* index.html server/ # Express REST API src/ index.ts # app entry, static file serving routes/ daily.ts # GET /api/daily guess.ts # POST /api/guess play-again.ts # POST /api/play-again feedback.ts # green/yellow/gray scoring logic validate.ts # word bank lookup daily-word.ts # date → wordId function package.json solver/ # offline pre-compute src/ index.ts # orchestrates solvers, writes rankings entropy.ts # entropy-based solver minimax.ts # minimax solver (or alternative) package.json shared/ # TypeScript types src/ api.ts # request/response interfaces word-bank.ts # WordBank, TargetWord, etc. game.ts # Guess, Feedback, Colors, etc. package.json data/ # generated / static data word-bank.json # guessable + targets with difficulty scores package.json # workspace root ``` ## Capability → Architecture Map | Capability / Area | Lives in | Governed by | | --- | --- | --- | | FR-1 First-visit detection | client/src/hooks/usePersistence.ts | AD-7 | | FR-2 Rules display | client/src/screens/Screen1.tsx | AD-7 | | FR-3 Grid rendering | client/src/components/Grid.tsx | AD-7 | | FR-4 Keyboard | client/src/components/Keyboard.tsx | AD-7 | | FR-5 Guess submission | client/src/hooks/useGame.ts → server/src/routes/guess.ts | AD-5, AD-6 | | FR-6 Invalid word rejection | server/src/validate.ts | AD-5 | | FR-7 Feedback scoring | server/src/feedback.ts | AD-6 | | FR-8 Win detection | client/src/hooks/useGame.ts | AD-7 | | FR-9 Loss detection | client/src/hooks/useGame.ts | AD-7 | | FR-10 Daily word selection | server/src/daily-word.ts | AD-2 | | FR-11 Play Again word selection | server/src/routes/play-again.ts | AD-8 | | FR-12 Player skill tracking | client/src/hooks/useStats.ts | AD-7 | | FR-13 Results display | client/src/screens/Screen3.tsx | AD-7 | | FR-14 Statistics display | client/src/screens/Screen3.tsx | AD-7 | | FR-15 Play Again button | client/src/screens/Screen3.tsx | AD-7 | | FR-16 Multi-algorithm solver | solver/src/ | Paradigm (offline script) | | FR-17 Word ranking output | solver/src/ → data/word-bank.json | AD-4 | | FR-18 Word bank curation | data/word-bank.json (generated) | AD-4 | | FR-19 Browser persistence | client/src/hooks/usePersistence.ts | AD-7 | ## Deferred - **Deployment mechanism** — how the server process is started, restarted, proxied (nginx, systemd). Owned by the deployment environment, not the architecture. - **Solver algorithm selection** — which two or more specific algorithms (entropy, minimax, frequency-based). Deferred to implementation; the solver package interface is the invariant. - **Skill metric formula** — exact calculation (simple average, weighted recent). Deferred to implementation; the API already carries a `skillMetric` number. - **Word bank source** — which public-domain word list. Deferred to implementation; the `WordBank` interface is the invariant. - **Hints on attempts 5-6** — post-v1. API already carries `tryNo`; the guess response shape is ready for a `hint` field. - **HTTPS/TLS termination** — owned by the deployment environment (reverse proxy), not the application. - **npm workspace configuration** — tooling detail, not an architectural invariant. The package boundary rules in AD-9 are sufficient. - **Screen navigation/routing** — 3 screens, state-based switching (React state or reducer). No router library needed at this scale. - **Feedback latency** — v1 accepts network round-trip per guess. Optimistic UI (show letters immediately, confirm colors) is a future enhancement. - **Accessibility (color-blind mode, ARIA, screen reader)** — deferred to post-v1. Core game uses green/yellow color distinction; a color-blind accessible palette is a future enhancement. - **Win celebration animation** — UI detail owned by the Results Screen component. No architectural constraint.