Mahjong solitaire is a matching game: you take pairs of identical tiles off a 144-tile pile, but only tiles that are free — nothing stacked on top, and at least one long side open.
The obvious way to deal a board is to shuffle 144 tiles and drop them onto the layout. It is also wrong. A random arrangement can easily be unwinnable — the last four tiles buried under each other, two of a pair locked in positions that can never both be free. You do not find out at the start. You find out twenty minutes in, when nothing matches and there is no way back.
Try a few free mahjong sites and you will hit it. It is not a rare edge case; forward dealing produces unwinnable boards routinely.
Deal the board by playing it backwards
The fix is to stop generating boards and then checking them, and instead generate a solution and derive the board from it.
Two steps:
- On the empty layout, simulate a legal removal order. Repeatedly pick any two currently-free slots and remove them, until the layout is empty. The positions have no faces yet, so "free" here is purely geometric.
- Paste the face pairs onto that order: the pair that lands on the two slots removed at step k is removable at step k.
Replaying that order clears the board. So a complete solution exists before the player touches anything.
"Free" is the only real predicate:
/** Free = nothing stacked on top, and at least one long side (left/right) is open. */
function isFree(i, goneSet) {
const tl = tiles[i];
const there = (z, hx, hy) => {
const j = index.get(z + '|' + hx + '|' + hy);
return j === undefined ? false : !goneSet.has(j);
};
// anything overlapping on the layer above blocks it
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++)
if (there(tl.z + 1, tl.hx + dx, tl.hy + dy)) return false;
const side = (dx) => there(tl.z, tl.hx + dx, tl.hy - 1)
|| there(tl.z, tl.hx + dx, tl.hy)
|| there(tl.z, tl.hx + dx, tl.hy + 1);
return !side(-2) || !side(2);
}
Positions are in half-tile units, which is why the offsets are ±1 and ±2: a tile covers two half-units on each axis, and half-units are what let the single top tile of the turtle sit centred on the 2×2 block below it instead of on a staircase.
Then the walk:
function removalOrder(idxs) {
for (let attempt = 0; attempt < 60; attempt++) {
const gone = new Set(/* everything not in idxs */);
const order = [];
let stuck = false;
while (gone.size < tiles.length) {
const free = idxs.filter((i) => !gone.has(i) && isFree(i, gone));
if (free.length < 2) { stuck = true; break; }
const a = pick(free);
let b = a;
while (b === a) b = pick(free);
order.push([a, b]);
gone.add(a); gone.add(b);
}
if (!stuck) return order;
}
return null;
}
Note the retry loop. Choosing greedily at random can strand the walk — you can reach a state with tiles left but fewer than two free slots. I could backtrack; instead the whole walk is just retried, because on the classic turtle it succeeds almost immediately and the code stays readable. Sixty attempts is far more headroom than it has ever needed.
There is also a fallback branch if all sixty fail, which pairs adjacent positions and produces a board that may not be solvable. It has never fired. I left it in because a slightly worse board is better than a blank screen, and I would rather have the degenerate path be visible in the source than pretend it cannot happen.
Pasting is the easy half:
function paste(order, pool) {
const pairs = shuffled(pool).slice(0, order.length);
order.forEach(([a, b], k) => {
tiles[a].face = pairs[k].faces[0];
tiles[b].face = pairs[k].faces[1];
});
}
The claim that is easy to overstate
"Every board is solvable" is not "you cannot lose". The deal is solvable. You can still take the wrong pair and strand yourself, exactly as in any mahjong solitaire — that is the game.
So the same construction runs again on demand. Shuffle collects the faces still on the board, builds a fresh removal order over the still-occupied positions, and pastes them back down:
const idxs = tiles.map((tl, i) => (tl.gone ? null : i)).filter((v) => v !== null);
const order = removalOrder(idxs);
if (order) paste(order, poolFromTiles(liveTiles()));
Whatever position you have played yourself into is recoverable. Which quietly changes a design decision too: running out of moves is a message and a button, never a loss. There is no game over screen, because there is no state the player can reach that they cannot get out of.
Then the tiles turned out to be harder than the algorithm
Unicode has a mahjong block, U+1F000–U+1F02B. Render the characters, done — except it is missing or half-broken in the default font stack on a lot of Windows and Android, and a board of tofu boxes would be the end of the game. So the faces are SVG.
Public-domain (CC0) riichi artwork covers 33 of the 42 faces. But riichi mahjong has no flower or season tiles and leaves its white dragon blank, so nine faces simply do not exist in that set.
Noto Sans Symbols 2 does cover the whole block, so I pulled the outlines out of the font with fontTools:
from fontTools.pens.svgPathPen import SVGPathPen
gs = font.getGlyphSet()
name = font.getBestCmap()[0x1F022] # 🀢 plum
pen = SVGPathPen(gs)
gs[name].draw(pen)
path = pen.getCommands()
Two things I did not expect.
Each glyph draws its own tile border, as the first two contours. They are the outer and inner edge of a stroked rounded rectangle, and they are byte-identical across all eight bonus tiles. Drop contours 0 and 1 and you have just the artwork. Better still, contour 1's bounding box is the drawing area the type designer composed inside — a much better box to scale every face by than each glyph's own ink extents, because a snowflake and a plum spray are supposed to fill that area differently and normalising each one's bounds would flatten the whole set.
The small character in the corner is separable. Every one of these tiles carries its character (梅, 春, …) in the top-left of the drawing area, so the contours whose bounds fall in that corner are the character and the rest are the picture:
x0, y0, x1, y1 = bounds(gs, [contours[1]]) # the designer's drawing area
char, pic = [], []
for c in contours[2:]:
cx0, cy0, cx1, cy1 = bounds(gs, [c])
corner = cx1 < x0 + (x1 - x0) * 0.46 and cy0 > y0 + (y1 - y0) * 0.58
(char if corner else pic).append(c)
Which means you can colour the two separately — picture in one colour, character in another — out of a single monochrome glyph.
That mattered more than I expected. My first attempt kept the font's own border and scaled the artwork down inside it. It looked terrible, and it took a side-by-side render to see why: none of the other 33 faces has a border, so a bordered tile reads as a sticker stuck onto a tile rather than a tile. Dropping the frame, filling the face, and matching the existing set's two-tone look was the whole difference.
The boring stack
Vanilla PHP, vanilla JS, SQLite. No framework, no build step, no npm anywhere in the request path. Every page is server-rendered and the JavaScript only adds interaction on top. The tile body is CSS — that is what gives the stack its depth and its shadows; the SVG is only the face plate.
It is live at juju.games/mahjong if you want to try to strand yourself.
The thing I am still unsure about: is a solvability guarantee even perceptible? A player who never gets stuck cannot tell it apart from luck, and a player who does get stuck is using Shuffle either way. It may be one of those properties that only shows up as the absence of a bad afternoon. How would you even measure it?
Top comments (0)