JavaScript Game Tutorial

How to Make a Memory Card Game in JavaScript (Matching Pairs)

A memory game teaches state management in miniature: which cards are face up, which are solved, and when the player is allowed to click. It is also the best excuse to learn how to shuffle an array properly.

BeginnerJavaScriptDOM12 min read

Production context

This guide studies Memory, a published Supagames game. Repository source: games/lvl01/03-memory.html.

Play the Supagames Memory on the 4x4 board first, then try 6x6. The code below follows that game, uses emoji instead of images so there are no assets to load, and includes the shuffle fix we applied to it.

You need a grid container and a moves counter. Cards are plain div elements; CSS classes control whether they show their face.

1. Build the Deck From Pairs

A memory board needs every symbol exactly twice. Take as many symbols as you need pairs, then concatenate the list with itself. A 4x4 board has 16 cards and therefore 8 pairs; a 6x6 board needs 18.

Store only the symbols in the cards array. A card is identified by its index, which stays stable for the whole game.

const symbols = ["🍎", "🍊", "🍋", "🍇", "🍓", "🍒",
                 "🍑", "🥝", "🍌", "🍉", "🍍", "🥭",
                 "🍈", "🌽", "🥕", "🥦", "🍄", "🥥"];

function buildDeck(size) {
  const pairs = (size * size) / 2;
  const chosen = symbols.slice(0, pairs);
  return shuffle([...chosen, ...chosen]);
}

2. Shuffle Fairly With Fisher-Yates

The most common shuffle on the internet is array.sort(() => Math.random() - 0.5). It is short and wrong: sorting algorithms assume a consistent comparison, so the result is biased and some layouts appear far more often than others. Our own Memory game used it until we wrote this tutorial.

Fisher-Yates walks from the end of the array and swaps each element with a random element at or before it. Every possible order is equally likely and it runs in linear time.

function shuffle(array) {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
}

// Avoid: array.sort(() => Math.random() - 0.5)  // biased

3. Track Flipped and Matched Cards

Two small arrays describe the whole game: flipped holds the indexes of face-up cards waiting to be compared, matched holds every index already solved. A card can be clicked only when it is in neither list and the board is not locked.

When the second card is flipped, count a move and compare the two symbols. A match moves both indexes into matched; a mismatch waits and flips them back.

let cards = [], flipped = [], matched = [];
let moves = 0, locked = false;

function flipCard(index) {
  if (locked || flipped.includes(index) || matched.includes(index)) return;
  flipped.push(index);
  render();
  if (flipped.length < 2) return;

  moves++;
  movesEl.textContent = moves;
  const [a, b] = flipped;
  if (cards[a] === cards[b]) {
    matched.push(a, b);
    flipped = [];
    render();
    checkWin();
  } else {
    hideMismatch();
  }
}

4. Lock Input While Cards Turn Back

After a mismatch the player needs a moment to see both cards. During that moment, extra clicks must be ignored, otherwise a fast player could flip a third card and break the two-card rule.

A locked flag plus a timeout solves it. 800 milliseconds is long enough to read two emoji and short enough not to feel slow.

function hideMismatch() {
  locked = true;
  setTimeout(() => {
    flipped = [];
    locked = false;
    render();
  }, 800);
}

5. Count Moves and Detect the Win

A move is one attempt at a pair, so it is counted when the second card is flipped. The minimum possible score equals the number of pairs, and that only happens with perfect luck on every first flip.

The game is won when every card index is in matched. Showing the move count in the win message gives players a reason to replay and improve.

function checkWin() {
  if (matched.length !== cards.length) return;
  statusEl.textContent = "Solved in " + moves + " moves!";
}

function init(size) {
  cols = size;
  cards = buildDeck(size);
  flipped = [];
  matched = [];
  moves = 0;
  locked = false;
  movesEl.textContent = "0";
  render();
}

6. Render Cards With State Classes

Render reads the three pieces of state and gives each card a class: flipped for face-up cards waiting to be compared, matched for solved pairs. Only those cards show their symbol, so the HTML never leaks the answer for hidden cards.

Because the visual state is a class, a CSS 3D flip is easy to add later: rotate the card with transform: rotateY(180deg) on .flipped and add a transition. No JavaScript changes are needed.

function render() {
  grid.style.gridTemplateColumns = "repeat(" + cols + ", 1fr)";
  grid.innerHTML = "";
  cards.forEach((symbol, i) => {
    const card = document.createElement("div");
    card.className = "card";
    if (matched.includes(i)) { card.classList.add("matched"); card.textContent = symbol; }
    else if (flipped.includes(i)) { card.classList.add("flipped"); card.textContent = symbol; }
    card.addEventListener("click", () => flipCard(i));
    grid.appendChild(card);
  });
}

7. Scale Difficulty With Board Size

Difficulty in a memory game is mostly the number of pairs. The Supagames version offers 4x4 with 8 pairs and 6x6 with 18 pairs. The board must have an even number of cells, so odd sizes such as 5x5 do not work without a blank or bonus card.

Wire each difficulty button to init with its size, and remember the current size so the restart button replays the same board size.

document.getElementById("diff4").addEventListener("click", () => init(4));
document.getElementById("diff6").addEventListener("click", () => init(6));
document.getElementById("restartBtn").addEventListener("click", () => init(cols));

// 4x4 -> 8 pairs, 6x6 -> 18 pairs; the symbol list needs at least 18 entries
init(4);

8. Build checklist

  • Create the deck by duplicating the chosen symbols.
  • Shuffle with Fisher-Yates, never with sort and Math.random.
  • Keep flipped and matched indexes as the single source of truth.
  • Lock input while a mismatched pair is visible.
  • Count a move per pair attempt and show it in the win message.
  • Use even board sizes and remember the size for restarts.

9. FAQ

Why not shuffle with array.sort(() => Math.random() - 0.5)?

Sort expects a comparison that always gives the same answer for the same pair. A random comparison breaks that assumption, so the resulting order is biased and depends on the browser's sorting algorithm. Fisher-Yates is unbiased and just as short.

What is a good score in a memory card game?

The theoretical minimum equals the number of pairs, but that requires lucky first flips. With good memory, finishing a 4x4 board in around 12 to 16 moves is a strong result.

Can I use images instead of emoji?

Yes. Store image URLs in the symbols array and render an img element or a background-image instead of text. Preload the images at the start so the first flip does not show an empty card while it downloads.

What is the memory card game also called?

It is widely known as Concentration, Pairs or Match Up. The rules are the same: turn over two cards per turn and keep the pairs you find.

Previous: Tic-Tac-Toe with minimax AI Next: Tetris-style block game