ARCHITECTURE-SPINE.md 18 KB


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<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]

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

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

Source tree

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.