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

Published: (April 14, 2026 at 08:16 PM EDT)
4 min read
Source: Dev.to

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.

Repository & Live Site

Company

https://sen.ltd/

0 views
Back to Blog

Related posts

Read more »