Creating Typeracer Game Using ReactJS — Part 2

Mario Gunawan
5 min readJan 6, 2023

--

Finished product of this part

This is a continuation of part 1 which build the basic functionality of the game.

For a quick refresher, here’s what we have built in part 1:

Seems pretty simple, right? There are some improvements that can be made to improve our UX:

  • Make the wrong word go red
  • Make the correct word go green
  • Make current word go underscore
  • Make already typed words go green

After that, we can display stats that shows words per minute, and let the player rest after finishing a round. For now, let’s focus on coloring our text.

Adding Colors & Styling To Already Typed Words

To add colors, we need to be able to separate words that have been typed, current word, and words to be typed. We already know the current word, so we need to build the other two. In index.tsx add two new states called alreadyTypedWords and wordsToBeTyped below the wordIdx state using useMemo.

// ...
const [wordIdx, setWordIdx] = useState<number>(0);
const alreadyTypedWords = useMemo(
() => quotesSplit.slice(0, wordIdx).join(" "),
[quotesSplit, wordIdx]
);
const wordsToBeTyped = useMemo(
() => quotesSplit.slice(wordIdx + 1, quotesSplit.length).join(" "),
[quotesSplit, wordIdx]
);
// ...

Then, let’s Place it within the font-mono paragraph correspondingly

// ...
<h1 className="mb-4">Typeracer</h1>
<p className="font-mono">
<span className="text-green-600">{alreadyTypedWords} </span>
<span className="text-black underline">{currentWord}</span>
<span className="text-black"> {wordsToBeTyped}</span>
</p>
// ...

Don’t forget to include a space after already typed words and before wordsToBeTyped to separate the words. Here’s how our game look after those improvement:

After this improvement, let’s make our app determine the color of the current word determined on player’s input. The color green means correct and red means incorrect.

Coloring Current User Input

For this part, let’s see how the original typeracer game handles it:

We can deduct that:

  • The green text comes before the red text
  • The red text comes before the currently typed word (underscored)

Let’s update the UI accordingly by adding two spans with each color:

// ...
<span className="text-green-600">{alreadyTypedWords} </span>
<span className="text-green-600"></span> // correct word
<span className="text-red-700 bg-red-200"></span> // wrong word
<span className="text-black underline">{currentWord}</span>
<span className="text-black"> {wordsToBeTyped}</span>
// ...

You might notice that there are two text-green-600 that can be merged, but we’ll do that later.

After updating the UI, we need to create two states, one that contains the correct word part, and one that contains the wrong word part. You can try to figure it out yourself, then go to the below’s solution if you’re stuck.

Add this code below alreadyTypedWords state:

// ...

const correctGreenWord = useMemo(() => {
if (currentWord) {
let i = 0;
while (i < text.length) {
if (text[i] != currentWord[i]) {
break;
}
i++;
}
return text.slice(0, i); // return only the correct part
}
return "";
}, [currentWord, text]);

const wrongRedWord = useMemo(
() =>
currentWord?.slice(
correctGreenWord.length,
text.length
),
[correctGreenWord, currentWord, text]
);

// ...

To get the correct green word, we have to iterate each character and find out when the current character does not match the word. If it doesn’t match, then begins the wrong red word, which starts on the latest correct part until the user’s input.

The last state would be currentWordTail which cut the currentWord from the text.length until the currentWord.length but since it’s a simple operation, I’ll just code it without the state. Also, don’t forget to group alreadyTypedWords and correctGreenWord for max efficiency. Here’s the final update to the UI:

// ...
<span className="text-green-600">{alreadyTypedWords} {correctGreenWord}</span>
<span className="text-red-700 bg-red-200">{wrongRedWord}</span>
<span className="underline">{currentWord?.slice(text.length)}</span>
<span className="text-black"> {wordsToBeTyped}</span>
// ...

Finally! This is how our app should look like:

That’s good. But, there’s a minor improvement that can be done, which is to focus the input bar when a player opens or refresh the app. There are 2 ways to do this, which are creating a ref to the input using useRef, or using the native document.getElementById and then adding our input as an id. Since this is a simple functionality, I’ll use the id approach. Let’s code it:

// ...imports

const inputId = "typeracer-input";

export default function TyperacerPage() {
// ...
<input
className="w-full border-black border px-4 py-2"
onChange={(text) => setText(text.target.value)}
value={text}
id={inputId}
/>
// ...
}

Then, below the wrongRedWord state, let’s create a useEffect that focuses our input:

  useEffect(() => {
document.getElementById(inputId)?.focus();
}, []);

With this change, every time we refresh or open the page, it will focus on the input bar. Neat, right?

This is the whole code for index.tsx for part 2:

import { useEffect, useMemo, useState } from "react";
import quotes from "./quotes.json";

type Quote = {
quote: string;
movieName: string;
};

const randomQuote = (): Quote =>
quotes[Math.floor(quotes.length * Math.random())];

const inputId = "typeracer-input";

export default function TyperacerPage() {
const [quote, setQuote] = useState<Quote>();
const [text, setText] = useState<string>("");
const [currentWord, setCurrentWord] = useState<string>();
const quotesSplit = useMemo(() => quote?.quote.split(" ") ?? [], [quote]);
const [wordIdx, setWordIdx] = useState<number>(0);
const alreadyTypedWords = useMemo(
() => quotesSplit.slice(0, wordIdx).join(" "),
[quotesSplit, wordIdx]
);
const wordsToBeTyped = useMemo(
() => quotesSplit.slice(wordIdx + 1, quotesSplit.length).join(" "),
[quotesSplit, wordIdx]
);
const correctGreenWord = useMemo(() => {
if (currentWord) {
let i = 0;
console.log(text);
while (i < text.length) {
console.log(text[i]);
console.log(currentWord[i]);
if (text[i] != currentWord[i]) {
break;
}
i++;
}
return text.slice(0, i);
}
return "";
}, [currentWord, text]);

const wrongRedWord = useMemo(
() => currentWord?.slice(correctGreenWord.length, text.length),
[correctGreenWord, currentWord, text]
);

useEffect(() => {
document.getElementById(inputId)?.focus();
}, []);

useEffect(() => {
setWordIdx(0);
setText("");
}, [quotesSplit]);

useEffect(() => {
setCurrentWord(quotesSplit[wordIdx]);
}, [wordIdx, quotesSplit]);

useEffect(() => {
const latestLetter = text?.charAt(text.length - 1);
if (latestLetter != " " && wordIdx != quotesSplit.length - 1) return;
const textWithoutTrailingSpace = text?.replace(/\s*$/, "");
if (textWithoutTrailingSpace == currentWord) {
console.log(text);
setText("");
setWordIdx(() => wordIdx + 1);
}
}, [text, currentWord, wordIdx, quotesSplit]);

useEffect(() => {
if (wordIdx == quotesSplit.length) {
setQuote(randomQuote());
}
}, [wordIdx, quotesSplit]);

return (
<div className="px-20">
<h1 className="mb-4">Typeracer</h1>
<p className="font-mono">
<span className="text-green-600">{alreadyTypedWords} {correctGreenWord}</span>
<span className="text-red-700 bg-red-200">{wrongRedWord}</span>
<span className="underline">{currentWord?.slice(text.length)}</span>
<span className="text-black"> {wordsToBeTyped}</span>
</p>
<input
className="w-full border-black border px-4 py-2"
onChange={(text) => setText(text.target.value)}
value={text}
id={inputId}
/>
</div>
);
}

Afterthoughts

Originally, I was planning to include stats like typing speed, quote name, etc. in part 2, but since the part would be too long, I’ll update the stats and maybe some minor improvements in part 3. Stay tuned to get more updates. See ya!

--

--

Mario Gunawan

I'm a mobile / web developer. I'm mostly writing articles about software development.