Skip to content

Instantly share code, notes, and snippets.

@straker
Last active January 24, 2024 07:13
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save straker/2fddb507d4bb6bec54ea2fdb022d020c to your computer and use it in GitHub Desktop.
Save straker/2fddb507d4bb6bec54ea2fdb022d020c to your computer and use it in GitHub Desktop.
Basic Sokoban HTML and JavaScript Game

Basic Sokoban HTML and JavaScript Game

This is a basic implementation of the game Sokoban, but it's missing a few things intentionally and they're left as further exploration for the reader.

Further Exploration

  • More levels
    • Add more levels and have the next level start once the last one is finished
  • Show number of moves
    • Keep track of how many times the player moves and display it. Use context.fillText() to display the number of moves to the screen
  • Mobile and touchscreen support
  • Support run length encoding
    • Run length encoding is a different way to encode the level that collapses the level data to a single line
  • Support the .sok file format
    • Currently the code understands the .sok file format level symbols, but a full .sok file format can have a lot of additional metadata, including having multiple levels in a single file

Important note: I will answer questions about the code but will not add more features or answer questions about adding more features. This series is meant to give a basic outline of the game but nothing more.

License

(CC0 1.0 Universal) You're free to use this game and code in any project, personal or commercial. There's no need to ask permission before using these. Giving attribution is not required, but appreciated.

Other Basic Games

Support

Basic HTML Games are made possible by users like you. When you become a Patron, you get access to behind the scenes development logs, the ability to vote on which games I work on next, and early access to the next Basic HTML Game.

Top Patrons

  • Karar Al-Remahy
  • UnbrandedTech
  • Innkeeper Games
  • Nezteb
<!DOCTYPE html>
<html>
<head>
<title>Basic Sokoban HTML Game</title>
<meta charset="UTF-8">
<style>
html, body {
height: 100%;
margin: 0;
}
body {
background: #ded6ae;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<canvas width="400" height="400" id="game"></canvas>
<script>
const canvas = document.getElementById('game');
const context = canvas.getContext('2d');
const grid = 64;
// create a new canvas and draw the wall image. then we can use this
// canvas to draw the images later on
const wallCanvas = document.createElement('canvas');
const wallCtx = wallCanvas.getContext('2d');
wallCanvas.width = wallCanvas.height = grid;
wallCtx.fillStyle = '#5b5530';
wallCtx.fillRect(0, 0, grid, grid);
wallCtx.fillStyle = '#a19555';
// 1st row brick
wallCtx.fillRect(1, 1, grid - 2, 20);
// 2nd row bricks
wallCtx.fillRect(0, 23, 20, 18);
wallCtx.fillRect(22, 23, 42, 18);
// 3rd row bricks
wallCtx.fillRect(0, 43, 42, 20);
wallCtx.fillRect(44, 43, 20, 20);
// the direction to move the player each frame. we'll use change in
// direction so "row: 1" means move down 1 row, "row: -1" means move
// up one row, etc.
let playerDir = { row: 0, col: 0 };
let playerPos = { row: 0, col: 0 }; // player position in the 2d array
let rAF = null; // keep track of the animation frame so we can cancel it
let width = 0; // find the largest row and use that as the game width
// create a mapping of object types using the sok file format
// @see http://www.sokobano.de/wiki/index.php?title=Level_format
const types = {
wall: '#',
player: '@',
playerOnGoal: '+',
block: '$',
blockOnGoal: '*',
goal: '.',
empty: ' '
};
// a sokoban level using the sok file format
const level1 = `
#####
### #
#.@$ #
### $.#
#.##$ #
# # . ##
#$ *$$.#
# . #
########
`;
// keep track of what is in every cell of the game using a 2d array
const cells = [];
// use each line of the level as the row (remove empty lines)
level1.split('\n')
.filter(rowData => !!rowData)
.forEach((rowData, row) => {
cells[row] = [];
if (rowData.length > width) {
width = rowData.length;
}
// use each character of the level as the col
rowData.split('').forEach((colData, col) => {
cells[row][col] = colData;
if (colData === types.player || colData === types.playerOnGoal) {
playerPos = { row, col };
}
});
});
// update the size of the canvas to the level size
canvas.width = width * grid;
canvas.height = cells.length * grid;
// move an entity from one cell to another
function move(startPos, endPos) {
const startCell = cells[startPos.row][startPos.col];
const endCell = cells[endPos.row][endPos.col];
const isPlayer = startCell === types.player || startCell === types.playerOnGoal;
// first remove then entity from its current cell
switch(startCell) {
// if the start cell is the player or a block (no goal)
// then leave empty
case types.player:
case types.block:
cells[startPos.row][startPos.col] = types.empty;
break;
// if the start cell has a goal then leave a goal
case types.playerOnGoal:
case types.blockOnGoal:
cells[startPos.row][startPos.col] = types.goal;
break;
}
// then move then entity into the new cell
switch(endCell) {
// if the end cell is empty, add the block or player
case types.empty:
cells[endPos.row][endPos.col] = isPlayer ? types.player : types.block;
break;
// if the cell has a goal then make sure to preserve the goal
case types.goal:
cells[endPos.row][endPos.col] = isPlayer ? types.playerOnGoal : types.blockOnGoal;
break;
}
}
// show the win screen
function showWin() {
cancelAnimationFrame(rAF);
context.fillStyle = 'black';
context.globalAlpha = 0.75;
context.fillRect(0, canvas.height / 2 - 30, canvas.width, 60);
context.globalAlpha = 1;
context.fillStyle = 'white';
context.font = '36px monospace';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('YOU WIN!', canvas.width / 2, canvas.height / 2);
}
// game loop
function loop() {
rAF = requestAnimationFrame(loop);
context.clearRect(0,0,canvas.width,canvas.height);
// check to see if the player can move in the desired direction
const row = playerPos.row + playerDir.row;
const col = playerPos.col + playerDir.col;
const cell = cells[row][col];
switch(cell) {
// allow the player to move into empty or goal cells
case types.empty:
case types.goal:
move(playerPos, { row, col });
playerPos.row = row;
playerPos.col = col;
break;
// don't allow the player to move into a wall cell
case types.wall:
break;
// only allow the player to move into a block cell if the cell
// after the block is empty or a goal
case types.block:
case types.blockOnGoal:
const nextRow = row + playerDir.row;
const nextCol = col + playerDir.col;
const nextCell = cells[nextRow][nextCol];
if (nextCell === types.empty || nextCell === types.goal) {
// move the block first, then the player
move({ row, col }, { row: nextRow, col: nextCol });
move(playerPos, { row, col });
playerPos.row = row;
playerPos.col = col;
}
break;
}
// reset player dir after checking move
playerDir = { row: 0, col: 0 };
// check to see if all blocks are on goals
let allBlocksOnGoals = true;
// draw the board. because multiple things can be drawn on the same
// cell we shouldn't use a switch as that would only allow us to draw
// a single thing per cell
context.strokeStyle = 'black';
context.lineWidth = 2;
for (let row = 0; row < cells.length; row++) {
for (let col = 0; col < cells[row].length; col++) {
const cell = cells[row][col];
if (cell === types.wall) {
context.drawImage(wallCanvas, col * grid, row * grid);
}
if (cell === types.block || cell === types.blockOnGoal) {
if (cell === types.block) {
context.fillStyle = '#ffbb5b';
// block is not on goal
allBlocksOnGoals = false;
}
else {
context.fillStyle = '#ba6a15';
}
context.fillRect(col * grid, row * grid, grid, grid);
context.strokeRect(col * grid, row * grid, grid, grid);
context.strokeRect((col + 0.1) * grid, (row + 0.1) * grid, grid - (0.2 * grid), grid - (0.2 * grid));
// X
context.beginPath();
context.moveTo((col + 0.1) * grid, (row + 0.1) * grid);
context.lineTo((col + 0.9) * grid, (row + 0.9) * grid);
context.moveTo((col + 0.9) * grid, (row + 0.1) * grid);
context.lineTo((col + 0.1) * grid, (row + 0.9) * grid);
context.stroke();
}
if (cell === types.goal || cell === types.playerOnGoal) {
context.fillStyle = '#914430';
context.beginPath();
context.arc((col + 0.5) * grid, (row + 0.5) * grid, 10, 0, Math.PI * 2);
context.fill();
}
if (cell === types.player || cell === types.playerOnGoal) {
context.fillStyle = 'black';
context.beginPath();
// head
context.arc((col + 0.5) * grid, (row + 0.3) * grid, 8, 0, Math.PI * 2);
context.fill();
// body
context.fillRect((col + 0.48) * grid, (row + 0.3) * grid, 2, grid/ 2.5 );
// arms
context.fillRect((col + 0.3) * grid, (row + 0.5) * grid, grid / 2.5, 2);
// legs
context.moveTo((col + 0.5) * grid, (row + 0.7) * grid);
context.lineTo((col + 0.65) * grid, (row + 0.9) * grid);
context.moveTo((col + 0.5) * grid, (row + 0.7) * grid);
context.lineTo((col + 0.35) * grid, (row + 0.9) * grid);
context.stroke();
}
}
}
if (allBlocksOnGoals) {
showWin();
}
}
// listen to keyboard events to move the player
document.addEventListener('keydown', function(e) {
playerDir = { row: 0, col: 0};
// left arrow key
if (e.which === 37) {
playerDir.col = -1;
}
// up arrow key
else if (e.which === 38) {
playerDir.row = -1;
}
// right arrow key
else if (e.which === 39) {
playerDir.col = 1;
}
// down arrow key
else if (e.which === 40) {
playerDir.row = 1;
}
});
// start the game
requestAnimationFrame(loop);
</script>
</body>
</html>
@FifaHawk
Copy link

FifaHawk commented Mar 1, 2021

hey do you have discord? i wanna work together on scripting, your a pro!

(suggestion: can you make a bubble game, like popping balls when 3 or more are together?)

@straker
Copy link
Author

straker commented Mar 1, 2021

@FifaHawk Sorry, don't have a discord, but thanks for the compliment.

I'll be sure to add Bubble Shooter to the next poll.

@FifaHawk
Copy link

FifaHawk commented Mar 3, 2021

@FifaHawk Sorry, don't have a discord, but thanks for the compliment.

I'll be sure to add Bubble Shooter to the next poll.

thx, but when you do get discord add Fifa Hawk#0001

@notiamb1
Copy link

Hey, was just asking, how do you add new levels? Thanks.

@straker
Copy link
Author

straker commented Sep 25, 2021

@notiamb1 You could either fully implement the Sok format with multiple levels, or create an array of levels and run the parsing algorithm on each level in turn.

@Epic4773
Copy link

Is the title being "Sokban" instead of "Sokoban" on purpose?

@straker
Copy link
Author

straker commented Feb 28, 2023

@Epic4773 Nope, that was a mistake haha. Thanks for letting me know.

@Manish7789
Copy link

How to go back to the previous step

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment