ESC

Type to search the knowledge base.

Memory Match Game

Flip two cards, match or unflip, lock the board while the timer runs, and shuffle a pair list without mutating the original deck.

intermediate9 min read
  • machine-coding
  • interview
  • react
  • games

A memory board that lets you flip a third card while two are still turning back is not a game, it is a race. Interviewers wait for that click. Memory match is a small state machine: zero, one, or two unmatched cards face up, and a lock that lasts exactly as long as the unflip timer.

The other fail is shuffling in place. deck.sort(() => Math.random() - 0.5) mutates the prop, is a biased shuffle, and makes a rematch share a scrambled array with the parent. Fisher-Yates on a copy. New ids per deal so React keys stay honest.

Key takeaways

  • Each card has id, value, faceUp, and matched. value is the pair key. id is identity.
  • Flip at most two unmatched cards. On a match they stay up. On a miss, lock, wait, unflip.
  • Ignore input while the unflip timer is running. That lock is the round.
  • Shuffle a copy with Fisher-Yates. Do not mutate the original values list.
  • Count a move when the second card flips. Announce wins in a live region.

Flip two, match or unflip, lock the board while the timer runs

Deal from a list of pair values. Duplicate, give ids, shuffle the copy:

type Card = {
  id: string;
  value: string;
  faceUp: boolean;
  matched: boolean;
};

function fisherYates<T>(input: readonly T[]): T[] {
  const a = input.slice();
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    const tmp = a[i];
    a[i] = a[j];
    a[j] = tmp;
  }
  return a;
}

function deal(values: readonly string[]): Card[] {
  const doubled = values.flatMap((value) => [
    { id: `${value}-a`, value, faceUp: false, matched: false },
    { id: `${value}-b`, value, faceUp: false, matched: false },
  ]);
  return fisherYates(doubled);
}

Stable ids like heart-a / heart-b are enough if values are unique. If the parent can pass duplicate labels, use crypto.randomUUID() or an incrementing deal nonce plus index. Keys must not be the array index after a reshuffle if you also animate; for a full replace on New game, new ids plus a new array is cleaner. See keys in lists.

Flip rules:

  1. Ignore matched cards, already face-up cards, and any click while locked.
  2. First card: turn it face up. Store its id as firstId.
  3. Second card: turn it face up, increment moves. If value matches, set matched on both, clear firstId. If not, set locked, start a timeout, then face both down and clear firstId and locked.
function flipCard(cards: Card[], id: string): Card[] {
  return cards.map((c) => (c.id === id ? { ...c, faceUp: true } : c));
}

function markMatched(cards: Card[], a: string, b: string): Card[] {
  return cards.map((c) =>
    c.id === a || c.id === b ? { ...c, matched: true, faceUp: true } : c
  );
}

function unflip(cards: Card[], a: string, b: string): Card[] {
  return cards.map((c) =>
    c.id === a || c.id === b ? { ...c, faceUp: false } : c
  );
}

The timeout is the only timer. 700-1000 ms is playable. Store the timeout id and clear it on reset and unmount. A second New game click that leaves the old timeout running will unflip cards from the next deal. That is a real bug.

locked is not optional. Without it, a fast third click becomes a third face-up card, your firstId logic falls over, and you have to invent a queue. Lock the board. The UI can keep the buttons in the tab order and no-op, or disabled the unmatched face-down cards while locked. Disabled face-up cards during the miss animation is fine; do not disable the whole grid if that drops focus with no restoration.

Moves: increment on the second flip, match or miss. Do not increment on ignored clicks. Win when cards.every((c) => c.matched).

This is the same “derive the result, do not stash a stale flag” lesson as tic-tac-toe. won is every matched, not a boolean you forget to clear.

Problem statement

Build a memory match grid. Cards start face down. Flip two. Matching pairs stay up. Non-matches flip back after a short delay, and no other flip is accepted during that delay. Shuffle without mutating the input. Show a move count. Keyboard can flip cards.

Requirements

Must have

  • Even number of cards, pairs by value
  • Flip two, match stay or timeout unflip
  • Board lock while the unflip timer runs
  • Fisher-Yates on a copy
  • Moves counter
  • New game reshuffles
  • Each card is a button

Should have

  • aria-live for match, miss, and win
  • Distinct accessible names (row/column or “Card 3, face down”)
  • Configurable pair list and unflip delay

Nice to have

  • 4x4 / 6x6 size prop
  • Keyboard arrows with roving tabindex
  • Best score in sessionStorage

Planning (5 minutes out loud)

  1. Card model and a deal function you can unit test
  2. MVP: click two, match or unflip, lock
  3. Moves + win + new game
  4. Then names and live status
  5. Clear the timeout on reset

Architecture

MemoryMatch
├── Status (moves, live region)
├── Board
│   └── CardButton × n
└── New game

API

type MemoryMatchProps = {
  values?: readonly string[];
  unflipMs?: number;
};

A reducer is justified: flip, unflip, match, reset share one cards array. useReducer keeps the timeout out of the reducer. Schedule in the event handler, dispatch unflip from the timeout.

Implementation sketch

const DEFAULT_VALUES = ["A", "B", "C", "D", "E", "F", "G", "H"] as const;

function MemoryMatch({
  values = DEFAULT_VALUES,
  unflipMs = 800,
}: MemoryMatchProps) {
  const [cards, setCards] = useState(() => deal(values));
  const [firstId, setFirstId] = useState<string | null>(null);
  const [locked, setLocked] = useState(false);
  const [moves, setMoves] = useState(0);
  const timerRef = useRef<number | null>(null);

  const won = cards.length > 0 && cards.every((c) => c.matched);

  useEffect(() => {
    return () => {
      if (timerRef.current != null) window.clearTimeout(timerRef.current);
    };
  }, []);

  function newGame() {
    if (timerRef.current != null) window.clearTimeout(timerRef.current);
    timerRef.current = null;
    setCards(deal(values));
    setFirstId(null);
    setLocked(false);
    setMoves(0);
  }

  function onFlip(id: string) {
    if (locked || won) return;
    const card = cards.find((c) => c.id === id);
    if (!card || card.matched || card.faceUp) return;

    const next = flipCard(cards, id);
    setCards(next);

    if (firstId == null) {
      setFirstId(id);
      return;
    }

    setMoves((m) => m + 1);
    const first = next.find((c) => c.id === firstId);
    if (first && first.value === card.value) {
      setCards(markMatched(next, firstId, id));
      setFirstId(null);
      return;
    }

    setLocked(true);
    timerRef.current = window.setTimeout(() => {
      setCards((cur) => unflip(cur, firstId, id));
      setFirstId(null);
      setLocked(false);
      timerRef.current = null;
    }, unflipMs);
  }

  const cols = Math.sqrt(cards.length);
  const status = won
    ? `All pairs matched in ${moves} moves`
    : locked
      ? "No match, wait"
      : `${moves} moves`;

  return (
    <div>
      <p role="status" aria-live="polite">
        {status}
      </p>
      <div
        role="grid"
        aria-label="Memory match"
        style={{
          display: "grid",
          gridTemplateColumns: `repeat(${cols}, 4rem)`,
        }}
      >
        {cards.map((card, i) => {
          const row = Math.floor(i / cols) + 1;
          const col = (i % cols) + 1;
          const face = card.matched
            ? `matched ${card.value}`
            : card.faceUp
              ? card.value
              : "face down";
          return (
            <button
              key={card.id}
              type="button"
              role="gridcell"
              aria-label={`Row ${row}, column ${col}, ${face}`}
              disabled={card.matched || locked}
              onClick={() => onFlip(card.id)}
            >
              {card.faceUp || card.matched ? card.value : ""}
            </button>
          );
        })}
      </div>
      <button type="button" onClick={newGame}>
        New game
      </button>
    </div>
  );
}

disabled={card.matched || locked} is the blunt lock. Face-up unmatched cards during a miss are disabled too, which is what you want. If you want the pair still readable to AT, keep them enabled but no-op in onFlip because faceUp is already true.

Do not sort(() => Math.random() - 0.5). It is biased and it mutates if you skip slice. Fisher-Yates as written copies first.

Math.random() is fine for an interview deck. Do not pull in a PRNG unless they ask.

Timeouts in tests: drive unflipMs to 0 or use fake timers from fake setTimeout. Real 800 ms sleeps in CI are how this round gets flaky.

Accessibility essentials

Piece Approach
Board role="grid" and a short name
Card <button> plus role="gridcell", name includes position and face
Status role="status" for moves, miss wait, and win
Lock Ignore flips; do not drop focus into a dead page
New game Real button
Motion Instant face-up is fine. If you animate the flip, honor prefers-reduced-motion

Tab-to-each-button is acceptable for 16 cards. Roving tabindex is extra. Empty buttons with no name fail a keyboard pass. CSS-only symbols (emoji background, no text) need the aria-label.

Interview scoring

Signal What they want
Lock Third click does nothing during unflip
Same card Second click on the first card is ignored
Shuffle Copy + Fisher-Yates, original values intact
Moves +1 on second flip only
Reset Clears timeout, new ids, moves at 0
A11y Buttons, names, live win

They will double-click one card, then try to smash a third while two are green. Narrate the guards before you style the back face.

Footguns

  1. No lock, three face-up cards, broken pair logic
  2. Mutating values with in-place shuffle
  3. Using value as React key, two cards collide
  4. Stale timeout after New game
  5. Counting ignored clicks as moves
  6. disabled on the focused card with no focus move, stranding the keyboard in some browsers. Prefer no-op in onFlip for the two open cards, disable only matched ones if that shows up.

setTimeout without storing the id is how reset races appear. Treat the timer as state you own.

Interview out-loud answer

I would deal a copied, Fisher-Yates shuffled list of cards with unique ids and a pair value. A flip ignores matched, face-up, and locked cards. The first card just turns up. The second increments moves. Equal values stay face up and marked matched. Unequal values lock the board, and a timeout unflips both then unlocks. New game clears that timeout and deals again. Status is derived: move count, a wait line while locked, and a win when every card is matched. Each cell is a named button.

Further reading