Build Wordle: guess a 5-letter word in six tries, with per-letter color feedback after each guess — green for the right letter in the right spot, yellow for a letter that's in the word but misplaced, grey for absent. The subtle part is the coloring algorithm: it must handle duplicate letters correctly, which takes two passes.
type Score = 'correct' | 'present' | 'absent';
function scoreGuess(guess: string, answer: string): Score[];
// A self-contained component. No props.
function App(): JSX.Element;
answer = REACT, guess = TRACE →
T present · R present · A correct · C present · E present
answer = REACT, guess = EERIE →
only ONE E is colored (answer has one E); the extra E's are absent
Two pieces of state — the submitted guesses and the in-progress current row — driven by keyboard events, plus one careful function: scoring a guess against the answer in two passes so duplicate letters color correctly.
The UI is a 6×5 grid: each submitted guess is a colored row, the current guess is the row you're typing, the rest are empty. Typing is keyboard-driven — letters extend the current row, Backspace trims it, Enter commits it. The genuinely tricky bit is the colors. A naive "is this letter in the word?" check double-counts duplicates: guess EERIE against REACT (one E) would wrongly color every E. The fix is to count the answer's letters and consume them — exact matches first, then the leftovers.
State: guesses (array of committed 5-letter strings) and current (the row being typed). A keydown handler appends a letter (if current.length < 5), deletes on Backspace, and on Enter — when current is 5 long — pushes it to guesses and clears current. scoreGuess(guess, answer) returns five Scores via two passes: pass 1 marks correct where letters match position and decrements that letter's remaining count; pass 2 marks present for any not-yet-correct letter that still has remaining count, else absent.
The naive scorer checks membership independently per letter:
guess.split('').map((ch, i) =>
ch === answer[i] ? 'correct' : answer.includes(ch) ? 'present' : 'absent',
);
This breaks on duplicates: every guessed letter that appears anywhere in the answer is colored, even if the answer has fewer of them. Real Wordle treats each answer letter as a finite resource: a letter can be "used" only as many times as it occurs. That requires counting and consuming, in two passes.
import { useState, useEffect } from 'react';
import './styles.css';
const ANSWER = 'REACT';
const ROWS = 6;
type Score = 'correct' | 'present' | 'absent';
function scoreGuess(guess: string, answer: string): Score[] {
const result: Score[] = Array(answer.length).fill('absent');
const counts: { [letter: string]: number } = {};
for (const ch of answer) counts[ch] = (counts[ch] || 0) + 1;
// Pass 1: exact matches, consuming that letter's count.
for (let i = 0; i < answer.length; i++) {
if (guess[i] === answer[i]) {
result[i] = 'correct';
counts[guess[i]]--;
}
}
// Pass 2: present if the letter still has remaining count.
for (let i = 0; i < answer.length; i++) {
if (result[i] === 'correct') continue;
const ch = guess[i];
if (counts[ch] > 0) {
result[i] = 'present';
counts[ch]--;
}
}
return result;
}
export default function App() {
const [guesses, setGuesses] = useState<string[]>([]);
const [current, setCurrent] = useState('');
const won = guesses[guesses.length - 1] === ANSWER;
const over = won || guesses.length === ROWS;
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (over) return;
if (e.key === 'Enter') {
if (current.length === ANSWER.length) {
setGuesses((g) => [...g, current]);
setCurrent('');
}
} else if (e.key === 'Backspace') {
setCurrent((c) => c.slice(0, -1));
} else if (/^[a-zA-Z]$/.test(e.key) && current.length < ANSWER.length) {
setCurrent((c) => c + e.key.toUpperCase());
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [current, over]);
const status = won
? 'You win!'
: over
? `Answer: ${ANSWER}`
: 'Guess the 5-letter word';
return (
<main className="container">
<h1>Wordle</h1>
<p className="status">{status}</p>
<div className="grid">
{Array.from({ length: ROWS }, (_, r) => {
const guess = guesses[r];
const scores = guess ? scoreGuess(guess, ANSWER) : null;
const letters = guess ?? (r === guesses.length ? current : '');
return (
<div className="row" key={r}>
{Array.from({ length: ANSWER.length }, (_, c) => {
const ch = letters[c] ?? '';
const cls = scores
? `tile ${scores[c]}`
: ch
? 'tile filled'
: 'tile';
return (
<div className={cls} key={c}>
{ch}
</div>
);
})}
</div>
);
})}
</div>
<p className="hint">Type a guess and press Enter.</p>
</main>
);
}
scoreGuess is the heart: counts holds how many of each letter the answer has; pass 1 colors exact matches and decrements; pass 2 colors present only while a letter's count remains, so duplicates can't over-claim. The keydown effect is keyed on [current, over] so it always sees fresh values; functional updates keep current/guesses consistent. Rendering each row picks the right letters — a committed guess, or current for the active row, or empty — and the tiles get a score class once submitted, a filled class while typing, or nothing.
answer = REACT, nothing typed.
current until it's "TRACE" (length 5). The active row (row 0) shows those letters with the filled style.current.length === 5 → guesses = ["TRACE"], current = "".TRACE vs REACT. counts = {R:1, E:1, A:1, C:1, T:1}. Pass 1: A (index 2) and C (index 3) match exactly, so both become correct and their counts reach 0. Pass 2 marks T, R, and E present. The row shows green A and C with yellow T, R, and E.EERIE. counts has E:1. Pass 1: the second E is correct and consumes that count. Pass 2: R is present; the other E tiles find count 0 and become absent. Only the exact E is colored.REACT. All five correct; won true → "You win!"; keydown returns early after.answer.includes(ch). Over-colors duplicate letters. Fix: count letters and consume them across two passes.correct first, then present.current in the listener. A keydown closure capturing old state misses letters. Fix: depend on [current] (or functional updates).removeEventListener in the effect cleanup.<5 letters shouldn't commit. Fix: guard current.length === 5.import{useEffect,useReducer}from'react';import'./styles.css';const ANSWER='REACT',ROWS=6;type Score='correct'|'present'|'absent';type State={guesses:string[];current:string};function score(guess:string){const out:Score[]=Array(5).fill('absent'),left:Record<string,number>={};for(const ch of ANSWER)left[ch]=(left[ch]||0)+1;for(let i=0;i<5;i++)if(guess[i]===ANSWER[i]){out[i]='correct';left[guess[i]]--;}for(let i=0;i<5;i++)if(out[i]!=='correct'&&left[guess[i]]>0){out[i]='present';left[guess[i]]--;}return out;}function reducer(state:State,key:string):State{const over=state.guesses.at(-1)===ANSWER||state.guesses.length===ROWS;if(over)return state;if(key==='Enter')return state.current.length===5?{guesses:[...state.guesses,state.current],current:''}:state;if(key==='Backspace')return{...state,current:state.current.slice(0,-1)};return/^[a-zA-Z]$/.test(key)&&state.current.length<5?{...state,current:state.current+key.toUpperCase()}:state;}export default function App(){const[state,dispatch]=useReducer(reducer,{guesses:[],current:''});useEffect(()=>{const onKey=(e:KeyboardEvent)=>dispatch(e.key);window.addEventListener('keydown',onKey);return()=>window.removeEventListener('keydown',onKey);},[]);const won=state.guesses.at(-1)===ANSWER,over=won||state.guesses.length===ROWS,status=won?'You win!':over?'Answer: '+ANSWER:'Guess the 5-letter word';return <main className="container"><h1>Wordle</h1><p className="status">{status}</p><div className="grid">{Array.from({length:ROWS},(_,r)=>{const guess=state.guesses[r],scores=guess?score(guess):null,letters=guess??(r===state.guesses.length?state.current:'');return <div className="row" key={r}>{Array.from({length:5},(_,c)=>{const ch=letters[c]??'',cls=scores?`tile ${scores[c]}`:ch?'tile filled':'tile';return <div className={cls} key={c}>{ch}</div>;})}</div>;})}</div><p className="hint">Type a guess and press Enter.</p></main>}The reducer owns input limits submission and terminal locking while rendering remains derived.
import{useEffect,useState}from'react';import'./styles.css';const ANSWER='REACT',ROWS=6;type Score='correct'|'present'|'absent';function score(guess:string):Score[]{const result:Score[]=Array(5).fill('absent'),counts:Record<string,number>={};for(const ch of ANSWER)counts[ch]=(counts[ch]||0)+1;guess.split('').forEach((ch,i)=>{if(ch===ANSWER[i]){result[i]='correct';counts[ch]--;}});guess.split('').forEach((ch,i)=>{if(result[i]!=='correct'&&counts[ch]>0){result[i]='present';counts[ch]--;}});return result;}function useWordle(){const[state,setState]=useState({guesses:[] as string[],current:''});useEffect(()=>{function onKey(e:KeyboardEvent){setState(old=>{const over=old.guesses.at(-1)===ANSWER||old.guesses.length===ROWS;if(over)return old;if(e.key==='Enter')return old.current.length===5?{guesses:[...old.guesses,old.current],current:''}:old;if(e.key==='Backspace')return{...old,current:old.current.slice(0,-1)};if(/^[a-zA-Z]$/.test(e.key)&&old.current.length<5)return{...old,current:old.current+e.key.toUpperCase()};return old;});}window.addEventListener('keydown',onKey);return()=>window.removeEventListener('keydown',onKey);},[]);return state;}export default function App(){const game=useWordle(),won=game.guesses.at(-1)===ANSWER,over=won||game.guesses.length===ROWS;return <main className="container"><h1>Wordle</h1><p className="status">{won?'You win!':over?'Answer: '+ANSWER:'Guess the 5-letter word'}</p><div className="grid">{Array.from({length:ROWS},(_,r)=>{const guess=game.guesses[r],scores=guess?score(guess):null,letters=guess??(r===game.guesses.length?game.current:'');return <div className="row" key={r}>{Array.from({length:5},(_,c)=>{const ch=letters[c]??'';return <div className={scores?'tile '+scores[c]:ch?'tile filled':'tile'} key={c}>{ch}</div>;})}</div>;})}</div><p className="hint">Type a guess and press Enter.</p></main>}The hook installs one stable listener and applies every key through a fresh immutable state update.
Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build Wordle: guess a 5-letter word in six tries, with per-letter color feedback after each guess — green for the right letter in the right spot, yellow for a letter that's in the word but misplaced, grey for absent. The subtle part is the coloring algorithm: it must handle duplicate letters correctly, which takes two passes.
type Score = 'correct' | 'present' | 'absent';
function scoreGuess(guess: string, answer: string): Score[];
// A self-contained component. No props.
function App(): JSX.Element;
answer = REACT, guess = TRACE →
T present · R present · A correct · C present · E present
answer = REACT, guess = EERIE →
only ONE E is colored (answer has one E); the extra E's are absent
Two pieces of state — the submitted guesses and the in-progress current row — driven by keyboard events, plus one careful function: scoring a guess against the answer in two passes so duplicate letters color correctly.
The UI is a 6×5 grid: each submitted guess is a colored row, the current guess is the row you're typing, the rest are empty. Typing is keyboard-driven — letters extend the current row, Backspace trims it, Enter commits it. The genuinely tricky bit is the colors. A naive "is this letter in the word?" check double-counts duplicates: guess EERIE against REACT (one E) would wrongly color every E. The fix is to count the answer's letters and consume them — exact matches first, then the leftovers.
State: guesses (array of committed 5-letter strings) and current (the row being typed). A keydown handler appends a letter (if current.length < 5), deletes on Backspace, and on Enter — when current is 5 long — pushes it to guesses and clears current. scoreGuess(guess, answer) returns five Scores via two passes: pass 1 marks correct where letters match position and decrements that letter's remaining count; pass 2 marks present for any not-yet-correct letter that still has remaining count, else absent.
The naive scorer checks membership independently per letter:
guess.split('').map((ch, i) =>
ch === answer[i] ? 'correct' : answer.includes(ch) ? 'present' : 'absent',
);
This breaks on duplicates: every guessed letter that appears anywhere in the answer is colored, even if the answer has fewer of them. Real Wordle treats each answer letter as a finite resource: a letter can be "used" only as many times as it occurs. That requires counting and consuming, in two passes.
import { useState, useEffect } from 'react';
import './styles.css';
const ANSWER = 'REACT';
const ROWS = 6;
type Score = 'correct' | 'present' | 'absent';
function scoreGuess(guess: string, answer: string): Score[] {
const result: Score[] = Array(answer.length).fill('absent');
const counts: { [letter: string]: number } = {};
for (const ch of answer) counts[ch] = (counts[ch] || 0) + 1;
// Pass 1: exact matches, consuming that letter's count.
for (let i = 0; i < answer.length; i++) {
if (guess[i] === answer[i]) {
result[i] = 'correct';
counts[guess[i]]--;
}
}
// Pass 2: present if the letter still has remaining count.
for (let i = 0; i < answer.length; i++) {
if (result[i] === 'correct') continue;
const ch = guess[i];
if (counts[ch] > 0) {
result[i] = 'present';
counts[ch]--;
}
}
return result;
}
export default function App() {
const [guesses, setGuesses] = useState<string[]>([]);
const [current, setCurrent] = useState('');
const won = guesses[guesses.length - 1] === ANSWER;
const over = won || guesses.length === ROWS;
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (over) return;
if (e.key === 'Enter') {
if (current.length === ANSWER.length) {
setGuesses((g) => [...g, current]);
setCurrent('');
}
} else if (e.key === 'Backspace') {
setCurrent((c) => c.slice(0, -1));
} else if (/^[a-zA-Z]$/.test(e.key) && current.length < ANSWER.length) {
setCurrent((c) => c + e.key.toUpperCase());
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [current, over]);
const status = won
? 'You win!'
: over
? `Answer: ${ANSWER}`
: 'Guess the 5-letter word';
return (
<main className="container">
<h1>Wordle</h1>
<p className="status">{status}</p>
<div className="grid">
{Array.from({ length: ROWS }, (_, r) => {
const guess = guesses[r];
const scores = guess ? scoreGuess(guess, ANSWER) : null;
const letters = guess ?? (r === guesses.length ? current : '');
return (
<div className="row" key={r}>
{Array.from({ length: ANSWER.length }, (_, c) => {
const ch = letters[c] ?? '';
const cls = scores
? `tile ${scores[c]}`
: ch
? 'tile filled'
: 'tile';
return (
<div className={cls} key={c}>
{ch}
</div>
);
})}
</div>
);
})}
</div>
<p className="hint">Type a guess and press Enter.</p>
</main>
);
}
scoreGuess is the heart: counts holds how many of each letter the answer has; pass 1 colors exact matches and decrements; pass 2 colors present only while a letter's count remains, so duplicates can't over-claim. The keydown effect is keyed on [current, over] so it always sees fresh values; functional updates keep current/guesses consistent. Rendering each row picks the right letters — a committed guess, or current for the active row, or empty — and the tiles get a score class once submitted, a filled class while typing, or nothing.
answer = REACT, nothing typed.
current until it's "TRACE" (length 5). The active row (row 0) shows those letters with the filled style.current.length === 5 → guesses = ["TRACE"], current = "".TRACE vs REACT. counts = {R:1, E:1, A:1, C:1, T:1}. Pass 1: A (index 2) and C (index 3) match exactly, so both become correct and their counts reach 0. Pass 2 marks T, R, and E present. The row shows green A and C with yellow T, R, and E.EERIE. counts has E:1. Pass 1: the second E is correct and consumes that count. Pass 2: R is present; the other E tiles find count 0 and become absent. Only the exact E is colored.REACT. All five correct; won true → "You win!"; keydown returns early after.answer.includes(ch). Over-colors duplicate letters. Fix: count letters and consume them across two passes.correct first, then present.current in the listener. A keydown closure capturing old state misses letters. Fix: depend on [current] (or functional updates).removeEventListener in the effect cleanup.<5 letters shouldn't commit. Fix: guard current.length === 5.import{useEffect,useReducer}from'react';import'./styles.css';const ANSWER='REACT',ROWS=6;type Score='correct'|'present'|'absent';type State={guesses:string[];current:string};function score(guess:string){const out:Score[]=Array(5).fill('absent'),left:Record<string,number>={};for(const ch of ANSWER)left[ch]=(left[ch]||0)+1;for(let i=0;i<5;i++)if(guess[i]===ANSWER[i]){out[i]='correct';left[guess[i]]--;}for(let i=0;i<5;i++)if(out[i]!=='correct'&&left[guess[i]]>0){out[i]='present';left[guess[i]]--;}return out;}function reducer(state:State,key:string):State{const over=state.guesses.at(-1)===ANSWER||state.guesses.length===ROWS;if(over)return state;if(key==='Enter')return state.current.length===5?{guesses:[...state.guesses,state.current],current:''}:state;if(key==='Backspace')return{...state,current:state.current.slice(0,-1)};return/^[a-zA-Z]$/.test(key)&&state.current.length<5?{...state,current:state.current+key.toUpperCase()}:state;}export default function App(){const[state,dispatch]=useReducer(reducer,{guesses:[],current:''});useEffect(()=>{const onKey=(e:KeyboardEvent)=>dispatch(e.key);window.addEventListener('keydown',onKey);return()=>window.removeEventListener('keydown',onKey);},[]);const won=state.guesses.at(-1)===ANSWER,over=won||state.guesses.length===ROWS,status=won?'You win!':over?'Answer: '+ANSWER:'Guess the 5-letter word';return <main className="container"><h1>Wordle</h1><p className="status">{status}</p><div className="grid">{Array.from({length:ROWS},(_,r)=>{const guess=state.guesses[r],scores=guess?score(guess):null,letters=guess??(r===state.guesses.length?state.current:'');return <div className="row" key={r}>{Array.from({length:5},(_,c)=>{const ch=letters[c]??'',cls=scores?`tile ${scores[c]}`:ch?'tile filled':'tile';return <div className={cls} key={c}>{ch}</div>;})}</div>;})}</div><p className="hint">Type a guess and press Enter.</p></main>}The reducer owns input limits submission and terminal locking while rendering remains derived.
import{useEffect,useState}from'react';import'./styles.css';const ANSWER='REACT',ROWS=6;type Score='correct'|'present'|'absent';function score(guess:string):Score[]{const result:Score[]=Array(5).fill('absent'),counts:Record<string,number>={};for(const ch of ANSWER)counts[ch]=(counts[ch]||0)+1;guess.split('').forEach((ch,i)=>{if(ch===ANSWER[i]){result[i]='correct';counts[ch]--;}});guess.split('').forEach((ch,i)=>{if(result[i]!=='correct'&&counts[ch]>0){result[i]='present';counts[ch]--;}});return result;}function useWordle(){const[state,setState]=useState({guesses:[] as string[],current:''});useEffect(()=>{function onKey(e:KeyboardEvent){setState(old=>{const over=old.guesses.at(-1)===ANSWER||old.guesses.length===ROWS;if(over)return old;if(e.key==='Enter')return old.current.length===5?{guesses:[...old.guesses,old.current],current:''}:old;if(e.key==='Backspace')return{...old,current:old.current.slice(0,-1)};if(/^[a-zA-Z]$/.test(e.key)&&old.current.length<5)return{...old,current:old.current+e.key.toUpperCase()};return old;});}window.addEventListener('keydown',onKey);return()=>window.removeEventListener('keydown',onKey);},[]);return state;}export default function App(){const game=useWordle(),won=game.guesses.at(-1)===ANSWER,over=won||game.guesses.length===ROWS;return <main className="container"><h1>Wordle</h1><p className="status">{won?'You win!':over?'Answer: '+ANSWER:'Guess the 5-letter word'}</p><div className="grid">{Array.from({length:ROWS},(_,r)=>{const guess=game.guesses[r],scores=guess?score(guess):null,letters=guess??(r===game.guesses.length?game.current:'');return <div className="row" key={r}>{Array.from({length:5},(_,c)=>{const ch=letters[c]??'';return <div className={scores?'tile '+scores[c]:ch?'tile filled':'tile'} key={c}>{ch}</div>;})}</div>;})}</div><p className="hint">Type a guess and press Enter.</p></main>}The hook installs one stable listener and applies every key through a fresh immutable state update.
Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.