Architecture & Core Concept
Unlike canvas games, memory card matching is best built using semantic HTML <button> elements. Each card contains two faces: - .face.front (the hidden picture) - .face.back (the card backing design) CSS transform: rotateY(180deg) flips the card. When two cards are opened: If their dataset.value matches, tag both with .matched and play a celebratory chime. If they mismatch, lock clicks for 800ms, then flip both back.
Key Game Mechanics Taught:
- CSS 3D perspective and card flipping with backface-visibility: hidden
- The Fisher-Yates shuffle algorithm for un-biased card distribution
- Game state locking during card flip evaluation timeouts
- Web Audio harmonic victory and error chords
- Move counters and match completion tracking
Step-by-Step Implementation
CSS 3D Card Flip Structure
Using CSS perspective and transform-style: preserve-3d enables GPU-accelerated 3D flips without complex 3D libraries.
.card {
perspective: 800px;
position: relative;
width: 100px; height: 120px;
background: transparent; border: none; cursor: pointer;
}
.card-inner {
width: 100%; height: 100%;
position: relative;
transform-style: preserve-3d;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.card.flipped .card-inner {
transform: rotateY(180deg);
}
.face {
position: absolute; inset: 0;
backface-visibility: hidden;
border-radius: 8px; display: flex; align-items: center; justify-content: center;
}
.face.back { background: #3b82f6; }
.face.front { background: #ffffff; transform: rotateY(180deg); }
The Fisher-Yates Shuffle Algorithm
To ensure perfectly random and fair card distribution every round, duplicate the icons and shuffle using the Fisher-Yates swap method.
function shuffleDeck(items) {
const deck = [...items, ...items]; // Duplicate for pairs
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
}
Handle Card Comparison & Input Locking
Lock user interactions while checking mismatched cards so the player cannot accidentally flip a third card before the animation completes.
let firstCard = null, secondCard = null, isLocked = false;
function onCardClick(card) {
if (isLocked || card === firstCard || card.classList.contains('matched')) return;
card.classList.add('flipped');
if (!firstCard) {
firstCard = card;
return;
}
secondCard = card;
isLocked = true;
if (firstCard.dataset.value === secondCard.dataset.value) {
// Match!
setTimeout(() => {
firstCard.classList.add('matched');
secondCard.classList.add('matched');
firstCard = secondCard = null;
isLocked = false;
playSuccessChime();
}, 400);
} else {
// Mismatch: flip back after 850ms
setTimeout(() => {
firstCard.classList.remove('flipped');
secondCard.classList.remove('flipped');
firstCard = secondCard = null;
isLocked = false;
}, 850);
}
}
Complete Standalone Source Code
Here is the full, self-contained implementation. Save this as an index.html file on your computer and open it in any web browser to run your game immediately:
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Memory Card Match Demo</title>
<style>
body { margin:0; background:#f0fdf4; display:flex; flex-direction:column; align-items:center; padding:30px; font-family:sans-serif; }
.grid { display:grid; grid-template-columns:repeat(4, 90px); gap:14px; margin-top:20px; }
.card { width:90px; height:110px; perspective:800px; border:none; background:none; cursor:pointer; }
.inner { width:100%; height:100%; position:relative; transform-style:preserve-3d; transition:transform 0.4s; }
.card.flipped .inner { transform:rotateY(180deg); }
.face { position:absolute; inset:0; backface-visibility:hidden; border-radius:10px; display:flex; align-items:center; justify-content:center; font-size:40px; box-shadow:0 4px 10px rgba(0,0,0,0.1); }
.back { background:#10b981; color:#fff; }
.front { background:#fff; transform:rotateY(180deg); }
.card.matched .front { background:#dcfce7; }
</style></head><body>
<h2>Memori Ceria</h2>
<div class="grid" id="board"></div>
<script>
const board = document.querySelector('#board');
const icons = ['๐','๐','๐','๐','๐','๐'];
const deck = [...icons, ...icons].sort(() => Math.random() - 0.5);
let c1 = null, c2 = null, locked = false;
deck.forEach(val => {
const card = document.createElement('button');
card.className = 'card';
card.innerHTML = `<div class="inner"><div class="face back">?</div><div class="face front">${val}</div></div>`;
card.onclick = () => {
if(locked || card === c1 || card.classList.contains('matched')) return;
card.classList.add('flipped');
if(!c1) { c1 = card; return; }
c2 = card; locked = true;
if(c1.querySelector('.front').textContent === c2.querySelector('.front').textContent) {
setTimeout(() => { c1.classList.add('matched'); c2.classList.add('matched'); c1 = c2 = null; locked = false; }, 350);
} else {
setTimeout(() => { c1.classList.remove('flipped'); c2.classList.remove('flipped'); c1 = c2 = null; locked = false; }, 800);
}
};
board.append(card);
});
</script>
</body></html>
Ideas to Expand Your Game
Now that you have the core mechanics running, consider adding:
- Local High Scores: Use
localStorage.setItem()andlocalStorage.getItem()to persist personal bests. - Visual Polish: Add screen shake on impacts by displacing
ctx.translate(shakeX, shakeY). - Mobile Touch Controls: Bind on-screen directional buttons using
onpointerdownandonpointerup.