DEV Community

SEN LLC
SEN LLC

Posted on

A Memory Card Matching Game With CSS 3D Flip and Immutable State

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,
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)