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:
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.
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.
[ADOPTED]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]shared/ is the single source of truth for API contracts, word-bank shape, game types, and the solver algorithms. [ADOPTED]{ guessable: string[], targets: { word: string, hint?: string }[] }.
id field — removed in raw/08).hint is an optional synonym shown after the 5th unsuccessful guess (raw/06).
The solver does not write difficulty back into data/. [ADOPTED]{ 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]server/src/feedback.ts (returns a string[]) and shared/src/solvers/feedback.ts (returns a 'ggggg' string + matchesFeedback(), used by the solvers). [ADOPTED]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]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]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]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]'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]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]/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]Map<wordId, CachedResult>) 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]/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]| 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 |
| 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 |
graph LR
B[Browser<br/>React SPA] -->|REST /api or /ru/api| S[Express Server]
S -->|reads at startup| WB[data/word-bank*.json<br/>EN + RU]
S -->|lazy compute| C[In-memory solver cache]
C -->|full 4-solver run| W[forked child process<br/>solver-worker.ts]
S -->|serves static files| B
B -->|stores| LS[Browser localStorage<br/>wordle-state-{lang}]
SV[Offline solver CLI<br/>solver/] -->|validates| WB
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 / 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 |
Resolved since v1 (moved from "deferred" into ADs or implemented):
raw/ (see raw/03, raw/07).App.tsx for /solver routes; no router library.Still deferred:
rsync deploy script exists in the root package.json.)server/src/__tests__/ exists but is empty; no test suite has been written yet.