Very often we come across small games and wonder how complex is it? Can we build it? More often than not we do not go beyond it. In this post however we will build a simple memory game which is easy to play and also easy to develop.
The card memory game is a simple game to test the player’s memory. In a deck of paired cards, the player needs to select a matching pair in consecutive turns. The player wins the game when all matching pairs are selected.
A simple UI of it may look like this:
Let us define the Rules of the Game
We can’t make a game unless we know the rules. So lets state them here:
We need a shuffled set of cards. There must be a pair of each card in our deck.
The game must flip the cards clicked by the player. Maximum of two cards will show at a time.
The game will handle matched and unmatched cards. Unmatched cards are flipped back after a short duration. Matched cards are removed from the deck.
Every time a player selects a pair, the game will increment the current move count
Once all pairs are found out, players sees a confirmation dialog with the score.
Game provides a functionality to restart.
So what are we waiting for... Lets get into it.
We first define our card structure. For a card we create an object with the type attribute and an image source.
{
type: 'Pickachu',
image: require('../images/Pickachu.png')
}
Now the next step is to shuffle the deck of cards. Ahh yes, this is the most important step. It is not really a memory game if we don’t shuffle
1. Shuffle
I will use **Fisher-Yates shuffle algorithm** for shuffling an array of cards.
// Fisher Yates Shuffle
function swap(array, i, j) {
const temp = array[i];
array[i] = array[j];
array[j] = temp;
}
function shuffleCards(array) {
const length = array.length;
for (let i = length; i > 0; i--) {
const randomIndex = Math.floor(Math.random() * i);
const currentIndex = i - 1;
swap(array, currIndex, randomIndex)
}
return array;
}
2. Render board for the deck of cards
In this example we are using 12 cards(6 pairs). After shuffling our cards, we render them as a grid of 3x4. You can either choose to split your card deck into 3 arrays of 4 items each and render using a nested map or use CSS flexbox or grid. I will be using CSS Grid to render it since it is easier to handle updates with a one dimension array.
export default function App({ uniqueCardsArray }) {
const [cards, setCards] = useState(
() => shuffleCards(uniqueCardsArray.concat(uniqueCardsArray))
);
const handleCardClick = (index) => {
// We will handle it later
};
return (
<div className="App">
<header>
<h3>Play the Flip card game</h3>
<div>
Select two cards with same content consequtively to make them vanish
</div>
</header>
<div className="container">
{cards.map((card, index) => {
return (
<Card
key={index}
card={card}
index={index}
onClick={handleCardClick}
/>
);
})}
</div>
</div>
)
}
.container {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr);
justify-items: center;
align-items: stretch;
gap: 1rem;
}
3. Flip Cards, evaluate match and count moves
The next step is to provide an interaction for the user to flip cards and evaluate if there is a match. For it we maintain the following states
openCards to track the cards that have been flipped by the player
clearedCards to track the cards that have matched and need to be removed from the deck
moves to keep track of the moves made by the player.
import { useEffect, useState, useRef } from "react";
import Card from "./card";
import uniqueElementsArray from './data';
import "./app.scss";
export default function App() {
const [cards, setCards] = useState(
() => shuffleCards(uniqueCardsArray.concat(uniqueCardsArray))
);
const [openCards, setOpenCards] = useState([]);
const [clearedCards, setClearedCards] = useState({});
const [moves, setMoves] = useState(0);
const [showModal, setShowModal] = useState(false);
const timeout = useRef(null);
// Check if both the cards have same type. If they do, mark them inactive
const evaluate = () => {
const [first, second] = openCards;
if (cards[first].type === cards[second].type) {
setClearedCards((prev) => ({ ...prev, [cards[first].type]: true }));
setOpenCards([]);
return;
}
// Flip cards after a 500ms duration
timeout.current = setTimeout(() => {
setOpenCards([]);
}, 500);
};
const handleCardClick = (index) => {
// Have a maximum of 2 items in array at once.
if (openCards.length === 1) {
setOpenCards((prev) => [...prev, index]);
// increase the moves once we opened a pair
setMoves((moves) => moves + 1);
} else {
// If two cards are already open, we cancel timeout set for flipping cards back
clearTimeout(timeout.current);
setOpenCards([index]);
}
};
useEffect(() => {
if (openCards.length === 2) {
setTimeout(evaluate, 500);
}
}, [openCards]);
const checkIsFlipped = (index) => {
return openCards.includes(index);
};
const checkIsInactive = (card) => {
return Boolean(clearedCards[card.type]);
};
return (
<div className="App">
<header>
<h3>Play the Flip card game</h3>
<div>
Select two cards with same content consequtively to make them vanish
</div>
</header>
<div className="container">
{cards.map((card, index) => {
return (
<Card
key={index}
card={card}
index={index}
isDisabled={shouldDisableAllCards}
isInactive={checkIsInactive(card)}
isFlipped={checkIsFlipped(index)}
onClick={handleCardClick}
/>
);
})}
</div>
</div>
);
}
At a time we shall only keep a maximum of two cards in openCards state. Since we have a static array and we aren’t actually deleting anything from our original cards array we can just store the index of the opened card in openCards state. Based on openCards and clearedCards state we pass a prop isFlipped or isInactive respectively to our Card component which it will then use to add the respective class.
Do look at this wonderful blog which explains how to handle Flip Card Animation. Note: Since we add an animation to our cards for flipping, we evaluate a match after few seconds to allow for the flip transition.
4. Check for game completion
Every time we evaluate for a match, we check if all pairs have been found. If yes, we show the player a completion modal.
const checkCompletion = () => {
// We are storing clearedCards as an object since its more efficient
//to search in an object instead of an array
if (Object.keys(clearedCards).length === uniqueCardsArray.length) {
setShowModal(true);
}
};
5. And finally, our restart functionality
Well restarting is simple, we just reset our states and reshuffle our cards.
<Button onClick={handleRestart} color="primary" variant="contained">
Restart
</Button>
const handleRestart = () => {
setClearedCards({});
setOpenCards([]);
setShowModal(false);
setMoves(0);
// set a shuffled deck of cards
setCards(shuffleCards(uniqueCardsArray.concat(uniqueCardsArray)));
};
Hurray! There we have our basic memory card game.
Conclusion
I am so glad we’ve reached this point. We created a shuffled deck, rendered it on a board, added a flip functionality and evaluated for a matching pair. We can extend this example to add a timer, add best score of the player and support level for higher numbers of cards as well.
You can check this Github repository for the full code.