DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Building Mahjong Solitaire from scratch: the free-tile rule, a backwards deal that's always solvable, and a deadlock scan

Mahjong Solitaire looks like it needs a 3D engine and a pile of art assets. It needs neither. When I built it in vanilla JavaScript on a single canvas, the whole board turned out to be a flat list of tiny objects, and two tiny rules run the entire game. Here is how it fits together — about 150 lines, no images, no libraries.

A tile is three numbers and a face

There is no grid array and no sprite atlas. Every tile is just a little object: a column x, a row y, a layer z, the face it shows, and whether it has been cleared. The columns and rows are measured in half-tile units, which is the one clever bit of the layout — because the unit is half a tile wide, an upper layer can be shoved half a tile across so it straddles the tiles beneath it. That straddling is the classic mahjong look, and it falls straight out of the numbers.

function buildTiles(){
  tiles = []; let id = 0;
  const push = (x, y, z) => tiles.push({ x, y, z, id: id++, face: null, gone: false });
  for (let r = 0; r < 3; r++) for (let c = 0; c < 8; c++) push(c*2, r*2, 0);     // base 8×3
  for (let r = 0; r < 2; r++) for (let c = 0; c < 6; c++) push(c*2+2, r*2+1, 1); // top 6×2
}
Enter fullscreen mode Exit fullscreen mode

One overlap test answers everything

Each tile covers a 2×2 square in those half-units, so two tiles overlap exactly when their columns differ by less than 2 and their rows differ by less than 2. This single line is reused for two completely different questions: ask it about a tile on a higher layer and it tells you whether something is on top of you; restrict it to the horizontal axis and it tells you whether a neighbour is beside you.

function overlapXY(a, b){ return Math.abs(a.x - b.x) < 2 && Math.abs(a.y - b.y) < 2; }
Enter fullscreen mode Exit fullscreen mode

The free rule: uncovered AND a side open

This is the heart of the game. A tile is free — and therefore clickable — only when two things are both true: nothing overlaps it from a higher layer (nothing on top), and it has no neighbour on at least one of its left or right sides. A tile pinned under another, or walled in on both sides, stays locked until whatever traps it is cleared. The lovely consequence is that removing a tile can only ever free more tiles, never trap them, so the board keeps opening up as you play.

function isFree(t, gone = u => u.gone){
  if (gone(t)) return false;
  for (const u of tiles)                                 // nothing on top
    if (u !== t && !gone(u) && u.z > t.z && overlapXY(u, t)) return false;
  let left = false, right = false;                       // a side open?
  for (const u of tiles){
    if (u === t || gone(u) || u.z !== t.z) continue;
    if (Math.abs(u.y - t.y) < 2){
      if (u.x === t.x - 2) left  = true;
      if (u.x === t.x + 2) right = true;
    }
  }
  return !left || !right;
}
Enter fullscreen mode Exit fullscreen mode

Deal a board that can actually be won

Sprinkle faces at random and a big share of boards are impossible — and the player has no way of knowing. The fix is a piece of backwards thinking that I think is the best trick in the whole build. Pretend the board is full, repeatedly find the tiles that are free right now, remove any two of them together, and stamp that pair a face from a shuffled bag. Because every pair you stamp was genuinely free at the instant you removed it, replaying the removals in order is a guaranteed winning solution. The player sees a random-looking board; underneath, a path home already exists.

function generateSolvable(activeIds, faceCounts){
  const present = {}; activeIds.forEach(id => present[id] = true);
  const gone = u => !present[u.id];
  const bag = [];
  for (const f in faceCounts) for (let i = 0; i < faceCounts[f]/2; i++) bag.push(f);
  shuffle(bag);
  const assign = {}; let left = activeIds.length;
  while (left > 0){
    const free = activeIds.filter(id => present[id] && isFree(tiles[id], gone));
    if (free.length < 2) return null;              // stuck -> caller retries
    shuffle(free);
    const a = free[0], b = free[1], f = bag.pop();
    assign[a] = assign[b] = f;                     // this pair matches
    present[a] = present[b] = false; left -= 2;
  }
  return assign;                                    // a provably solvable deal
}
Enter fullscreen mode Exit fullscreen mode

Deadlock detection in one scan

A solvable deal can still be played into a dead end, because each face has four copies and you can pair the wrong two, quietly burying a tile you needed later. So after every match I ask one question: among the tiles that are free right now, does any face appear at least twice? Group the free tiles by face; the first face that reaches a count of two means a move exists. No such face means deadlock — and the reshuffle button just runs the same backwards generator over whatever tiles are left (each remaining count is always even because you clear in pairs), so the rescue is guaranteed solvable too.

function hasMove(){
  const count = {};
  for (const t of freeTiles()){
    count[t.face] = (count[t.face] || 0) + 1;
    if (count[t.face] >= 2) return true;      // two free tiles share a face
  }
  return false;                               // nothing matchable -> deadlock
}
Enter fullscreen mode Exit fullscreen mode

The lesson underneath it all: keep the smallest possible source of truth — a list of tiles that are each just a column, a row, a layer and a face — and let coverage, adjacency, the deal, the deadlock check and the isometric draw all fall out as reads over that one list.

Play it, and hit Shuffle when you strand yourself:
https://dev48v.infy.uk/game/day37-mahjong-solitaire.html

Top comments (0)