- Published on
Yet another Wordle React implementation
Just watched this live coding exercise video on implementing Wordle in React in 1 hour.
As they were wrapping up their solution (around 39 minutes in), they noticed a new case that needed to be handled: multiple matches of the same letter. For example, when the solution is AYYAY
and your guess is AXAXA
- what color is the game supposed to assign to each of the three A's in the guess?
In the official game, you'll get something like this:
- The first A in your guess is in the same position as the first A in the solution => green
- The second A in your guess can still be found in the remaining letters of the solution => orange
- The third A in your guess has no match in the solution now => black
They assumed incorrectly that you would only get either green or orange in this case and they moved on. Still, that got me thinking about the dreaded question devs get all the time:
But wait... what about this other case?
In a live coding interview, with 20 minutes left on the clock, I don't think I would've been able to rewrite that solution to adapt to this new case... But, a few hours later, here's my attempt at it - yet another React Wordle implementation:
Demo time
Approach
The idea I started from was that the core of the game - the word matching rules - should be fully isolated from the rendering part.
This would make it easier to debug, to change, to adapt, to later build on and to test - manually or through a set of unit tests.
This way, your answer to "But wait... what about this other case?" might be "Oh, let me change the core function quickly", and not "Hmm, I suppose we'll need to rewrite the whole component for this..."
Implementation breakdown
The grid we need to render consists of three types of rows:
- your accepted guesses - these are words that you submitted and were accepted. They go in the top rows.
- your current guess - always a single row, partial or full word, 1 to 5 letters, not checked against the solution by the game yet. It goes in right after the accepted guesses.
- your remaining attempts - these are just blank rows. They go in after your current guess
Therefore, when rendering the grid, your accepted guesses and your current guess should be taken from the internal component state, where ideally they have already been stored in an easy-to-use format (for example, accepted guesses should already be compared against solution, colors should already be assigned to each of their letters etc).
To tackle this, I first defined a type that would store the state of each letter in the accepted guesses:
type LetterResult = {
letter: string
color: 'green' | 'orange' | 'white'
}
Then, I added the state needed to hold accepted guesses and current guess:
const [acceptedGuesses, setAcceptedGuesses] = useState<Array<LetterResult[]>>([])
const [currentGuess, setCurrentGuess] = useState<string>('')
Now, we need to build the game rules parser - this kicks in whenever the player submits a new word. We'll create a calculateColors
function and place it outside of the component. After we determine that a word is valid, we'll call this function to build our next accepted guess from it, before we store it in the internal state of the component.
This is the core part of the game, and we want to be able to easily test it manually and through unit tests without having to spin up the whole game:
/**
* Calculates the color to apply to each letter of a guess.
*
* NOTE: Once a letter has been matched (green), we still need
* to match other occurences of that letter against the solution:
*
* Frequency Counting
* We first create a frequency map of solution letters that aren't exact positional matches.
*
* Green Pass
* Identify exact matches (green tiles) and mark these positions as processed.
*
* Yellow Pass
* For remaining letters, check against the solution's frequency count. Only mark yellow if
* letters remain in the count.
*
* @param solution "ABCAD"
* @param guess "AXAXA"
* @returns
*[
{
"letter": "A", // <- first A, matches solution.
"color": "green"
},
{
"letter": "X",
"color": "white"
},
{
"letter": "A", // <- second A, still found in remaining letters of solution.
"color": "orange"
},
{
"letter": "X",
"color": "white"
},
{
"letter": "A", // <- third A, no longer found in remaining letters of solution.
"color": "white"
}
]
*/
function calculateColors(solution: string, guess: string): LetterResult[] {
const solutionLetters = [...solution]
const guessLetters = [...guess]
const result: LetterResult[] = Array(guess.length)
.fill(null)
.map(() => ({ letter: '', color: 'white' }))
const letterCounts: { [key: string]: number } = {}
// First pass: count letters in solution (excluding exact matches)
for (let i = 0; i < solutionLetters.length; i++) {
if (solutionLetters[i] !== guessLetters[i]) {
letterCounts[solutionLetters[i]] = (letterCounts[solutionLetters[i]] || 0) + 1
}
}
// Second pass: check for exact matches (greens)
for (let i = 0; i < guessLetters.length; i++) {
result[i].letter = guessLetters[i]
if (solutionLetters[i] === guessLetters[i]) {
result[i].color = 'green'
guessLetters[i] = '' // Mark as processed
}
}
// Third pass: check for yellows (existing letters in wrong position)
for (let i = 0; i < guessLetters.length; i++) {
if (guessLetters[i] === '') continue
if (letterCounts[guessLetters[i]] > 0) {
result[i].color = 'orange'
letterCounts[guessLetters[i]]--
}
}
return result
}
A small side quest - make it work on mobile
Normally, the game replaces your mobile keyboard with a custom keyboards that highlights matched letters, but we won't cover that part here.
So, since there are no actual input fields on the screen that would trigger it, there's no keyboard popping up on mobile devices. Therefore, before rendering the grid, we'll need to do some hacking to force it to show up:
return (
<div className="container">
{/* Focusable input for mobile keyboard */}
<div style={{ position: "relative" }}>
<input
ref={mobileKeyboardInputRef}
type="text"
inputMode="text"
autoFocus
value={currentGuess}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
maxLength={WORD_LENGTH}
style={{
position: "absolute",
top: 0,
left: 0,
width: 1,
height: 1,
opacity: 0.01,
zIndex: 1,
}}
aria-label="Wordle input"
/>
{/* Overlay to focus input on tap (for mobile reliability) - only covers the grid */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 0,
}}
onClick={() => mobileKeyboardInputRef.current?.focus()}
aria-hidden="true"
/>
<div className="grid">
TODO
</div>
</div>
</div>
);
Press a key to resume...
We can now start taking user input (users typing letters):
function submitGuess(guess: WordleWord) {
const isValidWord = UPPERCASE_WORDS.includes(guess.toUpperCase())
if (!isValidWord) {
setIsShaking(true)
setTimeout(() => {
setIsShaking(false)
}, 500)
return
}
const processedGuess = calculateColors(solution, guess)
setAcceptedGuesses([...acceptedGuesses, processedGuess])
setCurrentGuess('')
if (guess === solution) {
setGameResult('won')
return
}
if (acceptedGuesses.length === ATTEMPTS - 1) {
setGameResult('lost')
return
}
}
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
let value = e.target.value.toUpperCase()
// Only allow letters, max length
value = value.replace(/[^A-Z]/g, '').slice(0, WORD_LENGTH)
setCurrentGuess(value)
}
function handleInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && currentGuess.length === WORD_LENGTH) {
submitGuess(currentGuess)
e.preventDefault()
} else if ((e.key === 'Backspace' || e.key === 'Delete') && currentGuess) {
setCurrentGuess((prev) => prev.slice(0, -1))
e.preventDefault()
}
}
Build the rows:
const rows: Record<number, { type: 'accepted' | 'current' | 'blank' }> = {}
new Array(ATTEMPTS).fill('').forEach((_, idx) => {
if (acceptedGuesses[idx]) {
rows[idx] = { type: 'accepted' }
} else if (idx === acceptedGuesses.length) {
rows[idx] = { type: 'current' }
} else {
rows[idx] = { type: 'blank' }
}
})
All we need to do now is render the actual grid:
<div className="grid">
{Object.entries(rows).map(([key, value]) => {
const shakeClass =
isShaking && key === acceptedGuesses.length.toString()
? "shake"
: "";
return (
<div key={key} className={`row ${shakeClass}`}>
{value.type === "accepted" &&
new Array(WORD_LENGTH).fill("").map((_, idx) => {
const { color, letter } =
acceptedGuesses[key as unknown as number][idx];
return (
<div key={`a${key}${idx}`} className={`cell ${color}`}>
{letter}
</div>
);
})}
{value.type === "current" &&
new Array(WORD_LENGTH).fill("").map((_, idx) => {
const letter = currentGuess.split("")[idx];
return (
<div key={`c${key}${idx}`} className="cell grayletter">
{letter}
</div>
);
})}
{value.type === "blank" &&
new Array(WORD_LENGTH).fill("").map((_, idx) => {
return <div key={`b${key}${idx}`} className="cell"></div>;
})}
</div>
);
})}
</div>
Final result: