Creating Typeracer Game Using ReactJS — Final Part
Hello! If you haven’t read part 1 & 2 yet, read it first! You can read it here (part 1).
Welcome back to the 3rd and last part of the typeracer series! In previous parts, we’ve created:
- States & watchers that keep track of whether the quote is completed or not
- Autofocus on input
- Coloring for the user’s current word
Here’s what we have built in our previous sessions:
Now we’ll build stats to track how well we are doing in a game. Let’s go!
Code Cleanup
Before we move on to this part, we have some cleanups to do. Previously, we bundled the function randomQuote
and type Quote
within index.tsx
. We usually separate those functions/types from the UI file because it’s easier to read the code that way. So let’s create a new file, repository.ts
and move the Quote
type and randomQuote
function to the file.
// repository.ts
import quotes from "./quotes.json";
export type Quote = {
quote: string;
movieName: string;
};
export const randomQuote = (): Quote =>
quotes[Math.floor(quotes.length * Math.random())];
Then, you can remove the function & type from index.tsx
and add in the import:
// index.tsx
import { Quote, randomQuote } from "./repository";
Usually, we separate the model Quote
and the function randomQuote
even further, but it’s good enough right now.
The UI
Like previous parts, since the UI isn’t an important part of the app, you can copy and paste the code or customize it yourself.
Create a new file called stats_display.dart
which is responsible to display stats & have a next button. Copy & paste the below code to the file:
// stats_display.dart
import { FC } from 'react'
import { Quote } from './repository'
interface StatsDisplayProps {
quote: Quote
endTime: number
startTime: number
numOfWords: number
onClickNextQuote: () => void
}
const StatsDisplay: FC<StatsDisplayProps> = ({
quote,
startTime,
endTime,
numOfWords,
onClickNextQuote,
}) => {
const typeDurationInSeconds = (endTime - startTime) / 1000
const wps = numOfWords / typeDurationInSeconds
const wpm = Math.floor(wps * 60)
return (
<div className="w-full border rounded-xl p-8">
<p className="text-xl font-bold font-sans">
You just typed a quote from {quote.movieName}
</p>
<p className="mt-2">Your stats:</p>
<ul>
<li>Words Per Minute: {wpm}</li>
<li>Words Per Second: {wps.toFixed(2)}</li>
</ul>
<button
onClick={onClickNextQuote}
id="next_quote_button"
className="px-4 py-2 border rounded-xl bg-blue-500 text-white mt-4"
>
Next Quote
</button>
</div>
)
}
export default StatsDisplay
in StatsDisplay, we’re passing the time startTime
and endTime
which represents the time the player started typing and the time the player finishes it. We also passes the quote to extract its source (movieName
) and onClickNextQuote which is a callback when we click the “Next Quote” button.
Now add StatsDisplay
to index.tsx
page below the input
element:
// index.tsx
// ...
<input /* ... */ />
{quote && (
<StatsDisplay
startTime={0}
endTime={1000}
quote={quote}
numOfWords={quotesSplit.length}
onClickNextQuote={() => {}}
/>
)}
// ...
When you save the app and reload, this should be what you see:
It looks pretty good, right? Now there’s one more step to do: make the StatsDisplay functionality
Adding GameState
Create a new file, called game_state.ts
then add in those lines:
export const GameState = {
PLAYING: "PLAYING",
VIEW_STATS: "VIEW_STATS",
WAITING: "WAITING",
};
The GameState
variable is for determining the state of the game. Here is the GameState lifecycle:
- Starts at
WAITING
while we are loading the quote - After the quote is loaded, game state changes to
PLAYING
- After finished typing, it goes into
VIEW_STATS
- Finally, when the player clicks Next button, it goes into
PLAYING
again
After that, in index.tsx
, create a string state that contains the current game state below the correctGreenWord
state:
const correctGreenWord = useMemo(() => { /* ... */ });
const [gameState, setGameState] = useState(GameState.WAITING);
// ...
then, in the line where we do setQuote
, change it so that it changes the gameState
to GameState.PLAYING
:
// replace this:
/*
useEffect(() => {
if (wordIdx == quotesSplit.length) {
setQuote(randomQuote());
}
}, [wordIdx, quotesSplit]);
*/
// to this:
useEffect(() => {
setGameState(GameState.PLAYING);
}, []);
useEffect(() => {
if (gameState == GameState.PLAYING) {
setQuote(randomQuote());
}
}, [gameState]);
After that, we set gameState=VIEW_STATS
when the player have finished typing:
useEffect(() => {
const quoteFinished =
quotesSplit.length == wordIdx && quotesSplit.length != 0;
if (quoteFinished) {
setGameState(GameState.VIEW_STATS);
}
}, [wordIdx, quotesSplit]);
Finally, let’s implement the onClickNextQuote
functionality by creating a new method called nextQuote
. Below is the changes:
// ...
const nextQuote = () => {
setGameState(GameState.PLAYING);
};
return (
// ...
{quote && gameState == GameState.VIEW_STATS && (
<StatsDisplay
startTime={0}
endTime={1000}
quote={quote}
numOfWords={quotesSplit.length}
onClickNextQuote={nextQuote} // change this line
/>
)}
)
// ...
This should be the current state of our app:
Adding Start & End Time
We can look stats, but the word per minute and word per second are still an arbitrary number. Let’s fix that by creating 2 state, startTime
, and endTime
const [startTime, setStartTime] = useState<number>(0)
const [endTime, setEndTime] = useState<number>(0)
Let’s set the startTime
when the game begin ( gameState==PLAYING
) and set endTime
when the the player finished typing( gameState==VIEW_STATS
). Also, change the StatsDisplay so that it uses the state instead of arbitrary number:
useEffect(() => {
if (gameState == GameState.PLAYING) {
setQuote(randomQuote());
setStartTime(Date.now()); // add this line to an existing useEffect
}
if (gameState == GameState.VIEW_STATS) {
setEndTime(Date.now()); // add this line to an existing useEffect
}
}, [gameState]);
return (
// ...
{quote && gameState == GameState.VIEW_STATS && (
<StatsDisplay
startTime={startTime}
endTime={endTime}
quote={quote}
numOfWords={quotesSplit.length}
onClickNextQuote={nextQuote}
/>
)}
// ...
)
Now, we got some real Word Per Minute (WPM) / Word Per Second (WPS)!
Focusing Input After A Quote
The main functionality is done and all, but it’s annoying to click the input bar every time we’re done with a quote, don’t you agree? So, let’s fix that problem by moving the input.focus
part to when gameState == PLAYING
:
// Previously this:
/*
useEffect(() => {
document.getElementById(inputId)?.focus();
}, []);
*/
// move it to this part:
useEffect(() => {
if (gameState == GameState.PLAYING) {
document.getElementById(inputId)?.focus(); // add this line
setQuote(randomQuote());
setStartTime(Date.now());
}
if (gameState == GameState.VIEW_STATS) {
setEndTime(Date.now());
}
}, [gameState]);
That’s it, now our input is focused while we’re playing the game.
Disabling Input When Viewing Stats
This is the last part. Currently, our input is not disabled when the player is viewing stats. It should be disabled because the quote is finished. So, let’s change our code inside the <input … >
tag to be disabled while viewing stats:
<input
className="w-full border-black border px-4 py-2"
onChange={(text) => setText(text.target.value)}
value={text}
disabled={gameState == GameState.VIEW_STATS}
id={inputId}
/>
Tadah! It’s disabled when we’re viewing stats:
Final Code
You can see here for the finished index.tsx
code in our simple typeracer game. Also, here is the full repository for the game.
Afterthoughts
After a bit of self review and some days in between editing this code/article, I have realized something… that this code is really dirty. I will not clean it right now, since I’m currently finishing up my renewed portfolio website, but it might be done in the future for content :).
Anyway, thanks for reading! I hope this was helpful for you in some ways, or at least it was an interesting read.