Pure JavaScript class to handle Sokoban games, ready to communicate with your favorite HTML5 framework.

Read all posts about "" game

To keep the code as much reusable as I can, I am writting some pure JavaScript classes with no dependencies to handle some popular games.

I already published classes to handle games like Bejeweled, Samegame and Dungeon Raid. Now it’s time to share a class to handle Sokoban games.

The class is written in pure JavaScript, without any framework dependency, so you can use it as you want, no matter the environment you are working with, as long as it supports plain JavaScript.

Let me show you an example, written in plain JavaScript too:

The level is represented in the popular string format and you can play using the movement and undo buttons on the right of the level.

If you want to solve it, the solution is RDDLRUULDLDDLDDRURRUUULLDDLdRUUURRDLULDDLDDRUUURRDDLRUULLDLDDRU

Each move populates a series of data structures wich allows you to know where the player moved, which crate has been pushed, if any, the amount of crates on goals, and all information you may need in order to make the class interact with your framework and build your game.

Let’s have a look at the code to manage the game and the methods used:

<!DOCTYPE html>
<html>
	<head>
        <style type = "text/css">
            #thegame{
                font-family: "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
                font-size: 32px;
                font-style: normal;
                font-weight: bold;
                line-height: 24px;
                white-space: pre;
            }
            #moves{
                font-family: "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
                margin-top: 10px;
            }
            button{
                width: 60px;
                height: 60px;
            }
        </style>
    </head>
	<body>
        <table cellpadding = "0" cellspacing = "0">
            <tr>
                <td>
                    <div id = "thegame"></div>
                </td>
                <td>
                    <table cellpadding = "0" cellspacing = "0">
                        <tr>
                            <td></td>
                            <td><button type = "button">UP</button></td>
                            <td></td>
                        </tr>
                        <tr>
                            <td><button type = "button">LEFT</button></td>
                            <td></td>
                            <td><button type = "button">RIGHT</button></td>
                        </tr>
                        <tr>
                            <td></td>
                            <td><button type = "button">DOWN</button></td>
                            <td><button type = "button">UNDO</button></td>
                        </tr>
                    </table>
                </td>
            </tr>
        </table>
        <div id = "output"></div>
        <script src = "game.js"></script>
        <script>
            let level = "########\n#####@.#\n####.$$#\n#### $ #\n### .# #\n###    #\n###  ###\n########";
            let sokoban = new Sokoban();
            sokoban.buildLevelFromString(level);
            writeOutput(false);
            var buttons = document.getElementsByTagName("button");
            let move;
            for(let i = 0; i < buttons.length; i++){
                buttons[i].addEventListener("click", function(){
                    switch(i){
                        case 0:
                            move = sokoban.moveUp();
                            break;
                        case 1:
                            move = sokoban.moveLeft();
                            break;
                        case 2:
                            move = sokoban.moveRight();
                            break;
                        case 3:
                            move = sokoban.moveDown();
                            break;
                        case 4:
                            sokoban.undoMove();
                    }
                    writeOutput(move);
                })
            }
            function writeOutput(hasMoved){
                let player = sokoban.getPlayer();
                let crates = sokoban.getCrates();
                document.getElementById("thegame").innerHTML = sokoban.levelToString();
                let outputString = "<p>" + sokoban.getMoves() + "</p>";
                outputString += itemDetails(player, "Player", hasMoved);
                for(let i = 0; i < crates.length; i ++){
                    outputString += itemDetails(crates[i], "Crate " + (i + 1), hasMoved);
                }
                outputString += "<p>Crates on goal: " + sokoban.countCratesOnGoal() + "/" + sokoban.countCrates() + "</p>";
                outputString += "<p>Level solved: " + sokoban.isLevelSolved() + "</p>";
                document.getElementById("output").innerHTML = outputString;
            }
            function itemDetails(item, name, hasMoved){
                let string = "<p>" + name + ": ";
                if(item.hasMoved() && hasMoved){
                    string += "(" + item.getPrevRow() + "," + item.getPrevColumn() + ") >> ";
                }
                string += "(" + item.getRow() + "," + item.getColumn() + ")";
                if(item.isOnGoal()){
                    string += " on goal";
                }
                string += "</p>";
                return string;
            }
        </script>
	</body>
</html>

Let’s see the methods used:

new Sokoban() is the constructor.

buildLevelFromString(level) builds a Sokoban level starting from a level string passed as argument.

moveUp(), moveDown(), moveLeft() and moveRight() methods try to move the player in a direction, and return true if the attempt was successful, or false if the player couldn’t move in that direction.

getPlayer() returns the player.

getCrates() returns an array with all crates.

getMoves() returns a string with all moves.

countCrates() returns the number of crates in a level.

countCratesOnGoal() returns the number of crates over a goal in a level.

isLevelSolved() returns true is the level has been solved or false otherwise.

Both the player and the crates have their own methods:

hasMoved() returns true if the item has moved during the last turn, false otherwise.

isOnGoal() returns true if the item is over a goal, false otherwise.

getRow() and getColumn() return respectively the current row and column position of the item.

getPrevRow() and getPrevColumn() return respectively the previous row and column position of the item.

The mini game and the logs you can see below the level have been generated using only these methods, and this is the full class, yet to be optimized a bit but already working:

class Sokoban{
    floorValue = 0;
    wallValue = 1;
    goalValue = 2;
    crateValue = 3;
    playerValue = 4;
    left = {
        row: 0,
        column: -1
    };
    right = {
        row: 0,
        column: 1
    };
    up = {
        row: -1,
        column: 0
    }
    down = {
        row: 1,
        column: 0
    }
    stringItems = " #.$@*+";
    stringMoves = "UDLR";
    buildLevelFromString(levelString){
        this.level = [];
        this.undoArray = [];
        this.moves = "";
        this.crates = [];
        let rows = levelString.split("\n");
        for(let i = 0; i < rows.length; i++){
            this.level[i] = [];
            for(var j = 0; j < rows[i].length; j++){
                let value = this.stringItems.indexOf(rows[i].charAt(j));
                this.level[i][j] = value;
                if(this.isCrateAt(i, j)){
                    this.crates.push(new SokobanItem(i, j, this));
                }
                if(this.isPlayerAt(i, j)){
                    this.player = new SokobanItem(i, j, this);
                }
            }
        }
    }
    getPlayer(){
        return this.player;
    }
    getCrates(){
        return this.crates;
    }
    getItemAt(row, column){
        return this.level[row][column];
    }
    getLevelRows(){
        return this.level.length;
    }
    getLevelColumns(){
        return this.level[0].length;
    }
    countCrates(){
        return this.crates.length;
    }
    countCratesOnGoal(){
        let goals = 0;
        this.crates.forEach(function(crate){
            if(crate.isOnGoal()){
                goals ++;
            }
        })
        return goals;
    }
    isLevelSolved(){
        return this.countCrates() == this.countCratesOnGoal();
    }
    moveLeft(){
        if(this.canMove(this.left)){
            return this.doMove(this.left);
        }
        return false;
    }
    moveRight(){
        if(this.canMove(this.right)){
            return this.doMove(this.right);
        }
        return false;
    }
    moveUp(){
        if(this.canMove(this.up)){
            return this.doMove(this.up);
        }
        return false;
    }
    moveDown(){
        if(this.canMove(this.down)){
            return this.doMove(this.down);
        }
        return false;
    }
    isWalkableAt(row, column){
        return this.getItemAt(row, column) == this.floorValue || this.getItemAt(row, column) == this.goalValue;
    }
    isCrateAt(row, column){
        return this.getItemAt(row, column) == this.crateValue || this.getItemAt(row, column) == this.crateValue + this.goalValue;
    }
    isPlayerAt(row, column){
        return this.getItemAt(row, column) == this.playerValue || this.getItemAt(row, column) == this.playerValue + this.goalValue;
    }
    isGoalAt(row, column){
        return this.getItemAt(row, column) == this.goalValue || this.getItemAt(row, column) == this.playerValue + this.goalValue || this.getItemAt(row, column) == this.crateValue + this.goalValue;
    }
    isPushableCrateAt(row, column, direction){
        let movedCrateRow = row + direction.row;
        let movedCrateColumn = column + direction.column;
        return this.isCrateAt(row, column) && this.isWalkableAt(movedCrateRow, movedCrateColumn);
    }
    canMove(direction){
        let movedPlayerRow = this.player.getRow() + direction.row;
        let movedPlayerColumn = this.player.getColumn() + direction.column;
        return this.isWalkableAt(movedPlayerRow, movedPlayerColumn) || this.isPushableCrateAt(movedPlayerRow, movedPlayerColumn, direction);
    }
    removePlayerFrom(row, column){
        this.level[row][column] -= this.playerValue;
    }
    addPlayerTo(row, column){
        this.level[row][column] += this.playerValue;
    }
    moveCrate(crate, fromRow, fromColumn, toRow, toColumn){
        crate.moveTo(toRow, toColumn);
        crate.onGoal = this.isGoalAt(toRow, toColumn);
        this.level[fromRow][fromColumn] -= this.crateValue;
        this.level[toRow][toColumn] += this.crateValue;
    }
    movePlayer(fromRow, fromColumn, toRow, toColumn){
        this.player.moveTo(toRow, toColumn);
        this.player.onGoal = this.isGoalAt(toRow, toColumn);
        this.level[fromRow][fromColumn] -= this.playerValue;
        this.level[toRow][toColumn] += this.playerValue;;
    }
    doMove(direction){
        this.undoArray.push(this.copyArray(this.level));
        let stepRow = this.player.getRow() + direction.row;
        let stepColumn = this.player.getColumn() + direction.column;
        this.crates.forEach(function(crate){
            if(crate.getRow() == stepRow && crate.getColumn() == stepColumn){
                this.moveCrate(crate, stepRow, stepColumn, stepRow + direction.row, stepColumn + direction.column);
            }
            else{
                crate.dontMove();
            }
        }.bind(this));
        this.movePlayer(this.player.getRow(), this.player.getColumn(), stepRow, stepColumn);
        this.moves += this.stringMoves.charAt(direction.row == 0 ? (direction.column == 1 ? 3 : 2) : (direction.row == 1 ? 1 : 0));
        return true;
    }
    undoMove(){
        if(this.undoArray.length > 0){
            this.undoLevel = this.undoArray.pop();
            this.level = [];
            this.level = this.copyArray(this.undoLevel);
            this.moves = this.moves.substring(0, this.moves.length - 1);
            this.player.undoMove();
            this.crates.forEach(function(crate){
                crate.undoMove();
            }.bind(this));
            return false;
        }
    }
    levelToString(){
        let string = "";
        this.level.forEach(function(row){
            row.forEach(function(item){
                string += this.stringItems.charAt(item);
            }.bind(this));
            string += "\n";
        }.bind(this));
        return string;
    }
    getMoves(){
        return this.moves;
    }
    copyArray(a){
        var newArray = a.slice(0);
        for(let i = newArray.length; i > 0; i--){
            if(newArray[i] instanceof Array){
                newArray[i] = this.copyArray(newArray[i]);
            }
        }
        return newArray;
    }
}

class SokobanItem{
    constructor(row, column, parent){
        this.parent = parent;
        this.positionHistory = [{
            row: row,
            column: column
        }];
    }
    getRow(){
        return this.positionHistory[this.positionHistory.length - 1].row;
    }
    getColumn(){
        return this.positionHistory[this.positionHistory.length - 1].column;
    }
    getPrevRow(){
        return this.positionHistory[this.positionHistory.length - 2].row;
    }
    getPrevColumn(){
        return this.positionHistory[this.positionHistory.length - 2].column;
    }
    hasMoved(){
        return (this.positionHistory.length > 1) && (this.getRow() != this.getPrevRow() || this.getColumn() != this.getPrevColumn());
    }
    setData(data){
        this.data = data;
    }
    getData(){
        return this.data;
    }
    moveTo(row, column){
        this.positionHistory.push({
            row: row,
            column: column
        })
    }
    dontMove(){
        this.positionHistory.push({
            row: this.getRow(),
            column: this.getColumn()
        })
    }
    undoMove(){
        this.positionHistory.pop();
    }
    isOnGoal(){
        return this.parent.isGoalAt(this.getRow(), this.getColumn());
    }
}

As you can see, thanks to this class, we were able to build a complete Sokoban games in less than 50 JavaScript lines, and more examples to use this class in frameworks to get a full featured game will follow soon, meanwhile download the full source code.

Get the most popular Phaser 3 book

Through 202 pages, 32 source code examples and an Android Studio project you will learn how to build cross platform HTML5 games and create a complete game along the way.

Get the book

215 GAME PROTOTYPES EXPLAINED WITH SOURCE CODE
// 1+2=3
// 100 rounds
// 10000000
// 2 Cars
// 2048
// A Blocky Christmas
// A Jumping Block
// A Life of Logic
// Angry Birds
// Angry Birds Space
// Artillery
// Astro-PANIC!
// Avoider
// Back to Square One
// Ball Game
// Ball vs Ball
// Ball: Revamped
// Balloon Invasion
// BallPusher
// Ballz
// Bar Balance
// Bejeweled
// Biggification
// Block it
// Blockage
// Bloons
// Boids
// Bombuzal
// Boom Dots
// Bouncing Ball
// Bouncing Ball 2
// Bouncy Light
// BoxHead
// Breakout
// Bricks
// Bubble Chaos
// Bubbles 2
// Card Game
// Castle Ramble
// Chronotron
// Circle Chain
// Circle Path
// Circle Race
// Circular endless runner
// Cirplosion
// CLOCKS - The Game
// Color Hit
// Color Jump
// ColorFill
// Columns
// Concentration
// Crossy Road
// Crush the Castle
// Cube Jump
// CubesOut
// Dash N Blast
// Dashy Panda
// Deflection
// Diamond Digger Saga
// Don't touch the spikes
// Dots
// Down The Mountain
// Drag and Match
// Draw Game
// Drop Wizard
// DROP'd
// Dudeski
// Dungeon Raid
// Educational Game
// Elasticity
// Endless Runner
// Erase Box
// Eskiv
// Farm Heroes Saga
// Filler
// Flappy Bird
// Fling
// Flipping Legend
// Floaty Light
// Fuse Ballz
// GearTaker
// Gem Sweeper
// Globe
// Goat Rider
// Gold Miner
// Grindstone
// GuessNext
// Helicopter
// Hero Emblems
// Hero Slide
// Hexagonal Tiles
// HookPod
// Hop Hop Hop Underwater
// Horizontal Endless Runner
// Hundreds
// Hungry Hero
// Hurry it's Christmas
// InkTd
// Iromeku
// Jet Set Willy
// Jigsaw Game
// Knife Hit
// Knightfall
// Legends of Runeterra
// Lep's World
// Line Rider
// Lumines
// Magick
// MagOrMin
// Mass Attack
// Math Game
// Maze
// Meeblings
// Memdot
// Metro Siberia Underground
// Mike Dangers
// Mikey Hooks
// Nano War
// Nodes
// o:anquan
// One Button Game
// One Tap RPG
// Ononmin
// Pacco
// Perfect Square!
// Perfectionism
// Phyballs
// Pixel Purge
// PixelField
// Planet Revenge
// Plants Vs Zombies
// Platform
// Platform game
// Plus+Plus
// Pocket Snap
// Poker
// Pool
// Pop the Lock
// Pop to Save
// Poux
// Pudi
// Pumpkin Story
// Puppet Bird
// Pyramids of Ra
// qomp
// Quick Switch
// Racing
// Radical
// Rebuild Chile
// Renju
// Rise Above
// Risky Road
// Roguelike
// Roly Poly
// Run Around
// Rush Hour
// SameGame
// SamePhysics
// Save the Totem
// Security
// Serious Scramblers
// Shrink it
// Sling
// Slingy
// Snowflakes
// Sokoban
// Space Checkers
// Space is Key
// Spellfall
// Spinny Gun
// Splitter
// Spring Ninja
// Sproing
// Stabilize!
// Stack
// Stairs
// Stick Hero
// String Avoider
// Stringy
// Sudoku
// Super Mario Bros
// Surfingers
// Survival Horror
// Talesworth Adventure
// Tetris
// The Impossible Line
// The Moops - Combos of Joy
// The Next Arrow
// Threes
// Tic Tac Toe
// Timberman
// Tiny Wings
// Tipsy Tower
// Toony
// Totem Destroyer
// Tower Defense
// Trick Shot
// Tunnelball
// Turn
// Turnellio
// TwinSpin
// vvvvvv
// Warp Shift
// Way of an Idea
// Whack a Creep
// Wheel of Fortune
// Where's my Water
// Wish Upon a Star
// Word Game
// Wordle
// Worms
// Yanga
// Yeah Bunny
// Zhed
// zNumbers