Build a Card Memory Game with React

Build a Card Memory Game with React

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.

Did you find this article valuable?

Support Shubham Khatri by becoming a sponsor. Any amount is appreciated!