DEV Community

Cover image for Let's build a box pushing puzzle game from scratch! πŸ“¦πŸ•ΉοΈ
Pascal Thormeier
Pascal Thormeier

Posted on

Let's build a box pushing puzzle game from scratch! πŸ“¦πŸ•ΉοΈ

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:

An animated Sokoban puzzle and its solution
(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>
Enter fullscreen mode Exit fullscreen mode

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:

Cat texture

Box texture:

Box texture

Floor texture:

Floor texture

Wall texture:

Wall texture

Target 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...
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        )
      }
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

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()
  // ...
})
Enter fullscreen mode Exit fullscreen mode

Playing field rendered

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()
})
Enter fullscreen mode Exit fullscreen mode

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
  }
Enter fullscreen mode Exit fullscreen mode

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
  }
Enter fullscreen mode Exit fullscreen mode

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'
  }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!'
  }
Enter fullscreen mode Exit fullscreen mode

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!

Buy me a coffee button

Top comments (2)

 
thormeier profile image
Pascal Thormeier

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?

Collapse
 
thormeier profile image
Pascal Thormeier

Didn't know about Phaser, it looks very interesting, thank you!