How to build a Memory Game from scratch using React
August 17, 2020
React makes building games on the web easier, and in this tutorial we will be creating a memory game from scratch.
Here is a preview of what the game will look like:
We will be using NextJS for our React setup and React Spring for the card rotate animation. The only requirement for this tutorial is that you have Node JS installed.
Memory Game Rules
The game of memory involves selecting two squares from a group of squares in attempt to find a match. These are a few details we can plan out now before setting up the game.
- Game has an even number (n) of squares
- Different color for every 2 squares (n / 2 options)
- Each turn the user gets 2 choices (unlimited turns)
- Cards only stay flipped over when 2 match
- Game ends when all matches occur (n / 2) matches
React Setup
Let’s start by creating a React application using create-next-app
npx create-next-app memory-game
Next let’s change directory into this project and install React Spring for our card animation with the commands
cd memory-game
npm install react-spring
Our project is now configured and we can start it in the browser with
npm run dev
visit the running project at http://localhost:3000/
Memory Game Logic
The basic architecture for this project will be:
- an
App
parent component for the main menu - a
MemoryGame
child component to load the game in - a
Card
grandchild component to populate the game with
Step 1: Main Menu
Let’s start by creating the App
component with our basic menu logic. Replace the code in index.js
in the pages folder with the following.
import React, { useState, useEffect } from "react";
import { useSpring, animated as a } from "react-spring";
export default function App() {
const [options, setOptions] = useState(null)
const [highScore, setHighScore] = useState(0)
useEffect(() => {
// Loads when the game starts
}, [])
return (
<div>
<div className="container">
<h1>Memory Game</h1>
<div>High Score: {highScore}</div>
<div>
{options === null ? (
<>
<button onClick={() => setOptions(12)}>Easy</button>
<button onClick={() => setOptions(18)}>Medium</button>
<button onClick={() => setOptions(24)}>Hard</button>
</>
) : (
<>
<button
onClick={() => {
const prevOptions = options
setOptions(null)
setTimeout(() => {
setOptions(prevOptions)
}, 5)
}}
>
Start Over
</button>
<button onClick={() => setOptions(null)}>Main Menu</button>
</>
)}
</div>
</div>
{options ? (
<MemoryGame
options={options}
setOptions={setOptions}
highScore={highScore}
setHighScore={setHighScore}
/>
) : (
<h2>Choose a difficulty to begin!</h2>
)}
</div>
)
}
function MemoryGame(props) {
return <div>Game goes here</div>
}
Above we scaffolded out a menu component which relies on the options
value in state.
This variable will determine whether the user is playing a game and which difficulty it
is. An unstarted game is considered null
, while a current game can be 12
, 18
, or
24
depending on how many tiles you want to guess. We also added a highScore
useState
hook and an empty useEffect
hook we come go back to later.
Next, we conditionally rendered 5 different settings buttons depending on whether the
options were set (and the game started). If no game has been started the user can select
from Easy, Medium, and Hard. If the user has already started a game, they can
either start over the current game or return to the main menu. The Start Over button
utilizes a short (5ms) timeout in order to trigger a new game as otherwise the cards would
have the same props and not reset.
Lastly we render out a MemoryGame
component if the options exist, and a main menu
message otherwise.
Step 2: Game Board
Now that we’ve setup the menu logic, we can add a basic game board to the application.
This board will accept the number of options
as props and render that many game cards.
The MemoryGame
component will keep track of the current game state, the number of
attempts made, and the indexes of the cards that were guessed.
Replace the
MemoryGame
placeholder component from above with the following
function MemoryGame({options, setOptions, highScore, setHighScore}) {
const [game, setGame] = useState([])
const [flippedCount, setFlippedCount] = useState(0)
const [flippedIndexes, setFlippedIndexes] = useState([])
const colors = [
'#ecdb54',
'#e34132',
'#6ca0dc',
'#944743',
'#dbb2d1',
'#ec9787',
'#00a68c',
'#645394',
'#6c4f3d',
'#ebe1df',
'#bc6ca7',
'#bfd833',
]
useEffect(() => {
const newGame = []
for (let i = 0; i < options / 2; i++) {
const firstOption = {
id: 2 * i,
colorId: i,
color: colors[i],
flipped: false,
}
const secondOption = {
id: 2 * i + 1,
colorId: i,
color: colors[i],
flipped: false,
}
newGame.push(firstOption)
newGame.push(secondOption)
}
const shuffledGame = newGame.sort(() => Math.random() - 0.5)
setGame(shuffledGame)
}, [])
useEffect(() => {
// Loads when the game variable changes
}, [game])
if (flippedIndexes.length === 2) {
// Runs if two cards have been flipped
}
if (game.length === 0) return <div>loading...</div>
else {
return (
<div id="cards">
{game.map((card, index) => (
<div className="card" key={index}>
<Card
id={index}
color={card.color}
game={game}
flippedCount={flippedCount}
setFlippedCount={setFlippedCount}
flippedIndexes={flippedIndexes}
setFlippedIndexes={setFlippedIndexes}
/>
</div>
))}
</div>
)
}
}
function Card(props) {
return <div>i'm a card</div>
}
This component creates and renders our game board. The game
variable is an array with
length equal to the number stored in the options
variable.
The useEffect
hook runs here when the component renders and randomly assigns
options / 2
colors out to all the cards. Each card also has a flipped
property to
track if has been matched already, and a colorId
property to help with the matching.
Lastly the cards are shuffled using Math.random() and the game is set.
Below that we included an empty useEffect
hook which we will use later on to track our
game logic. Above the return statement we also included a loading state so that the game
only renders when the cards have been assigned color pairs (game.length > 0
).
Lastly we render out the cards (objects in the game
array) with map. We also passed
down the color
, game
, and both of our flipped hooks to each of the cards to use in
the click handlers we will soon set up.
Step 3: Adding Styles
Before we continue building the game, let’s add some styles so we can see what we’re working with. Next JS
allows you to add inline CSS styles with their <style>
tag. Add the following just below
the closing </div>
of the App
component.
<style jsx global>
{`
body {
text-align: center;
font-family: -apple-system, sans-serif;
}
.container {
width: 1060px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
button {
background: #00ad9f;
border-radius: 4px;
font-weight: 700;
color: #fff;
border: none;
padding: 7px 15px;
margin-left: 8px;
cursor: pointer;
}
button:hover {
background: #008378;
}
button:focus {
outline: 0;
}
#cards {
width: 1060px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
}
.card {
width: 160px;
height: 160px;
margin-bottom: 20px;
}
.card:not(:nth-child(6n)) {
margin-right: 20px;
}
.c {
position: absolute;
max-width: 160px;
max-height: 160px;
width: 50ch;
height: 50ch;
cursor: pointer;
will-change: transform, opacity;
}
.front,
.back {
background-size: cover;
}
.back {
background-image: url(https://images.unsplash.com/photo-1544511916-0148ccdeb877?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&w=1901&q=80i&auto=format&fit=crop);
}
.front {
background-image: url(https://images.unsplash.com/photo-1540206395-68808572332f?ixlib=rb-1.2.1&w=1181&q=80&auto=format&fit=crop);
}
`}
</style>
Step 4: Card Setup
React Spring has great
animation examples in their docs, and
for this project we will be using their
Flip Card Animation example as reference. Set
up the Card
component now by replacing the placeholder (at the bottom) with
function Card({
id,
color,
game,
flippedCount,
setFlippedCount,
flippedIndexes,
setFlippedIndexes,
}) {
const [flipped, set] = useState(false)
const {transform, opacity} = useSpring({
opacity: flipped ? 1 : 0,
transform: `perspective(600px) rotateX(${flipped ? 180 : 0}deg)`,
config: {mass: 5, tension: 500, friction: 80},
})
useEffect(() => {
console.log('Flipped Indexes Changed')
}, [flippedIndexes])
const onCardClick = () => {
console.log('Card Clicked')
set(state => state)
}
return (
<div onClick={onCardClick}>
<a.div
className="c back"
style={{
opacity: opacity.interpolate(o => 1 - o),
transform,
}}
/>
<a.div
className="c front"
style={{
opacity,
transform: transform.interpolate(t => `${t} rotateX(180deg)`),
background: color,
}}
/>
</div>
)
}
Each card will also have a flipped
value on the individual component. This value works
with the useSpring
hook below it to implement the flipping card animation.
We also included a useEffect
hook and a onCardClick
method which we will be adding
logic to. The Card’s jsx includes styles for the front and back of each card. We will
apply a background image to the back of every card and a background color to the
front.
These cards can be flipped over by running set((state) => !state)
, like in the
onCardClick
method. If you start a game in the browser now the cards will be flippable.
Step 5: Click Logic
Now that we can click all of the cards, it makes sense to add some JavaScript that will
check whether they are allowed to be flipped. If a card has already been guessed or
matched, we wouldn’t want to flip it again! To avoid these concerns we can update the
flippedCount
and flippedIndexes
values that we inherited from the MemoryGame
component.
The flippedCount
variable will increase by one each time either:
- The first guess is flipped
- The second guess is flipped
- The two cards match or don’t match The
flippedIndexes
variable will push the index of a flipped card. When two cards are flipped, if they are a match we will pushfalse
as the third entry in this array to avoid a reset. If they are not a match we will instead pushtrue
and trigger both cards to flip back over.
Replace theonCardClick
method in theCard
component with
const onCardClick = () => {
if (!game[id].flipped && flippedCount % 3 === 0) {
set(state => !state)
setFlippedCount(flippedCount + 1)
const newIndexes = [...flippedIndexes]
newIndexes.push(id)
setFlippedIndexes(newIndexes)
} else if (
flippedCount % 3 === 1 &&
!game[id].flipped &&
flippedIndexes.indexOf(id) < 0
) {
set(state => !state)
setFlippedCount(flippedCount + 1)
const newIndexes = [...flippedIndexes]
newIndexes.push(id)
setFlippedIndexes(newIndexes)
}
}
This method will act on two different conditions, the first and second valid clicks of
each turn. They each essentially do the same thing, updating the MemoryGame
state
variables mentioned above. The second condition also checks that the first guess wasn’t
guessed again.
Step 6: Match Logic
Now that we’ve set a click listener to update our state, we can specify what happens to
the game when two indexes are added to flippedIndexes
. We scaffolded this condition out
in the MemoryGame
component earlier. Replace that empty code block with the following
if (flippedIndexes.length === 2) {
const match = game[flippedIndexes[0]].colorId === game[flippedIndexes[1]].colorId
if (match) {
const newGame = [...game]
newGame[flippedIndexes[0]].flipped = true
newGame[flippedIndexes[1]].flipped = true
setGame(newGame)
const newIndexes = [...flippedIndexes]
newIndexes.push(false)
setFlippedIndexes(newIndexes)
} else {
const newIndexes = [...flippedIndexes]
newIndexes.push(true)
setFlippedIndexes(newIndexes)
}
}
When two cards are flipped they will either be a match or not, and we can check for that.
First we set a boolean variable match
to check whether the indexes of the cards we
flipped have the same colorId
in the game board. The colorId
is an index 0, 1, 2, etc
for the color of the card which we generated at the beginning of the game.
If the match is made and the colorId
s of each card are the same, we clone the game board
to update the flipped
values of those cards in the game
array. Once the newGame
variable is ready, we update the game and set the third flippedIndexes
value to be
false, preventing a flip reset.
If the match wasn’t made, we instead leave the game board alone and add true
to the
flippedIndexes
array, triggering a flip reset.
Lastly, for our match logic we need to fill out the useEffect
hook we setup earlier in
the Card
component. Replace this hook now with the following
useEffect(() => {
if (flippedIndexes[2] === true && flippedIndexes.indexOf(id) > -1) {
setTimeout(() => {
set(state => !state)
setFlippedCount(flippedCount + 1)
setFlippedIndexes([])
}, 1000)
} else if (flippedIndexes[2] === false && id === 0) {
setFlippedCount(flippedCount + 1)
setFlippedIndexes([])
}
}, [flippedIndexes])
Under the first condition, if the third value in flippedIndexes
is true we don’t have a
match. Instead of flipping the cards back immediately, we are using a setTimeout
method
for 1 second to add a delay. Both conditions will add to the flippedCount
and empty the
flippedIndexes
array. Since we used flippedCount % 3
earlier for our card click
listener, the count will now be reset and another turn started.
Step 7: High Score
The game will eventually end when all the cards are matched. Since we have a
flippedCount
variable we can keep track of the users score based on how many attempts
they made. Replace the second (empty) useEffect
hook in the MemoryGame
component with
the following
useEffect(() => {
const finished = !game.some(card => !card.flipped)
if (finished && game.length > 0) {
setTimeout(() => {
const bestPossible = game.length
let multiplier
if (options === 12) {
multiplier = 5
} else if (options === 18) {
multiplier = 2.5
} else if (options === 24) {
multiplier = 1
}
const pointsLost = multiplier * (0.66 * flippedCount - bestPossible)
let score
if (pointsLost < 100) {
score = 100 - pointsLost
} else {
score = 0
}
if (score > highScore) {
setHighScore(score)
const json = JSON.stringify(score)
localStorage.setItem('memorygamehighscore', json)
}
const newGame = confirm('You Win!, SCORE: ' + score + ' New Game?')
if (newGame) {
const gameLength = game.length
setOptions(null)
setTimeout(() => {
setOptions(gameLength)
}, 5)
} else {
setOptions(null)
}
}, 500)
}
}, [game])
If all of the cards are flipped (or not some of the cards are not flipped 😅) the game
is finished. We can set a quick .5s timeout to allow the card animations to play out. The
next few blocks of code will determine a score for the game that is approximately 100
points for first. I added multipliers based on the difficulty, since it will definitely
take more turns to perfectly solve a hard game than an easy one.
The game will next check to see if your score was higher than the previous high score
before asking to start a new game. Notice we used
React Local Storage to set the
memorygamehighscore
. This variable will get stored in each user’s browser’s
localStorage
which we can also read from to load in the previous score. We can do that
now by replacing the empty useEffect hook in the App
main menu component
useEffect(() => {
const json = localStorage.getItem('memorygamehighscore')
const savedScore = JSON.parse(json)
if (savedScore) {
setHighScore(savedScore)
}
}, [])
Conclusion
Congratulations 🎉 we’ve setup a working Memory Game in React using hooks, localStorage, and spring animations!! React is a great library for building browser games and games are a great way to build strong coding skills. 🔥