An information-theoretic Wordle solver. For every legal guess it computes the expected information gain (in bits) and recommends the word that, on average, eliminates the most possibilities.
Built with Next.js 16, React 19 and a Web Worker so the UI stays at 60 fps while thousands of words are scored.
Recorded against Wordle #1826 (June 19, 2026).
Enter the guesses you have already played and the colors Wordle gave back; the solver replies with a ranked list of next words. Each suggestion shows its Shannon entropy (how much it is expected to narrow the search) and the expected number of words remaining after playing it.
The whole thing runs client-side: the word lists (a 12,972-word dictionary, referred to as "~13k" throughout) are bundled into the app and all scoring happens in a Web Worker, so there is no backend and no network round-trip during a game.
- 🧠 Information-theory ranking — expected information gain (bits) for every candidate, with a deterministic tie-break that prefers words which can still be the answer.
- ⚡ Off-main-thread compute — filtering and entropy scoring run in a Web Worker; the main thread never iterates the ~13k-word dictionary, keeping input responsive.
- 🔬 Allocation-free hot loop — words are encoded into typed arrays and each
guess is scored in a single pass (no per-call
split/Map/object churn). - 🧪 Tested core — the solving pipeline is pure, shared between the worker and the test suite, and covered by Vitest (incl. an end-to-end "solve real words" golden test).
- 🎨 Design-handoff UI — a high-fidelity, light-themed interface (Libre Franklin, exact Wordle tile palette) built on Tailwind CSS v4, with a clickable board and an inline add-a-move flow.
| Concern | Choice |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) |
| UI library | React 19.2 + React Compiler |
| Language | TypeScript 5.9 (strict) |
| Styling | Tailwind CSS v4 |
| Components | Radix UI primitives (shadcn-style) |
| Icons | Lucide |
| Concurrency | Web Workers API (module worker) |
| Testing | Vitest |
- Node.js 20.9+ (required by Next.js 16)
- pnpm (recommended)
git clone https://github.com/P4ST4S/next-wordle-bot.git
cd next-wordle-bot
pnpm install
pnpm dev # http://localhost:3000| Command | Description |
|---|---|
pnpm dev |
Start the dev server (Turbopack) |
pnpm build |
Production build |
pnpm start |
Serve the production build |
pnpm test |
Run the Vitest suite once |
pnpm test:watch |
Run Vitest in watch mode |
pnpm lint |
Lint with ESLint |
Every guess produces one of 3⁵ = 243 color patterns (each tile is gray / yellow / green). The solver treats "which pattern will I see?" as a random variable and picks the guess whose answer is least predictable — i.e. the one that splits the remaining candidates most evenly.
For a guess w against the set of remaining answers, it groups the answers by the
pattern they would produce and computes the Shannon entropy of that distribution:
where
Pipeline per turn (see lib/logic/solver.ts):
- Filter — reduce the dictionary to words consistent with every clue so far (handles duplicate letters and exact counts correctly).
- Pick candidates — the remaining words, plus a few extra dictionary probes when the set is small.
- Score — entropy + expected-remaining for each candidate in one typed-array pass. (If thousands of words still match, a fast letter-frequency heuristic is used to stay interactive.)
- Rank — sort by entropy, breaking ties toward words that could win outright.
Strategy note: this is a greedy, depth-1 entropy maximizer — strong and fast, but not a provably optimal multi-step solver. A pathological double-letter word like
jazzymay take an extra guess. Seedocs/ARCHITECTURE.mdfor the trade-offs.
├── app/ # Next.js App Router (single client page + layout)
├── components/
│ ├── solver/ # Header, board, add-a-move, suggestions, stats
│ └── ui/ # Reusable primitives (button, card, table, …)
├── hooks/ # useWordleSolver (orchestrator), useGameState, useWorker
├── lib/
│ ├── logic/ # Pure solving logic + Vitest specs (*.test.ts)
│ ├── data/ # Bundled word lists (imported, not fetched)
│ └── types/ # Shared TypeScript types
└── workers/ # Web Worker that runs the shared solver logic
The solving logic lives in
lib/logic/solver.tsand is imported by both the Web Worker and the tests — there is no duplicated logic between threads.
The UI thread never does heavy work. Its only jobs are rendering and posting a tiny message to the worker; all of the expensive work — filtering ~13k words and scoring entropy over 243 patterns — happens on a separate thread. Because the main thread is never blocked, the browser keeps hitting its 16.7 ms frame budget, so animations and input stay at a constant 60 fps even mid-calculation.
Measured, not asserted: the worker-side
solve()runs in ~0.7 ms after the first guess (≈39 candidates) and stays sub-millisecond as the set shrinks; a full round-trip includingpostMessageand re-render reports ~5 ms in the browser. No long task (>50 ms) is registered on the main thread, so the frame loop is never starved.
flowchart LR
subgraph MT["🖥️ Main thread — render only, never blocked → 60 fps"]
direction TB
UI["React UI<br/>(board · add-a-move · suggestions)"]
ORCH["useWordleSolver<br/>orchestrator hook"]
UI -->|"add guess (clues)"| ORCH
ORCH -->|"derived state<br/>(suggestions, remaining, time)"| UI
end
subgraph WK["⚙️ Web Worker — owns the heavy compute"]
direction TB
SOLVE["solve(guesses)"]
FILTER["1. filter dictionary<br/>→ remaining words"]
SCORE["2. score entropy<br/>(single typed-array pass)"]
RANK["3. rank + tie-break"]
SOLVE --> FILTER --> SCORE --> RANK
end
DICT[("📚 Word lists<br/>bundled & imported<br/>once at worker startup")]
ORCH -->|"postMessage:<br/>{ guesses } — a few bytes"| SOLVE
RANK -->|"postMessage:<br/>{ suggestions, remainingCount }"| ORCH
SCORE -.->|"PROGRESS events<br/>(every 100 words)"| ORCH
DICT -.->|"import (no transfer<br/>per request)"| WK
classDef maincls fill:#1e293b,stroke:#38bdf8,color:#e2e8f0;
classDef workcls fill:#14532d,stroke:#4ade80,color:#dcfce7;
classDef datacls fill:#422006,stroke:#f59e0b,color:#fef3c7;
class UI,ORCH maincls;
class SOLVE,FILTER,SCORE,RANK workcls;
class DICT datacls;
Why it stays smooth — three deliberate decisions:
- All compute is off-main-thread.
useGameStateno longer filters the dictionary on the UI thread; the worker is the single source of truth for the remaining-word count and suggestions. The main thread does O(1) work per guess. - The dictionary never crosses the wire per request. It is
import-ed into the worker once at startup (a one-time bundle cost, not free — the trade is initial weight for synchronous availability and no runtimefetch), so asolvecall ships only the handful of guesses (a few bytes) instead of structured-cloning ~150 KB of strings every turn. - The hot loop is allocation-free. Words are encoded into a flat
Uint8Arrayand each guess is scored in one pass with a reusableInt32Array(243)— nosplit,Map, or object allocation per comparison — so the worker finishes in single-digit milliseconds and rarely needs to report progress at all.
A full deep dive — data flow, threading model, the entropy math and its
typed-array implementation, performance trade-offs, and the testing strategy —
lives in docs/ARCHITECTURE.md.
pnpm testThe pure logic is covered by Vitest:
- Pattern matching — base-3 encoding, duplicate-letter edge cases.
- Constraint filtering — min/exact letter counts, wrong-position rules.
- Entropy — the typed-array scorer is cross-checked against a reference implementation for bit-for-bit agreement.
- Golden / end-to-end — full games are played against real answers to pin the solver's behavior and guard against regressions.
MIT — see LICENSE.
Built with ❤️ by Antoine Rospars
