Here is a 3×3 sliding puzzle (the 8-puzzle). Click any tile next to the empty square to slide it. Try to reach 1 2 3 / 4 5 6 / 7 8 · — the empty square in the corner.
<p class="hint">A 3×3 sliding puzzle (the 8-puzzle). Click a tile next to the empty square to slide it. Goal: <b>1 2 3 / 4 5 6 / 7 8 ·</b>. Press <b>Swap two tiles</b> to flip the hidden parity — the board then looks normal but can <b>never</b> be solved.</p>
<div id="board" class="board"></div>
<div class="status" id="status">Slide tiles toward the goal.</div>
<div class="btns">
<button id="shuffle" type="button">Shuffle (solvable)</button>
<button id="swap" type="button">Swap two tiles</button>
<button id="check" type="button" class="ghost">Check solvability</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 { display: grid; grid-template-columns: repeat(3, 64px); gap: 6px; margin: .4rem 0; }
.tile { width: 64px; height: 64px; display: flex; align-items: center; justify-content: center;
font: 700 26px ui-monospace, monospace; border-radius: 10px; user-select: none;
background: #1d3557; color: #fff; border: 1px solid #15273f; transition: transform .08s; }
.tile.movable { cursor: pointer; }
.tile.movable:hover { transform: scale(1.04); background: #2a4a73; }
.tile.blank { background: #e8eef3; border: 1px dashed #cdd9e3; }
.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 #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
// The 8-puzzle. Tiles 1..8 plus a blank (0) on a 3x3 grid.
// SOLVABILITY (easy, linear time): for an odd-width board, a state is solvable
// iff the number of INVERSIONS (pairs i<j with tile[i]>tile[j], ignoring the
// blank) has the SAME parity as the goal. Sliding never changes that parity —
// it is an INVARIANT. Swapping two tiles flips it, making the board unsolvable.
// FINDING the shortest solution for the generalized n x n puzzle is NP-hard
// (Ratner & Warmuth, 1986/1990).
const W = 3, GOAL = [1,2,3,4,5,6,7,8,0];
let board = GOAL.slice();
function inversions(arr){
const t = arr.filter(x => x !== 0); let inv = 0;
for (let i = 0; i < t.length; i++)
for (let j = i+1; j < t.length; j++) if (t[i] > t[j]) inv++;
return inv;
}
// odd width => solvable iff inversion parity matches the goal's (goal has 0).
function solvable(arr){ return (inversions(arr) % 2) === (inversions(GOAL) % 2); }
function solved(arr){ return arr.every((v,i) => v === GOAL[i]); }
function setStatus(t, cls){ const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + (cls||''); }
function render(){
const b = document.getElementById('board'); b.innerHTML = '';
const blank = board.indexOf(0);
board.forEach((v, i) => {
const d = document.createElement('div');
if (v === 0){ d.className = 'tile blank'; }
else {
const adj = neighbors(blank).includes(i);
d.className = 'tile' + (adj ? ' movable' : '');
d.textContent = v;
if (adj) d.addEventListener('click', () => { slide(i); });
}
b.appendChild(d);
});
}
function neighbors(idx){
const r = Math.floor(idx / W), c = idx % W, out = [];
if (r > 0) out.push(idx - W); if (r < W-1) out.push(idx + W);
if (c > 0) out.push(idx - 1); if (c < W-1) out.push(idx + 1);
return out;
}
function slide(i){
const blank = board.indexOf(0);
[board[blank], board[i]] = [board[i], board[blank]];
render();
if (solved(board)) setStatus('✓ Solved! Tiles are in order.', 'ok');
else setStatus('Keep sliding toward the goal.', '');
}
function shuffle(){
do { for (let k = 0; k < 200; k++){ const blank = board.indexOf(0);
const ns = neighbors(blank); const pick = ns[Math.floor(Math.random()*ns.length)];
[board[blank], board[pick]] = [board[pick], board[blank]]; }
} while (solved(board));
render(); setStatus('Shuffled with real slides — still solvable.', 'ok');
}
function swapTwo(){
// swap the first two non-blank tiles -> flips inversion parity -> unsolvable.
const idx = board.map((v,i)=>v!==0?i:-1).filter(i=>i>=0);
const a = idx[0], b2 = idx[1];
[board[a], board[b2]] = [board[b2], board[a]];
render();
setStatus('Two tiles swapped. Parity flipped — now unsolvable.', 'bad');
}
function check(){
if (solvable(board)) setStatus('✓ Solvable: inversion parity matches the goal.', 'ok');
else setStatus('✗ Unsolvable: no slides can ever fix it (parity differs).', 'bad');
}
document.getElementById('shuffle').addEventListener('click', shuffle);
document.getElementById('swap').addEventListener('click', swapTwo);
document.getElementById('check').addEventListener('click', check);
render();
<p class="hint">A 3×3 sliding puzzle (the 8-puzzle). Click a tile next to the empty square to slide it. Goal: <b>1 2 3 / 4 5 6 / 7 8 ·</b>. Press <b>Swap two tiles</b> to flip the hidden parity — the board then looks normal but can <b>never</b> be solved.</p>
<div id="board" class="board"></div>
<div class="status" id="status">Slide tiles toward the goal.</div>
<div class="btns">
<button id="shuffle" type="button">Shuffle (solvable)</button>
<button id="swap" type="button">Swap two tiles</button>
<button id="check" type="button" class="ghost">Check solvability</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 { display: grid; grid-template-columns: repeat(3, 64px); gap: 6px; margin: .4rem 0; }
.tile { width: 64px; height: 64px; display: flex; align-items: center; justify-content: center;
font: 700 26px ui-monospace, monospace; border-radius: 10px; user-select: none;
background: #1d3557; color: #fff; border: 1px solid #15273f; transition: transform .08s; }
.tile.movable { cursor: pointer; }
.tile.movable:hover { transform: scale(1.04); background: #2a4a73; }
.tile.blank { background: #e8eef3; border: 1px dashed #cdd9e3; }
.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 #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
// The 8-puzzle. Tiles 1..8 plus a blank (0) on a 3x3 grid.
// SOLVABILITY (easy, linear time): for an odd-width board, a state is solvable
// iff the number of INVERSIONS (pairs i<j with tile[i]>tile[j], ignoring the
// blank) has the SAME parity as the goal. Sliding never changes that parity —
// it is an INVARIANT. Swapping two tiles flips it, making the board unsolvable.
// FINDING the shortest solution for the generalized n x n puzzle is NP-hard
// (Ratner & Warmuth, 1986/1990).
const W = 3, GOAL = [1,2,3,4,5,6,7,8,0];
let board = GOAL.slice();
function inversions(arr){
const t = arr.filter(x => x !== 0); let inv = 0;
for (let i = 0; i < t.length; i++)
for (let j = i+1; j < t.length; j++) if (t[i] > t[j]) inv++;
return inv;
}
// odd width => solvable iff inversion parity matches the goal's (goal has 0).
function solvable(arr){ return (inversions(arr) % 2) === (inversions(GOAL) % 2); }
function solved(arr){ return arr.every((v,i) => v === GOAL[i]); }
function setStatus(t, cls){ const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + (cls||''); }
function render(){
const b = document.getElementById('board'); b.innerHTML = '';
const blank = board.indexOf(0);
board.forEach((v, i) => {
const d = document.createElement('div');
if (v === 0){ d.className = 'tile blank'; }
else {
const adj = neighbors(blank).includes(i);
d.className = 'tile' + (adj ? ' movable' : '');
d.textContent = v;
if (adj) d.addEventListener('click', () => { slide(i); });
}
b.appendChild(d);
});
}
function neighbors(idx){
const r = Math.floor(idx / W), c = idx % W, out = [];
if (r > 0) out.push(idx - W); if (r < W-1) out.push(idx + W);
if (c > 0) out.push(idx - 1); if (c < W-1) out.push(idx + 1);
return out;
}
function slide(i){
const blank = board.indexOf(0);
[board[blank], board[i]] = [board[i], board[blank]];
render();
if (solved(board)) setStatus('✓ Solved! Tiles are in order.', 'ok');
else setStatus('Keep sliding toward the goal.', '');
}
function shuffle(){
do { for (let k = 0; k < 200; k++){ const blank = board.indexOf(0);
const ns = neighbors(blank); const pick = ns[Math.floor(Math.random()*ns.length)];
[board[blank], board[pick]] = [board[pick], board[blank]]; }
} while (solved(board));
render(); setStatus('Shuffled with real slides — still solvable.', 'ok');
}
function swapTwo(){
// swap the first two non-blank tiles -> flips inversion parity -> unsolvable.
const idx = board.map((v,i)=>v!==0?i:-1).filter(i=>i>=0);
const a = idx[0], b2 = idx[1];
[board[a], board[b2]] = [board[b2], board[a]];
render();
setStatus('Two tiles swapped. Parity flipped — now unsolvable.', 'bad');
}
function check(){
if (solvable(board)) setStatus('✓ Solvable: inversion parity matches the goal.', 'ok');
else setStatus('✗ Unsolvable: no slides can ever fix it (parity differs).', 'bad');
}
document.getElementById('shuffle').addEventListener('click', shuffle);
document.getElementById('swap').addEventListener('click', swapTwo);
document.getElementById('check').addEventListener('click', check);
render();
Now press Swap two tiles. The board looks like a normal scramble, but a hidden quantity — the parity — has flipped, and no sequence of slides will ever solve it. The demo computes that parity instantly and tells you. Checking whether a board is solvable is easy. Finding the shortest path of slides for a big puzzle is the hard part.
Comments
Loading comments...