13

15-Puzzle: Sliding Tiles

tap an adjacent tile · button: solve / shuffle

The classic 15-puzzle on a 4x4 grid: tiles 1 through 15 plus a single blank cell. Tap any tile that sits directly above, below, or beside the gap and it slides into place. The goal is to put the tiles in row-major order (1, 2, 3, 4 across the top, 13, 14, 15, blank across the bottom). Every shuffle is produced by random legal moves rather than a random permutation, which guarantees the position is reachable — half of all permutations of the 16 cells are unsolvable, and this avoids them. The puzzle reports your move count and elapsed time and announces a win automatically. The Solve button hands the current board to an A* search with the Manhattan-distance plus linear-conflicts heuristic and then animates the optimal solution at roughly three moves per second, which is one of the cleaner ways to watch a heuristic search at work. Press R to reshuffle.

idle
529 lines · vanilla
view source
// 15-Puzzle: Sliding Tiles
// 4x4 grid, tiles numbered 1..15 and a blank (0). Tap an adjacent tile to
// slide it into the gap. Win when tiles are in row-major order with the
// blank in the bottom-right.
//
// Shuffle is performed by random legal moves only, so the puzzle is always
// solvable. "Solve" runs A* with Manhattan + linear-conflict heuristic and
// animates the solution at ~3 moves/sec.

const N = 4;
const GOAL = new Uint8Array(N * N);
for (let i = 0; i < N * N - 1; i++) GOAL[i] = i + 1;
GOAL[N * N - 1] = 0;

let board;            // Uint8Array(16), values 0..15, 0 is blank
let blank;            // index of blank
let moveCount = 0;
let startTime = 0;
let elapsedMs = 0;
let won = false;
let winFlash = 0;     // 0..1, fades out after win

// Sliding animation: only one tile animates at a time. We snapshot the
// post-move board, but draw the moved tile interpolating from its old cell.
let anim = null;      // { from, to, t, dur, value }
let queue = [];       // queue of pending tile indices to slide (for solver)
let solving = false;
let solveDelayMs = 0; // accumulator between solver-driven moves

// UI
let W = 0, H = 0;
let buttons = [];     // [{ x, y, w, h, label, action }]
let lastMouseDown = false;
let pressStart = null;

function shuffleByRandomMoves(steps) {
  // Random legal moves from the goal state. Avoid immediate reversal.
  let last = -1;
  for (let s = 0; s < steps; s++) {
    const nbs = neighbors(blank);
    const choices = last < 0 ? nbs : nbs.filter((n) => n !== last);
    const pick = choices[(Math.random() * choices.length) | 0];
    swap(blank, pick);
    last = blank; // the cell we just emptied (where the moved tile came from)
    blank = pick;
  }
}

function neighbors(i) {
  const x = i % N, y = (i / N) | 0;
  const out = [];
  if (x > 0)     out.push(i - 1);
  if (x < N - 1) out.push(i + 1);
  if (y > 0)     out.push(i - N);
  if (y < N - 1) out.push(i + N);
  return out;
}

function swap(a, b) {
  const t = board[a]; board[a] = board[b]; board[b] = t;
}

function reset() {
  board = new Uint8Array(GOAL);
  blank = N * N - 1;
  // 80 random legal moves is enough to look thoroughly scrambled (the
  // true optimal-solve distance averages ~20-30 moves at this depth),
  // while keeping the A* solver fast.
  shuffleByRandomMoves(80);
  moveCount = 0;
  startTime = performance.now();
  elapsedMs = 0;
  won = false;
  winFlash = 0;
  anim = null;
  queue = [];
  solving = false;
  solveDelayMs = 0;
}

function init({ width, height }) {
  W = width; H = height;
  reset();
}

function isGoal() {
  for (let i = 0; i < N * N; i++) if (board[i] !== GOAL[i]) return false;
  return true;
}

function tryMoveTileAt(idx) {
  if (anim || solving) return false;
  if (won) return false;
  if (board[idx] === 0) return false;
  // Must be orthogonally adjacent to the blank.
  if (!neighbors(idx).includes(blank)) return false;
  startSlide(idx, blank);
  moveCount++;
  return true;
}

function startSlide(fromIdx, toIdx) {
  // The tile at fromIdx slides into toIdx (the blank). After animation we
  // update the board logically.
  anim = {
    from: fromIdx,
    to: toIdx,
    t: 0,
    dur: solving ? 0.18 : 0.11,
    value: board[fromIdx],
  };
}

function finishAnim() {
  if (!anim) return;
  swap(anim.from, anim.to);
  blank = anim.from;
  anim = null;
  if (!won && isGoal()) {
    won = true;
    winFlash = 1;
  }
}

// ----- A* solver -----
// 16-cell boards encoded as a 64-bit hex string (4 bits per cell).
function encode(b) {
  let s = "";
  for (let i = 0; i < N * N; i++) s += b[i].toString(16);
  return s;
}
function decode(s) {
  const b = new Uint8Array(N * N);
  for (let i = 0; i < N * N; i++) b[i] = parseInt(s[i], 16);
  return b;
}
function blankOf(b) {
  for (let i = 0; i < N * N; i++) if (b[i] === 0) return i;
  return -1;
}

const GOAL_POS = new Uint8Array(N * N);
for (let v = 0; v < N * N; v++) {
  // value v -> its goal index
  if (v === 0) GOAL_POS[v] = N * N - 1;
  else GOAL_POS[v] = v - 1;
}

function heuristic(b) {
  // Manhattan distance + linear conflicts (each conflict adds 2).
  let h = 0;
  for (let i = 0; i < N * N; i++) {
    const v = b[i];
    if (v === 0) continue;
    const gi = GOAL_POS[v];
    const x = i % N, y = (i / N) | 0;
    const gx = gi % N, gy = (gi / N) | 0;
    h += Math.abs(x - gx) + Math.abs(y - gy);
  }
  // Linear conflicts along rows
  for (let y = 0; y < N; y++) {
    for (let i = 0; i < N; i++) {
      const a = b[y * N + i];
      if (a === 0) continue;
      const ay = (GOAL_POS[a] / N) | 0;
      if (ay !== y) continue;
      for (let j = i + 1; j < N; j++) {
        const c = b[y * N + j];
        if (c === 0) continue;
        const cy = (GOAL_POS[c] / N) | 0;
        if (cy !== y) continue;
        if (GOAL_POS[a] > GOAL_POS[c]) h += 2;
      }
    }
  }
  // Linear conflicts along columns
  for (let x = 0; x < N; x++) {
    for (let i = 0; i < N; i++) {
      const a = b[i * N + x];
      if (a === 0) continue;
      const ax = GOAL_POS[a] % N;
      if (ax !== x) continue;
      for (let j = i + 1; j < N; j++) {
        const c = b[j * N + x];
        if (c === 0) continue;
        const cx = GOAL_POS[c] % N;
        if (cx !== x) continue;
        if (GOAL_POS[a] > GOAL_POS[c]) h += 2;
      }
    }
  }
  return h;
}

// Simple binary-heap priority queue keyed on f.
class PQ {
  constructor() { this.a = []; }
  push(item, f) {
    this.a.push({ item, f });
    let i = this.a.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.a[p].f <= this.a[i].f) break;
      [this.a[p], this.a[i]] = [this.a[i], this.a[p]];
      i = p;
    }
  }
  pop() {
    if (!this.a.length) return null;
    const top = this.a[0];
    const last = this.a.pop();
    if (this.a.length) {
      this.a[0] = last;
      let i = 0, n = this.a.length;
      for (;;) {
        const l = 2 * i + 1, r = 2 * i + 2;
        let m = i;
        if (l < n && this.a[l].f < this.a[m].f) m = l;
        if (r < n && this.a[r].f < this.a[m].f) m = r;
        if (m === i) break;
        [this.a[m], this.a[i]] = [this.a[i], this.a[m]];
        i = m;
      }
    }
    return top.item;
  }
  get size() { return this.a.length; }
}

function solveAStar(startBoard) {
  // Returns array of board indices (the tile to slide each step), or null.
  const startKey = encode(startBoard);
  const goalKey = encode(GOAL);
  if (startKey === goalKey) return [];

  const open = new PQ();
  const gScore = new Map();
  const parent = new Map(); // key -> { prev, movedTile }

  gScore.set(startKey, 0);
  open.push(startKey, heuristic(startBoard));

  // Cap to keep the worker responsive; 15-puzzle solutions average ~50
  // moves and the search can blow up — but our shuffles are 200 random
  // legal moves so this is bounded in practice.
  const NODE_CAP = 250000;
  let expanded = 0;

  while (open.size) {
    const key = open.pop();
    if (key === goalKey) break;
    expanded++;
    if (expanded > NODE_CAP) return null;

    const b = decode(key);
    const bl = blankOf(b);
    const g = gScore.get(key);
    const nbs = neighbors(bl);
    for (const nb of nbs) {
      const movedTile = b[nb];
      // Swap blank and nb
      const nb2 = new Uint8Array(b);
      nb2[bl] = movedTile; nb2[nb] = 0;
      const k2 = encode(nb2);
      const tentative = g + 1;
      const prev = gScore.get(k2);
      if (prev === undefined || tentative < prev) {
        gScore.set(k2, tentative);
        parent.set(k2, { prev: key, moved: nb }); // nb was the tile's cell
        open.push(k2, tentative + heuristic(nb2));
      }
    }
  }

  if (!parent.has(goalKey) && startKey !== goalKey) return null;
  // Reconstruct: each parent entry records the cell of the tile that moved
  // (its location BEFORE the move, i.e. relative to the previous board).
  const path = [];
  let cur = goalKey;
  while (cur !== startKey) {
    const p = parent.get(cur);
    if (!p) return null;
    path.push(p.moved);
    cur = p.prev;
  }
  path.reverse();
  return path;
}

function startSolve() {
  if (won || solving || anim) return;
  const path = solveAStar(board);
  if (!path) return;
  if (path.length === 0) return;
  // Translate path of "cell containing the tile to move on the snapshot at
  // step k" into a sequence we can drive frame-by-frame. We'll re-derive
  // the moving tile relative to the current board state as we go, so we
  // also need to walk a virtual board to map each step's cell to the live
  // cell at the moment of execution. Since A* already produced moves on
  // the actual board sequence, we can replay them directly.
  queue = path.slice();
  solving = true;
  solveDelayMs = 0;
}

// ----- Layout / rendering -----
function layout() {
  const headerH = 56;
  const buttonH = 44;
  const padding = 16;
  const availH = H - headerH - buttonH - padding * 3;
  const availW = W - padding * 2;
  const side = Math.max(120, Math.min(availW, availH));
  const ox = (W - side) / 2;
  const oy = headerH + padding;
  const gap = Math.max(3, side * 0.012);
  const cell = (side - gap * (N + 1)) / N;

  // buttons row beneath the board
  const by = oy + side + padding;
  const bw = Math.min(160, (W - padding * 3) / 2);
  const bgap = padding;
  const totalBW = bw * 2 + bgap;
  const bx0 = (W - totalBW) / 2;
  buttons = [
    { x: bx0,             y: by, w: bw, h: buttonH, label: "Solve",   action: "solve" },
    { x: bx0 + bw + bgap, y: by, w: bw, h: buttonH, label: "Shuffle", action: "shuffle" },
  ];

  return { side, ox, oy, gap, cell };
}

function cellRect(i, L) {
  const x = i % N, y = (i / N) | 0;
  return {
    x: L.ox + L.gap + x * (L.cell + L.gap),
    y: L.oy + L.gap + y * (L.cell + L.gap),
    s: L.cell,
  };
}

function cellAt(px, py, L) {
  // Returns cell index or -1
  for (let i = 0; i < N * N; i++) {
    const r = cellRect(i, L);
    if (px >= r.x && px < r.x + r.s && py >= r.y && py < r.y + r.s) return i;
  }
  return -1;
}

function pointInRect(px, py, r) {
  return px >= r.x && px < r.x + r.w && py >= r.y && py < r.y + r.h;
}

function easeOutCubic(t) {
  const u = 1 - t;
  return 1 - u * u * u;
}

function formatTime(ms) {
  const t = Math.floor(ms / 1000);
  const m = (t / 60) | 0;
  const s = t % 60;
  return `${m}:${String(s).padStart(2, "0")}`;
}

function drawTile(ctx, x, y, s, value) {
  const r = Math.max(4, s * 0.08);
  // Soft gradient fill per tile
  const grad = ctx.createLinearGradient(x, y, x, y + s);
  // Subtle hue shift across rows so it's pretty but stays readable
  const row = ((value - 1) / N) | 0;
  const stops = [
    ["#2a4365", "#1e3a5f"], // row 0 — blue
    ["#2c5282", "#234e7a"], // row 1
    ["#2b6cb0", "#1f5894"], // row 2
    ["#3182ce", "#2563a8"], // row 3
  ];
  const [c1, c2] = stops[Math.min(3, Math.max(0, row))];
  grad.addColorStop(0, c1);
  grad.addColorStop(1, c2);
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + s - r, y);
  ctx.quadraticCurveTo(x + s, y, x + s, y + r);
  ctx.lineTo(x + s, y + s - r);
  ctx.quadraticCurveTo(x + s, y + s, x + s - r, y + s);
  ctx.lineTo(x + r, y + s);
  ctx.quadraticCurveTo(x, y + s, x, y + s - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
  ctx.fill();

  // Highlight stripe
  ctx.fillStyle = "rgba(255,255,255,0.08)";
  ctx.fillRect(x + r, y + Math.max(2, s * 0.05), s - 2 * r, Math.max(1, s * 0.04));

  // Value
  const correct = (value - 1) === GOAL_POS[value] && false; // not used; could highlight
  ctx.fillStyle = "#f0f6ff";
  const fs = Math.floor(s * (value >= 10 ? 0.38 : 0.46));
  ctx.font = `bold ${fs}px sans-serif`;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText(String(value), x + s / 2, y + s / 2 + s * 0.02);
  void correct;
}

function drawSlot(ctx, x, y, s) {
  const r = Math.max(4, s * 0.08);
  ctx.fillStyle = "#1a1f2a";
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + s - r, y);
  ctx.quadraticCurveTo(x + s, y, x + s, y + r);
  ctx.lineTo(x + s, y + s - r);
  ctx.quadraticCurveTo(x + s, y + s, x + s - r, y + s);
  ctx.lineTo(x + r, y + s);
  ctx.quadraticCurveTo(x, y + s, x, y + s - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
  ctx.fill();
}

function drawButton(ctx, b, hot) {
  const r = 10;
  ctx.fillStyle = hot ? "#2c5282" : "#1f2937";
  ctx.beginPath();
  ctx.moveTo(b.x + r, b.y);
  ctx.lineTo(b.x + b.w - r, b.y);
  ctx.quadraticCurveTo(b.x + b.w, b.y, b.x + b.w, b.y + r);
  ctx.lineTo(b.x + b.w, b.y + b.h - r);
  ctx.quadraticCurveTo(b.x + b.w, b.y + b.h, b.x + b.w - r, b.y + b.h);
  ctx.lineTo(b.x + r, b.y + b.h);
  ctx.quadraticCurveTo(b.x, b.y + b.h, b.x, b.y + b.h - r);
  ctx.lineTo(b.x, b.y + r);
  ctx.quadraticCurveTo(b.x, b.y, b.x + r, b.y);
  ctx.closePath();
  ctx.fill();
  ctx.strokeStyle = "rgba(255,255,255,0.12)";
  ctx.lineWidth = 1;
  ctx.stroke();
  ctx.fillStyle = "#e6edf3";
  ctx.font = "bold 16px sans-serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText(b.label, b.x + b.w / 2, b.y + b.h / 2);
}

function handleInput(input) {
  // Keyboard
  if (input.justPressed("r") || input.justPressed("R")) {
    reset();
    return;
  }

  // Track press-then-release as a tap (avoid drag-misfires).
  const md = input.mouseDown;
  if (md && !lastMouseDown) {
    pressStart = { x: input.mouseX, y: input.mouseY };
  } else if (!md && lastMouseDown && pressStart) {
    const dx = input.mouseX - pressStart.x;
    const dy = input.mouseY - pressStart.y;
    if (Math.abs(dx) < 14 && Math.abs(dy) < 14) {
      onTap(input.mouseX, input.mouseY);
    }
    pressStart = null;
  }
  lastMouseDown = md;

  // Drain any click events (some platforms only emit click, not down/up).
  const clicks = input.consumeClicks ? input.consumeClicks() : [];
  for (const c of clicks) onTap(c.x, c.y);
}

function onTap(px, py) {
  // Buttons take priority.
  for (const b of buttons) {
    if (pointInRect(px, py, b)) {
      if (b.action === "shuffle") {
        reset();
      } else if (b.action === "solve") {
        if (solving) {
          // cancel solver
          solving = false;
          queue = [];
        } else {
          startSolve();
        }
      }
      return;
    }
  }
  // Board taps
  const L = layout();
  const idx = cellAt(px, py, L);
  if (idx >= 0) tryMoveTileAt(idx);
}

function tick({ ctx, dt, width, height, input }) {
  W = width; H = height;
  const L = layout();

  if (!won && !solving) elapsedMs = performance.now() - startTime;

  handleInput(input);

  // Advance animation
  if (anim) {
    anim.t += dt / anim.dur;
    if (anim.t >= 1) {
      anim.t = 1;
      finishAnim();
    }
  } else if (solving && queue.length) {
    solveDelayMs += dt * 1000;
    if (solveDelayMs >= 150) {
      solveDelayMs = 0;
      const next = queue.shift();
      // The solver path enumerates the cell of the tile to slide on the
      // board snapshot at that step. Because A* produced these moves
      // against the actual sequence starting from our current board, this
      // is consistent: `next` is the live cell of an adjacent tile.
      if (board[next] !== 0 && neighbors(next).includes(blank)) {
        startSlide(next, blank);
        moveCount++;
      } else {
        // Sanity fallback: stop solver if state diverged.
        solving = false;
        queue = [];
      }
      if (queue.length === 0) {
        // last move starting; let it animate, then drop solving flag.
      }
    }
  } else if (solving && !queue.length) {
    solving = false;
  }

  // Background
  const bg = ctx.createLinearGradient(0, 0, 0, H);
  bg.addColorStop(0, "#0b0e14");
  bg.addColorStop(1, "#11151c");
  ctx.fillStyle = bg;
  ctx.fillRect(0, 0, W, H);

  // Header
  ctx.fillStyle = "#e6edf3";
  ctx.font = "bold 18px sans-serif";
  ctx.textBaseline = "top";
  ctx.textAlign = "left";
  ctx.fillText(`Moves ${moveCount}`, 14, 14);
  ctx.textAlign = "right";
  ctx.fillText(`Time ${formatTime(elapsedMs)}`, W - 14, 14);
  ctx.textAlign = "center";
  ctx.font = "12px sans-serif";
  ctx.fillStyle = "rgba(230,237,243,0.55)";
  ctx.fillText(solving ? "solving with A* — tap Solve to cancel" : "tap an adjacent tile  ·  R to shuffle", W / 2, 18);

  // Board frame
  const frameR = Math.max(6, L.side * 0.018);
  ctx.fillStyle = "#0d1320";
  ctx.beginPath();
  ctx.moveTo(L.ox + frameR, L.oy);
  ctx.lineTo(L.ox + L.side - frameR, L.oy);
  ctx.quadraticCurveTo(L.ox + L.side, L.oy, L.ox + L.side, L.oy + frameR);
  ctx.lineTo(L.ox + L.side, L.oy + L.side - frameR);
  ctx.quadraticCurveTo(L.ox + L.side, L.oy + L.side, L.ox + L.side - frameR, L.oy + L.side);
  ctx.lineTo(L.ox + frameR, L.oy + L.side);
  ctx.quadraticCurveTo(L.ox, L.oy + L.side, L.ox, L.oy + L.side - frameR);
  ctx.lineTo(L.ox, L.oy + frameR);
  ctx.quadraticCurveTo(L.ox, L.oy, L.ox + frameR, L.oy);
  ctx.closePath();
  ctx.fill();
  ctx.strokeStyle = "rgba(255,255,255,0.04)";
  ctx.lineWidth = 1;
  ctx.stroke();

  // Empty slot wells
  for (let i = 0; i < N * N; i++) {
    const r = cellRect(i, L);
    drawSlot(ctx, r.x, r.y, r.s);
  }

  // Tiles (skip the one currently animating; we draw it last)
  for (let i = 0; i < N * N; i++) {
    if (anim && i === anim.from) continue; // value here is 0 in board but tile is in flight
    const v = board[i];
    if (v === 0) continue;
    const r = cellRect(i, L);
    drawTile(ctx, r.x, r.y, r.s, v);
  }

  // Animating tile
  if (anim) {
    const eased = easeOutCubic(anim.t);
    const a = cellRect(anim.from, L);
    const b = cellRect(anim.to, L);
    const x = a.x + (b.x - a.x) * eased;
    const y = a.y + (b.y - a.y) * eased;
    drawTile(ctx, x, y, a.s, anim.value);
  }

  // Buttons. When solving, swap the Solve button's label to "Stop" in place
  // and restore it afterwards — avoids allocating a clone object every frame.
  for (const b of buttons) {
    const hot = (input.mouseDown && pointInRect(input.mouseX, input.mouseY, b))
              || (b.action === "solve" && solving);
    if (b.action === "solve" && solving) {
      const orig = b.label;
      b.label = "Stop";
      drawButton(ctx, b, true);
      b.label = orig;
    } else {
      drawButton(ctx, b, hot);
    }
  }

  // Win flourish
  if (won) {
    winFlash = Math.max(0, winFlash - dt * 0.6);
    // Pulsing gold overlay on the board
    const a = 0.18 + 0.18 * Math.sin(performance.now() * 0.006);
    ctx.fillStyle = `rgba(237,194,46,${0.35 + a * 0.3})`;
    // Draw thin rings expanding from center
    const cx = L.ox + L.side / 2, cy = L.oy + L.side / 2;
    for (let k = 0; k < 3; k++) {
      const phase = ((performance.now() * 0.001) + k * 0.33) % 1;
      const rad = phase * (L.side * 0.55);
      ctx.beginPath();
      ctx.arc(cx, cy, rad, 0, Math.PI * 2);
      ctx.strokeStyle = `rgba(237,194,46,${(1 - phase) * 0.55})`;
      ctx.lineWidth = 3;
      ctx.stroke();
    }
    // Banner
    ctx.fillStyle = "rgba(8,12,20,0.78)";
    const banH = 64;
    const banY = cy - banH / 2;
    ctx.fillRect(L.ox, banY, L.side, banH);
    ctx.fillStyle = "#ffd966";
    ctx.font = "bold 28px sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText("Solved!", cx, banY + banH / 2 - 6);
    ctx.fillStyle = "#cfd6e2";
    ctx.font = "13px sans-serif";
    ctx.fillText(`${moveCount} moves · ${formatTime(elapsedMs)}  ·  tap Shuffle for a new puzzle`, cx, banY + banH / 2 + 18);
  }
}

Comments (0)

Log in to comment.