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.