Ad – 728Γ—90
πŸ› οΈ Projects

JavaScript Memory Game – Build a Card Matching Game

Build a complete memory card game with 16 cards (8 pairs), Fisher-Yates shuffle, CSS flip animations, match detection, move counter, timer, win screen, and restart. This project combines closures for game state, setTimeout for the flip-back delay, and CSS 3D transforms for smooth card animations.

⏱️ 35 min read 🎯 Advanced πŸ“… Updated 2026

Game Design Overview

The memory game presents 16 face-down cards. The player flips two cards per turn. If they match, they stay revealed. If not, they flip back after a brief delay. The game ends when all 8 pairs are matched.

Game ElementImplementation
16 cards (8 pairs)Array of 8 symbols duplicated and shuffled
Card flip animationCSS 3D transform (rotateY 180Β°)
ShuffleFisher-Yates in-place algorithm
Match detectionCompare data-symbol of two flipped cards
Flip-back delaysetTimeout 1000ms for unmatched pair
Win detectionCheck if matchedCount === 8
Move counterIncrement on every pair attempt
TimersetInterval each second
Lock mechanismBoolean flag prevents 3rd card flip during check

Fisher-Yates Shuffle Explained

The Fisher-Yates (also called Knuth) shuffle produces an unbiased permutation β€” every arrangement is equally likely. It works by iterating from the end of the array and swapping each element with a randomly chosen element from 0 to the current index.

JavaScript
// Fisher-Yates Shuffle β€” O(n) time, O(1) extra space
function shuffle(arr) {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1)); // random index 0..i
    [arr[i], arr[j]] = [arr[j], arr[i]];            // ES6 swap
  }
  return arr;
}

// Example:
const deck = ["🦁","🦁","🐯","🐯","🦊","🦊","🐻","🐻"];
console.log(shuffle(deck));
// ["🐻","🦊","🦁","🐯","🦁","🐻","🦊","🐯"] (order varies)

// Why not arr.sort(() => Math.random() - 0.5)?
// That approach is biased β€” some permutations appear more
// often than others due to how sort algorithms work.
// Fisher-Yates guarantees uniform randomness.
Common Mistake: Never use array.sort(() => Math.random() - 0.5) to shuffle. It produces biased results because comparison-based sorts call the comparator multiple times per element, giving some permutations a higher probability. Fisher-Yates is always the correct answer in interviews.

HTML Structure

JavaScript
/* memory.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Memory Game</title>
  <link rel="stylesheet" href="memory-style.css">
</head>
<body>
  <div class="game-container">
    <header class="game-header">
      <h1>Memory Game</h1>
      <div class="game-stats">
        <div class="stat"><span>Moves</span><span id="moves">0</span></div>
        <div class="stat"><span>Time</span><span id="timer">0:00</span></div>
        <div class="stat"><span>Pairs</span><span id="pairs">0 / 8</span></div>
      </div>
      <button id="restart-btn">Restart</button>
    </header>

    <div class="card-grid" id="card-grid"></div>

    <!-- Win overlay -->
    <div id="win-overlay" class="win-overlay hidden">
      <div class="win-box">
        <h2>You Won! πŸŽ‰</h2>
        <p id="win-message"></p>
        <button id="play-again-btn">Play Again</button>
      </div>
    </div>
  </div>
  <script src="memory-script.js"></script>
</body>
</html>
*/

Complete Game Logic

JavaScript
// memory-script.js β€” Complete Memory Game

// 8 unique symbols β†’ duplicated to make 16 cards
const SYMBOLS = ["🦁","🐯","🦊","🐻","🐼","πŸ¦‹","🌸","⭐"];

// ---------- Game State (closure pattern) ----------
let flippedCards  = [];   // max 2 elements
let matchedCount  = 0;
let moves         = 0;
let isLocked      = false; // prevent flipping 3rd card during check
let timerInterval = null;
let seconds       = 0;

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

// ---------- Create Deck ----------
function createDeck() {
  // Duplicate each symbol and assign a unique ID to each card
  return shuffle([...SYMBOLS, ...SYMBOLS].map((symbol, i) => ({
    id: i,
    symbol
  })));
}

// ---------- Render Board ----------
function renderBoard(deck) {
  const grid = document.getElementById("card-grid");
  grid.innerHTML = deck.map(card => `
    <div class="card" data-id="${card.id}" data-symbol="${card.symbol}">
      <div class="card-inner">
        <div class="card-front">?</div>
        <div class="card-back">${card.symbol}</div>
      </div>
    </div>
  `).join("");
}

// ---------- Update Stats Display ----------
function updateStats() {
  document.getElementById("moves").textContent = moves;
  document.getElementById("pairs").textContent = `${matchedCount} / 8`;
}

// ---------- Timer ----------
function startTimer() {
  stopTimer();
  seconds = 0;
  timerInterval = setInterval(() => {
    seconds++;
    const m = Math.floor(seconds / 60);
    const s = seconds % 60;
    document.getElementById("timer").textContent =
      `${m}:${s.toString().padStart(2, "0")}`;
  }, 1000);
}
function stopTimer() {
  clearInterval(timerInterval);
  timerInterval = null;
}

// ---------- Card Flip ----------
function flipCard(card) {
  if (isLocked) return;                        // locked during check
  if (card.classList.contains("flipped")) return; // already flipped
  if (card.classList.contains("matched")) return; // already matched

  card.classList.add("flipped");
  flippedCards.push(card);

  if (flippedCards.length === 2) {
    moves++;
    updateStats();
    checkMatch();
  }
}

// ---------- Match Check ----------
function checkMatch() {
  const [a, b] = flippedCards;
  const isMatch = a.dataset.symbol === b.dataset.symbol;

  if (isMatch) {
    a.classList.add("matched");
    b.classList.add("matched");
    matchedCount++;
    updateStats();
    flippedCards = [];
    if (matchedCount === SYMBOLS.length) {
      setTimeout(showWin, 400);
    }
  } else {
    isLocked = true; // lock board while unmatched cards are showing
    setTimeout(() => {
      a.classList.remove("flipped");
      b.classList.remove("flipped");
      flippedCards = [];
      isLocked = false; // unlock after flip-back
    }, 1000);
  }
}

// ---------- Win Screen ----------
function showWin() {
  stopTimer();
  const m = Math.floor(seconds / 60);
  const s = seconds % 60;
  const overlay = document.getElementById("win-overlay");
  document.getElementById("win-message").textContent =
    `Completed in ${moves} moves and ${m}:${s.toString().padStart(2,"0")} minutes!`;
  overlay.classList.remove("hidden");
}

// ---------- Start / Restart ----------
function startGame() {
  // Reset state
  flippedCards  = [];
  matchedCount  = 0;
  moves         = 0;
  isLocked      = false;

  document.getElementById("win-overlay").classList.add("hidden");
  updateStats();

  const deck = createDeck();
  renderBoard(deck);

  // Attach click listeners after board is rendered
  document.querySelectorAll(".card").forEach(card => {
    card.addEventListener("click", () => flipCard(card));
  });

  startTimer();
}

// ---------- Event Listeners ----------
document.getElementById("restart-btn").addEventListener("click", startGame);
document.getElementById("play-again-btn").addEventListener("click", startGame);

// ---------- Initialize ----------
startGame();
Game Flow: Board renders with 16 face-down cards. Timer starts. Player clicks two cards β€” if symbols match they stay flipped and glow green. If not, they flip back after 1 second. Board locks during the 1-second delay to prevent cheating. After all 8 pairs match, the win overlay shows moves and time.

CSS Flip Animation

The 3D card flip effect uses CSS perspective, transform-style: preserve-3d, and rotateY transforms. The front and back faces are absolutely positioned on top of each other β€” one starts rotated 180Β° so only one face is visible at a time.

JavaScript
/* memory-style.css β€” Key flip animation styles

.card-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 12px;
  max-width: 460px;
  margin: 0 auto;
}

.card {
  aspect-ratio: 1;
  perspective: 500px; /* 3D perspective for the flip */
  cursor: pointer;
}

.card-inner {
  width: 100%;
  height: 100%;
  position: relative;
  transform-style: preserve-3d;
  transition: transform 0.5s ease;
  border-radius: 12px;
}

/* Flip when .flipped class is added */
.card.flipped .card-inner,
.card.matched .card-inner {
  transform: rotateY(180deg);
}

.card-front, .card-back {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 12px;
  backface-visibility: hidden; /* hide the reverse side */
  font-size: 36px;
}

.card-front {
  background: linear-gradient(135deg, #3b82f6, #8b5cf6);
  color: white;
  font-size: 28px;
  font-weight: 700;
}

.card-back {
  background: #f8fafc;
  border: 2px solid #e2e8f0;
  transform: rotateY(180deg); /* starts facing away */
}

.card.matched .card-inner {
  animation: matchPulse 0.4s ease;
}
@keyframes matchPulse {
  0%   { transform: rotateY(180deg) scale(1); }
  50%  { transform: rotateY(180deg) scale(1.1); }
  100% { transform: rotateY(180deg) scale(1); }
}
.card.matched .card-back {
  background: #d1fae5;
  border-color: #10b981;
}
*/
backface-visibility: hidden is the critical CSS property that makes the flip illusion work. Without it, you'd see a mirrored version of each face bleeding through during the rotation. Both faces are stacked on top of each other; backface-visibility: hidden ensures only the face currently pointing toward the viewer is visible.
Concepts Practiced: Arrays (shuffle, duplicating symbols), closures (game state variables), setTimeout for async delays, setInterval for the timer, CSS 3D transforms, event handling, and game state management with a lock mechanism.
Ad – 336Γ—280

Game State Management

All game state lives at the top of the script as module-level variables captured by event handlers as closures. This is a clean functional approach that avoids global namespace pollution while keeping the state accessible to all game functions.

JavaScript
// State machine perspective:
// idle β†’ flip-first-card β†’ flip-second-card β†’ checking-match
//                                                    ↓ (match)    β†’ idle (matched++)
//                                                    ↓ (no match) β†’ locked β†’ flip-back β†’ idle

// The isLocked boolean represents the "locked" state
// flippedCards.length represents position in the flip sequence

// Alternative: class-based game state (cleaner for larger games)
class MemoryGame {
  #deck = [];
  #flipped = [];
  #matched = 0;
  #moves = 0;
  #locked = false;

  constructor(symbols) {
    this.#deck = this.#shuffle([...symbols, ...symbols]);
  }

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

  get deck()    { return [...this.#deck]; }
  get moves()   { return this.#moves; }
  get matched() { return this.#matched; }
  get isWon()   { return this.#matched === this.#deck.length / 2; }

  flip(symbolIndex) {
    if (this.#locked || this.#flipped.length === 2) return null;
    this.#flipped.push(symbolIndex);
    if (this.#flipped.length === 2) {
      this.#moves++;
      const isMatch = this.#deck[this.#flipped[0]] === this.#deck[this.#flipped[1]];
      if (isMatch) this.#matched++;
      return isMatch;
    }
    return null;
  }

  resetFlipped() { this.#flipped = []; this.#locked = false; }
  lock()         { this.#locked = true; }
}

Enhancements

Extend the Memory Game

  • Difficulty levels β€” Easy (4Γ—3, 6 pairs), Medium (4Γ—4, 8 pairs), Hard (5Γ—4, 10 pairs)
  • High score board β€” localStorage leaderboard with best times per difficulty
  • Sound effects β€” use the Web Audio API to play a tone on match and a buzz on miss
  • Custom emoji sets β€” let the user choose from Animals, Food, Flags, etc.
  • Accessibility β€” add aria-pressed for flipped state and keyboard navigation (Tab + Enter to flip)

Keyboard Accessibility Challenge

Make the game fully playable with keyboard only:

// 1. Add tabindex="0" to all cards
// 2. Listen for keydown Enter/Space
document.querySelectorAll(".card").forEach(card => {
  card.setAttribute("tabindex", "0");
  card.setAttribute("role", "button");
  card.setAttribute("aria-label", "Memory card");
  card.addEventListener("keydown", e => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      flipCard(card);
    }
  });
  // Update aria-pressed when flipped
  const observer = new MutationObserver(() => {
    card.setAttribute("aria-pressed", card.classList.contains("flipped").toString());
  });
  observer.observe(card, { attributes: true, attributeFilter: ["class"] });
});

Concepts Practiced

ConceptWhere Used
ArraysDeck creation, shuffle, flippedCards tracking
Fisher-Yates Shuffleshuffle() for unbiased randomization
ClosuresGame state variables captured by event handlers
setTimeout1-second delay before flipping back unmatched cards
setIntervalTimer counting elapsed seconds
CSS 3D TransformsCard flip animation with perspective
DOM GenerationCard grid built from data with template literals
Lock MechanismisLocked flag prevents invalid state transitions

FAQ

Why is isLocked needed?

Without the lock, if a user clicks a third card before the 1-second flip-back timeout fires, the game state becomes corrupted β€” three cards could be in the flippedCards array. The lock ensures only two cards can be "in play" at any time.

Why duplicate symbols before shuffling instead of generating pairs on the fly?

Creating the full deck array first ([...symbols, ...symbols]) then shuffling it is cleaner and more explicit. It makes the data structure easy to inspect and debug. You could generate pairs on the fly, but it adds complexity without benefit.

Why use CSS for the flip animation instead of JavaScript?

CSS animations run off the main thread (on the compositor), making them smooth even under JavaScript load. Using transition: transform 0.5s and toggling a class is much more performant than manually animating with requestAnimationFrame.

How do I add sound effects without a library?

Use the Web Audio API: const ctx = new AudioContext(), create an oscillator, set frequency and type, connect to destination, and call start()/stop(). For match: a pleasant chord. For miss: a short low buzz. No audio files required.

How would I save a high score?

On win, compare the current time against the stored best time in localStorage. If better, update the record. Display the best time on the win screen and game header.

Summary

  • Fisher-Yates shuffle: the correct, unbiased way to randomize arrays in JavaScript
  • Lock mechanism: essential for sequential async game state β€” prevents invalid flips during setTimeout delay
  • CSS 3D flip: perspective + transform-style: preserve-3d + backface-visibility: hidden
  • Game state: closures capture all state variables (flippedCards, matchedCount, moves) for event handlers
  • Timer: setInterval increments seconds; stopTimer clears interval on win or restart
  • Fisher-Yates shuffle is one of the most commonly asked algorithm questions β€” know it cold
  • The lock flag demonstrates understanding of async state management β€” a very mature programming concept
  • CSS flip animations without JavaScript show CSS mastery alongside JavaScript skills
  • This project is a strong portfolio piece β€” it combines algorithms, animations, state management, and game logic
  • The class-based alternative shows you can architect code for scale, not just for one small feature