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 submitting
guesses will be an array of the words the user has already guessed
correct will hold the letters in secret that the user has guessed at the correct place
present will hold the letters in secret that the user has guessed at incorrect place
absent will hold the letters not in secret but guessed by the user
finished 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.