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.
- 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, andmatched.valueis the pair key.idis 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:
- Ignore matched cards, already face-up cards, and any click while
locked. - First card: turn it face up. Store its id as
firstId. - Second card: turn it face up, increment
moves. Ifvaluematches, setmatchedon both, clearfirstId. If not, setlocked, start a timeout, then face both down and clearfirstIdandlocked.
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-livefor 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)
- Card model and a
dealfunction you can unit test - MVP: click two, match or unflip, lock
- Moves + win + new game
- Then names and live status
- 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
- No lock, three face-up cards, broken pair logic
- Mutating
valueswith in-place shuffle - Using
valueas Reactkey, two cards collide - Stale timeout after New game
- Counting ignored clicks as moves
disabledon the focused card with no focus move, stranding the keyboard in some browsers. Prefer no-op inonFlipfor 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.
Related on this site
- Tic-Tac-Toe Board
- Keys in lists
- useReducer for Complex State
- Keyboard Accessibility Checklist
- Implement a Fake setTimeout