Do you like my tutorials?

Then consider supporting me on Ko-fi

Talking about Sokoban game, Game development, HTML5, Javascript, Phaser and TypeScript.

One of the principles of coding is to build scripts as much reusable as you can. This is why I already built a pure JavaScript Sokoban class with no dependencies to handle Sokoban games.

This time I am showing you an even simpler TypeScript class to handle Sokoban games. It’s so simple you will be able to build a Sokoban game in less than 10 lines of code, and you’ll only need to manage user input and visual output.

Look at the game:

Move the character with ARROW keys.

If you want to solve the level, here is the walkthrough:

RDDLRUULDLDDLDDRURRUUULLDDLDRUUURRDLULDDLDDRUUURRDDLRUULLDLDDRU

Now, let’s have a look at the source code, made of one HTML file and 5 TypeScript files:

index.html

The webpage which hosts the game

<!DOCTYPE html>
<html>
	<head>
        <style type = "text/css">
            * {
                padding: 0;
                margin: 0;
            }
            body{
                background: #000;
            }
            canvas {
                touch-action: none;
                -ms-touch-action: none;
            }
        </style>
        <script src = "main.js"></script>
    </head>
	<body>
        <div id = "thegame"></div>
	</body>
</html>

gameOptions.ts

All configurable game options are stored in this file. This time we only need one options, but you’ll never know

// CONFIGURABLE GAME OPTIONS

export const GameOptions = {

    // size of each tile, in pixels
    tileSize : 40
}

main.ts

This is where the game is instanced, with all Phaser related options

// MAIN GAME FILE

// modules to import
import Phaser from 'phaser';
import { PreloadAssets } from './preloadAssets';
import { PlayGame} from './playGame';

// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
    mode : Phaser.Scale.FIT,
    autoCenter : Phaser.Scale.CENTER_BOTH,
    parent : 'thegame',
    width : 320,
    height : 320
}

// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
    type : Phaser.AUTO,
    backgroundColor : 0x262626,
    scale : scaleObject,
    scene : [PreloadAssets, PlayGame],
    pixelArt : true
}

// the game itself
new Phaser.Game(configObject);

preloadAssets.ts

Here we preload all assets to be used in the game. Only one image this time, but again, you’ll never know.

// CLASS TO PRELOAD ASSETS

import { GameOptions } from "./gameOptions";

// this class extends Scene class
export class PreloadAssets extends Phaser.Scene {

    // constructor    
    constructor() {
        super({
            key : 'PreloadAssets'
        });
    }

    // method to be execute during class preloading
    preload(): void {

        this.load.spritesheet("tiles", "assets/tiles.png", {
            frameWidth : GameOptions.tileSize,
            frameHeight : GameOptions.tileSize
        });
	}

    // method to be called once the instance has been created
	create(): void {

        // call PlayGame class
        this.scene.start('PlayGame');
	}
}

playGame.ts

This is the mail game file, when I handle user input and visual output.

I highlighted the Sokoban related lines, so you can see how it’s simple to build your own game starting from my TypeScript class:

// THE GAME ITSELF

// modules to import
import { GameOptions } from './gameOptions';
import { Sokoban } from './sokoban';

// this class extends Scene class
export class PlayGame extends Phaser.Scene {

    sokobanGame : Sokoban;

    arrowKeys : Phaser.Types.Input.Keyboard.CursorKeys;

    isPlayerMoving : boolean;

    // constructor
    constructor() {
        super({
            key: 'PlayGame'
        });
    }

    // method to be called once the class has been created
    create() : void {

        // player is not moving
        this.isPlayerMoving = false;

        // Sokoban level in standard text notation
        const levelString : string = '########\n#####@.#\n####.$$#\n#### $ #\n### .# #\n###    #\n###  ###\n########';
 
        // create a new Sokoban instance
        this.sokobanGame = new Sokoban();
 
        // build the Sokoban level
        this.sokobanGame.buildLevelFromString(levelString);

        // iterate through all level actors
        this.sokobanGame.actors.map((actor) => {

            // add the sprite
            let sprite : Phaser.GameObjects.Sprite = this.add.sprite(GameOptions.tileSize * actor.column, GameOptions.tileSize * actor.row, 'tiles', actor.type);
            
            // set sprite registration point
            sprite.setOrigin(0);
            actor.data = sprite;
        });

        // initialize arrow keys
        this.arrowKeys = this.input.keyboard.createCursorKeys();
    }    
    
    update() : void {

        // is the player moving?
        if (this.isPlayerMoving) {

            // are all arrow keys unpressed?
            if (!this.arrowKeys.up.isDown && !this.arrowKeys.right.isDown && !this.arrowKeys.down.isDown && !this.arrowKeys.left.isDown) {
                
                // player is no longer moving
                this.isPlayerMoving = false;
            }
        }

        // player is not moving
        else {

            // we store player move in this variable
            let playerMove : (number | null) = null;

            // is "up" arrow key pressed?
            if (this.arrowKeys.up.isDown) {

                // playerMove is up
                playerMove = this.sokobanGame.up;
            }

            // same concept for right direction
            if (this.arrowKeys.right.isDown) {
                playerMove = this.sokobanGame.right;
            }

            // same concept for down direction
            if (this.arrowKeys.down.isDown) {
                playerMove = this.sokobanGame.down;
            }

            // same concept for left direction
            if (this.arrowKeys.left.isDown) {
                playerMove = this.sokobanGame.left;
            }

            // does player move have a value?
            if (playerMove != null) {

                // player is moving
                this.isPlayerMoving = true;

                // loop through all movements
                this.sokobanGame.move(playerMove).map((move) => {

                    // set new position of the actor
                    move.actor.data.setPosition(GameOptions.tileSize * move.to.column, GameOptions.tileSize * move.to.row);
                    
                    // set new frame of the actor
                    move.actor.data.setFrame(this.sokobanGame.getValueAt(move.to.row, move.to.column));

                    if (this.sokobanGame.solved) {
                        this.cameras.main.shake(500);
                    }
                });
            }
        }
    }  
}

sokoban.ts

And finally the TypeScript class you can use in your projects, no matter the framework you are about to use.

It’s fully commented so I am sure you will find it quite clear:

// tile types: 0: floor, 1: wall, 2: goal, 3: crate, 4: player, 5 (3+2): crate on goal, 6 (4+2): player on goal
enum tileType {
    FLOOR,
    WALL,
    GOAL,
    CRATE,
    PLAYER       
}

// player direction: 0: up, 1: right, 2: down, 3: left 
enum playerDirection {
    UP,
    RIGHT,
    DOWN,
    LEFT
}

// SOKOBAN CLASS    
export class Sokoban {

    // movement information mapping for up, right, down and left diretion
    private movementInfo : SokobanCoordinate[] = [
        new SokobanCoordinate(-1, 0),
        new SokobanCoordinate(0, 1),
        new SokobanCoordinate(1, 0),
        new SokobanCoordinate(0, -1)
    ];
     
    // possible string items according to sokoban level notation standard
    private stringItems : string = ' #.$@*+';
   
    // the player
    private player : SokobanActor;

    // the crates
    private crates : SokobanActor [];

    // the tiles
    private tiles : SokobanActor[]; 

    // the level
    private level : number [][];

    // constructor
    constructor() {

        // initialize all arrays
        this.crates = [];
        this.level = [];
        this.crates = [];
        this.tiles = [];
    }

    // method to build a level form a string
    // argument: the string, which we assume to be correct
    buildLevelFromString(levelString: string) : void {

        // split the string in rows
        let rows : string[] = levelString.split("\n");

        // iterate through all rows
        for (let i : number = 0; i < rows.length; i ++) {

            // set level i-th row
            this.level[i] = [];

            // iterate through all columns (string's characters)
            for (let j : number = 0; j < rows[i].length; j ++) {

                // get tile value according to its position in stringItems string
                let value = this.stringItems.indexOf(rows[i].charAt(j));

                // set level value
                this.level[i][j] = value;

                // create the actors to be placed in this tile
                this.createActors(i, j, value);
            }
        }
    }

    // method to create actors
    // arguments: level row, level column and level value
    private createActors(row : number, column : number, value : tileType) : void {

        // a simple switch to handle different values
        // it could be optimized but I prefer to show you how to do it case by case
        switch (value) {

            // floor, goal and wall are simple elements, as there is only one actor: the floor, the wall or the goal
            case tileType.FLOOR :
            case tileType.WALL :
            case tileType.GOAL :

                // add the actor to tiles array
                this.tiles.push(new SokobanActor(row, column, value));
                break;

            // anything with a crate enters this block of code, now we have to split crate and floor type
            case tileType.FLOOR + tileType.CRATE :
            case tileType.GOAL + tileType.CRATE :

                // add the actor below the crate in tiles array
                this.tiles.push(new SokobanActor(row, column, value - tileType.CRATE));

                // add the crate actor in crates array
                this.crates.push(new SokobanActor(row, column, tileType.CRATE));
                break;

            // same concept is applied to the player
            case tileType.FLOOR + tileType.PLAYER :
            case tileType.GOAL + tileType.PLAYER :
                this.tiles.push(new SokobanActor(row, column, value - tileType.PLAYER));
                this.player = new SokobanActor(row, column, tileType.PLAYER);
                break;
        }
    }

    // getter to get "up" direction value
    get up() : number {
        return playerDirection.UP;
    }

    // getter to get "down" direction value
    get down() : number {
        return playerDirection.DOWN;
    }

    // getter to get "left" direction value
    get left() : number {
        return playerDirection.LEFT;   
    }

    // getter to get "right" direction value
    get right() : number {
        return playerDirection.RIGHT;   
    }

    // getter to get all Sokoban actors
    get actors() : SokobanActor[] {

        // for a matter of z-indexing, first I return all floor tiles, then all crates
        let actorsArray : SokobanActor[] = this.tiles.concat(this.crates);

        // finally, the player is added
        actorsArray.push(this.player);
        return actorsArray;    
    }

    // method to get a tile value
    // arguments: the row and the column
    getValueAt(row : number, column: number) : number {
        return this.level[row][column];
    }

    // method to check if the level is solved
    get solved() : boolean {

        // we don't want to find crates
        return this.level.findIndex(row => row.includes(tileType.CRATE)) == -1;    
    }

    // method to move the player, if possible, and return an array of movements
    // argument: the direction
    move(direction: playerDirection) : SokobanMovement[] {

        // array to store movements
        let movements : SokobanMovement[] = [];

        // check if it's a legal move
        if (this.canMove(direction)) {

            // determine player destination
            let playerDestination : SokobanCoordinate = new SokobanCoordinate(this.player.row + this.movementInfo[direction].row, this.player.column + this.movementInfo[direction].column);
            
            // loop through all crates
            this.crates.forEach ((crate : SokobanActor) => {

                // if there is a crate on destination tile...
                if (crate.row == playerDestination.row && crate.column == playerDestination.column) {

                    // determine crate destination
                    let crateDestination : SokobanCoordinate = new SokobanCoordinate(this.player.row + 2 * this.movementInfo[direction].row, this.player.column + 2* this.movementInfo[direction].column);

                    // move the crate
                    movements.push(this.moveActor(crate, new SokobanCoordinate(crate.row, crate.column), crateDestination));
                }
            });

            // move the player
            movements.push(this.moveActor(this.player, new SokobanCoordinate(this.player.row, this.player.column), playerDestination));
        }
        
        // return movements array
        return movements;
    }

    // method to check if a tile is walkable
    // arguments: tile row and column
    private isWalkableAt(row : number, column : number) : boolean {

        // tile is walkable if it's a floor or a goal
        return this.getValueAt(row, column) == tileType.FLOOR || this.getValueAt(row, column) == tileType.GOAL;
    }

    // method to check if there is a crate on a tile
    // arguments: tile row and column
    private isCrateAt(row : number, column : number) : boolean {

        // there's a crate if the tile is a crate or the tile is a crate over the goal
        return this.getValueAt(row, column) == tileType.CRATE || this.getValueAt(row, column) == tileType.CRATE + tileType.GOAL;
    }

    // method to check if there is a pushable crate on a tile
    // arguments: tile row and column, and movement direction
    private isPushableCrateAt(row : number, column: number, direction : playerDirection) : boolean {
      
        // there's a pushable crate if there is a crate and the destination tile is walkable
        return this.isCrateAt(row, column) && this.isWalkableAt(row + this.movementInfo[direction].row, column + this.movementInfo[direction].column);
    }

    // method to check if the player can move in a given direction
    // argument: the direction
    private canMove(direction : playerDirection) : boolean {

        // determine destination row and column
        let destinationRow : number = this.player.row + this.movementInfo[direction].row;
        let destinationColumn : number = this.player.column + this.movementInfo[direction].column;

        // player can move if destination tile is walkable or is a pushable crate
        return this.isWalkableAt(destinationRow, destinationColumn) || this.isPushableCrateAt(destinationRow, destinationColumn, direction);
    }

    // method to move an actor
    // arguments: the actor, the starting tile and the destination tile
    private moveActor(actor : SokobanActor, from : SokobanCoordinate, to : SokobanCoordinate) : SokobanMovement {
        
        // move the actor
        actor.moveTo(to.row, to.column);

        // adjust level values
        this.level[from.row][from.column] -= actor.type;
        this.level[to.row][to.column] += actor.type;

        // return movement information
        return new SokobanMovement(actor, from, to);
    }    
}

// SOKOBAN ACTOR CLASS
class SokobanActor {

    // actor customizable data
    data : any;

    // actor position
    private position : SokobanCoordinate; 

    // actor tile type
    private _type : tileType;

    // constructor
    // arguments: row, column and tile type
    constructor(row : number, column : number, type: tileType) {
        this.position = new SokobanCoordinate(row, column);
        this._type = type;
    }

    // get type of the actor
    get type() : tileType {
        return this._type;
    }

    // is the actor a crate?
    get isCrate() : boolean {
        return this._type == tileType.CRATE;
    }

    // is the actor the player?
    get isPlayer() : boolean {
        return this._type == tileType.PLAYER;
    }

    // get actor column
    get column() : number {
        return this.position.column;    
    }

    // get actor row
    get row() : number {
        return this.position.row;
    }   

    // method to move the actor
    // arguments: row and column
    moveTo(row : number, column : number) : void {
        this.position.setCoordinate(row, column);
    }
}

// SOKOBAN MOVEMENT CLASS
class SokobanMovement {

    // actor to move
    actor : SokobanActor;

    // current coordinate
    from : SokobanCoordinate;

    // destination coordinate
    to : SokobanCoordinate;

    // constructor
    // arguments: the actor, current coordinate, destination coordinate   
    constructor(actor : SokobanActor, from : SokobanCoordinate, to : SokobanCoordinate) {
        this.actor = actor;
        this.from = new SokobanCoordinate(from.row, from.column);
        this.to = new SokobanCoordinate(to.row, to.column);
    } 
}

// SOKOBAN COORDINATE CLASS
class SokobanCoordinate {

    // row and column, just two values to use as x,y coordinates
    private _row : number;
    private _column : number;

    constructor(row : number, column: number) {
        this._row = row;
        this._column = column;
    }

    // get row
    get row() : number {
        return this._row
    }

    // get column
    get column() : number {
        return this._column;
    }

    // method to set coordinate
    // arguments: row and column
    setCoordinate(row : number, column : number) : void {
        this._row = row;
        this._column = column;
    }
}

Now, I will build a complete Sokoban game with levels starting from this class. Follow me on Twitter to stay up to date, and download the source code to start creating your own Sokoban game.

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