Wordle Clone
Intro
In this post we will create a simple wordle clone using Preact, Redux and Tailwind CSS. Check out the source code at https://github.com/yasakdogra/wordle and the end product at https://wordle.projects.yasakdogra.com/
Setup
We can create a Vite project using a package manager and selecting Preact framework with Typescript.
bun create vite wordle
✔ Select a framework: › Preact
✔ Select a variant: › TypeScript
The command above will point react
and react-dom
to ./node_modules/preact/compat/
in Typescript configuration file. We can just add the react-redux
package to use it with Preact.
bun add react-redux @reduxjs/toolkit
Add Tailwind by following the instructions at https://tailwindcss.com/docs/guides/vite
We will need some five letter words for wordle. Check out my other post on how to create word list for wordle. Copy the word list as words.txt in the src/assets folder
Code
State
Let’s start with our app state first. Create a state folder in src folder and create wordleSlice.ts file in it.
Import the words file as string and create an array of words from it
import words from '../assets/words.txt?raw';
const WORDLIST = words.split("\n");
Add types for Letters and Words guessed by the user
export type LocType = "correct" | "present" | "absent" | "unknown";
export type LetterType = {
value: string;
loc: LocType;
}
export type WordType = LetterType[];
GuessType
might have been better than WordType
🤔
Create an interface for wordle state
interface WordleState {
secret: string;
current: string;
guesses: WordType[];
correct: string[];
present: string[];
absent: string[];
finished?: boolean;
}
secret
will be the word the user has to guess. Guess? Find? Divine?current
will hold the word the user is currently typing or submittingguesses
will be an array of the words the user has already guessedcorrect
will hold the letters in secret
that the user has guessed at the correct placepresent
will hold the letters in secret
that the user has guessed at incorrect placeabsent
will hold the letters not in secret
but guessed by the userfinished
will be true
if the user guessed the word correctly or exceeded the maximum number of guesses.
Set an initial state with a random word as secret
.
const initialState: WordleState = {
secret: WORDLIST[Math.floor(Math.random() * WORDLIST.length)].toUpperCase(),
current: "",
guesses: [],
correct: [],
present: [],
absent: [],
finished: false
}
Let’s create a redux slice for our app
const wordleSlice = createSlice({
name: "wordle",
initialState,
reducers: {
}
});
Now we can add reducers that will act as event handlers for events dispatched by our app.
Add an event handler for new game event. This will basically set the wordle state to the initial state.
newGame(state) {
state.secret = WORDLIST[Math.floor(Math.random() * WORDLIST.length)].toUpperCase();
state.current = "";
state.guesses = [];
state.correct = [];
state.present = [];
state.absent = [];
state.finished = false;
}
[!TIP]
This following not work because it will set the secret to the same word again.
newGame : () => initialState
We can, however, de-structure the initial state and set the
secret
again.newGame: () => { return { ...initialState, secret: WORDLIST[Math.floor(Math.random() * WORDLIST.length)].toUpperCase() } },
Add a reducer for adding characters on keypress
addChar(state, action: PayloadAction<string>) {
if (state.finished)
return
if(state.current.length < 5)
state.current += action.payload;
},
Add a reducer for deleting characters on backspace
delChar(state) {
if(state.current.length > 0)
state.current = state.current.slice(0, -1)
},
Add a reducer to submit word on enter key. This is where most of the logic is. Do nothing if wordle is finished or there are not enough letters in the submitted guess or the guess is not a valid word in our word list.
For a valid guess, add each letter to the corresponding correct
, present
or absent
array and add the word to the guess
array.
Check if the user has correctly guessed or max tried has reached and finally clear the current
input.
submitGuess(state) {
if(state.finished)
return
if(state.guesses.length >=6)
return
if(state.current.length < 5)
return
if(!WORDLIST.includes(state.current))
return
let guess : WordType = [];
for (let i = 0; i < 5; i++) {
guess.push({ value: "", loc: "absent" })
}
state.current.split("").forEach((letter, index) => {
guess[index].value = letter
if (letter === state.secret[index]) {
guess[index].loc = "correct"
!state.correct.includes(letter) && state.correct.push(letter)
} else if (state.secret.includes(letter)) {
guess[index].loc = "present"
!state.present.includes(letter) && state.present.push(letter)
} else {
guess[index].loc = "absent"
!state.absent.includes(letter) && state.absent.push(letter)
}
})
state.guesses.push(guess)
if (state.current === state.secret || state.guesses.length === 6) {
state.finished = true;
}
state.current = "";
}
Export the action creators and wordle reducer
export const { newGame, addChar, delChar, submitGuess } = wordleSlice.actions;
export default wordleSlice.reducer;
In src/state/store.ts file, create a Redux store with the wordle reducer
import { configureStore } from '@reduxjs/toolkit'
import wordleReducer from './wordleSlice'
export const store = configureStore({
reducer: {
wordle: wordleReducer
},
})
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
In src/main.tsx wrap the entire App
component in Redux store provider
import { store } from './state/store.ts';
import { Provider } from 'react-redux';
<Provider store={store}>
<App />
</Provider>,
Now in our JSX components, we can access the store and reducers with
import { useDispatch, useSelector } from 'react-redux'
import { AppDispatch, RootState } from '../../state/store'
import { newGame } from '../../state/wordleSlice'
export default function SomeComponent {
const current = useSelector((state: RootState) => state.wordle.current)
const dispatch = useDispatch<AppDispatch>()
dispatch(newGame())
}
This is all we need for managing the state. Next we will work on the user interface.
User Interface
We will use a top down approach for this project. We will start with Wordle
component which will have and array of Word
components and a Keyboard
component.
Each Word
component will have an array Letter
components.
The Keyboard
component will have an array of Key
components.
Wordle Component
Add empty Wordle component and import the necessary states and action creators
import { useDispatch, useSelector } from 'react-redux'
import { AppDispatch, RootState } from '../../state/store'
import { LocType, addChar, delChar, newGame, submitGuess } from '../../state/wordleSlice'
export default function Wordle () {
const current = useSelector((state: RootState) => state.wordle.current)
const guesses = useSelector((state: RootState) => state.wordle.guesses)
const finished = useSelector((state: RootState) => state.wordle.finished)
const secret = useSelector((state: RootState) => state.wordle.secret)
const dispatch = useDispatch<AppDispatch>()
return (
<div></div>
)
}
Add JSX to show the guesses and available tries. Each guess is a Word
component with WordType
value.
JSX to show the previously submitted guesses
{guesses.map(guess => <Word value={guess} />)}
JSX to show the current guess the user is typing
{guesses.length < 6 &&
<Word value={
[...current].map((val) => { return { value: val as string, loc: "unknown" as LocType } })
.concat(Array(5 - current.length).fill({ value: "" as string, loc: "unknown" as LocType }))
} />
}
JSX to show the tries left
{guesses.length < 5 && Array(5 - guesses.length).fill(<Word value={[]} />)}
Show the correct word if the wordle is finished
{finished && <div class="py-2"><h1 class="bg-gray-600 text-white px-2 py-2 rounded-md">{secret}</h1></div>}
Add a button to create a new game
<button type="button" class="text-purple-700 hover:text-white border border-purple-700 hover:bg-purple-800 focus:ring-4 focus:outline-none focus:ring-purple-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-purple-400 dark:text-purple-400 dark:hover:text-white dark:hover:bg-purple-500 dark:focus:ring-purple-900"
onClick={(e) => { dispatch(newGame()); e.currentTarget.blur() }}
>New Game</button>
Add physical keyboard key presses
useEffect(() => {
document.addEventListener("keyup", (e) => {
if (e.key === "Enter")
dispatch(submitGuess())
else if (e.key === "Backspace")
dispatch(delChar())
else if ((e.key >= "A" && e.key <= "Z") || (e.key >= "a" && e.key <= "z"))
dispatch(addChar(e.key.toUpperCase()))
})
}, [])
We can add an onscreen keyboard and a key press handler for it
<Keyboard onKeyPress={handleKeyPress} />
const handleKeyPress = (key: string) => {
if (key === "ENTER")
dispatch(submitGuess())
else if (key === "DEL")
dispatch(delChar())
else
dispatch(addChar(key))
}
Word and Letter components
For the word component, we map each letter (LetterType
) to Letter
component
<div class="flex my-1">
{[...word].map(letter => (<Letter value={letter.value} loc={letter.loc} />))}
</div>
We just need to handle the case where empty value is given
let word = props.value;
if(word.length === 0)
{
for(let i = 0; i < 5; i++)
{
word.push({value: "", loc:"unknown"})
}
}
For the Letter
component, we need to show different colors based on whether the letter was correctly guessed
<div className={`flex items-center justify-center h-14 w-14 p-4 m-1 text-2xl font-bold border-2 rounded-lg
${props.loc === 'correct' ? 'bg-green-500 text-white' : props.loc === 'present' ? 'bg-yellow-400 text-white' : props.loc === "absent" ? 'bg-neutral-400 text-white' : 'bg-white'}`}
>{props.value}</div>
Keyboard and Key components
We will define a layout for the keyboard
const KeyboardLayout = [
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
["DEL", "Z", "X", "C", "V", "B", "N", "M", "ENTER"]
]
Then the JSX just needs to map over the rows and keys
<div className="keyboard">
{KeyboardLayout.map((row, i) => (
<div key={i} className="flex justify-center">
{row.map((key, j) => (
<Key key={j} value={key} onKeyPress={props.onKeyPress} info={correct.includes(key) ? 'correct' : present.includes(key) ? 'present' : absent.includes(key) ? 'absent' : ''} />
))}
</div>
))}
</div>
And then the JSX for the Key
component
<kbd className={`
px-4 py-2 m-1 text-2xl font-semibold text-gray-800
bg-gray-100 border border-gray-200 rounded-lg
dark:bg-gray-600 dark:text-gray-100 dark:border-gray-500
${props.info === 'correct' ? 'bg-green-500 text-white' : props.info === 'present' ? 'bg-yellow-400 text-white' : props.info === "absent" ? 'bg-neutral-400 text-white' : 'bg-white'}
`}
onClick={() => props.onKeyPress(props.value)}
>
{props.value}
</kbd>
Run
We can now run the dev server with bun run dev
or build with bun run build
and run the build with bun run preview
.