1

Sokoban: 12 Levels

tap arrows or WASD · U undo · R restart

Classic Sokoban with twelve hand-crafted levels, scaling from a one-push warmup to three-box puzzles that demand a forced ordering. You can push boxes but never pull them, so a single careless shove into a corner means restart. Tap the on-screen D-pad or use arrow keys / WASD; press U (or tap Undo) to step back a move, R to restart. Use the ← / → arrows at the top to jump between levels. Boxes turn green when they're on a target.

idle
354 lines · vanilla
view source
// Sokoban — 12 hand-built levels. Push boxes onto targets; can't pull.
// Tap arrows / WASD to move. U undo a step. R restart. ← / → switch level.

// Standard Sokoban notation:
//   # wall  ' ' floor  . target  $ box  * box-on-target  @ player  + player-on-target
const LEVELS = [
  // L1 — trivial: one push, straight line. (~5s)
  [
    "#####",
    "#@$.#",
    "#####",
  ],
  // L2 — corner the box around an L. (~10s)
  [
    "#######",
    "#.    #",
    "# $   #",
    "#  @  #",
    "#######",
  ],
  // L3 — two boxes, two targets, simple lane. (~15s)
  [
    "########",
    "#.   . #",
    "# $$   #",
    "#   @  #",
    "########",
  ],
  // L4 — wrap around a stub to deliver to the corner target. (~20s)
  [
    "########",
    "#.     #",
    "#  #   #",
    "# $    #",
    "#   @  #",
    "########",
  ],
  // L5 — two boxes, must avoid corner-traps. (~20s)
  [
    "#######",
    "#.    #",
    "# $$  #",
    "#  @  #",
    "#    .#",
    "#######",
  ],
  // L6 — push up around a stub to the upper target. (~25s)
  [
    "#########",
    "#    .  #",
    "##  ##  #",
    "#       #",
    "#  $    #",
    "#  @    #",
    "#########",
  ],
  // L7 — three boxes side by side. (~30s)
  [
    "#########",
    "#. . .  #",
    "#       #",
    "# $$$   #",
    "#   @   #",
    "#########",
  ],
  // L8 — pocket: target inside a recess. (~30s)
  [
    "########",
    "###  ###",
    "# .    #",
    "# $ @  #",
    "#      #",
    "########",
  ],
  // L9 — forced order: must place far box first. (~40s)
  [
    "##########",
    "#. .     #",
    "# $$     #",
    "#        #",
    "#    @   #",
    "##########",
  ],
  // L10 — two pockets on opposite sides. (~45s)
  [
    "##########",
    "#   .    #",
    "#   $    #",
    "## ### ###",
    "#   $    #",
    "#   .  @ #",
    "##########",
  ],
  // L11 — three boxes, three pockets in a row. (~50s)
  [
    "##########",
    "# .  .  .#",
    "#        #",
    "# $   $  #",
    "#    $   #",
    "#   @    #",
    "##########",
  ],
  // L12 — tight room, must thread through. (~60s)
  [
    "##########",
    "#.       #",
    "# $## $  #",
    "#  #  ## #",
    "#  $ .   #",
    "# @    . #",
    "##########",
  ],
];

let levelIdx = 0;
let grid = null;          // 2D: '#', ' ', '.'  (statics)
let boxes = null;         // Set of "r,c" positions
let targets = null;       // Set of "r,c" positions
let player = { r: 0, c: 0 };
let history = [];         // {pr,pc, boxFrom?, boxTo?}
let cols = 0, rows = 0;
let cellPx = 32;
let originX = 0, originY = 0;
let won = false;
let lastKeyTime = 0;
let prevTouch = false;    // edge-detect on-screen tap
let buttons = [];         // {x,y,w,h,action,label}
let flashWin = 0;

function parseLevel(idx) {
  const src = LEVELS[idx];
  rows = src.length;
  cols = Math.max(...src.map(r => r.length));
  grid = [];
  boxes = new Set();
  targets = new Set();
  player = { r: 0, c: 0 };
  for (let r = 0; r < rows; r++) {
    const row = [];
    for (let c = 0; c < cols; c++) {
      const ch = src[r][c] || " ";
      let cell = " ";
      if (ch === "#") cell = "#";
      else if (ch === "." || ch === "*" || ch === "+") {
        cell = ".";
        targets.add(r + "," + c);
      }
      if (ch === "$" || ch === "*") boxes.add(r + "," + c);
      if (ch === "@" || ch === "+") { player.r = r; player.c = c; }
      row.push(cell);
    }
    grid.push(row);
  }
  history = [];
  won = false;
  flashWin = 0;
}

function checkWin() {
  if (boxes.size !== targets.size) return false;
  for (const k of boxes) if (!targets.has(k)) return false;
  return true;
}

function tryMove(dr, dc) {
  if (won) return;
  const nr = player.r + dr, nc = player.c + dc;
  if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) return;
  if (grid[nr][nc] === "#") return;
  const targetKey = nr + "," + nc;
  if (boxes.has(targetKey)) {
    const br = nr + dr, bc = nc + dc;
    if (br < 0 || br >= rows || bc < 0 || bc >= cols) return;
    if (grid[br][bc] === "#") return;
    const boxToKey = br + "," + bc;
    if (boxes.has(boxToKey)) return;
    // valid push
    history.push({ pr: player.r, pc: player.c, boxFrom: targetKey, boxTo: boxToKey });
    boxes.delete(targetKey);
    boxes.add(boxToKey);
    player.r = nr; player.c = nc;
  } else {
    history.push({ pr: player.r, pc: player.c });
    player.r = nr; player.c = nc;
  }
  if (checkWin()) { won = true; flashWin = 1; }
}

function undo() {
  if (won) return;
  const h = history.pop();
  if (!h) return;
  player.r = h.pr; player.c = h.pc;
  if (h.boxTo) {
    boxes.delete(h.boxTo);
    boxes.add(h.boxFrom);
  }
}

function changeLevel(delta) {
  levelIdx = (levelIdx + delta + LEVELS.length) % LEVELS.length;
  parseLevel(levelIdx);
}

function layout(width, height) {
  // Reserve ~140px at bottom for D-pad + nav. On tiny canvases, scale down.
  const bottomBar = Math.min(160, Math.max(120, Math.floor(height * 0.32)));
  const topBar = 32;
  const playW = width - 16;
  const playH = height - bottomBar - topBar - 8;
  cellPx = Math.max(12, Math.floor(Math.min(playW / cols, playH / rows)));
  const gridW = cellPx * cols, gridH = cellPx * rows;
  originX = Math.floor((width - gridW) / 2);
  originY = topBar + Math.floor((playH - gridH) / 2);

  // Buttons in bottom bar
  buttons = [];
  const cx = Math.floor(width / 2);
  const by = height - bottomBar + 8;  // top of bottom bar
  const btn = Math.max(44, Math.min(64, Math.floor(bottomBar / 2.6)));
  const gap = 6;
  // D-pad cross
  buttons.push({ x: cx - btn / 2, y: by, w: btn, h: btn, action: "up", label: "↑" });
  buttons.push({ x: cx - btn / 2, y: by + btn + gap * 2, w: btn, h: btn, action: "down", label: "↓" });
  buttons.push({ x: cx - btn / 2 - btn - gap, y: by + btn / 2 + gap, w: btn, h: btn, action: "left", label: "←" });
  buttons.push({ x: cx + btn / 2 + gap, y: by + btn / 2 + gap, w: btn, h: btn, action: "right", label: "→" });

  // Side controls: undo on left, restart on right of d-pad
  const sideBtnH = Math.max(36, Math.floor(btn * 0.8));
  const sideBtnW = Math.max(72, Math.floor(width * 0.18));
  buttons.push({
    x: 8, y: by + Math.floor(btn * 0.6),
    w: sideBtnW, h: sideBtnH, action: "undo", label: "Undo",
  });
  buttons.push({
    x: width - 8 - sideBtnW, y: by + Math.floor(btn * 0.6),
    w: sideBtnW, h: sideBtnH, action: "restart", label: "Restart",
  });

  // Level nav arrows at top
  const navW = 44, navH = 28;
  buttons.push({ x: 8, y: 4, w: navW, h: navH, action: "prev", label: "←" });
  buttons.push({ x: width - 8 - navW, y: 4, w: navW, h: navH, action: "next", label: "→" });
}

function pointInBtn(x, y, b) {
  return x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h;
}

function init({ width, height }) {
  parseLevel(0);
  layout(width, height);
}

function tick({ ctx, dt, width, height, input }) {
  // Re-layout if size changed (rare in this runtime, but cheap)
  layout(width, height);

  // ---- Keyboard input (edge-detected) ----
  if (input.justPressed("ArrowUp") || input.justPressed("w") || input.justPressed("W")) tryMove(-1, 0);
  if (input.justPressed("ArrowDown") || input.justPressed("s") || input.justPressed("S")) tryMove(1, 0);
  if (input.justPressed("ArrowLeft") || input.justPressed("a") || input.justPressed("A")) tryMove(0, -1);
  if (input.justPressed("ArrowRight") || input.justPressed("d") || input.justPressed("D")) tryMove(0, 1);
  if (input.justPressed("u") || input.justPressed("U") || input.justPressed("z") || input.justPressed("Z")) undo();
  if (input.justPressed("r") || input.justPressed("R")) parseLevel(levelIdx);
  if (input.justPressed("[") || input.justPressed(",")) changeLevel(-1);
  if (input.justPressed("]") || input.justPressed(".")) changeLevel(1);
  if (won && (input.justPressed("Enter") || input.justPressed(" "))) changeLevel(1);

  // ---- Tap/click input on buttons (mobile + desktop) ----
  const clicks = input.consumeClicks();
  for (const ck of clicks) {
    // If win overlay is showing, any tap on the board advances
    if (won) {
      // tap inside grid area to advance, otherwise buttons still work
      const inBtn = buttons.some(b => pointInBtn(ck.x, ck.y, b));
      if (!inBtn) { changeLevel(1); continue; }
    }
    for (const b of buttons) {
      if (!pointInBtn(ck.x, ck.y, b)) continue;
      if (b.action === "up") tryMove(-1, 0);
      else if (b.action === "down") tryMove(1, 0);
      else if (b.action === "left") tryMove(0, -1);
      else if (b.action === "right") tryMove(0, 1);
      else if (b.action === "undo") undo();
      else if (b.action === "restart") parseLevel(levelIdx);
      else if (b.action === "prev") changeLevel(-1);
      else if (b.action === "next") changeLevel(1);
      break;
    }
  }

  if (flashWin > 0) flashWin = Math.max(0, flashWin - dt);

  draw(ctx, width, height, input);
}

function draw(ctx, width, height, input) {
  // Background
  ctx.fillStyle = "#0d1117";
  ctx.fillRect(0, 0, width, height);

  // Top bar: level label
  ctx.fillStyle = "#9aa0b4";
  ctx.font = "13px system-ui, sans-serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText(`Level ${levelIdx + 1} / ${LEVELS.length}`, width / 2, 18);

  // Step count
  ctx.font = "11px system-ui, sans-serif";
  ctx.textAlign = "right";
  ctx.fillStyle = "#6b7185";
  ctx.fillText(`steps ${history.length}`, width - 60, 18);
  ctx.textAlign = "left";

  // Grid cells
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      const x = originX + c * cellPx;
      const y = originY + r * cellPx;
      const ch = grid[r][c];
      if (ch === "#") {
        ctx.fillStyle = "#2a3045";
        ctx.fillRect(x, y, cellPx, cellPx);
        // brick highlight
        ctx.fillStyle = "#3a4262";
        ctx.fillRect(x + 1, y + 1, cellPx - 2, 2);
      } else {
        // floor
        ctx.fillStyle = (r + c) % 2 === 0 ? "#1a1f2e" : "#161b28";
        ctx.fillRect(x, y, cellPx, cellPx);
        if (ch === ".") {
          // target marker
          ctx.fillStyle = "#34c759";
          const cx = x + cellPx / 2, cy = y + cellPx / 2;
          ctx.beginPath();
          ctx.arc(cx, cy, Math.max(3, cellPx * 0.14), 0, Math.PI * 2);
          ctx.fill();
        }
      }
    }
  }

  // Boxes
  for (const k of boxes) {
    const [r, c] = k.split(",").map(Number);
    const x = originX + c * cellPx;
    const y = originY + r * cellPx;
    const onTarget = targets.has(k);
    const pad = Math.max(2, Math.floor(cellPx * 0.1));
    ctx.fillStyle = onTarget ? "#34c759" : "#c08040";
    ctx.fillRect(x + pad, y + pad, cellPx - pad * 2, cellPx - pad * 2);
    ctx.strokeStyle = onTarget ? "#1f7a3a" : "#7a4a20";
    ctx.lineWidth = 2;
    ctx.strokeRect(x + pad + 1, y + pad + 1, cellPx - pad * 2 - 2, cellPx - pad * 2 - 2);
    // cross detail
    ctx.beginPath();
    ctx.moveTo(x + pad, y + pad); ctx.lineTo(x + cellPx - pad, y + cellPx - pad);
    ctx.moveTo(x + cellPx - pad, y + pad); ctx.lineTo(x + pad, y + cellPx - pad);
    ctx.stroke();
  }

  // Player
  {
    const x = originX + player.c * cellPx;
    const y = originY + player.r * cellPx;
    const cx = x + cellPx / 2, cy = y + cellPx / 2;
    ctx.fillStyle = "#5ac8fa";
    ctx.beginPath();
    ctx.arc(cx, cy, cellPx * 0.32, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#0a1822";
    ctx.beginPath();
    ctx.arc(cx - cellPx * 0.1, cy - cellPx * 0.06, Math.max(1.2, cellPx * 0.045), 0, Math.PI * 2);
    ctx.arc(cx + cellPx * 0.1, cy - cellPx * 0.06, Math.max(1.2, cellPx * 0.045), 0, Math.PI * 2);
    ctx.fill();
  }

  // Buttons
  for (const b of buttons) {
    drawButton(ctx, b, input);
  }

  // Win overlay
  if (won) {
    ctx.fillStyle = "rgba(10,15,25,0.78)";
    const gridW = cellPx * cols, gridH = cellPx * rows;
    ctx.fillRect(originX - 8, originY - 8, gridW + 16, gridH + 16);
    ctx.fillStyle = "#34c759";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.font = "bold 22px system-ui, sans-serif";
    ctx.fillText("Solved!", originX + gridW / 2, originY + gridH / 2 - 12);
    ctx.fillStyle = "#e8e8f0";
    ctx.font = "12px system-ui, sans-serif";
    const msg = levelIdx + 1 < LEVELS.length
      ? "tap to continue · → next"
      : "all 12 cleared — tap to loop";
    ctx.fillText(msg, originX + gridW / 2, originY + gridH / 2 + 12);
  }
}

function drawButton(ctx, b, input) {
  const hot = input.mouseDown
    && input.mouseX >= b.x && input.mouseX <= b.x + b.w
    && input.mouseY >= b.y && input.mouseY <= b.y + b.h;
  ctx.fillStyle = hot ? "#3a4566" : "#222a3e";
  ctx.fillRect(b.x, b.y, b.w, b.h);
  ctx.strokeStyle = "#3a4566";
  ctx.lineWidth = 1;
  ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1);
  ctx.fillStyle = "#e8e8f0";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  const isArrow = b.label === "↑" || b.label === "↓" || b.label === "←" || b.label === "→";
  ctx.font = isArrow ? "bold 22px system-ui, sans-serif" : "13px system-ui, sans-serif";
  ctx.fillText(b.label, b.x + b.w / 2, b.y + b.h / 2 + 1);
}

Comments (0)

Log in to comment.