Perfect Wordle - Computes the Perfect World Moves.
import React, { useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
type WordEntry = {
text: string;
chars: Uint8Array; // 0..25
};
type Move = {
move: number;
guess: string;
pattern: number; // base-3 encoded 5 digits: 0=gray,1=yellow,2=green
candidatesBefore: number;
candidatesAfter: number;
evaluatedGuesses: number;
bestExpectedRemaining: number;
bestEntropyBits: number;
ms: number;
};
const POW3 = [1, 3, 9, 27, 81] as const;
const PATTERN_COUNT = 3 ** 5; // 243
type WordLists = {
words: WordEntry[];
wordTextSet: Set<string>;
};
function lowerBoundWordText(words: WordEntry[], needle: string): number {
let lo = 0;
let hi = words.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (words[mid].text < needle) lo = mid + 1;
else hi = mid;
}
return lo;
}
function prefixSuggestions(words: WordEntry[], prefix: string, limit: number): string[] {
if (!prefix) return [];
const start = lowerBoundWordText(words, prefix);
const out: string[] = [];
for (let i = start; i < words.length && out.length < limit; i++) {
const t = words[i].text;
if (!t.startsWith(prefix)) break;
out.push(t);
}
return out;
}
function mulberry32(seed: number) {
return function () {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function randomSeedU32(): number {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] >>> 0;
}
function toChars5(word: string): Uint8Array | null {
if (!/^[a-z]{5}$/.test(word)) return null;
const a = new Uint8Array(5);
for (let i = 0; i < 5; i++) a[i] = word.charCodeAt(i) - 97;
return a;
}
function parseWordTokens(raw: string): string[] {
if (!raw) return [];
const tokens = raw.split(/\s+/g).map((s) => s.trim().toLowerCase());
const unique = new Set<string>();
for (const t of tokens) {
if (!t) continue;
if (!/^[a-z]{5}$/.test(t)) continue;
unique.add(t);
}
return Array.from(unique).sort();
}
function feedbackPattern(guess: Uint8Array, answer: Uint8Array): number {
const status = new Uint8Array(5);
const counts = new Uint8Array(26);
for (let i = 0; i < 5; i++) {
if (guess[i] === answer[i]) status[i] = 2;
else counts[answer[i]]++;
}
for (let i = 0; i < 5; i++) {
if (status[i] !== 0) continue;
if (counts[guess[i]] > 0) {
status[i] = 1;
counts[guess[i]]--;
}
}
let code = 0;
for (let i = 0; i < 5; i++) code += status[i] * POW3[i];
return code;
}
function decodePattern(pattern: number): (0 | 1 | 2)[] {
const out: (0 | 1 | 2)[] = [0, 0, 0, 0, 0];
for (let i = 0; i < 5; i++) {
out[i] = (Math.floor(pattern / POW3[i]) % 3) as 0 | 1 | 2;
}
return out;
}
function entropyBitsFromCounts(counts: Int32Array, total: number): number {
let h = 0;
for (let i = 0; i < counts.length; i++) {
const c = counts[i];
if (c <= 0) continue;
const p = c / total;
h -= p * Math.log2(p);
}
return h;
}
function buildHeuristicGuessSet(all: WordEntry[], candidateIdxs: number[], maxGuesses: number): number[] {
const pos = Array.from({ length: 5 }, () => new Float64Array(26));
const overall = new Float64Array(26);
for (const idx of candidateIdxs) {
const w = all[idx].chars;
for (let i = 0; i < 5; i++) {
const ch = w[i];
pos[i][ch] += 1;
overall[ch] += 1;
}
}
const denom = candidateIdxs.length || 1;
for (let i = 0; i < 5; i++) for (let c = 0; c < 26; c++) pos[i][c] /= denom;
for (let c = 0; c < 26; c++) overall[c] /= denom;
const scored: Array<{ idx: number; score: number }> = [];
for (let i = 0; i < all.length; i++) {
const w = all[i].chars;
const seen = new Uint8Array(26);
let uniqBonus = 0;
for (let k = 0; k < 5; k++) {
if (seen[w[k]] === 0) {
seen[w[k]] = 1;
uniqBonus += 0.02;
}
}
let s = 0;
for (let k = 0; k < 5; k++) s += pos[k][w[k]] * 1.1 + overall[w[k]] * 0.6;
s += uniqBonus;
scored.push({ idx: i, score: s });
}
scored.sort((a, b) => b.score - a.score);
const out: number[] = [];
const picked = new Set<number>();
if (candidateIdxs.length <= Math.min(maxGuesses / 2, 400)) {
for (const idx of candidateIdxs) {
picked.add(idx);
out.push(idx);
}
}
for (let i = 0; i < scored.length && out.length < maxGuesses; i++) {
if (!picked.has(scored[i].idx)) {
picked.add(scored[i].idx);
out.push(scored[i].idx);
}
}
return out;
}
function chooseBestGuessExact(
all: WordEntry[],
candidateIdxs: number[],
guessIdxsToEvaluate: number[]
): { guessIdx: number; expectedRemaining: number; entropyBits: number } {
const n = candidateIdxs.length;
if (n <= 0) return { guessIdx: guessIdxsToEvaluate[0] ?? 0, expectedRemaining: 0, entropyBits: 0 };
if (n === 1) return { guessIdx: candidateIdxs[0], expectedRemaining: 1, entropyBits: 0 };
const counts = new Int32Array(PATTERN_COUNT);
let bestIdx = guessIdxsToEvaluate[0] ?? candidateIdxs[0];
let bestExpected = Number.POSITIVE_INFINITY;
let bestEntropy = -1;
for (const gIdx of guessIdxsToEvaluate) {
counts.fill(0);
const g = all[gIdx].chars;
for (const aIdx of candidateIdxs) {
counts[feedbackPattern(g, all[aIdx].chars)]++;
}
let sumSq = 0;
for (let i = 0; i < counts.length; i++) {
if (counts[i] !== 0) sumSq += counts[i] * counts[i];
}
const expected = sumSq / n;
const h = entropyBitsFromCounts(counts, n);
if (expected < bestExpected - 1e-12) {
bestExpected = expected;
bestEntropy = h;
bestIdx = gIdx;
} else if (Math.abs(expected - bestExpected) <= 1e-12) {
if (h > bestEntropy + 1e-12) {
bestEntropy = h;
bestIdx = gIdx;
} else if (Math.abs(h - bestEntropy) <= 1e-12) {
if (all[gIdx].text < all[bestIdx].text) bestIdx = gIdx;
}
}
}
return { guessIdx: bestIdx, expectedRemaining: bestExpected, entropyBits: bestEntropy };
}
function filterCandidatesByPattern(
all: WordEntry[],
candidateIdxs: number[],
guessIdx: number,
observedPattern: number
): number[] {
const g = all[guessIdx].chars;
const out: number[] = [];
for (const aIdx of candidateIdxs) {
if (feedbackPattern(g, all[aIdx].chars) === observedPattern) out.push(aIdx);
}
return out;
}
function formatNumber(n: number, digits = 2) {
if (!Number.isFinite(n)) return "—";
return n.toFixed(digits);
}
function PatternTiles({ guess, pattern }: { guess: string; pattern: number }) {
const statuses = decodePattern(pattern);
return (
<div className="tiles" role="img" aria-label={`Feedback for ${guess}`}>
{guess.split("").map((ch, i) => {
const s = statuses[i];
const cls = s === 2 ? "tile tile--g" : s === 1 ? "tile tile--y" : "tile tile--x";
return (
<div key={i} className={cls} aria-hidden="true">
{ch.toUpperCase()}
</div>
);
})}
</div>
);
}
function App() {
const lists = useMemo<WordLists>(() => {
const tokens = parseWordTokens(tb.data(0));
const words: WordEntry[] = tokens.map((t) => ({ text: t, chars: toChars5(t)! }));
const wordTextSet = new Set(tokens);
return { words, wordTextSet };
}, []);
const { words, wordTextSet } = lists;
const wordToIdx = useMemo(() => {
const m = new Map<string, number>();
for (let i = 0; i < words.length; i++) m.set(words[i].text, i);
return m;
}, [words]);
const [rng] = useState(() => mulberry32(randomSeedU32()));
const [secretIdx, setSecretIdx] = useState<number | null>(null);
const [moves, setMoves] = useState<Move[]>([]);
const [running, setRunning] = useState(false);
const [secretDraft, setSecretDraft] = useState("");
const [secretError, setSecretError] = useState<string | null>(null);
const [suggestOpen, setSuggestOpen] = useState(false);
const [suggestHi, setSuggestHi] = useState(0);
const normalizedDraft = secretDraft.trim().toLowerCase();
const looksLikeWord = normalizedDraft.length === 5 && /^[a-z]{5}$/.test(normalizedDraft);
const draftIdx = wordToIdx.get(normalizedDraft);
const isValidWord = wordTextSet.has(normalizedDraft);
const suggestions = useMemo(() => {
const p = /^[a-z]{1,5}$/.test(normalizedDraft) ? normalizedDraft : "";
return prefixSuggestions(words, p, 14);
}, [normalizedDraft, words]);
useEffect(() => {
setSuggestHi(0);
}, [normalizedDraft]);
useEffect(() => {
if (words.length === 0 || running || draftIdx === undefined || !isValidWord || draftIdx === secretIdx) return;
const t = window.setTimeout(() => setSecretByIdx(draftIdx), 450);
return () => window.clearTimeout(t);
}, [draftIdx, isValidWord, running, secretIdx, words.length]);
useEffect(() => {
if (!looksLikeWord || isValidWord) {
setSecretError(null);
return;
}
const t = window.setTimeout(() => setSecretError("Not found in dictionary."), 450);
return () => window.clearTimeout(t);
}, [isValidWord, looksLikeWord]);
async function runPerfectSolve(nextSecretIdx: number) {
setRunning(true);
setMoves([]);
await new Promise((r) => setTimeout(r, 0));
let candidateIdxs = Array.from({ length: words.length }, (_, i) => i);
const localMoves: Move[] = [];
for (let move = 1; move <= 6; move++) {
const t0 = performance.now();
const n = candidateIdxs.length;
if (n <= 0) break;
const guessIdxsToEvaluate = n <= 900 ? words.map((_, i) => i) : buildHeuristicGuessSet(words, candidateIdxs, 260);
const best = chooseBestGuessExact(words, candidateIdxs, guessIdxsToEvaluate);
const guessIdx = best.guessIdx;
const pattern = feedbackPattern(words[guessIdx].chars, words[nextSecretIdx].chars);
const nextCandidates = filterCandidatesByPattern(words, candidateIdxs, guessIdx, pattern);
localMoves.push({
move,
guess: words[guessIdx].text,
pattern,
candidatesBefore: n,
candidatesAfter: nextCandidates.length,
evaluatedGuesses: guessIdxsToEvaluate.length,
bestExpectedRemaining: best.expectedRemaining,
bestEntropyBits: best.entropyBits,
ms: performance.now() - t0,
});
candidateIdxs = nextCandidates;
if (guessIdx === nextSecretIdx) break;
await new Promise((r) => setTimeout(r, 0));
}
setMoves(localMoves);
setRunning(false);
}
function setSecretByIdx(idx: number) {
setSecretIdx(idx);
setSecretDraft(words[idx]?.text ?? "");
setSecretError(null);
setSuggestOpen(false);
void runPerfectSolve(idx);
}
function pickNewSecret() {
if (words.length > 0) {
setSecretByIdx(Math.floor(rng() * words.length));
}
}
useEffect(() => {
if (words.length > 0 && secretIdx === null) pickNewSecret();
}, [words.length]);
const secret = secretIdx === null ? null : words[secretIdx]?.text ?? null;
return (
<div className="app">
<header className="header">
<div>
<h1 className="title">Perfect Wordle</h1>
<p className="subtitle">
Not a game: this simulates what a statistics-driven “perfect” solver would guess, move by move, for a
randomly chosen secret word.
</p>
</div>
<div className="actions">
<div className="typeahead">
<div className="fieldLabel">Secret word</div>
<div className="typeaheadRow">
<input
className="field mono"
value={secretDraft}
placeholder="Type a word…"
spellCheck={false}
aria-label="Secret word"
disabled={running || words.length === 0}
onChange={(e) => {
setSecretDraft(e.target.value);
setSuggestOpen(true);
}}
onFocus={() => setSuggestOpen(true)}
onBlur={() => window.setTimeout(() => setSuggestOpen(false), 120)}
onKeyDown={(e) => {
if (e.key === "Escape") setSuggestOpen(false);
else if (e.key === "ArrowDown") {
if (!suggestOpen) setSuggestOpen(true);
if (suggestions.length) setSuggestHi((v) => Math.min(v + 1, suggestions.length - 1));
e.preventDefault();
} else if (e.key === "ArrowUp") {
if (suggestions.length) setSuggestHi((v) => Math.max(v - 1, 0));
e.preventDefault();
} else if (e.key === "Enter") {
if (suggestOpen && suggestions.length && suggestHi >= 0 && suggestHi < suggestions.length) {
const picked = suggestions[suggestHi];
setSecretDraft(picked);
const idx = wordToIdx.get(picked);
if (idx !== undefined) setSecretByIdx(idx);
} else if (draftIdx !== undefined && isValidWord) {
setSecretByIdx(draftIdx);
} else if (looksLikeWord) {
setSecretError("Not found in dictionary.");
}
e.preventDefault();
}
}}
/>
<button
className="btn btn--icon"
onClick={pickNewSecret}
disabled={running || words.length === 0}
title="New random word"
>
⟳
</button>
</div>
{secretError ? <div className="errorText">{secretError}</div> : null}
{suggestOpen && suggestions.length > 0 ? (
<div className="suggest" role="listbox">
{suggestions.map((s, i) => (
<button
key={s}
className={i === suggestHi ? "suggestItem suggestItem--active mono" : "suggestItem mono"}
onMouseEnter={() => setSuggestHi(i)}
onMouseDown={(e) => {
e.preventDefault();
setSecretDraft(s);
const idx = wordToIdx.get(s);
if (idx !== undefined) setSecretByIdx(idx);
}}
>
{s.toUpperCase()}
</button>
))}
</div>
) : null}
</div>
</div>
</header>
<section className="panel">
<div className="metaGrid">
<div className="metaItem">
<div className="metaLabel">Dictionary size</div>
<div className="metaValue">{words.length.toLocaleString()}</div>
</div>
<div className="metaItem">
<div className="metaLabel">Secret word</div>
<div className="metaValue mono">{secret ? secret.toUpperCase() : "—"}</div>
</div>
<div className="metaItem">
<div className="metaLabel">Status</div>
<div className="metaValue">{running ? "Computing…" : moves.length ? "Done" : "—"}</div>
</div>
</div>
<div className="legend">
<span className="legendItem">
<span className="legendSwatch legendSwatch--g" /> Correct spot
</span>
<span className="legendItem">
<span className="legendSwatch legendSwatch--y" /> Wrong spot
</span>
<span className="legendItem">
<span className="legendSwatch legendSwatch--x" /> Not in word
</span>
</div>
</section>
<section className="panel">
<h2 className="panelTitle">Perfect line (up to 6 moves)</h2>
{moves.length === 0 ? (
<div className="empty">
{running ? (
<>
<div className="spinner" aria-hidden="true" />
<div>Computing the statistically best guesses…</div>
</>
) : (
<div>Pick a word to generate a “perfect” solve trace.</div>
)}
</div>
) : (
<div className="tableWrap">
<table className="table">
<thead>
<tr>
<th className="colMove">#</th>
<th className="colGuess">Guess + feedback</th>
<th className="colNum">Candidates</th>
<th className="colNum">E[remaining]</th>
<th className="colNum">Entropy (bits)</th>
<th className="colNum">Guesses eval’d</th>
<th className="colNum">Time</th>
</tr>
</thead>
<tbody>
{moves.map((m) => (
<tr key={m.move}>
<td className="mono" data-label="#">
{m.move}
</td>
<td data-label="Guess">
<div className="guessRow">
<div className="mono guessWord">{m.guess.toUpperCase()}</div>
<PatternTiles guess={m.guess} pattern={m.pattern} />
</div>
</td>
<td className="mono" data-label="Candidates">
{m.candidatesBefore.toLocaleString()} → {m.candidatesAfter.toLocaleString()}
</td>
<td className="mono" data-label="E[remaining]">
{formatNumber(m.bestExpectedRemaining, 2)}
</td>
<td className="mono" data-label="Entropy (bits)">
{formatNumber(m.bestEntropyBits, 2)}
</td>
<td className="mono" data-label="Guesses eval’d">
{m.evaluatedGuesses.toLocaleString()}
</td>
<td className="mono" data-label="Time">
{formatNumber(m.ms, 0)} ms
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="footnote">
Selection rule: for each move, we choose the guess that minimizes the expected remaining candidate count given
the current candidate set (computed from the full feedback distribution). Ties are broken by higher feedback
entropy, then alphabetically. For very large candidate sets, we first shortlist strong guesses via simple
frequency statistics, then evaluate that shortlist exactly.
</p>
</section>
</div>
);
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("Missing #root");
createRoot(rootEl).render(<App />);