---
stepsCompleted: [1, 2, 3, 4]
inputDocuments:
  - prds/prd-wordle-2026-07-07/prd.md
  - architecture/architecture-wordle-2026-07-07/ARCHITECTURE-SPINE.md
---

# Wordle Clone - Epic Breakdown

## Overview

This document provides the complete epic and story breakdown for the Wordle Clone, decomposing the requirements from the PRD and Architecture into implementable stories.

## Requirements Inventory

### Functional Requirements

FR-1: The system can detect whether the player has previously visited and show or skip the rules screen accordingly.
FR-2: The system can display the game rules: 5-letter words, 6 attempts allowed, green/yellow/gray feedback meaning, and a Play button to proceed.
FR-3: The system can render a 5-column × 6-row grid. Each cell displays one guessed letter with its feedback color after submission.
FR-4: The system can render an on-screen QWERTY keyboard. After each guess, keys update to reflect the best feedback for that letter across all guesses.
FR-5: The player can type a 5-letter word via physical keyboard or on-screen keyboard and submit it.
FR-6: The system can validate the guess against the word bank. If the guess is not in the bank, display "Unknown word," allow backspace to clear the row, and do not consume the attempt.
FR-7: The system can apply the Feedback Scoring Rule to each guess: greens first (exact position matches), then yellows allocated to remaining letter count in the word, excess instances gray.
FR-8: The system can detect when all 5 letters are green (correct guess) and transition to Screen 3 with a congratulations message.
FR-9: The system can detect when the 6th attempt is exhausted without a correct guess and transition to Screen 3 with the correct word revealed.
FR-10: The system can select and serve the same target word to all players for a given calendar day. Word changes at midnight EST/EDT.
FR-11: The system can generate a new word after the daily word is completed, selected randomly from the word bank with weighting based on the player's skill level.
FR-12: The system can compute and store a player skill metric derived from past game performance.
FR-13: The system can show the game outcome: congratulations with attempt count (win) or correct word revealed (loss).
FR-14: The system can show lifetime statistics: games played, win rate (%), attempt-count histogram (1-6 + fail), current streak, max streak.
FR-15: The system can display a Play Again button on Screen 3 that triggers a new game.
FR-16: The system can run at least two distinct solving algorithms against every word in the target word bank and record the number of attempts each algorithm required.
FR-17: The system can produce a difficulty score for each word derived from solver attempt data, stored in a format consumable by the game.
FR-18: The system can load a word bank of valid 5-letter English words, filtered for recent internet usage frequency (last ~5 years). Distinguishes guessable words from target words.
FR-19: The system can persist player state (rules-seen cookie, lifetime statistics, skill metric) to browser storage and restore it on subsequent visits.

### NonFunctional Requirements

NFR-1: Responsive design — the game must be usable on viewport widths from 375px (small phone) through desktop, via CSS media queries.
NFR-2: Feedback latency — guess validation round-trip should complete within acceptable web-app responsiveness. No optimistic UI required for v1.
NFR-3: Persistence — statistics, rules cookie, and skill metric must survive browser close and restore on next visit.
NFR-4: Stateless server — the server stores no session state between requests. All context is carried in the request.
NFR-5: Wire format — API feedback colors use single-character codes: 'g' (green), 'y' (yellow), 'x' (gray).
NFR-6: TypeScript — all code (client, server, solver) is TypeScript with shared types in a `shared/` package.
NFR-7: Word bank confidentiality — the guessable word list never leaves the server. Only single-word reveal on game over.

### Additional Requirements

- **Project structure:** Monorepo with `client/` (React + Vite), `server/` (Express), `solver/` (offline script), `shared/` (TypeScript types), `data/` (word bank JSON)
- **Starter template:** React + Vite + TypeScript for client; Node.js + Express + TypeScript for server. Vite 8.x, React 19.x, Node 24 LTS, Express 5.x, TypeScript 6.x
- **API endpoints:** GET /api/daily → {wordId}, POST /api/guess → {valid, colors?, correct?, word?}, POST /api/play-again → {wordId}. All contracts defined as TypeScript interfaces in `shared/src/api.ts` before implementation (AD-10)
- **No database:** Word bank is a static JSON file (`data/word-bank.json`). Shape: `{ guessable: string[], targets: { id, word, difficulty }[] }` (AD-4)
- **Daily word:** Deterministic — `(daysSinceEpoch(EST/EDT) mod targetCount) + 1` (AD-2)
- **Play Again:** Weighted random selection around player skill level (AD-8)
- **Persistence:** Single localStorage key `wordle-state` with typed `PersistedState` interface (AD-12)
- **Deployment:** Home Linux server, internet-exposed. Express serves both API and static SPA build
- **Solver:** Standalone TypeScript script, runs offline, writes difficulty scores into `data/word-bank.json`

### UX Design Requirements

No UX design document provided. UX requirements are derived from PRD user journeys:
- Three-screen flow: Rules (Screen 1) → Game (Screen 2) → Results (Screen 3)
- CSS media queries for mobile/desktop responsive layout
- Color feedback uses green/yellow/dark-gray backgrounds on grid cells and keyboard keys
- Invalid word: "Unknown word" message displayed on Screen 2
- Win: brief celebration on Screen 3 (visual feedback TBD in implementation)
- Loss: correct word revealed on Screen 3

### FR Coverage Map

| FR | Epic | Description |
|---|---|---|
| FR-1 | Epic 1 | First-visit detection |
| FR-2 | Epic 1 | Rules display |
| FR-3 | Epic 1 | Grid rendering |
| FR-4 | Epic 1 | Keyboard rendering and highlighting |
| FR-5 | Epic 1 | Guess submission |
| FR-6 | Epic 1 | Invalid word rejection |
| FR-7 | Epic 1 | Feedback scoring |
| FR-8 | Epic 1 | Win detection |
| FR-9 | Epic 1 | Loss detection |
| FR-10 | Epic 1 | Daily word selection |
| FR-11 | Epic 1 | Play Again word selection |
| FR-12 | Epic 1 | Player skill tracking |
| FR-13 | Epic 1 | Results display |
| FR-14 | Epic 1 | Statistics display |
| FR-15 | Epic 1 | Play Again button |
| FR-18 | Epic 1 | Word bank curation |
| FR-19 | Epic 1 | Browser persistence |
| FR-16 | Epic 2 | Multi-algorithm solver |
| FR-17 | Epic 2 | Word ranking output |

## Epic List

### Epic 1: Play the Game
The player can play the complete Wordle clone: open the browser, see rules (once), guess the daily word with green/yellow/gray feedback, view results with lifetime statistics, and play again with skill-appropriate words. End-to-end: React SPA client + Express REST API + static word bank + browser persistence.

**FRs covered:** FR-1, FR-2, FR-3, FR-4, FR-5, FR-6, FR-7, FR-8, FR-9, FR-10, FR-11, FR-12, FR-13, FR-14, FR-15, FR-18, FR-19

### Story 1.1: Project Foundation & Daily Word Setup

As a player,
I want to open the game in my browser and see the game board ready to play,
So that I can start playing without friction.

**Acceptance Criteria:**

**Given** I am a first-time visitor with no prior cookie,
**When** I open the game URL,
**Then** Screen 1 displays: game rules (5 letters, 6 attempts, green/yellow/gray meaning) and a Play button.
**And** tapping Play navigates to Screen 2 with an empty 5×6 grid and QWERTY keyboard in light gray.

**Given** I am a returning visitor with a prior-visit cookie and no game in progress,
**When** I open the game URL,
**Then** I land directly on Screen 2 (game board) without seeing the rules screen.

**Given** I am a returning visitor who previously closed the browser mid-game,
**When** I open the game URL,
**Then** my in-progress game state (word ID, guesses so far with their colors, attempt number) is restored from localStorage.
**And** I continue from where I left off.

**Given** the server has a word bank loaded,
**When** the client calls GET /api/daily,
**Then** the response contains `{ wordId: number }` where wordId is the same for all players on the same calendar day (EST/EDT).

**Given** the project is initialized,
**When** I run the client and server,
**Then** the monorepo structure matches: `client/` (React+Vite), `server/` (Express), `shared/` (TypeScript types), `data/` (word-bank.json).
**And** `shared/src/api.ts` defines concrete TypeScript interfaces for GET /api/daily request/response shapes.
**And** the client renders a responsive layout usable at 375px (phone) through desktop widths.

### Story 1.2: Guess Validation & Feedback

As a player,
I want to type 5-letter guesses and see color feedback on each letter,
So that I can deduce the target word within 6 attempts, and resume if I close the browser.

**Acceptance Criteria:**

**Given** I am on Screen 2 with a daily word loaded,
**When** I type a valid 5-letter word and submit (Enter or on-screen),
**Then** the client sends POST /api/guess `{ wordId, guess, tryNo }` to the server.
**And** the server validates the guess against the word bank.
**And** the server returns `{ valid: true, colors: ['x','y','g','x','x'], correct: false }` with 5 single-char codes.
**And** the grid row fills with the guess letters and each cell shows the corresponding background color (green='g', yellow='y', dark gray='x').
**And** the keyboard updates with each key's best feedback color across all guesses.

**Given** I type a word not in the guessable word bank,
**When** I submit,
**Then** the server returns `{ valid: false }`.
**And** the client displays "Unknown word" message.
**And** I can backspace all letters on the current row.
**And** the attempt counter does not increment.

**Given** I make a guess and receive feedback,
**When** the guess response arrives from the server,
**Then** the current game state is saved to localStorage: `{ wordId, guesses: [{ guess, colors }] }`.
**And** an empty or absent guesses array means no game in progress.
**And** game status is derived: last guess all-green → won; 6 guesses without all-green → lost; else playing.

**Given** I submit a guess where all 5 letters match the target word in the correct positions,
**When** the response has `correct: true, difficulty: 3.2`,
**Then** the client transitions to Screen 3 showing "Congratulations!" and "Solved in N attempts."
**And** lifetime statistics update: games played incremented, win recorded, streak incremented, attempt-count histogram updated.
**And** the in-progress game is cleared from localStorage.

**Given** I submit a 6th guess that is not correct,
**When** the server responds with `correct: false, word: "crane", difficulty: 4.1`,
**Then** the client transitions to Screen 3 showing "Better luck next time" and "The word was: crane."
**And** lifetime statistics update: games played incremented, loss recorded, streak reset.
**And** the in-progress game is cleared from localStorage.

**Given** I close the browser and reopen,
**When** I return to the game,
**Then** my lifetime statistics (games played, win rate, histogram, streak) are restored from localStorage key `wordle-state`.
**And** my rules-seen flag is restored.

**Given** I am on Screen 3,
**When** I view the statistics,
**Then** I see: games played, win rate percentage, attempt-count histogram (1 through 6 + fail), current streak, max streak.

### Story 1.3: Play Again & Skill Tracking

As a player,
I want to play additional rounds after the daily word, with words appropriate to my skill level,
So that I can keep playing and the challenge adapts to me.

**Acceptance Criteria:**

**Given** I am on Screen 3 after completing or failing a game,
**When** I view the screen,
**Then** a Play Again button is visible.

**Given** I tap Play Again,
**When** the client sends POST /api/play-again `{ skillMetric: number }`,
**Then** the server selects a word from the target bank using weighted random selection (words near the player's skill level are more likely).
**And** the response contains `{ wordId: number }`.
**And** the client navigates to Screen 2 with an empty grid for the new word.

**Given** I complete any game (win or loss),
**When** the final guess response includes `difficulty: number`,
**Then** the client uses this difficulty value to update statistics.
**And** the client recalculates the skill metric from lifetime statistics (average attempts weighted by word difficulty).
**And** the updated skill metric is persisted to localStorage.

**Given** I tap Play Again repeatedly,
**When** I play multiple Play Again rounds,
**Then** each request sends the latest skill metric for word selection.
**And** no repeat-word prevention is applied (target set is large enough).

**Given** the server has no solver-ranked word bank yet (Epic 2 not complete),
**When** Play Again selects a word,
**Then** all target words have a default neutral difficulty score (e.g., 3.0), so selection is effectively random pending solver data.

### Epic 2: Solver Engine & Word Ranking
Computer solver pre-computes attempt counts for every word using multiple algorithms and produces objective difficulty scores. Replaces manual/default difficulty estimates with data-driven rankings that improve Play Again word selection.

**FRs covered:** FR-16, FR-17

### Story 2.1: Solver Script & Word Ranking

As a developer,
I want a solver script that pre-computes attempt counts for every target word using multiple algorithms,
So that word difficulty scores are data-driven, not manual estimates.

**Acceptance Criteria:**

**Given** the solver script is run against the word bank,
**When** it processes each target word with at least two solving algorithms,
**Then** it records per-algorithm attempt counts for each word.
**And** it computes an aggregated difficulty score per word (e.g., average attempts across algorithms).
**And** it writes the updated `data/word-bank.json` with `difficulty` fields populated.

**Given** the solver output exists,
**When** the server loads `data/word-bank.json` at startup,
**Then** Play Again word selection uses real difficulty scores instead of default neutral values.

**Given** the solver is implemented in TypeScript,
**When** it imports word bank types from `shared/`,
**Then** it uses the same `WordBank` and `TargetWord` interfaces as the server.

**Given** a word is not solvable within 6 attempts by any algorithm,
**When** the solver processes it,
**Then** that word is flagged or removed from the target set.
