Writing a Sokoban Puzzle Game in JavaScript

So the other day, I made an implementation of a Sokoban puzzle game in JavaScript.

Here's the source code and here's the demo.

The game consists of a wall, a playable character, blocks, and spots on the ground that are storage locations. The aim of the game is to push all the blocks into all the storage locations. It can be challenging because it's easy to end up in a state where a block can no longer be moved and now you have to restart the game.

Here's the one I made:

The original game has slightly better graphics:

In my version, the big blue dot is the character, the pink dots are the storage locations, and the orange blocks are the crates.

I wrote it up on the fly over the course of a few hours. Making little games is a lot different than what I usually do at work, so I found it to be a fun, achievable challenge. Fortunately with some previous projects (Snek and Chip8) I had some experience with the concept of plotting out coordinates.

Map and entities

The first thing I did was build out the map, which is a two-dimensional array where each row corresponds to a y coordinate and each column corresponds to an x coordinate.

const map = [
  ['y0 x0', 'y0 x1', 'y0 x2', 'y0 x3'],
  ['y1 x0', 'y1 x1', 'y1 x2', 'y1 x3'],
  // ...etc
]

So accessing map[0][0] would be y0 x0 and map[1][3] would be y1 x3.

From there, it's easy to make a map based on an existing Sokoban level where each coordinate is an entity in the game - terrain, player, etc.

Entities
const EMPTY = 'empty'
const WALL = 'wall'
const BLOCK = 'block'
const SUCCESS_BLOCK = 'success_block'
const VOID = 'void'
const PLAYER = 'player'
Map
const map = [
  [EMPTY, EMPTY, WALL, WALL, WALL, WALL, WALL, EMPTY],
  [WALL, WALL, WALL, EMPTY, EMPTY, EMPTY, WALL, EMPTY],
  [WALL, VOID, PLAYER, BLOCK, EMPTY, EMPTY, WALL, EMPTY],
  // ...etc

With that data, I can map each entity to a color and render it to the screen on an HTML5 canvas. So now I have a map that looks right, but it doesn't do anything yet.

Game logic

There aren't too many actions to worry about. The player can move orthogonally - up, down, left, and right - and there are a few things to consider:

  • The PLAYER and BLOCK cannot move through a WALL
  • The PLAYER and BLOCK can move through an EMPTY space or a VOID space (storage location)
  • The player can push a BLOCK
  • A BLOCK becomes a SUCCESS_BLOCK when it's on top of a VOID.

And that's literally it. I also coded one more thing in that's not part of the original game, but it made sense to me:

  • A BLOCK can push all other BLOCK pieces

When the player pushes a block that's next to other blocks, all the blocks will move until it collides with a wall.

In order to do this I just need to know the entities adjacent to the player, and the entities adjacent to a block if a player is pushing a block. If a player is pushing multiple blocks, I'll have to recursively count how many there are.

Moving

Therefore, the first thing we need to do any time a change happens is find the player's current coordinates, and what type of entity is above, below, to the left, and to the right of them.

function findPlayerCoords() {
  const y = map.findIndex(row => row.includes(PLAYER))
  const x = map[y].indexOf(PLAYER)

  return {
    x,
    y,
    above: map[y - 1][x],
    below: map[y + 1][x],
    sideLeft: map[y][x - 1],
    sideRight: map[y][x + 1],
  }
}

Now that you have the player and adjacent coordinates, every action will be a move action. If the player is trying to move through a traversible cell (empty or void), just move the player. If the player is trying to push a block, move the player and block. If the adjacent unit is a wall, do nothing.

function move(playerCoords, direction) {
  if (isTraversible(adjacentCell[direction])) {
    movePlayer(playerCoords, direction)
  }

  if (isBlock(adjacentCell[direction])) {
    movePlayerAndBlocks(playerCoords, direction)
  }
}

Using the initial game state, you can figure out what should be there. As long as I pass the direction to the function, I can set the new coordinates - adding or removing a y will be up and down, adding or removing an x will be left or right.

function movePlayer(playerCoords, direction) {
  // Replace previous spot with initial board state (void or empty)
  map[playerCoords.y][playerCoords.x] = isVoid(levelOneMap[playerCoords.y][playerCoords.x])
    ? VOID
    : EMPTY

  // Move player
  map[getY(playerCoords.y, direction, 1)][getX(playerCoords.x, direction, 1)] = PLAYER
}

If the player is moving a block, I wrote a little recursive function to check how many blocks are in a row, and once it has that count, it will check what the adjacent entity is, move the block if possible, and move the player if the block moved.

function countBlocks(blockCount, y, x, direction, board) {
  if (isBlock(board[y][x])) {
    blockCount++
    return countBlocks(blockCount, getY(y, direction), getX(x, direction), direction, board)
  } else {
    return blockCount
  }
}

const blocksInARow = countBlocks(1, newBlockY, newBlockX, direction, map)

Then, if the block can be moved, it will just either move it or move it and transform it into a success block, if it's over a storage location, followed by moving the player.

map[newBoxY][newBoxX] = isVoid(levelOneMap[newBoxY][newBoxX]) ? SUCCESS_BLOCK : BLOCK
movePlayer(playerCoords, direction)

Rendering

It's easy to keep track of the entire game in a 2D array and render the update game to the screen with each movement. The game tick is incredibly simple - any time a keydown event happens for up, down, left, right (or w, a, s, d for intense gamers) the move() function will be called, which uses the player index and adjacent cell types to determine what the new, updated state of the game should be. After the change, the render() function is called, which just paints the entire board with the updated state.

const sokoban = new Sokoban()
sokoban.render()

// re-render
document.addEventListener('keydown', event => {
  const playerCoords = sokoban.findPlayerCoords()

  switch (event.key) {
    case keys.up:
    case keys.w:
      sokoban.move(playerCoords, directions.up)
      break
    case keys.down:
    case keys.s:
      sokoban.move(playerCoords, directions.down)
      break
    case keys.left:
    case keys.a:
      sokoban.move(playerCoords, directions.left)
      break
    case keys.right:
    case keys.d:
      sokoban.move(playerCoords, directions.right)
      break
    default:
  }

  sokoban.render()
})

The render function just maps through each coordinate and creates a rectangle or circle with the right color.

function render() {
  map.forEach((row, y) => {
    row.forEach((cell, x) => {
      paintCell(context, cell, x, y)
    })
  })
}

Basically all rendering in the HTML canvas made a path for the outline (stroke), and a path for the inside (fill). Since one pixel per coordinate would be a pretty tiny game, I multiplied each value by a multipler, which was 75 pixels in this case.

function paintCell(context, cell, x, y) {
  // Create the fill
  context.beginPath()
  context.rect(x * multiplier + 5, y * multiplier + 5, multiplier - 10, multiplier - 10)
  context.fillStyle = colors[cell].fill
  context.fill()

  // Create the outline
  context.beginPath()
  context.rect(x * multiplier + 5, y * multiplier + 5, multiplier - 10, multiplier - 10)
  context.lineWidth = 10
  context.strokeStyle = colors[cell].stroke
  context.stroke()
}

The render function also checks for a win condition (all storage locations are now success blocks) and shows "A winner is you!" if you win.

Conclusion

This was a fun little game to make. I organized the files like this:

  • Constants for entity data, map data, mapping colors to entities, and key data.
  • Utility functions for checking what type of entity exists at a particular coordinate, and determining what the new coordinates should be for the player.
  • Sokoban class for maintaining game state, logic, and rendering.
  • Script for initializing the instance of the app and handling key events.

I found it easier to code than to solve. 😆

Hope you enjoyed reading about this and feel inspired to make your own little games and projects.

Comments