Here is a tiny Mahjong layout. A tile is free if nothing sits on top of it and at least one side (left or right) is open. Click a free tile, then click its matching twin to remove the pair. Clear all the tiles to win.
<p class="hint">A tile is <b>free</b> if nothing covers it and one side (left or right) is open. Click a free tile, then its matching twin, to remove the pair. Clear the board to win — but beware: one greedy move can lock everything into a dead end.</p>
<div id="board" class="board"></div>
<div class="status" id="status">Pick two matching free tiles.</div>
<div class="btns">
<button id="solve" type="button">Auto-solve</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.board { position: relative; height: 110px; margin: .6rem 0; }
.tile { position: absolute; width: 52px; height: 64px; display: flex; align-items: center;
justify-content: center; font: 700 26px ui-monospace, monospace; border-radius: 8px;
background: #f6efe0; border: 2px solid #c9b78c; color: #6b3f12; box-shadow: 2px 2px 0 #c9b78c;
user-select: none; transition: transform .08s; }
.tile.free { cursor: pointer; }
.tile.free:hover { transform: translateY(-2px); }
.tile.covered, .tile.blocked { filter: brightness(.82) saturate(.6); cursor: not-allowed; box-shadow: none; }
.tile.sel { outline: 3px solid #2a7de1; outline-offset: 1px; }
.tile.gone { display: none; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #6b3f12;
background: #6b3f12; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #6b3f12; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Mahjong solitaire: remove matching FREE tiles two at a time until the board is
// empty. A tile is free iff no tile lies on top of it (a higher layer overlapping
// it) AND at least one horizontal side (left or right, same row & layer) is open.
// Checking a sequence of moves is a quick replay; deciding whether ANY order clears
// a layout is NP-complete — the order matters, and a wrong choice can dead-end.
//
// This deal is winnable, but it hides a trap. Faces: four A, two B.
// Row (layer 0): A(0) A(1) B(2) B(3) A(4) at x = 0..4
// Cap (layer 1): A(5) resting on top of the two B tiles (covers x=2 and x=3)
// Correct play removes A(5) early via the A pairs; the greedy move A(4)+A(5)
// strands tiles A(0),A(1) (each wedged between neighbours) — a deadlock.
const DEAL = [
{ id:0, face:'A', x:0, y:0, z:0 },
{ id:1, face:'A', x:1, y:0, z:0 },
{ id:2, face:'B', x:2, y:0, z:0 },
{ id:3, face:'B', x:3, y:0, z:0 },
{ id:4, face:'A', x:4, y:0, z:0 },
{ id:5, face:'A', x:2.5, y:0, z:1 },
];
let tiles = [];
let sel = null;
const clone = deal => deal.map(t => ({ ...t, gone:false }));
function covered(t, all){
return all.some(o => !o.gone && o.z === t.z + 1 && Math.abs(o.x - t.x) < 1);
}
function sideOpen(t, all){
const row = all.filter(o => !o.gone && o.z === t.z && o.y === t.y && o.id !== t.id);
const left = row.some(o => Math.abs(o.x - (t.x - 1)) < 0.01);
const right = row.some(o => Math.abs(o.x - (t.x + 1)) < 0.01);
return !left || !right;
}
function isFree(t, all){ return !t.gone && !covered(t, all) && sideOpen(t, all); }
function render(){
const b = document.getElementById('board'); b.innerHTML = '';
tiles.forEach(t => {
const d = document.createElement('div');
const free = isFree(t, tiles);
const cls = t.gone ? 'gone' : free ? 'free' : (covered(t, tiles) ? 'covered' : 'blocked');
d.className = 'tile ' + cls + (sel === t.id ? ' sel' : '');
d.style.left = (t.x * 40 + 10) + 'px';
d.style.top = (50 - t.z * 22) + 'px';
d.style.zIndex = String(t.z * 10 + 1);
d.textContent = t.face;
if(free) d.addEventListener('click', () => clickTile(t.id));
b.appendChild(d);
});
}
function setStatus(txt, cls){ const s = document.getElementById('status'); s.textContent = txt; s.className = 'status ' + (cls || ''); }
function clickTile(id){
const t = tiles.find(x => x.id === id);
if(!isFree(t, tiles)) return;
if(sel === null){ sel = id; render(); return; }
if(sel === id){ sel = null; render(); return; }
const a = tiles.find(x => x.id === sel);
if(a.face === t.face){
a.gone = true; t.gone = true; sel = null; render();
if(tiles.every(x => x.gone)){ setStatus('✓ Board cleared — you won!', 'ok'); return; }
if(!anyMove(tiles)){ setStatus('✗ Deadlock! Matching tiles remain but none are free. Reset and try another order.', 'bad'); return; }
setStatus('Pair removed. Keep going.', '');
} else {
sel = id; render(); setStatus('Those tiles do not match. Pick a matching twin.', 'bad');
}
}
// Is there at least one removable matching free pair right now?
function anyMove(all){
const free = all.filter(t => isFree(t, all));
for(let i = 0; i < free.length; i++) for(let j = i + 1; j < free.length; j++)
if(free[i].face === free[j].face) return true;
return false;
}
// DFS over move orders: return a clearing sequence of [id,id] pairs, or null.
function solve(state){
if(state.every(t => t.gone)) return [];
const free = state.filter(t => isFree(t, state));
for(let i = 0; i < free.length; i++) for(let j = i + 1; j < free.length; j++){
if(free[i].face !== free[j].face) continue;
const next = state.map(t => ({ ...t }));
next.find(t => t.id === free[i].id).gone = true;
next.find(t => t.id === free[j].id).gone = true;
const rest = solve(next);
if(rest) return [[free[i].id, free[j].id], ...rest];
}
return null;
}
function autoSolve(){
sel = null;
tiles = clone(DEAL); render();
const plan = solve(tiles.map(t => ({ ...t })));
if(!plan){ setStatus('✗ This layout cannot be cleared in any order.', 'bad'); return; }
let k = 0;
const step = () => {
if(k >= plan.length){ setStatus(fmt('✓ Solved in %length% moves — the order was the trick.', { length: plan.length }), 'ok'); return; }
const [a, b] = plan[k++];
tiles.find(t => t.id === a).gone = true;
tiles.find(t => t.id === b).gone = true;
render();
setTimeout(step, 600);
};
setStatus('Searching for a winning order…', '');
step();
}
function load(){ tiles = clone(DEAL); sel = null; render(); setStatus('Pick two matching free tiles. Hint: clear the top A early!', ''); }
document.getElementById('solve').addEventListener('click', autoSolve);
document.getElementById('reset').addEventListener('click', load);
load();
<p class="hint">A tile is <b>free</b> if nothing covers it and one side (left or right) is open. Click a free tile, then its matching twin, to remove the pair. Clear the board to win — but beware: one greedy move can lock everything into a dead end.</p>
<div id="board" class="board"></div>
<div class="status" id="status">Pick two matching free tiles.</div>
<div class="btns">
<button id="solve" type="button">Auto-solve</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.board { position: relative; height: 110px; margin: .6rem 0; }
.tile { position: absolute; width: 52px; height: 64px; display: flex; align-items: center;
justify-content: center; font: 700 26px ui-monospace, monospace; border-radius: 8px;
background: #f6efe0; border: 2px solid #c9b78c; color: #6b3f12; box-shadow: 2px 2px 0 #c9b78c;
user-select: none; transition: transform .08s; }
.tile.free { cursor: pointer; }
.tile.free:hover { transform: translateY(-2px); }
.tile.covered, .tile.blocked { filter: brightness(.82) saturate(.6); cursor: not-allowed; box-shadow: none; }
.tile.sel { outline: 3px solid #2a7de1; outline-offset: 1px; }
.tile.gone { display: none; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #6b3f12;
background: #6b3f12; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #6b3f12; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Mahjong solitaire: remove matching FREE tiles two at a time until the board is
// empty. A tile is free iff no tile lies on top of it (a higher layer overlapping
// it) AND at least one horizontal side (left or right, same row & layer) is open.
// Checking a sequence of moves is a quick replay; deciding whether ANY order clears
// a layout is NP-complete — the order matters, and a wrong choice can dead-end.
//
// This deal is winnable, but it hides a trap. Faces: four A, two B.
// Row (layer 0): A(0) A(1) B(2) B(3) A(4) at x = 0..4
// Cap (layer 1): A(5) resting on top of the two B tiles (covers x=2 and x=3)
// Correct play removes A(5) early via the A pairs; the greedy move A(4)+A(5)
// strands tiles A(0),A(1) (each wedged between neighbours) — a deadlock.
const DEAL = [
{ id:0, face:'A', x:0, y:0, z:0 },
{ id:1, face:'A', x:1, y:0, z:0 },
{ id:2, face:'B', x:2, y:0, z:0 },
{ id:3, face:'B', x:3, y:0, z:0 },
{ id:4, face:'A', x:4, y:0, z:0 },
{ id:5, face:'A', x:2.5, y:0, z:1 },
];
let tiles = [];
let sel = null;
const clone = deal => deal.map(t => ({ ...t, gone:false }));
function covered(t, all){
return all.some(o => !o.gone && o.z === t.z + 1 && Math.abs(o.x - t.x) < 1);
}
function sideOpen(t, all){
const row = all.filter(o => !o.gone && o.z === t.z && o.y === t.y && o.id !== t.id);
const left = row.some(o => Math.abs(o.x - (t.x - 1)) < 0.01);
const right = row.some(o => Math.abs(o.x - (t.x + 1)) < 0.01);
return !left || !right;
}
function isFree(t, all){ return !t.gone && !covered(t, all) && sideOpen(t, all); }
function render(){
const b = document.getElementById('board'); b.innerHTML = '';
tiles.forEach(t => {
const d = document.createElement('div');
const free = isFree(t, tiles);
const cls = t.gone ? 'gone' : free ? 'free' : (covered(t, tiles) ? 'covered' : 'blocked');
d.className = 'tile ' + cls + (sel === t.id ? ' sel' : '');
d.style.left = (t.x * 40 + 10) + 'px';
d.style.top = (50 - t.z * 22) + 'px';
d.style.zIndex = String(t.z * 10 + 1);
d.textContent = t.face;
if(free) d.addEventListener('click', () => clickTile(t.id));
b.appendChild(d);
});
}
function setStatus(txt, cls){ const s = document.getElementById('status'); s.textContent = txt; s.className = 'status ' + (cls || ''); }
function clickTile(id){
const t = tiles.find(x => x.id === id);
if(!isFree(t, tiles)) return;
if(sel === null){ sel = id; render(); return; }
if(sel === id){ sel = null; render(); return; }
const a = tiles.find(x => x.id === sel);
if(a.face === t.face){
a.gone = true; t.gone = true; sel = null; render();
if(tiles.every(x => x.gone)){ setStatus('✓ Board cleared — you won!', 'ok'); return; }
if(!anyMove(tiles)){ setStatus('✗ Deadlock! Matching tiles remain but none are free. Reset and try another order.', 'bad'); return; }
setStatus('Pair removed. Keep going.', '');
} else {
sel = id; render(); setStatus('Those tiles do not match. Pick a matching twin.', 'bad');
}
}
// Is there at least one removable matching free pair right now?
function anyMove(all){
const free = all.filter(t => isFree(t, all));
for(let i = 0; i < free.length; i++) for(let j = i + 1; j < free.length; j++)
if(free[i].face === free[j].face) return true;
return false;
}
// DFS over move orders: return a clearing sequence of [id,id] pairs, or null.
function solve(state){
if(state.every(t => t.gone)) return [];
const free = state.filter(t => isFree(t, state));
for(let i = 0; i < free.length; i++) for(let j = i + 1; j < free.length; j++){
if(free[i].face !== free[j].face) continue;
const next = state.map(t => ({ ...t }));
next.find(t => t.id === free[i].id).gone = true;
next.find(t => t.id === free[j].id).gone = true;
const rest = solve(next);
if(rest) return [[free[i].id, free[j].id], ...rest];
}
return null;
}
function autoSolve(){
sel = null;
tiles = clone(DEAL); render();
const plan = solve(tiles.map(t => ({ ...t })));
if(!plan){ setStatus('✗ This layout cannot be cleared in any order.', 'bad'); return; }
let k = 0;
const step = () => {
if(k >= plan.length){ setStatus(fmt('✓ Solved in %length% moves — the order was the trick.', { length: plan.length }), 'ok'); return; }
const [a, b] = plan[k++];
tiles.find(t => t.id === a).gone = true;
tiles.find(t => t.id === b).gone = true;
render();
setTimeout(step, 600);
};
setStatus('Searching for a winning order…', '');
step();
}
function load(){ tiles = clone(DEAL); sel = null; render(); setStatus('Pick two matching free tiles. Hint: clear the top A early!', ''); }
document.getElementById('solve').addEventListener('click', autoSolve);
document.getElementById('reset').addEventListener('click', load);
load();
Notice the asymmetry. Checking a finished sequence of moves is effortless — replay it and confirm every pair was legal and the board ended empty. Finding a winning order is the hard part. Press Auto-solve and the computer searches through the possible move orders; press Reset and try the trap layout, where one greedy wrong move locks the board into a dead end with tiles still on it.
Comments
Loading comments...