Skip to main content

How to Build a Sokoban Game

This tutorial was written for v2 of the engine.

Sokoban is one of those games that looks trivial until you're three moves in and realize you've bricked the level. You push boxes onto targets. That's it. But "that's it" hides a deep puzzle — every push is irreversible (unless you build undo), every wall matters, and one wrong move can make a level unsolvable.

We're going to build the whole thing: a grid-based puzzle game with multiple levels, a scene system for navigating between them, a hint overlay for when you're stuck, and animated polish to make it feel good. By the end you'll have a playable Sokoban with undo, level select, and juice — and you'll have seen how the tilemap, sprite, input, and scene APIs fit together in a real game.

The game builds up in layers. We start with a grid and a player. Then boxes. Then targets and win conditions. Then undo. Then multiple levels with scene transitions. Then hints. Then we replace the instant movement with smooth sliding and add particles. Each section adds one idea and has a playable demo so you can see exactly what changed.

The Grid

Everything in Sokoban happens on a grid. Walls, floors, the player, the boxes — all positioned on tiles. The tilemap API handles the static parts (walls and floors), and we draw the player as a sprite on top.

The level is a 2D array where 1 means wall and 2 means floor:

const WALL = 1;
const FLOOR = 2;

const level = [
    [1, 1, 1, 1, 1, 1, 1, 1],
    [1, 2, 2, 2, 2, 2, 2, 1],
    [1, 2, 2, 1, 2, 2, 2, 1],
    [1, 2, 2, 2, 2, 2, 2, 1],
    [1, 2, 2, 2, 2, 1, 2, 1],
    [1, 2, 2, 2, 2, 2, 2, 1],
    [1, 2, 2, 2, 2, 2, 2, 1],
    [1, 1, 1, 1, 1, 1, 1, 1],
];

During init, we loop over this array and call mset to place each tile. The tilemap remembers what's where, and map() draws everything in one call:

function init() {
    for (let y = 0; y < rows; y++) {
        for (let x = 0; x < cols; x++) {
            if (level[y][x] === WALL) mset(x, y, 'wall');
            else if (level[y][x] === FLOOR) mset(x, y, 'floor');
        }
    }
}

The level is 8 tiles wide and 8 tall on a 128x128 canvas, so we center it with an offset:

const cols = level[0].length;
const rows = level.length;
const ox = Math.floor((128 - cols * 8) / 2);
const oy = Math.floor((128 - rows * 8) / 2);

That offset gets passed to map() and added to the player's pixel position when drawing.

Movement is grid-based — each btnp() press moves the player one tile. Let's handle that in update:

function update() {
    let dx = 0, dy = 0;
    if (btnp('ArrowLeft')) dx = -1;
    else if (btnp('ArrowRight')) dx = 1;
    else if (btnp('ArrowUp')) dy = -1;
    else if (btnp('ArrowDown')) dy = 1;

    if (dx === 0 && dy === 0) return;

    const nx = px + dx;
    const ny = py + dy;

    if (tileAt(nx, ny) !== WALL) {
        px = nx;
        py = ny;
    }
}

btnp fires once per key press — no auto-repeat. That's what you want for a puzzle game where every move counts. The tileAt helper checks the level array and treats out-of-bounds as wall, so the player can never walk off the edge.

Drawing is three lines:

function draw() {
    cls(0);
    map(0, 0, 0, cols, rows, ox, oy);
    spr('player', ox + px * 8, oy + py * 8);
}
Sokoban: The Grid
Arrow keys to move. The player walks on floors and can't pass through walls.

Pushing Boxes

A grid with a walking player isn't Sokoban yet. We need boxes — and boxes that follow the right rules. You can push a box by walking into it, but only if the tile behind the box (in the direction you're pushing) is free. If there's a wall or another box behind it, the push is blocked and you don't move.

Boxes are stored as a separate coordinate array, not as tiles. The tilemap handles the static level — walls and floors that never change. Boxes are dynamic, so they live outside the tilemap and get drawn as sprites on top:

let boxes = [
    { x: 3, y: 3 },
    { x: 4, y: 5 },
];

Let's add a helper to check whether a box exists at a given tile:

function boxAt(x, y) {
    return boxes.find(b => b.x === x && b.y === y);
}

The movement logic grows a bit. Before, we just checked if the destination tile was a wall. Now there's a third case — destination has a box:

const nx = px + dx, ny = py + dy;

if (tileAt(nx, ny) === WALL) return;

const box = boxAt(nx, ny);
if (box) {
    const bx = nx + dx, by = ny + dy;
    if (tileAt(bx, by) === WALL || boxAt(bx, by)) return;
    box.x = bx;
    box.y = by;
}

px = nx;
py = ny;

If the destination has a box, we check one tile further in the same direction. Wall or another box? The whole move is blocked — player stays put. Otherwise the box slides one tile and the player steps into where the box was. The same dx/dy that moves the player also moves the box, which is why pushing always goes in the direction you're walking.

Drawing adds one loop — iterate over boxes and draw each one between the tilemap and the player:

function draw() {
    cls(0);
    map(0, 0, 0, cols, rows, ox, oy);
    for (const b of boxes) {
        spr('box', ox + b.x * 8, oy + b.y * 8);
    }
    spr('player', ox + px * 8, oy + py * 8);
}
Sokoban: Pushing Boxes
Arrow keys to move. Walk into a box to push it. Boxes stop at walls and can't push through each other.

Solving the Puzzle

Pushing boxes around is fun for about ten seconds. What makes it a puzzle is having somewhere the boxes need to go. Target tiles mark where each box should end up, and the level is solved when every box is sitting on a target.

Targets are a third tile type. We add TARGET = 3 alongside wall and floor, and store target positions in their own array:

const TARGET = 3;

const targets = [
    { x: 5, y: 2 },
    { x: 5, y: 5 },
];

Targets get placed on the tilemap during init just like walls and floors — a floor tile with a yellow diamond on top. The player and boxes walk over them freely:

for (const t of targets) {
    mset(t.x, t.y, 'target');
}

The wall-check logic needs updating. Before, we checked tileAt(nx, ny) !== WALL. Now that targets are walkable too, let's use a helper:

function isWalkable(x, y) {
    const t = tileAt(x, y);
    return t === FLOOR || t === TARGET;
}

For visual feedback, boxes change color when they're on a target. We check each box's position against the target list and pick the right sprite:

function isOnTarget(bx, by) {
    return targets.some(t => t.x === bx && t.y === by);
}

// in draw():
for (const b of boxes) {
    const name = isOnTarget(b.x, b.y) ? 'box_on' : 'box';
    spr(name, ox + b.x * 8, oy + b.y * 8);
}

Orange means misplaced. Green means "this one's done." The instant color change when a box hits a target is the first real feedback the game gives you — it tells you you're making progress without needing a UI element.

The win condition checks after every move:

function checkWin() {
    return boxes.every(b => isOnTarget(b.x, b.y));
}

When every box is on a target, won flips to true and input stops. One level, one goal, two boxes. Give it a play — try to solve it in as few moves as you can.

Sokoban: Solving the Puzzle
Push both boxes onto the yellow diamond targets. Boxes turn green when placed correctly.

Undo and Reset

Sokoban without undo is cruel. One wrong push and you're restarting the entire level. Undo turns the game from "memorize the solution" to "experiment and backtrack" — which is how puzzle games should work.

The approach is straightforward: before every successful move, save a snapshot of the game state. To undo, pop the last snapshot and restore it. The snapshot only needs the player position and box positions — everything else (the level, the tilemap) is static:

function snapshot() {
    return { px, py, boxes: boxes.map(b => ({ ...b })) };
}

function restore(snap) {
    px = snap.px;
    py = snap.py;
    boxes = snap.boxes.map(b => ({ ...b }));
}

The spread operator in boxes.map(b => ({ ...b })) matters. Without it, you'd be storing references to the same box objects — and when a box moves, the "saved" positions would change too. Each snapshot needs its own copies.

The history is just an array used as a stack:

let history = [];

// before a move:
history.push(snapshot());

// on undo:
if (history.length > 0) {
    restore(history.pop());
    moves--;
}

Z undoes the last move. X resets the whole level — player goes back to start, boxes go back to their initial positions, history clears out:

function resetLevel() {
    px = initPx;
    py = initPy;
    boxes = initBoxes.map(b => ({ ...b }));
    history = [];
    moves = 0;
    won = false;
}

The initial positions are stored separately from the mutable state so resetLevel always has clean data to restore from.

We also track a move counter. It goes up on every successful move and back down on undo. When you win, the counter shows up in the victory message — so you can see how efficient (or not) your solution was. Try solving a level, then undoing everything and solving it again in fewer moves.

Sokoban: Undo and Reset
Arrow keys to move. Z to undo the last move. X to reset the whole level. Move counter at the bottom.

Levels and Scenes

One level isn't much of a game. We need multiple levels and a way to navigate between them — a title screen, a level select grid, the game itself, and a completion screen. This is the same scene pattern from the scene management tutorial, applied to a real game.

Each level is now a self-contained object with its map, box positions, target positions, and player start:

const levels = [
    {
        map: [
            [1, 1, 1, 1, 1, 1],
            [1, 2, 2, 2, 2, 1],
            [1, 2, 2, 2, 2, 1],
            [1, 2, 2, 2, 2, 1],
            [1, 2, 2, 2, 2, 1],
            [1, 1, 1, 1, 1, 1],
        ],
        boxes: [{ x: 3, y: 3 }],
        targets: [{ x: 4, y: 1 }],
        player: { x: 1, y: 1 },
    },
    // ...more levels
];

Loading a level clears the tilemap and rebuilds it from the level data. This matters — different levels have different dimensions, so the previous level's tiles would bleed through if you didn't clear first:

function loadLevel(index) {
    const lv = levels[index];
    mclear();
    for (let y = 0; y < lv.map.length; y++) {
        for (let x = 0; x < lv.map[0].length; x++) {
            if (lv.map[y][x] === WALL) mset(x, y, 'wall');
            else if (lv.map[y][x] === FLOOR) mset(x, y, 'floor');
        }
    }
    for (const t of lv.targets) mset(t.x, t.y, 'target');
    px = lv.player.x;
    py = lv.player.y;
    boxes = lv.boxes.map(b => ({ ...b }));
    history = [];
    moves = 0;
    won = false;
}

The scene system is a single scene variable — 'title', 'select', 'game', or 'complete'. Both update and draw branch on it. Nothing fancy:

if (scene === 'title') {
    // handle title input and drawing
} else if (scene === 'select') {
    // handle level select
} else if (scene === 'game') {
    // handle gameplay
} else if (scene === 'complete') {
    // handle level complete
}

Scene transitions use a wipe effect — a black rectangle slides in from the left, covering the screen. When it reaches the far side, we switch scenes and the rectangle slides back out:

function wipe(targetScene, cb) {
    wipeProgress = 0;
    wipeTarget = targetScene;
    wipeCallback = cb;
}

During the wipe-in, wipeProgress increases from 0 to 128. When it hits 128, the scene swaps and the optional callback fires — that's where loadLevel runs. Then the wipe reverses, revealing the new scene underneath. The wipe blocks input during the transition (the update function returns early while wipeTarget is set).

The level select screen draws a numbered button for each level. Completed levels get green numbers. Left/right arrows cycle the selection, and Enter starts the selected level:

for (let i = 0; i < levels.length; i++) {
    const x = 30 + i * 24;
    const y = 50;
    const done = completed.includes(i);
    const sel = i === currentLevel;
    rectfill(x, y, x + 18, y + 18, sel ? 12 : 1);
    text(String(i + 1), x + 6, y + 5, done ? 11 : 7);
}

When you solve a level, the game waits half a second then wipes to the completion screen. From there, Enter goes to the next level (or back to select if you've finished them all). Escape always takes you back to level select from gameplay.

Sokoban: Levels and Scenes
Title screen to start. Pick a level with arrow keys and Enter. Escape returns to level select. Completed levels show green numbers.

Hint Overlay

Puzzle games need a safety valve. When someone's been staring at the same level for five minutes, they need a nudge — not the solution, just enough to get unstuck. The hint overlay from the hint UI tutorial is perfect for this.

Pressing H toggles the overlay on and off. When it's active, two things happen: empty targets pulse to draw your eye, and red lines connect each misplaced box to its nearest target. Let's start with the pulsing.

We use a sine wave on a frame counter. The target squares alternate between yellow and orange every few frames — a breathing effect that says "put a box here":

function drawHints(lv, gox, goy) {
    const pulse = Math.sin(frame * 0.15) * 0.5 + 0.5;
    const color = pulse > 0.5 ? 10 : 9;

    for (const t of lv.targets) {
        const tx = gox + t.x * 8;
        const ty = goy + t.y * 8;
        if (!boxAt(t.x, t.y)) {
            rectfill(tx + 1, ty + 1, tx + 7, ty + 7, color);
        }
    }

The if (!boxAt(t.x, t.y)) check skips targets that already have a box on them. No point highlighting a target that's already solved.

Connection lines use Manhattan distance to find the nearest target for each misplaced box, then draw a red line between their centers:

    for (const b of boxes) {
        if (isOnTarget(lv, b.x, b.y)) continue;
        const nearest = nearestTarget(lv, b.x, b.y);
        if (!nearest) continue;
        const bx = gox + b.x * 8 + 4;
        const by = goy + b.y * 8 + 4;
        const tx = gox + nearest.x * 8 + 4;
        const ty = goy + nearest.y * 8 + 4;
        line(bx, by, tx, ty, 8);
    }
}

The +4 offset centers the line on each 8x8 tile instead of anchoring it to the top-left corner.

The hint layer draws between the tilemap and the sprites — after map() but before the box and player spr() calls. The pulsing targets sit behind the boxes and the connection lines peek out from under them, which feels right visually. The H key label in the corner changes color when hints are active so you can tell at a glance whether they're on.

It's a nudge, not a solution. Enough to get unstuck without spoiling the puzzle.

Sokoban: Hint Overlay
Press H to toggle hints. Pulsing yellow squares mark empty targets. Red lines connect misplaced boxes to their nearest target.

Complete Example

Everything up to now has used instant movement — press a key, the player teleports one tile. It works, but it feels flat. The game juice tutorial covers why: without visual feedback, actions don't register. This final version adds smooth sliding, particles, and screen flash to make the same mechanics feel alive.

Smooth sliding replaces instant snapping with a 6-frame animation. Instead of updating positions immediately, we store the start and end coordinates and interpolate between them with easeOutQuad:

function easeOutQuad(t) {
    return t * (2 - t);
}

function lerp(a, b, t) {
    return a + (b - a) * t;
}

When the player moves, we set up the slide and lock input until it's done:

slidePlayer = { fx: px, fy: py, tx: nx, ty: ny };
sliding = true;
slideFrames = 0;

During the slide, the player's draw position is interpolated. The easing curve makes the movement start fast and decelerate — it feels like the player is walking and settling into position rather than drifting at constant speed:

const t = sliding ? easeOutQuad(Math.min(slideFrames / SLIDE_DURATION, 1)) : 1;

let playerDrawX = gox + lerp(slidePlayer.fx, slidePlayer.tx, t) * 8;
let playerDrawY = goy + lerp(slidePlayer.fy, slidePlayer.ty, t) * 8;
spr('player', Math.floor(playerDrawX), Math.floor(playerDrawY));

The actual grid positions (px, py) only update when the slide completes. During the animation, the player is visually between tiles but logically still at the old position. This means input is locked — you can't queue moves while sliding, which prevents the player from accidentally pushing a box twice.

Boxes slide the same way. When a box lands on a target, a green particle burst fires from the landing spot:

if (isOnTarget(lv, slideBoxRef.x, slideBoxRef.y)) {
    spawnParticles(
        gox + slideBoxRef.x * 8 + 4,
        goy + slideBoxRef.y * 8 + 4,
        11, 12
    );
}

The particle system is simple. Each particle has a position, velocity, and lifetime. Every frame they drift, pick up a bit of gravity, and count down:

for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.x += p.vx;
    p.y += p.vy;
    p.vy += 0.05;
    p.life--;
    if (p.life <= 0) particles.splice(i, 1);
}

When you clear a level, the game fires a big yellow particle burst from the center of the screen and flashes white for a few frames. The combination — smooth slide into target, green particles, brief pause, white flash, yellow explosion — turns "you solved it" from a text message into an event.

The title text and level complete text use a sine-wave bounce to keep them from sitting dead on screen:

const pulse = Math.sin(frame * 0.08) * 2;
text('sokoban', 40, 38 + Math.floor(pulse), 7);

None of this changes the game logic. The puzzle rules, the undo system, the scene manager, the hint overlay — all identical to the previous sections. Juice is a layer on top. It makes the same game feel better without touching the rules.

Sokoban: Complete Example
The complete game with 5 levels. Smooth sliding, particle bursts on target placement, screen flash on level clear. H for hints, Z for undo, X for reset.

Going Further

  • Par moves — Define a target move count for each level. Show a star rating based on how close the player gets: 3 stars for at or under par, 2 for close, 1 for way over. Display the par number on the level select screen so players know what to aim for.
  • Timer / speedrun mode — Add an optional timer that starts when the level loads and stops when you win. Show the best time per level on the select screen. For competitive players, add a total time across all levels.
  • Procedural level generation — Generate solvable levels by working backwards: start with a solved state (all boxes on targets) and reverse random valid moves. This guarantees solvability and can produce unlimited content. The hard part is making levels that are interesting, not just solvable.
  • Ice and dark tiles — Ice tiles make the player (or boxes) slide until hitting a wall. Dark tiles are one-way — you can walk onto them but not back. Both add mechanical depth without new sprites.
  • Level editor — Let players click to place walls, floors, targets, boxes, and the player start position. Export the level as a JSON object they can paste into their own code. The tilemap API already supports everything you need — the editor is just a UI for calling mset.
  • Sound effects — Pair visuals with audio: a short blip on push, a satisfying thunk when a box hits a target, a fanfare on level complete, a soft click on undo. Use sfx() with short waveform definitions for each event.