Beginner / Game state & scoring

Shuffle a Memory Card Deck with Fisher–Yates

Screenshot of a source excerpt explained in this article.
Screenshot of a source excerpt explained in this article.

Randomizing a memory board is an algorithm problem before it is a visual one. A board can look shuffled while favoring some arrangements, duplicating a card object, or losing a symbol. The Memory Match deck builder is small enough to examine each of those risks directly.

What you'll build

We will isolate the real makeDeck function, explain its Fisher–Yates loop, and write checks that every supported board still contains exactly two cards per symbol. The exercise changes neither the scoring system nor the card renderer.

Working example · Open Memory Match and source

Prerequisites

Understand arrays, object identity, and a for loop. Open assets/js/engines/memory.js. The function accepts a pair count and an optional random-number function, so you can test it without starting a Canvas game.

Construct objects before shuffling positions

Each symbol produces two distinct card objects with the same symbol and an initially false matched flag. The objects must be separate. Putting the same object reference into the array twice would mean changing one card's flag also changes the other reference.

assets/js/engines/memory.js
export function makeDeck(pairCount, random = Math.random) {
  const symbols = ['★', '◆', '●', '▲', '☀', '♣', '☂', '♥'];
  const deck = symbols.slice(0, pairCount).flatMap(symbol => [
    { symbol, matched: false }, { symbol, matched: false }
  ]);
  // Fisher–Yates: each remaining position has the same chance of being picked.
  for (let i = deck.length - 1; i > 0; i--) {
    const j = Math.floor(random() * (i + 1));
    [deck[i], deck[j]] = [deck[j], deck[i]];
  }
  return deck;
}

This function supports the pair counts the factory actually requests: six, seven, and eight. It is not a general deck builder for arbitrary positive integers. The source contains eight available symbols, so asking for more pairs would not produce that requested size. A public utility should validate its input; this internal example relies on its factory contract.

Work backward through the array

At position i, the algorithm chooses an index from zero through i, inclusive. It swaps those two positions and then moves left. Positions to the right are finished and are not considered again. The random choice includes i itself, so leaving a card in its current position is a legitimate outcome.

For an array of three items, the first step has three possible choices and the next has two. Under a suitable uniform random source, those choices give six equally likely permutations. The reasoning extends because every step gives every remaining item an equal chance to fill the next final position.

Do not replace the upper bound with i instead of i + 1. That excludes one possible choice and changes the algorithm. Off-by-one mistakes are particularly easy to miss here because the output still appears disordered.

Why a random comparator is a poor substitute

An array sort comparator describes an ordering relationship. Returning unrelated random signs does not provide a consistent ordering. Its output depends on how the sort implementation chooses comparisons, so it is not a clear way to obtain uniformly distributed permutations.

The shuffle loop also communicates intent better. A reader can see exactly which positions remain eligible and why the algorithm ends. It takes a number of swaps proportional to the deck length, which is more than sufficient for this small game.

Test the inventory, not one expected picture

A useful first test checks that the deck length equals twice the pair count and that every symbol appears twice. A second check confirms the duplicate symbols belong to different objects. These invariants remain meaningful regardless of the particular random arrangement.

javascript
const deck = makeDeck(6);
const counts = new Map();
for (const card of deck) {
  counts.set(card.symbol, (counts.get(card.symbol) || 0) + 1);
}
console.assert(deck.length === 12);
console.assert([...counts.values()].every(count => count === 2));

Checking that two consecutive shuffles differ is not a reliable correctness test. Random processes can legitimately produce the same arrangement. For a reproducible execution path, inject a deterministic generator rather than asserting that chance never repeats itself.

Use the injected generator deliberately

The optional random parameter defaults to Math.random. Tests can replace it with a function that returns known values, allowing them to drive the swap loop without modifying global browser behavior. The generator's contract matters: it should return values in the half-open range from zero up to one.

A constant generator is useful for a predictable test board, but it is not a good production shuffle. Similarly, this game does not claim cryptographic unpredictability. The requirement is a fair casual arrangement, not protection against an adversary inspecting client-side state.

Next steps

Run the deck checks for six, seven, and eight pairs, then inspect how the Memory Match factory creates a fresh deck on restart. Keep shuffling separate from rendering and input. That separation lets you improve randomness or add a new board size without rewriting the logic that decides whether two revealed cards match.