Do you like my tutorials?

Then consider supporting me on Ko-fi

Talking about Sokoban game, Game development and Javascript.

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.

Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.