--- name: 'Wordle Clone Architecture Spine' type: architecture-spine purpose: build-substrate altitude: feature paradigm: 'SPA + REST API, stateless server' scope: 'Wordle Clone — bilingual (EN/RU) web application, solver engine, word bank, solver tool' status: final created: '2026-07-07' updated: '2026-08-14' binds: ['FR-1'..'FR-19', 'UJ-1'..'UJ-5'] sources: - 'prds/prd-wordle-2026-07-07/prd.md' - 'raw/01…13 (post-v1 change log)' - 'source code (reconciled 2026-08-14)' companions: [] --- # Architecture Spine — Wordle Clone > **Reconciliation note (2026-08-14):** this document has been updated to match the > implemented code, which evolved past the original v1 design via the changes logged in > `raw/01…13`. Decisions marked `[ADOPTED]` reflect what is in the code, not the original > design intent. Where a decision changed materially, the change is noted inline. ## 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; word difficulty is a pure function of solver output, computed and cached at runtime rather than stored). The application is **bilingual (English + Russian)**. The same server and SPA serve both languages via URL-prefixed routes (`/api` vs `/ru/api`, `/solver` vs `/ru/solver`) and language-specific word banks. ``` Browser (React SPA) ─── REST ─── Server (Express, stateless) │ ├── data/word-bank.json (EN, static) ├── data/word-bank-ru.json (RU, static) └── solver cache (lazy, in-memory) └── forked child process for full 4-solver run ``` **Layer map:** `client/` (presentation + game state) → `server/` (REST API, guess validation, feedback, word selection, solver filtering) → `data/` (word banks). `shared/` holds the TypeScript types **and the solver algorithms** consumed by all layers. `solver/` is an offline validation CLI, 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. The only in-memory state is the solver *cache*, which is derived data (keyed by word ID), not per-player state. `[ADOPTED]` ### AD-2 — Deterministic daily word - **Binds:** GET /api/daily, GET /ru/api/daily, server/src/daily-word.ts - **Prevents:** stored daily-word state, cron jobs for rotation, clock-drift between requests - **Rule:** Daily word ID = `daysSinceEpoch(America/New_York) mod targetCount`, **0-indexed** and used directly as an array index into `targets` (there is **no `+1`**). Same date always maps to same word for every player. Epoch is a fixed reference (2026-01-01 EST). The date is extracted in the target timezone via `Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' })` to avoid server-timezone drift. `[ADOPTED]` ### AD-3 — TypeScript everywhere - **Binds:** all - **Prevents:** multi-language drift, duplicate type definitions, solver/API type mismatches - **Rule:** All code (client, server, solver, shared) is TypeScript. `shared/` is the single source of truth for API contracts, word-bank shape, game types, **and the solver algorithms**. `[ADOPTED]` ### AD-4 — Word bank as static data; difficulty computed at runtime - **Binds:** server/, solver/, shared/src/word-bank.ts, data/ - **Prevents:** database dependency, migration overhead, stale pre-computed difficulty in the JSON - **Rule:** The word bank is a static JSON file loaded once at server startup. Shape: `{ guessable: string[], targets: { word: string, hint?: string }[] }`. - A target's **ID is its array index** (there is no `id` field — removed in raw/08). - **Difficulty and solver replays are NOT stored** in the JSON (removed in raw/01/02); they are computed at runtime from solver output and cached (see AD-14). - `hint` is an optional synonym shown after the 5th unsuccessful guess (raw/06). The solver does **not** write difficulty back into `data/`. `[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 in the core game flow. Every guess is validated server-side against the full word bank. Invalid guesses return `{ valid: false }` without consuming an attempt. **Exception 1:** on loss (`tryNo=6 && !correct`) the correct word is returned — a single-word reveal, not a list leak. **Exception 2 (post-v1, deliberate):** the solver tool endpoint (`POST /solver`) returns filtered *candidate lists*, capped at 200 words, to keep the SPA lean (raw/10). `[ADOPTED]` ### AD-6 — Feedback scoring on server - **Binds:** server/src/feedback.ts, shared/src/solvers/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 on the server; the client only renders the colors it receives. Note: there are **two** implementations that must stay in sync — `server/src/feedback.ts` (returns a `string[]`) and `shared/src/solvers/feedback.ts` (returns a `'ggggg'` string + `matchesFeedback()`, used by the solvers). `[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 flag, lifetime statistics, and the skill metric. All persist to browser storage. The server never knows what attempt the player is on except what `tryNo` the client sends. The **skill metric is a client-side running average of word difficulty** (starts at 3.0, updated after each completed game). `[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 — weight for each word is `1 / (1 + (difficulty - skill)^2)` (a Gaussian-like falloff). Words near the player's skill are more likely. Uncached words default to difficulty 3.0 until played. 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 npm workspaces: `client/`, `server/`, `shared/`, `solver/`, plus static `data/`. `shared/` exports TypeScript interfaces for API request/response shapes, word-bank structure, and game types. **The solver algorithms also live in `shared/src/solvers/`** so both the server (runtime replays) and the `solver/` CLI (validation) reuse them; `solver/` is an offline validation script, not the home of the algorithms. 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-en`** or **`wordle-state-ru`** (language-suffixed since the RU language was added). The value is a JSON-serialized `PersistedState` interface defined in `shared/src/game.ts`. One hook (`usePersistence`) owns all read/write access; `useGame` consumes it and never touches storage directly. `[ADOPTED]` ### AD-13 — Bilingual EN/RU (post-v1) - **Binds:** server/src/index.ts, client/src/messages/*, data/word-bank*.json - **Prevents:** duplicated client builds per language, diverging EN/RU logic - **Rule:** One server + one SPA serve both languages. Language is derived from the URL path (`/ru…` → Russian, otherwise English) via `detectLang()`. The server mounts every router twice (`/api` for EN, `/ru/api` for RU) against a per-language word bank and per-language solver cache. All UI strings live in `client/src/messages/{en,ru}.ts` behind a structural `Messages` interface; keyboard layouts are per-language (RU uses a Cyrillic layout). `[ADOPTED]` ### AD-14 — Runtime difficulty with lazy solver cache + forked worker (post-v1) - **Binds:** server/src/index.ts, server/src/routes/guess.ts, server/src/solver-pool.ts, server/src/solver-worker.ts, shared/src/solvers/index.ts - **Prevents:** slow startup (pre-computing difficulty for all words), blocking the event loop on the ~14 s entropy run - **Rule:** The cache (`Map`) starts empty. On first request for a word, the server computes replays/difficulty with **3 fast solvers (~20 ms)** synchronously for an immediate response, then launches the **full 4-solver run (including entropy, ~14 s) in a forked child process** and upgrades the cache entry when it completes. `GET /daily` and `POST /play-again` pre-warm the cache the same way. The child process is spawned with `child_process.fork(..., { execArgv: ['--import', 'tsx'] })` — fork rather than `worker_threads` so tsx can load the TypeScript solver modules. Difficulty = average of per-solver attempt counts, a failed solver (>6 attempts) counting as 10, rounded to 2 decimals. `[ADOPTED]` ### AD-15 — Player-facing solver tool (post-v1) - **Binds:** server/src/routes/solver.ts, client/src/screens/ScreenSolver.tsx, client/src/components/SolverBoard.tsx - **Prevents:** shipping the full dictionary to the client, duplicating filtering logic client-side - **Rule:** A solver tool at `/solver` (EN) and `/ru/solver` (RU) lets the player filter the dictionary by entering guess→feedback rows. All filtering runs **server-side** (`POST /solver`); the client sends `{ rows: [{ guess, result }] }` and renders the returned candidates (alphabetical, capped at 200), a suggested next guess, and the top-15 letter frequencies. An empty board returns a best-starting-word hint. `[ADOPTED]` ## Consistency Conventions | Concern | Convention | | --- | --- | | Naming (files, functions, interfaces) | camelCase functions/variables, PascalCase types/interfaces/components, kebab-case files | | Module imports | ES modules with explicit `.js` extensions on relative imports (`./foo.js`), even in TypeScript; `@wordle/shared` for cross-package imports | | 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/request, 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 an error message on request failure and, for daily-word init, falls back to persisted state (offline-tolerant init; no full offline mode) | | Logging | Minimal — no structured logging framework; the solver worker logs failures to stderr | ## Stack | Name | Version | | --- | --- | | TypeScript | ^6.x | | Node.js | ^24 LTS | | React | ^19.x | | Vite | ^7.x | | Express | ^5.x | | tsx | ^4.x (server + solver runtime TS loader) | | `shared/` types package | workspace | ## Structural Seed ### Context diagram ```mermaid graph LR B[Browser
React SPA] -->|REST /api or /ru/api| S[Express Server] S -->|reads at startup| WB[data/word-bank*.json
EN + RU] S -->|lazy compute| C[In-memory solver cache] C -->|full 4-solver run| W[forked child process
solver-worker.ts] S -->|serves static files| B B -->|stores| LS[Browser localStorage
wordle-state-{lang}] SV[Offline solver CLI
solver/] -->|validates| WB ``` ### Source tree ```text wordle/ client/ # React + Vite SPA src/ screens/ # Screen1Rules, Screen2Game, Screen3Results, ScreenSolver components/ # Grid, Keyboard, LangSwitch, SolverBoard hooks/ # useGame, usePersistence messages/ # i18n: index (Messages iface, detectLang), en, ru api.ts # fetch wrappers for /api/* App.tsx # screen state machine + solver route + stats/skill main.tsx index.html server/ # Express REST API (stateless) src/ index.ts # entry, dual /api + /ru/api mounts, static serving routes/ daily.ts # GET /daily guess.ts # POST /guess play-again.ts # POST /play-again validate.ts # GET /validate/:word solver.ts # POST /solver feedback.ts # green/yellow/gray scoring (array form) validate.ts # word bank lookup daily-word.ts # date → wordId function play-again.ts # skill-weighted word selection solver-pool.ts # forked child-process runner solver-worker.ts # child: computeReplays / computeReplaysFull __tests__/ # (empty — no tests yet) package.json shared/ # TypeScript types + solver algorithms (single source of truth) src/ api.ts # request/response interfaces word-bank.ts # WordBank, TargetWord, SolverReplay game.ts # GuessEntry, GameStats, PersistedState index.ts # re-exports solvers/ # feedback, entropy, frequency, vowel-first, greedy, index package.json solver/ # offline CLI: validates all targets solvable in ≤6 src/index.ts data/ # static word banks word-bank.json # EN: guessable + targets { word, hint } word-bank-ru.json # RU package.json # workspace root ``` ## Capability → Architecture Map | Capability / Area | Lives in | Governed by | | --- | --- | --- | | FR-1 First-visit detection | client/src/hooks/usePersistence.ts, client/src/App.tsx | AD-7 | | FR-2 Rules display | client/src/screens/Screen1Rules.tsx | AD-7 | | FR-3 Grid rendering | client/src/components/Grid.tsx | AD-7 | | FR-4 Keyboard | client/src/components/Keyboard.tsx | AD-7, AD-13 | | 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/play-again.ts, server/src/routes/play-again.ts | AD-8 | | FR-12 Player skill tracking | client/src/App.tsx (running average) + usePersistence | AD-7 | | FR-13 Results display | client/src/screens/Screen3Results.tsx | AD-7 | | FR-14 Statistics display | client/src/screens/Screen3Results.tsx | AD-7 | | FR-15 Play Again button | client/src/screens/Screen3Results.tsx | AD-7 | | FR-16 Multi-algorithm solver | shared/src/solvers/ | AD-3, AD-9 | | FR-17 Word ranking output | shared/src/solvers/index.ts (runtime difficulty) | AD-4, AD-14 | | FR-18 Word bank curation | data/word-bank*.json (generated by raw/ scripts) | AD-4 | | FR-19 Browser persistence | client/src/hooks/usePersistence.ts | AD-7, AD-12 | ## Deferred / Resolved **Resolved since v1 (moved from "deferred" into ADs or implemented):** - **Solver algorithm selection** — four algorithms chosen: entropy, frequency, vowel-first, greedy (no minimax). See AD-9/AD-14. - **Skill metric formula** — running average of per-game difficulty, client-side (AD-7). - **Word bank source** — frequency-sorted word lists generated by scripts in `raw/` (see raw/03, raw/07). - **Hints on attempt 5** — implemented as synonym hints (raw/06, AD-4). - **npm workspace configuration** — npm workspaces in place (AD-9). - **Screen navigation/routing** — state-based switching plus a pathname check in `App.tsx` for `/solver` routes; no router library. **Still deferred:** - **Deployment mechanism** — how the server process is started, restarted, proxied (nginx, systemd). Owned by the deployment environment, not the architecture. (A `rsync` deploy script exists in the root `package.json`.) - **HTTPS/TLS termination** — owned by the deployment environment (reverse proxy). - **Optimistic UI** — v1 accepts a network round-trip per guess; showing letters immediately is a future enhancement. - **Accessibility (color-blind mode, ARIA, screen reader)** — post-v1. Core game uses green/yellow color distinction. - **Win celebration animation** — UI detail owned by the Results Screen component. - **Automated tests** — `server/src/__tests__/` exists but is empty; no test suite has been written yet.