VOOZH about

URL: https://dev.to/sendotltd/a-memory-card-matching-game-with-css-3d-flip-and-immutable-state-3g8n

⇱ A Memory Card Matching Game With CSS 3D Flip and Immutable State - DEV Community


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

👁 Screenshot

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.