A Memory Card Matching Game With CSS 3D Flip and Immutable State
Source: Dev.to
Overview
Memory / concentration / 神経衰弱 — every culture has a name for the same game. Flip two cards, see if they match, try to clear the board. The game logic is about 100 lines of pure functions; the interesting bits are the CSS 3D flip animation and the state machine that handles “two cards showing but not yet matched”.
Live Demo
🔗 https://sen.ltd/portfolio/memory-game/
Source Code
GitHub: https://github.com/sen-ltd/memory-game
Features
- 4 difficulty levels (12 to 64 cards)
- 5 themes: emoji, numbers, alphabet, shapes, hiragana
- CSS 3D flip animation
- Moves counter + timer
- Best‑score tracking per difficulty (localStorage)
- Confetti celebration on win
- Japanese / English UI
- Zero dependencies, 38 tests
State Shape
{
cards: [{ id, value, flipped, matched }],
firstCard: null | cardId, // the unmatched flipped card, if any
moves: 0,
matches: 0,
started: boolean,
startedAt: number | null,
}
Each card has a stable id and a value. Two cards in a pair share the same value but have different ids. The firstCard field tracks “one card is currently flipped, waiting for the second”.
flipCard(state, cardId)
export function flipCard(state, cardId) {
const card = state.cards.find(c => c.id === cardId);
if (!card || card.flipped || card.matched) return state; // no‑op
// Starting timer on first flip
const started = state.started || true;
const startedAt = state.startedAt || Date.now();
const newCards = state.cards.map(c =>
c.id === cardId ? { ...c, flipped: true } : c
);
if (state.firstCard === null) {
// First of a pair
return { ...state, cards: newCards, firstCard: cardId, started, startedAt };
}
// Second of a pair — increment moves, caller will follow up with checkMatch
return {
...state,
cards: newCards,
firstCard: state.firstCard,
moves: state.moves + 1,
started,
startedAt,
};
}
Every function returns a new state — no mutation. The guard if (card.flipped || card.matched) makes the function idempotent: clicking an already‑flipped card does nothing. The UI wraps this with state = flipCard(state, id) inside the click handler.
checkMatch(state)
export function checkMatch(state) {
if (state.firstCard === null) return state;
const flipped = state.cards.filter(c => c.flipped && !c.matched);
if (flipped.length
c.flipped && !c.matched ? { ...c, matched: true } : c
),
firstCard: null,
matches: state.matches + 1,
};
}
// No match — flip both back
return {
...state,
cards: state.cards.map(c =>
c.flipped && !c.matched ? { ...c, flipped: false } : c
),
firstCard: null,
};
}
Matched cards stay flipped with matched: true (the UI shows them slightly faded). Non‑matched cards flip back and firstCard resets. Clicking while the timer is running is prevented by the guard in flipCard.
CSS 3D Flip Animation
.card {
perspective: 1000px;
cursor: pointer;
}
.card-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.4s;
}
.card.flipped .card-inner {
transform: rotateY(180deg);
}
.card-front,
.card-back {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.card-back {
transform: rotateY(180deg);
}
The key property is backface-visibility: hidden. Without it, both faces would render on top of each other while rotating. With it, each face is visible only when facing the camera — the front at 0°, the back at 180°.
Shuffle Algorithm
export function shuffle(array) {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
This is a Fisher–Yates shuffle, uniform over all permutations. The naive .sort(() => Math.random() - 0.5) is biased and should not be used for games.
Portfolio Entry
This is entry #99 in a series of 100+ public portfolio projects.