Creating Typeracer Game Using ReactJS — Part 2
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!