Sokoban Box Pushing Game — Free HTML CSS JS Snippet
Sokoban Box Pushing Game · Games · Plain HTML, CSS & JS · Live preview
For supporters
Source Code
Want the source code?
Play the game for free. Become a WebDevPuneet supporter to unlock the complete HTML, CSS and JavaScript source code, the exports (HTML, React, Vue, Angular, Tailwind) and Fork.
Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Sokoban Box Pushing Game — Grid Puzzle with Push-Only Physics and Undo History

Sokoban ("warehouse keeper" in Japanese) is a classic grid-based puzzle: push crates onto target squares by walking into them, one at a time, in a warehouse full of walls. The core constraint that makes it a genuine puzzle rather than a maze is that crates can only ever be pushed, never pulled — a bad push can permanently trap a crate against a wall. This snippet implements a complete playable version in vanilla JavaScript: level parsing from plain text grids, push physics with full collision checking, an undo stack, and automatic progression through multiple bundled levels.
Levels as plain text grids
Each entry in LEVELS is an array of equal-purpose strings using standard Sokoban notation: # for a wall, a space for open floor, . for a target square, $ for a crate, * for a crate already on a target, @ for the player, and + for the player standing on a target. parseLevel() walks every character of every row once and sorts each symbol into a Set — walls, targets, or boxes — keyed by a "r,c" string, plus the player's starting { r, c } position. Using plain text for level data means new levels can be authored by just typing a grid, with no separate level editor or JSON schema required.
Push physics: the collision chain
attemptMove(dir) is the entire physics engine. It first checks whether the player's destination cell is a wall — if so, the move is silently rejected. If the destination holds a crate, it computes where *that* crate would land one more step in the same direction, and rejects the whole move if that landing cell is a wall or already holds another crate (isWall(br, bc) || isBox(br, bc)) — this is what prevents pushing a crate into another crate or into a wall. Only after both checks pass does the function actually mutate state: the crate's key moves from its old position to its new one in the boxes Set, and the player advances into the crate's old cell.
An undo stack instead of a full board-state history
Rather than snapshotting the entire board after every move, history stores only the minimal delta needed to reverse one step: the player's previous position, plus pushedBoxFrom/pushedBoxTo keys if a crate moved on that turn (both null if it didn't). undo() pops the most recent entry, restores the player position, and — if a crate was involved — deletes it from its new key and re-adds it at its old key. This keeps undo cheap regardless of board size and trivially supports unlimited undo depth since every entry is small and independent.
Win detection and auto-advance
checkWin() runs after every completed move and checks whether every key currently in the boxes Set also exists in the targets Set — the moment that's true, every crate is correctly placed regardless of which specific crate sits on which specific target. On a win, the game shows the final move count and, after a short delay, automatically advances to the next bundled level via levelIndex++ and a fresh loadLevel() call, or stays on the final level if none remain.
Step by step
How to Use
- 1Click the board to focus itThe board is a focusable div; clicking it (or an on-screen pad button) lets arrow-key input register via the keydown listener.
- 2Push crates onto targetsMove with arrow keys or the on-screen directional pad. Walking into a crate pushes it one cell further in the same direction, if that cell is open.
- 3Watch for dead-end pushesA push is rejected outright if the crate would land on a wall or another crate — attemptMove() checks this before allowing the move at all, so you cannot lock a crate in place by accident from the push itself.
- 4Undo a bad moveClick Undo to pop the last entry off the history stack, restoring the player position and reversing any crate push from that turn.
- 5Clear the levelcheckWin() watches whether every crate key also exists in the targets set. Getting every crate onto a target completes the level and auto-advances to the next one.
- 6Add your own levelsAppend a new grid of equal-length strings to the LEVELS array using #, space, ., $, *, @, and + — no additional configuration is needed.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
No. The player can only walk into a crate to push it one cell further in the same direction; there is no pull mechanic, matching the classic Sokoban rule set. This means a poorly planned push can trap a crate against a wall or corner with no way to free it except undo.
attemptMove() computes the crate's would-be landing cell one step past its current position and checks isWall(br, bc) || isBox(br, bc) before allowing the move. If either is true, the entire move (including the player's own step) is rejected and neither the player nor the crate moves.
Each history entry stores only the player's previous position and, if a crate was pushed that turn, its previous and new key. undo() reverses exactly that delta: it restores the player position and moves the pushed crate's key back to its previous position in the boxes Set.
checkWin() checks that every key in the boxes Set also exists in the targets Set, using [...boxes].every(b => targets.has(b)). This is true regardless of which specific crate ends up on which specific target, as long as every crate is on some target.
Append a new array of equal-length strings to the LEVELS array using # for walls, space for floor, . for targets, $ for crates, * for a crate already on a target, and @ (or + for on a target) for the player start position. No other code changes are required.
checkWin() calls loadLevel() again with an incremented levelIndex inside a short setTimeout after showing the "Level cleared" message, so players can chain through the bundled level set without clicking a manual next-level button — it simply stays on the final level if none remain.