How to Make a Push Puzzle – Let’s Create a Sokoban-Style Puzzle (First Half)

Game

Let’s make a simple game using just HTML and JavaScript, without any game engines or libraries.

This is for people who:

Want to try making a game but have no idea where to start.
Just want to make a simple game without diving into complex tools like Unity or Unreal Engine.

This guide is aimed at those who already have some programming knowledge.

What is Sokoban?

Sokoban (Wikipedia)

Sokoban is a classic puzzle game released in 1982. The player moves boxes around a warehouse, trying to get them to designated spots. You can push the boxes but can’t pull them. If you push a box to the wrong place, you’re stuck.

What We’ll Create in This Article

We’re going to create a demo that implements the basic rules of a Sokoban game. Go ahead and try it out!
In the demo below: The yellow rectangle is you. The brown rectangle is a box. The circles are the designated spots.
i … Up, m … Down, j … Left … Right, r … Reset

Source Code

<html>
    <center>
        <canvas id="gameCanvas" width="640" height="480" style="border:1px solid #000000; background-color: #000;"></canvas>
    <script type="text/javascript" src="index.js"></script>
</html>
const canvas = document.getElementById('gameCanvas');
const context = canvas.getContext('2d');

const SCREEN_W = 20
const SCREEN_H = 15
const BLOCK_SIZE = 32

const Const = {
    WALL: 1,
    BOX: 2,
    POINT: 3,
    PLAYER: 4,
}

const map = [
//  1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 1
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 2
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 3
    0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0, // 4
    0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0, // 5
    0,0,0,0,0,0,0,1,3,2,4,0,0,1,0,0,0,0,0,0, // 6
    0,0,0,0,0,0,1,1,1,0,0,0,0,1,0,0,0,0,0,0, // 7
    0,0,0,0,0,0,1,0,0,0,0,1,1,1,0,0,0,0,0,0, // 8
    0,0,0,0,0,0,1,3,2,0,0,1,0,0,0,0,0,0,0,0, // 9
    0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,0,0,0,0,0, // 10
    0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0, // 11
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 12
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 13
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 14
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 15
]

class Obj {
    constructor(x,y) {
        this.x = x;
        this.y = y;
    }
}

let player = new Obj(0,0);
let box_list = [];

// Input event handler
document.addEventListener('keydown', keyDownHandler, false);

function keyDownHandler(event) {
    if (event.key === 'i') { // Up
        movePlayer(0, -1);
    } 

    if (event.key === 'm') { // Down
        movePlayer(0, 1);
    }

    if (event.key === 'j') { // Left
        movePlayer(-1, 0);
    }

    if (event.key == 'l') { // Right
        movePlayer(1, 0);
    }

    if (event.key == 'r') { // Right
        init();
    }
}

function movePlayer(dx, dy) {
    if (hitBox(player.x+dx, player.y+dy)) {
        // If there's a box in the direction you're moving, check if it can be moved further.
        if (! hitObj(player.x+dx+dx, player.y+dy+dy, Const.WALL)
            && ! hitBox(player.x+dx+dx, player.y+dy+dy)) 
        {
            // If it can be moved, push the box and move yourself as well.
            moveBox(player.x+dx, player.y+dy, player.x+dx+dx, player.y+dy+dy);
            player.x += dx;
            player.y += dy;
        }
    }
    else if (!hitObj(player.x+dx, player.y+dy, Const.WALL)) {
        player.x += dx;
        player.y += dy;
    }
}

function hitObj(x, y, id) {
    return id==map[x + y *SCREEN_W]
}

function hitBox(x, y) {
    for (item of box_list) {
        if (item.x == x && item.y == y) 
            return true;
    }
    return false;
}

function moveBox(sx, sy, tx, ty) {
    for (item of box_list) {
        if (item.x == sx && item.y == sy) {
            item.x = tx;
            item.y = ty;
        }
    }
}

function drawRect(x,y, obj) {
    context.strokeStyle = '#fff';
    context.lineWidth = 2;
    context.strokeRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
    
    if (obj==1) {
        context.fillStyle = '#fff'
        context.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
    }
}

function drawScreen() {
    for (j = 0; j < SCREEN_H; j++) {
        for (i = 0; i < SCREEN_W; i++) {
            obj = map[ i + j*SCREEN_W ];
            if (obj == Const.POINT) {
                drawPoint(i, j);
            } else {
                drawRect(i, j, obj);
            }
        }
    }
}

function drawPlayer() {
    context.fillStyle = 'yellow'
    context.fillRect(player.x * BLOCK_SIZE, player.y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
}

function drawBox() {
    for (const item of box_list) {
        context.fillStyle = 'brown'
        context.fillRect(item.x * BLOCK_SIZE, item.y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
    }
}

function drawPoint(ix, iy) {
    const radius = 8
    x = ix * BLOCK_SIZE;
    y = iy * BLOCK_SIZE; 
    context.beginPath();
    context.arc(x+radius*2, y+radius*2, radius, 0, Math.PI * 2, false);
    context.fillStyle = 'lightblue';
    context.fill();
    context.closePath();
}

function isGameClear() {
    for (const item of box_list) {
        if (map[item.x + item.y * SCREEN_W] != Const.POINT) {
            return false;
        }
    }
    return true;
}

function init() {
    box_list = [];
    for (y = 0; y < SCREEN_H; y++) {
        for (x = 0; x < SCREEN_W; x++) {
            obj = map[x + y*SCREEN_W];
            switch (obj) {
            case Const.PLAYER:
                player.x = x;
                player.y = y;
                break;
            case Const.BOX:
                box_list.push(new Obj(x,y));
                break;
            }
        }
    }
}

function draw_text_center(text, color='#fff') {
    x = BLOCK_SIZE;
    y = canvas.height/2 - 40;
    context.fillStyle = 'black';
    context.fillRect(x, y, canvas.width-BLOCK_SIZE*2, 60);

    context.fillStyle = color;
    context.font = '20px Consolas';
    context.textAlign = 'left';
    text_w = context.measureText(text).width;
    context.fillText(text, canvas.width/2-text_w/2, canvas.height/2);
}

function update() {
    context.clearRect(0, 0, canvas.width, canvas.height);
    drawScreen();
    drawBox();
    drawPlayer();
    if (isGameClear()) {
        draw_text_center("Game Clear!");
    }
}

init();

setInterval(update, 10);

How We Handle Data

How you handle data is crucial in programming. The data structure determines the algorithm. In this demo, we'll use the following types of data:

  • Map Data (a 20x15 array)
  • Player Coordinates (x, y)
  • A List of Box Coordinates (x, y)

Now, let's go through each part in detail.

Map Data (a 20x15 array)

The map data is follows.

const map = [
//  1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 1
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 2
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 3
    0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0, // 4
    0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0, // 5
    0,0,0,0,0,0,0,1,3,2,4,0,0,1,0,0,0,0,0,0, // 6
    0,0,0,0,0,0,1,1,1,0,0,0,0,1,0,0,0,0,0,0, // 7
    0,0,0,0,0,0,1,0,0,0,0,1,1,1,0,0,0,0,0,0, // 8
    0,0,0,0,0,0,1,3,2,0,0,1,0,0,0,0,0,0,0,0, // 9
    0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,0,0,0,0,0, // 10
    0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0, // 11
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 12
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 13
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 14
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 15
];

For simplicity, we define it as a one-dimensional array. The numbers represent:

  1. Wall
  2. Box
  3. Storage Area
  4. Player's Starting Position

In this demo, we only have one stage, so it's just one 20x15 map. If you add more stages, you can manage them with a two-dimensional array.

Editing each stage's data manually is a hassle, so if you're making a serious game, you'd probably need to create a tool for it.

Player Coordinates (x, y)

Here's the implementation. The Obj class is defined to hold (x, y) coordinates. We'll use this for the box and storage area coordinates as well.

class Obj {
    constructor(x,y) {
        this.x = x;
        this.y = y;
    }
}

let player = new Obj(0,0);

A List of Box Coordinates (x, y)

Here's the source code.

let box_list = [];

Data Initialization

The map data has fixed placements for walls and storage areas, but the positions of boxes and the player will change as the game progresses. Before the game starts, we need to extract the initial placement data from the map and put the coordinates into player and box_list.

Here's how we implement this.

const Const = {
    WALL: 1,
    BOX: 2,
    POINT: 3,
    PLAYER: 4,
}
・・・
function init() {
    box_list = [];
    point_list = [];
    for (y = 0; y < SCREEN_H; y++) {
        for (x = 0; x < SCREEN_W; x++) {
            obj = map[x + y*SCREEN_W];
            switch (obj) {
            case Const.PLAYER:
                player.x = x;
                player.y = y;
                break;
            case Const.BOX:
                box_list.push(new Obj(x,y));
                break;
            }
        }
    }
}

We extract data from the map using obj = map[x + y*SCREEN_W], identify the type, and store the coordinate information.

Rules

Following figure shows all rules of Sokoban.

From the player's perspective, the basic rules boil down to these patterns:

When you move the player in one of four directions:

  1. Is there a box in the next square?
    • If there is a box, is there a wall or another box in the square beyond it?
  2. If there isn't a box, is there a wall in the next square?

This is implemented in the following function, which can handle all four directions.

function movePlayer(dx, dy) {
    if (hitBox(player.x+dx, player.y+dy)) {
        // If there's a box in the direction you're moving, check if it can be moved further.
        if (! hitObj(player.x+dx+dx, player.y+dy+dy, Const.WALL)
            && ! hitBox(player.x+dx+dx, player.y+dy+dy)) 
        {
            // If it can be moved, push the box and move yourself as well.
            moveBox(player.x+dx, player.y+dy, player.x+dx+dx, player.y+dy+dy);
            player.x += dx;
            player.y += dy;
        }
    }
    else if (!hitObj(player.x+dx, player.y+dy, Const.WALL)) {
        player.x += dx;
        player.y += dy;
    }
}

Moving the Boxes

Whether you can move a box is already checked in the movePlayer() function. If you can move it, you just move it to the next square.

Here's the function for that. sx, sy are the coordinates you're moving from, and tx, ty are the coordinates you're moving to.

function moveBox(sx, sy, tx, ty) {
    for (item of box_list) {
        if (item.x == sx && item.y == sy) {
            item.x = tx;
            item.y = ty;
        }
    }
}

Checking for Game Clear

The game is cleared when all boxes are in storage areas. Implementing this is straightforward.

function isGameClear() {
    for (const item of box_list) {
        if (map[item.x + item.y * SCREEN_W] != Const.POINT) {
            return false;
        }
    }
    return true;
}

It just goes through all the box coordinates in box_list and checks if their position on the map corresponds to a storage area, defined by Const.POINT, which is just the number 3.

Next Step

In this implementation, we've covered all the rules of Sokoban, so you can play it if you don't care about the visuals. (All you need is some map data, which is the puzzle element.)

However, just using rectangles and circles for the display doesn't give it the right vibe, so next time, we'll use proper pixel art to complete the game.


コメント

Copied title and URL