Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 9 min read

How to Build a JavaScript Solitaire Game: Modeling Klondike State, Run Moves, and Draw-3 Rules

When developers ask how to build a JavaScript solitaire game, they usually expect the hard part to be dragging cards around the screen. In practice, pointer events are the easy half. The real craft in Klondike Solitaire

When developers ask how to build a JavaScript solitaire game, they usually expect the hard part to be dragging cards around the screen. In practice, pointer events are the easy half. The real craft in Klondike Solitaire is keeping your state transitions watertight when a player drags a five-card stack across the board, undoes three turns, switches from Draw 1 to Draw 3, or pulls a card back down from a foundation pile to rescue a trapped column.

Klondike is one of the best intermediate browser projects you can build because the state is finite and completely visible to your code: one stock pile, one waste pile, four foundations, and seven tableau columns. There is no server tick and no hidden opponent AIβ€”just strict state validation and crisp UI feedback.

Let’s walk through the architecture of a clean, bug-resistant Klondike engine in plain JavaScript, focusing on the parts tutorials usually gloss over: multi-card run validation, Draw 3 waste visibility, undo history, and why your shuffle shouldn't rely on Math.random().

1. Designing the Data Model

Before touching the DOM or a <canvas>, define the data structures so that any board state can be serialized into a single plain object. That single decision makes unlimited Undo trivial later.

A standard deck has 52 cards. Giving each card an immutable id, numeric rank (1 for Ace through 13 for King), suit, color, and a mutable (or cloned) faceUp boolean keeps rule checks fast and readable:

const SUITS = ['spades', 'hearts', 'clubs', 'diamonds'];
const COLORS = {
  spades: 'black',
  clubs: 'black',
  hearts: 'red',
  diamonds: 'red',
};

function createDeck() {
  const deck = [];
  for (const suit of SUITS) {
    for (let rank = 1; rank <= 13; rank++) {
      deck.push({
        id: `${suit}-${rank}`,
        suit,
        rank,
        color: COLORS[suit],
        faceUp: false,
      });
    }
  }
  return deck;
}

Piles as Arrays (Last Element = Top Card)

Represent every zone on the table as an array where pile[pile.length - 1] is the top card. That convention matches Array.prototype.push and pop naturally:

function createInitialState(drawCount = 1) {
  return {
    drawCount, // 1 or 3
    stock: [], // face-down draw pile
    waste: [], // face-up flipped cards from stock
    foundations: {
      spades: [],
      hearts: [],
      clubs: [],
      diamonds: [],
    },
    tableau: [[], [], [], [], [], [], []], // 7 columns
  };
}

2. Cryptographically Fair Shuffling and the Deal

A quick note on shuffling and winnability: Klondike is a game of skill mixed with the luck of the deal. Most Draw 1 deals are winnable with careful play, while some deals are mathematically unwinnable from the start, and Draw 3 is noticeably tougher because two-thirds of each stock flip starts buried. You don't need to rig the deck or promise that every deal has a solutionβ€”players actually appreciate an honest shuffle.

Instead of Math.random(), you can drive a Fisher–Yates shuffle using the browser's built-in Web Crypto API (crypto.getRandomValues) so the deal is unbiased:

function cryptoShuffle(deck) {
  const copy = deck.map(card => ({ ...card }));
  const randomBuffer = new Uint32Array(1);

  for (let i = copy.length - 1; i > 0; i--) {
    crypto.getRandomValues(randomBuffer);
    const j = randomBuffer[0] % (i + 1);
    [copy[i], copy[j]] = [copy[j], copy[i]];
  }
  return copy;
}

function dealKlondike(drawCount = 1) {
  const state = createInitialState(drawCount);
  const deck = cryptoShuffle(createDeck());
  let cursor = 0;

  // Deal 1 to 7 cards across the 7 tableau columns; only the top card is face-up
  for (let col = 0; col < 7; col++) {
    for (let row = 0; row <= col; row++) {
      const card = deck[cursor++];
      card.faceUp = row === col;
      state.tableau[col].push(card);
    }
  }

  // Remaining 24 cards go to the stock, face-down
  while (cursor < deck.length) {
    const card = deck[cursor++];
    card.faceUp = false;
    state.stock.push(card);
  }

  return state;
}

3. Validating Legal Moves (Including Multi-Card Runs)

A surprisingly common bug in amateur solitaire clones is only checking the card the user grabbed, or forgetting that cards on the foundation aren't stuck there forever. Let's list the exact movement rules for standard Klondike:

  1. To a Foundation: Must be a single card (no stacks). If the foundation for that suit is empty, only an Ace (rank === 1) is legal. Otherwise, the card must match the foundation's suit and have rank === topCard.rank + 1 (ascending Ace to King).
  2. To a Tableau Column (Non-Empty): The bottom-most card of the moving stack must be the opposite color of the target column's current top card, and have rank === targetTop.rank - 1 (descending and alternating color).
  3. To an Empty Tableau Column: Only a King (rank === 13), or a valid face-up run starting with a King at its base, can fill an empty column.
  4. Moving a Tableau Run: A player can grab any face-up card in a tableau column and move it along with all cards resting on top of it as a single unit.
  5. Foundation Back to Tableau: Players are allowed to move the top card of a foundation pile back onto a tableau column if it satisfies the normal tableau rule. This is a critical tactical escape hatchβ€”sometimes you send a 4 of Hearts to the foundation too early and realize you still needed it on the board to hold a 3 of Spades.

Here is how to keep the validator pure and decoupled from your UI code:

function canMoveToFoundation(movingCards, foundationPile, suit) {
  // Only one card at a time can go to a foundation
  if (movingCards.length !== 1) return false;
  const card = movingCards[0];
  if (card.suit !== suit || !card.faceUp) return false;

  if (foundationPile.length === 0) {
    return card.rank === 1; // Ace
  }
  const top = foundationPile[foundationPile.length - 1];
  return card.rank === top.rank + 1;
}

function isValidTableauRun(cards) {
  if (cards.length === 0) return false;
  for (let i = 0; i < cards.length; i++) {
    if (!cards[i].faceUp) return false;
    if (i > 0) {
      const prev = cards[i - 1];
      const curr = cards[i];
      const isAlternating = prev.color !== curr.color;
      const isDescending = prev.rank === curr.rank + 1;
      if (!isAlternating || !isDescending) return false;
    }
  }
  return true;
}

function canMoveToTableau(movingCards, targetColumn) {
  if (!isValidTableauRun(movingCards)) return false;

  const baseCard = movingCards[0]; // The card that will touch the target column

  if (targetColumn.length === 0) {
    // Empty columns only accept a King or a King-led run
    return baseCard.rank === 13;
  }

  const targetTop = targetColumn[targetColumn.length - 1];
  if (!targetTop.faceUp) return false;

  return (
    baseCard.color !== targetTop.color &&
    baseCard.rank === targetTop.rank - 1
  );
}

Notice how canMoveToTableau checks movingCards[0] against the destination column's top card. When you slice a sub-stack from a source column (sourceColumn.slice(grabIndex)), index 0 of that slice is the highest-ranking card at the bottom of the moving stack, while the rest of the array rides along on top of it.

When a move completes and vacates a tableau column that still has face-down cards, flip the new top card face-up automatically:

function revealTopIfNeeded(column) {
  if (column.length > 0) {
    const top = column[column.length - 1];
    if (!top.faceUp) top.faceUp = true;
  }
}

4. Draw 1 vs. Draw 3 and Stock Recycling

The difference between Draw 1 and Draw 3 changes both state transitions and how you render the waste pile:

  • Draw 1: Clicking the stock moves 1 card to the waste pile, face-up.
  • Draw 3: Clicking the stock moves up to 3 cards (or whatever remains in the stock if fewer than 3) to the waste pile, face-up. Crucially, only the top card of the waste pile is playable. The other two cards fanned underneath are visible for planning, but locked until you play the card covering them.
  • Recycling the Stock: When the stock is empty, clicking it turns the entire waste pile back over into the stock (with unlimited passes through the deck) so the order of cards is preserved.

Because our piles treat the last element as the top card, popping from stock and pushing to waste naturally reverses the order, and recycling reverses it back:

function flipFromStock(state) {
  if (state.stock.length === 0) {
    if (state.waste.length === 0) return state; // Nothing to recycle

    // Recycle waste back into stock in original order
    while (state.waste.length > 0) {
      const card = state.waste.pop();
      card.faceUp = false;
      state.stock.push(card);
    }
    return state;
  }

  const count = Math.min(state.drawCount, state.stock.length);
  for (let i = 0; i < count; i++) {
    const card = state.stock.pop();
    card.faceUp = true;
    state.waste.push(card);
  }
  return state;
}

Rendering the Draw-3 Fan

In the UI, don't offset all 20 cards in the waste pileβ€”you'll run out of screen space. Slice the last Math.min(3, state.waste.length) cards to render the visible fan, and only attach drag/click listeners to state.waste[state.waste.length - 1]:

function getVisibleWasteFan(waste, drawCount) {
  if (waste.length === 0) return [];
  const fanSize = drawCount === 3 ? 3 : 1;
  const start = Math.max(0, waste.length - fanSize);

  return waste.slice(start).map((card, idx, arr) => ({
    card,
    offsetIndex: idx, // 0, 1, or 2 for horizontal CSS offset
    isPlayable: idx === arr.length - 1, // Only the top card can move
  }));
}

5. Drag-and-Drop, Click-to-Move, and Bounce-Back

A polished browser card game should support both drag-and-drop and click-to-move. On desktop, some players like dragging stacks; on trackpads or mobile screens, tapping a card to send it to its valid home is much faster.

Handling the Drag Lifecycle

When a player starts dragging a face-up card at index i in tableau[col]:

  1. Grab the slice movingCards = tableau[col].slice(i).
  2. Verify isValidTableauRun(movingCards) (in standard Klondike, all face-up cards in a column already form a valid run, but checking prevents state corruption).
  3. Record the origin coordinates of the dragged DOM elements and translate them together using transform: translate3d(dx, dy, 0) during pointermove.
  4. On pointerup, hit-test the drop zone (either a foundation pile or one of the 7 tableau columns) and run canMoveToFoundation or canMoveToTableau.
  5. If legal: push a snapshot of the previous state onto your undoStack, mutate the piles, flip any newly exposed tableau card face-up, and re-render.
  6. If illegal: animate the dragged cards back to their origin coordinates (transition: transform 180ms ease-out back to translate3d(0, 0, 0)) and leave the game state untouched.

Smart Click-to-Move and Gameplay Nuances

If you implement single-click or double-click auto-move, think carefully about priority. Novice developers often auto-send every clicked card straight to the foundation whenever it's legal. Experienced Klondike players hate when a game forces that, because good strategy relies on board control:

  • Revealing face-down cards (especially in the longest columns) is usually higher priority than padding a foundation early.
  • Avoid emptying a column unless you have a King readyβ€”an empty space does nothing for you without a King to anchor it.
  • Hold onto mid-rank cards on the tableau if you still need them to catch a lower opposite-color card from the stock.

Because of those tactical trade-offs, keep your auto-finish feature conservative: only trigger Auto-Finish once all cards on the table are face-up (and the stock/waste are empty or resolved), at which point the game is deterministically solved and you can cascade the remaining cards up to the foundations. And always offer unlimited Undoβ€”rolling back a speculative line of play is a normal part of solving tough layouts.

If you want to inspect how these details feel together in a finished buildβ€”including Draw 1 and Draw 3 switching, both drag and click controls, unlimited undo, and foundation-to-tableau movesβ€”you can try Klondike Solitaire on FreeTableGames right in the browser with no download, sign-up, or ads.

6. Putting Undo and Auto-Finish Together

Because our entire game state is just nested arrays of plain objects, implementing unlimited Undo and the Auto-Finish check takes fewer than 30 lines of code:

class KlondikeHistory {
  constructor() {
    this.stack = [];
  }

  pushSnapshot(state) {
    // structuredClone is built into modern browsers
    this.stack.push(structuredClone(state));
  }

  undo(currentState) {
    if (this.stack.length === 0) return currentState;
    return this.stack.pop();
  }
}

function canAutoFinish(state) {
  if (state.stock.length > 0 || state.waste.length > 0) return false;
  return state.tableau.every(col => col.every(card => card.faceUp));
}

Once canAutoFinish(state) returns true, you can run a short requestAnimationFrame or timer loop that scans the tops of the seven tableau columns for the lowest-ranked card and steps it onto its matching foundation until all four foundations hold 13 cards.

Start with the pure state module first, test your edge cases (moving a King-led stack to an empty column, pulling a card back from a foundation, recycling a Draw-3 waste pile), and only then wire up your pointer events. Your future self debugging a phantom card at 11 PM will thank you.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.