A Memory Card Matching Game With CSS 3D Flip and Immutable State
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".
Matching games are a classic coding exercise because they teach state management. When the player clicks a card, what happens depends on how many cards are already flipped, whether they match, and whether a flip-back timer is running.
🔗 Live demo: https://sen.ltd/portfolio/memory-game/
📦 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
The state model
{
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 have the same value but different ids. The firstCard field tracks "one card is currently flipped, waiting for the second".
The flip logic
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 if (card.flipped || card.matched) guard makes the function idempotent: clicking an already-flipped card is a no-op. The UI wraps this with state = flipCard(state, id) inside the click handler.
Match check after a delay
When the second card is flipped, the UI waits ~700ms before calling checkMatch so the player has time to see the second card. After the delay:
export function checkMatch(state) {
if (state.firstCard === null) return state;
const flipped = state.cards.filter(c => c.flipped && !c.matched);
if (flipped.length < 2) return state;
const [a, b] = flipped;
if (a.value === b.value) {
// Match! Mark both as matched, clear firstCard
return {
...state,
cards: state.cards.map(c =>
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. The state machine is simple because "clicking while timer running" is prevented by the guard in flipCard — already-flipped cards are no-ops.
CSS 3D flip
The visual flip uses CSS transform: rotateY on a card container with transform-style: preserve-3d:
.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°.
Fisher-Yates shuffle
Standard shuffling algorithm for creating the initial deck:
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;
}
Uniform over all permutations. The naive .sort(() => Math.random() - 0.5) is biased and shouldn't be used for games.
Series
This is entry #99 in my 100+ public portfolio series — one away from the goal.
- 📦 Repo: https://github.com/sen-ltd/memory-game
- 🌐 Live: https://sen.ltd/portfolio/memory-game/
- 🏢 Company: https://sen.ltd/

Top comments (0)