When I was a kid, I used to play puzzle games a lot. One of them was called Sokoban. The principle is simple: Push boxes around in a maze until all boxes are at their target spot. As seen in this animation I found on Wikipedia:
(Gif by Carloseow at English Wikipedia)
I wanted to play this again for ages now, so I figured, why not build my own version? Let's get right into it!
Boilerplating
The usual: Some HTML with an empty JS file. The HTML is pretty straight forward:
<!DOCTYPE html>
<html>
<head></head>
<body>
<canvas width="500" height="500" id="canvas"></canvas>
<div
id="message"
style="font-size: 20px; font-weight: bold;"
>
Use arrow keys to move the boxes around.
</div>
<script src="./blockPushingGame.js"></script>
</body>
</html>
Gathering the textures
So first, I need textures. I look through a popular search engineโข for a wall texture, a sand texture, a box texture, some red dot to indicate the target and a cat I can use as a player.
These are the textures I'm going to use:
Player texture:
Box texture:
Floor texture:
Wall texture:
Target texture:
I use promises to load all the textures beforehand to not load them every time I want to render something:
/**
* Loads a texture async
* @param texture
* @returns {Promise<unknown>}
*/
const loadTexture = texture => new Promise(resolve => {
const image = new Image()
image.addEventListener('load', () => {
resolve(image)
})
image.src = texture
})
Promise.allSettled([
loadTexture('./floor.jpg'),
loadTexture('./wall.jpg'),
loadTexture('./target.jpg'),
loadTexture('./box.jpg'),
loadTexture('./cat.png'),
]).then(results => {
const [
floorTexture,
wallTexture,
targetTexture,
boxTexture,
catTexture
] = results.map(result => result.value)
// more stuff here...
})
Defining the playing field
There's several different objects in a block pushing game:
- The floor
- Walls
- Boxes
- Targets to move the boxes onto
- The player moving the boxes
I define different nested arrays for each of them, to be able to render and compare them:
const floor = new Array(9).fill(new Array(9).fill('X'))
const walls = [
[' ', ' ', 'X', 'X', 'X', 'X', 'X', 'X', ' '],
['X', 'X', 'X', ' ', ' ', ' ', ' ', 'X', ' '],
['X', ' ', ' ', ' ', ' ', ' ', ' ', 'X', ' '],
['X', 'X', 'X', ' ', ' ', ' ', ' ', 'X', ' '],
['X', ' ', 'X', 'X', ' ', ' ', ' ', 'X', ' '],
['X', ' ', 'X', ' ', ' ', ' ', ' ', 'X', 'X'],
['X', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'X'],
['X', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'X'],
['X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X'],
]
const targets = [
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', 'X', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', 'X', ' ', ' '],
[' ', 'X', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', 'X', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', 'X', ' ', ' ', ' ', 'X', ' '],
[' ', ' ', ' ', ' ', 'X', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
]
const boxes = [
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', 'X', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', 'X', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', 'X', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', 'X', ' ', 'X', 'X', 'X', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
]
const player = [
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', 'X', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
];
let playerX = 2
let playerY = 2
With this approach, I basically abstracted everything away into a "visual" approach for the programmer: By setting 'X'
and ' '
at the right coordinates, I can either make something be a wall, or an empty space. I can add boxes and their targets wherever I want and don't have to fiddle around with setting X and Y coordinates of them.
I can now use these arrays and the textures together!
A first render of the playing field
In order to render, for example, all the walls, I need to loop over the array of arrays and put the texture on the canvas at the coordinates where an X is.
Since the canvas is 500 by 500 pixels and I've defined the playing field as 9 by 9, each grid cell of the playing field is 500 / 9 = ~56
pixels in width and height. Example: If a piece of wall is placed at playing field X=3/Y=4
, this means that the texture's top left corner will render at X=3 * 56 = 168/Y=4 * 56 = 224
In code, this would look like this:
/**
* Renders a grid of blocks with a given texture
* @param blocks
* @param textureImage
* @param canvas
* @returns {Promise<unknown>}
*/
const renderBlocks = (blocks, textureImage, canvas) => {
// Scale the grid of the nested blocks array to the pixel grid of the canvas
const pixelWidthBlock = canvas.width / blocks[0].length
const pixelHeightBlock = canvas.height / blocks.length
const context = canvas.getContext('2d')
blocks.forEach((row, y) => {
row.forEach((cell, x) => {
if (cell === 'X') {
context.drawImage(
textureImage,
x * pixelWidthBlock,
y * pixelHeightBlock,
pixelWidthBlock,
pixelHeightBlock
)
}
})
})
}
Together with the textures, I can now render a playing field for the first time:
Promise.allSettled([
loadTexture('./floor.jpg'),
loadTexture('./wall.jpg'),
loadTexture('./target.jpg'),
loadTexture('./box.jpg'),
loadTexture('./cat.png'),
]).then(results => {
const [
floorTexture,
wallTexture,
targetTexture,
boxTexture,
catTexture
] = results.map(result => result.value)
const canvas = document.querySelector('#canvas')
const render = () => {
renderBlocks(floor, floorTexture, canvas)
renderBlocks(walls, wallTexture, canvas)
renderBlocks(targets, targetTexture, canvas)
renderBlocks(boxes, boxTexture, canvas)
renderBlocks(player, catTexture, canvas)
}
render()
// ...
})
Making it interactive
The next step is to give the player character the ability to move. As indicated in the HTML part, the player will be able to use the arrow keys to move around.
I attach the event listener right after rendering the field for the first time:
window.addEventListener('keydown', event => {
let xMovement = 0
let yMovement = 0
switch (event.key) {
case 'ArrowUp':
yMovement = -1
break
case 'ArrowDown':
yMovement = 1
break
case 'ArrowLeft':
xMovement = -1
break
case 'ArrowRight':
xMovement = 1
break
}
const newPlayerX = playerX + xMovement
const newPlayerY = playerY + yMovement
// ...
// Remove player at old position
player[playerY][playerX] = ' '
// Set player at new position
player[newPlayerY][newPlayerX] = 'X'
playerX = newPlayerX
playerY = newPlayerY
render()
})
The reason I work with two variables and don't update the new player position right away, is that it allows me to do all the collision checks later on in a more generalized way.
Speaking of collision checks, let's check if the player is actually jumping off the field, first:
// Collision with end of playing field
if (
newPlayerX < 0
|| newPlayerY < 0
|| newPlayerX > floor[0].length - 1
|| newPlayerY > floor.length - 1
) {
return
}
Pretty straight forward: If the new coordinates would be outside of the field, don't move. Same goes for the walls:
// Wall collision
if (walls[newPlayerY][newPlayerX] === 'X') {
return
}
The boxes are a bit more complex. The rule is, that I cannot move a box whose way is blocked by either a wall, or a second box (I can only push one box at a time).
To implement that, I first need to figure out if the player is colliding with a box. If that's the case, I need to find out if the boxes way would be blocked. I therefore check in the direction of movement if there's a wall or another box in the way. If there's none, I move the box.
// Box collision
if (boxes[newPlayerY][newPlayerX] === 'X') {
if (
boxes[newPlayerY + yMovement][newPlayerX + xMovement] === 'X'
|| walls[newPlayerY + yMovement][newPlayerX + xMovement] === 'X'
) {
return
}
boxes[newPlayerY][newPlayerX] = ' '
boxes[newPlayerY + yMovement][newPlayerX + xMovement] = 'X'
}
The last step is to render the changed field again, by calling render()
. Almost done!
Checking if the player has won
The game is won if all boxes are placed on targets. It doesn't matter which box is on which target, though. This means that I only need to check if the array of boxes is the same as the array of targets:
/**
* Determines if the game was won
* @param targets
* @param boxes
* @returns {boolean}
*/
const hasWon = (targets, boxes) => {
for (let y = 0; y < targets.length; y++) {
for (let x = 0; x < targets[0].length; x++) {
if (targets[y][x] !== boxes[y][x]) {
// Some box is not aligned with a target.
return false
}
}
}
return true
}
To show the player that they've solved the puzzle, I add this to the event listener I added earlier:
if (hasWon(targets, boxes)) {
document.querySelector('#message').innerHTML = 'You\'ve won!'
}
Let's play!
Have fun! Because I certainly will!
I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a โค๏ธ or a ๐ฆ! I write tech articles in my free time and like to drink coffee every once in a while.
If you want to support my efforts, please consider buying me a coffee โ or following me on Twitter ๐ฆ! You can also support me and my writing directly via Paypal!
Top comments (2)
Oh nice! If that's what's possible with Phaser, I do have to give it a try! Where did you get the graphics from?
Didn't know about Phaser, it looks very interesting, thank you!