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 Element | Implementation |
|---|---|
| 16 cards (8 pairs) | Array of 8 symbols duplicated and shuffled |
| Card flip animation | CSS 3D transform (rotateY 180Β°) |
| Shuffle | Fisher-Yates in-place algorithm |
| Match detection | Compare data-symbol of two flipped cards |
| Flip-back delay | setTimeout 1000ms for unmatched pair |
| Win detection | Check if matchedCount === 8 |
| Move counter | Increment on every pair attempt |
| Timer | setInterval each second |
| Lock mechanism | Boolean 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.
// 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.
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
/* 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
// 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();
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.
/* 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 ensures only the face currently pointing toward the viewer is visible.
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.
// 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
| Concept | Where Used |
|---|---|
| Arrays | Deck creation, shuffle, flippedCards tracking |
| Fisher-Yates Shuffle | shuffle() for unbiased randomization |
| Closures | Game state variables captured by event handlers |
| setTimeout | 1-second delay before flipping back unmatched cards |
| setInterval | Timer counting elapsed seconds |
| CSS 3D Transforms | Card flip animation with perspective |
| DOM Generation | Card grid built from data with template literals |
| Lock Mechanism | isLocked 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