Use of the Basics: Case Study - Making a 2048 Game with Just JS Canvas
Introduction This article is part of the series "Mastering HTML Canvas: From Basics to Beyond", designed to take you from the fundamentals of HTML Canvas to advanced techniques and real-world applications. This series aims to guide you through interactive examples, case studies, and practical projects so that you can master rendering with HTML and JavaScript. In this second article, we’ll focus on how you can apply some of the basics of JavaScript Canvas we have learnt in Getting Started with HTML Canvas: The Basics You Need to Know. Reading the first article is advised but not necessary. Project Goals A playable 2048 game Pure JavaScript + HTML Canvas (no frameworks or libraries) Simple and clean visual rendering using only Canvas The final game: Code: GitHub The Game The game is played on a 4×4 grid. You use arrow keys (← ↑ → ↓) to slide all tiles in the chosen direction. When two tiles with the same number collide while moving, they merge into one. Each move adds a new tile to the board, usually a 2 (sometimes a 4). The goal is to reach the tile with the number 2048. The game ends when: You create the 2048 tile (you win), or There are no possible moves left (you lose) 1. Canvas setup First, we need to do the initial setup, which includes rendering the canvas to an HTML. We also set some basic CSS style for our canvas so it is visible on the page. #gameCanvas { background-color: #542169; border-radius: 16px; max-width: 75vh; } From this point forward, most of the work is done using JavaScript. We first need access to this element. const canvas = document.getElementById("gameCanvas"); Now, this is all fine and well, but as we have learnt, we can't draw on our canvas without its CanvasRenderingContext2D. "Think of as a blank sheet of paper, while CanvasRenderingContext2D is like the tools (pens, brushes, erasers) that allow you to create visuals." const context = canvas.getContext("2d"); Let's finish up the setup phase by sizing our canvas. const CANVAS_SIZE = 800; canvas.height = CANVAS_SIZE; canvas.width = CANVAS_SIZE; 2. Drawing game start state Now that we have the canvas prepared and our drawing tools ready, let's start with initilaizing the base grid. We are going to need a 4x4 grid. We will store our grid in the variable grid. The plan is to have a matrix that stores numbers so that each array element will represent a cell in our game. We use the value 0 for the empty cells for the ease of mathematic operations and clean types. let grid = []; Disclaimer: We could have just went with an Array, but for the combination logic I came up with for this tutorial and because of the combination behaviour observed on the original game's site, I will be using Array Now, let's fill the array with the starting state. First, fill the matrix with 0. let grid = []; grid = Array.from({ length: 4 }, () => new Array(4).fill({ value: 0, isCombined: false }) ); The game usually starts with 2 random cells filled with the value 2 or 4. We now need to select and set those. let grid = []; /** * Initializes the game by creating a grid with two random 2 or 4 tiles */ function initGame() { grid = Array.from({ length: 4 }, () => new Array(4).fill({ value: 0, isCombined: false }) ); const { x1S, x2S, y1S, y2S } = getStartCoordinates(); grid[x1S][y1S] = getRandomInt(0, 1) ? { value: 2, isCombined: false } : { value: 4, isCombined: false }; grid[x2S][y2S] = getRandomInt(0, 1) ? { value: 2, isCombined: false } : { value: 4, isCombined: false }; } /** * Returns a random integer between min and max (inclusive) * @param {number} min The minimum value * @param {number} max The maximum value * @returns {number} A random integer between min and max (inclusive) */ function getRandomInt(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; } /** * Selects two random coordinates on the grid; ensures that the two coordinates are unique * @returns {object} An object with the coordinates of the two tiles */ function getStartCoordinates() { const coordinates = [ { x: getRandomInt(0, 3), y: getRandomInt(0, 3) }, { x: getRandomInt(0, 3), y: getRandomInt(0, 3) }, ]; // Ensure the two coordinates are unique while ( coordinates[0].x === coordinates[1].x && coordinates[0].y === coordinates[1].y ) { coordinates[1] = { x: getRandomInt(0, 3), y: getRandomInt(0, 3) }; } return { x1S: coordinates[0].x, y1S: coordinates[0].y, x2S: coordinates[1].x, y2S: coordinates[1].y, }; } initGame(); Now we have a 4x4 grid, let's draw it on the canvas. First, we define sizes and colors for aesthetics ✨. const cellColor = { 2: "#BD8CF5", 4: "#E9C46A", 8: "#F4A261", 16: "#DAA1D3", 32: "#F7B7A3", 64: "#C08

Introduction
This article is part of the series "Mastering HTML Canvas: From Basics to Beyond", designed to take you from the fundamentals of HTML Canvas to advanced techniques and real-world applications. This series aims to guide you through interactive examples, case studies, and practical projects so that you can master rendering with HTML and JavaScript.
In this second article, we’ll focus on how you can apply some of the basics of JavaScript Canvas we have learnt in Getting Started with HTML Canvas: The Basics You Need to Know. Reading the first article is advised but not necessary.
Project Goals
- A playable 2048 game
- Pure JavaScript + HTML Canvas (no frameworks or libraries)
- Simple and clean visual rendering using only Canvas
The final game:
Code: GitHub
The Game
- The game is played on a 4×4 grid.
- You use arrow keys (← ↑ → ↓) to slide all tiles in the chosen direction.
- When two tiles with the same number collide while moving, they merge into one.
- Each move adds a new tile to the board, usually a 2 (sometimes a 4).
- The goal is to reach the tile with the number 2048.
- The game ends when:
- You create the 2048 tile (you win), or
- There are no possible moves left (you lose)
1. Canvas setup
First, we need to do the initial setup, which includes rendering the canvas to an HTML.
We also set some basic CSS style for our canvas so it is visible on the page.
#gameCanvas {
background-color: #542169;
border-radius: 16px;
max-width: 75vh;
}
From this point forward, most of the work is done using JavaScript. We first need access to this element.
const canvas = document.getElementById("gameCanvas");
Now, this is all fine and well, but as we have learnt, we can't draw on our canvas without its CanvasRenderingContext2D.
"Think of
as a blank sheet of paper, while CanvasRenderingContext2D is like the tools (pens, brushes, erasers) that allow you to create visuals."
const context = canvas.getContext("2d");
Let's finish up the setup phase by sizing our canvas.
const CANVAS_SIZE = 800;
canvas.height = CANVAS_SIZE;
canvas.width = CANVAS_SIZE;
2. Drawing game start state
Now that we have the canvas prepared and our drawing tools ready, let's start with initilaizing the base grid. We are going to need a 4x4 grid.
We will store our grid in the variable grid
. The plan is to have a matrix that stores numbers so that each array element will represent a cell in our game. We use the value 0
for the empty cells for the ease of mathematic operations and clean types.
let grid = [];
Disclaimer: We could have just went with an
Array
, but for the combination logic I came up with for this tutorial and because of the combination behaviour observed on the original game's site, I will be using> Array
>
Now, let's fill the array with the starting state. First, fill the matrix with 0
.
let grid = [];
grid = Array.from({ length: 4 }, () =>
new Array(4).fill({ value: 0, isCombined: false })
);
The game usually starts with 2 random cells filled with the value 2 or 4. We now need to select and set those.
let grid = [];
/**
* Initializes the game by creating a grid with two random 2 or 4 tiles
*/
function initGame() {
grid = Array.from({ length: 4 }, () =>
new Array(4).fill({ value: 0, isCombined: false })
);
const { x1S, x2S, y1S, y2S } = getStartCoordinates();
grid[x1S][y1S] = getRandomInt(0, 1)
? { value: 2, isCombined: false }
: { value: 4, isCombined: false };
grid[x2S][y2S] = getRandomInt(0, 1)
? { value: 2, isCombined: false }
: { value: 4, isCombined: false };
}
/**
* Returns a random integer between min and max (inclusive)
* @param {number} min The minimum value
* @param {number} max The maximum value
* @returns {number} A random integer between min and max (inclusive)
*/
function getRandomInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* Selects two random coordinates on the grid; ensures that the two coordinates are unique
* @returns {object} An object with the coordinates of the two tiles
*/
function getStartCoordinates() {
const coordinates = [
{ x: getRandomInt(0, 3), y: getRandomInt(0, 3) },
{ x: getRandomInt(0, 3), y: getRandomInt(0, 3) },
];
// Ensure the two coordinates are unique
while (
coordinates[0].x === coordinates[1].x &&
coordinates[0].y === coordinates[1].y
) {
coordinates[1] = { x: getRandomInt(0, 3), y: getRandomInt(0, 3) };
}
return {
x1S: coordinates[0].x,
y1S: coordinates[0].y,
x2S: coordinates[1].x,
y2S: coordinates[1].y,
};
}
initGame();
Now we have a 4x4 grid, let's draw it on the canvas.
First, we define sizes and colors for aesthetics ✨.
const cellColor = {
2: "#BD8CF5",
4: "#E9C46A",
8: "#F4A261",
16: "#DAA1D3",
32: "#F7B7A3",
64: "#C08497",
128: "#D291BC",
256: "#E5989B",
512: "#9D8189",
1024: "#BD93D8",
2048: "#A078C4",
};
const emptyColor = "#E7D7FE";
const textColor = "#FFF";
const gridPadding = 12;
const cellSize = (CANVAS_SIZE - gridPadding * 5) / 4;
Then, we define the function that clears the canvas and then draws the game state based on the grid variable state.
What we are going to need:
-
context.fillStyle
: to change the color based on the number -
context.beginPath()
: to start a new path for each rectangle -
context.roundRect()
: to define a rectangle shape with rounded corners -
context.fill()
: to render paths -
context.font
: to set font size -
context.measureText()
: to get text width for center positioning -
context.fillText()
: to render text on for the numbers
/**
* Draws the colored tiles and the numbers on the canvas
*/
function drawTiles() {
let i = 0;
let j = 0;
if (context) {
for (
let y = gridPadding;
y <= CANVAS_SIZE - cellSize;
y += cellSize + gridPadding
) {
j = 0; // Reset j for each row
for (
let x = gridPadding;
x <= CANVAS_SIZE - cellSize;
x += cellSize + gridPadding
) {
let element = grid[i][j].value;
// Set the fill color based on the element value
if (element === 0) {
context.fillStyle = emptyColor;
} else {
context.fillStyle = cellColor[element];
}
// Draw the tile
context.beginPath(); // Start a new path for each tile
context.roundRect(x, y, cellSize, cellSize, 12);
context.fill(); // Fill the tile with the current fillStyle
// Draw the number if the tile is not empty
if (element !== 0) {
context.fillStyle = textColor;
context.beginPath();
context.font = `${FONT_SIZE}px sans-serif`;
const textWidth = context.measureText(element).width;
context.fillText(
element,
j * (cellSize + gridPadding) +
(cellSize / 2 + gridPadding - textWidth / 2),
i * (cellSize + gridPadding) +
cellSize / 2 +
FONT_SIZE / 2 +
FONT_SIZE / 10
);
context.stroke();
}
j++;
}
i++;
}
}
}
/**
* Draws the current state of the game on the canvas
*/
function drawState() {
context.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
drawTiles();
}
drawState();
3. Handling movement
Now that we have the starting state of we can focus on user inputs and reactions to those inputs.
First things first, let's listen for keyboard events.
/**
* Main event handler for keyboard events and game logic
* @param {KeyboardEvent} event
* @returns
*/
function keyPressed(event) {
const directions = {
ArrowUp: () => moveVertical(-1),
ArrowDown: () => moveVertical(1),
ArrowLeft: () => moveHorizontal(-1),
ArrowRight: () => moveHorizontal(1),
};
const moveAction = directions[event.code];
if (moveAction) {
moveAction();
drawState();
}
}
document.onkeydown = keyPressed;
Then, we define what happens when we move in a direction.
When the player presses an arrow key (left, right, up, or down), the game performs several steps in order:
- Slide Tiles
- All tiles on the board are moved as far as possible in the chosen direction, stopping either at the edge of the board or just before another tile.
- Merge Matching Tiles
- After sliding, if two adjacent tiles have the same value, they merge into one tile with double the value.
- For example: 2 + 2 → 4, 8 + 8 → 16
- Merging can only happen once per tile per move.
- After sliding, if two adjacent tiles have the same value, they merge into one tile with double the value.
- Slide Again
- After a merge, tiles may slide again to fill any new empty spaces in the direction of movement.
To avoid having to do all that iteration, I came up with a recursive approach to the problem where each tile is moved until it can't be moved to an empty space or can't be merged with the neighbouring tile in the selected direction. When moving rows, the recursion starts from the start or the end of the row, depending on the direction of the movement. During vertical moves, we apply the same recursion to the transposed matrix.
Disclaimer: The movement logic in this implementation could potentially be refactored for better abstraction — for example, by unifying the horizontal and vertical move operations into a single function that takes direction as a parameter and by generalizing the recursion logic used for left and right movement. However, since the primary goal of this project was to explore Canvas rendering, I chose to prioritize that focus over perfecting the game logic. I'm always open to feedback and improvements—feel free to share your suggestions in the comments!
let score = 0;
/**
* Moves the tiles vertically
* @param {number} dir The direction to move (1 for up, -1 for down)
*/
function moveVertical(dir) {
const transposedGrid = transposeMatrix(grid);
for (let i = 0; i < 4; i++) {
const row = transposedGrid[i];
const start = dir === 1 ? 3 : 0;
const end = dir === 1 ? -1 : 4;
const step = dir === 1 ? -1 : 1;
for (let j = start; j !== end; j += step) {
if (row[j].value !== 0) {
dir === 1 ? rightRecursion(j, row) : leftRecursion(j, row);
}
}
}
grid = transposeMatrix(transposedGrid);
}
/**
* Moves the tiles horizontally
* @param {number} dir The direction to move (1 for left, -1 for right)
*/
function moveHorizontal(dir) {
for (let i = 0; i < 4; i++) {
const row = grid[i];
const start = dir === 1 ? 3 : 0;
const end = dir === 1 ? -1 : 4;
const step = dir === 1 ? -1 : 1;
for (let j = start; j !== end; j += step) {
if (row[j].value !== 0) {
dir === 1 ? rightRecursion(j, row) : leftRecursion(j, row);
}
}
}
}
/**
* A recursive function to move an element to the left based on the rules of the game
* @param {number} indexToMove The index of the element to move
* @param {array} row The row to move the element in
* @returns
*/
function leftRecursion(indexToMove, row) {
if (indexToMove - 1 < 0) {
return row;
}
let element = row[indexToMove].value;
let elementIsCombined = row[indexToMove].isCombined;
let elementBefore = row[indexToMove - 1].value;
let elementBeforeIsCombined = row[indexToMove - 1].isCombined;
if (
element === elementBefore &&
!elementIsCombined &&
!elementBeforeIsCombined
) {
row[indexToMove - 1] = { value: element + elementBefore, isCombined: true };
row[indexToMove] = { value: 0, isCombined: true };
score += element + elementBefore;
return row;
} else if (elementBefore === 0) {
row[indexToMove - 1] = {
value: element + elementBefore,
isCombined: false,
};
row[indexToMove] = { value: 0, isCombined: false };
}
return leftRecursion(indexToMove - 1, row);
}
/**
* A recursive function to move an element to the right based on the rules of the game
* @param {number} indexToMove The index of the element to move
* @param {array} row The row to move the element in
* @returns
*/
function rightRecursion(indexToMove, row) {
if (indexToMove + 1 >= row.length) {
return row;
}
let element = row[indexToMove].value;
let elementIsCombined = row[indexToMove].isCombined;
let elementAfter = row[indexToMove + 1].value;
let elementAfterIsCombined = row[indexToMove + 1].isCombined;
if (
element === elementAfter &&
!elementIsCombined &&
!elementAfterIsCombined
) {
row[indexToMove + 1] = { value: element + elementAfter, isCombined: true };
row[indexToMove] = { value: 0, isCombined: true };
score += element + elementAfter;
return row;
} else if (elementAfter === 0) {
row[indexToMove + 1] = { value: element + elementAfter, isCombined: false };
row[indexToMove] = { value: 0, isCombined: false };
}
return rightRecursion(indexToMove + 1, row);
}
/**
* Transposes a matrix
* @param {array} matrix
* @returns {array} The transposed matrix
*/
function transposeMatrix(matrix) {
return matrix[0].map((_, i) => matrix.map((row) => row[i]));
}
4. After merge & move
Now we need to check for the game state and if the player has won or lost and display the right message if either scenario happens.
If not, then add a new tile and redraw the game state.
Let's expand our keyPressed handler:
let movementBlocked = false;
/**
* Waits for the specified number of milliseconds
* @param {number} milliseconds
* @returns
*/
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
/**
* Main event handler for keyboard events and game logic
* @param {KeyboardEvent} event
* @returns
*/
function keyPressed(event) {
if (movementBlocked) return;
const directions = {
ArrowUp: () => moveVertical(-1),
ArrowDown: () => moveVertical(1),
ArrowLeft: () => moveHorizontal(-1),
ArrowRight: () => moveHorizontal(1),
};
const moveAction = directions[event.code];
if (moveAction) {
const gridBefore = copyGrid(grid);
moveAction();
drawState();
if (isGridChanged(gridBefore, grid)) {
movementBlocked = true;
delay(250).then(() => {
addRandomElement();
checkGameState();
});
}
}
}
As you might have noticed, we are checking if the valid keypress did any change in the grid and if the user is not just spamming the key.
/**
* Checks if the grid has changed
* @param {array} gridBefore The grid before the change
* @param {array} gridAfter The grid after the change
* @returns {boolean} True if the grid has changed, false otherwise
*/
function isGridChanged(gridBefore, gridAfter) {
return gridBefore.some((row, i) =>
row.some((cell, j) => cell.value !== gridAfter[i][j].value)
);
}
/**
* Copies the grid to a new array
* @param {array} grid The grid to copy
* @returns {array} A new array with the copied grid
*/
function copyGrid(grid) {
return grid.map((row) => row.map((cell) => ({ ...cell })));
}
Then we wait 250ms before we add the new element so the user has time to process what happened.
/**
* Adds a random element to the grid
*/
function addRandomElement() {
const randomElement = {
value: getRandomInt(0, 1) ? 2 : 4,
isCombined: false,
};
const emptyFields = getEmptyFieldIndexes();
if (emptyFields.length) {
const { x, y } = emptyFields[getRandomInt(0, emptyFields.length - 1)];
grid[x][y] = randomElement;
}
}
Then, we check for win or lose conditions.
/**
* Checks if the game is over and draws the game over screen if it is
*/
function checkGameState() {
// Drawing game state under the transparent game over screen
drawState();
const gameState = getGameState();
if (gameState.gameOver) {
drawGameOver(gameState.gameOverText, score);
} else {
movementBlocked = false;
}
resetCombined();
}
/**
* Checks if the game is over and returns the appropriate game state
* @returns {object} An object with the game state
*/
function getGameState() {
if (grid.some((row) => row.some((el) => el.value === 2048))) {
return { gameOver: true, gameOverText: "You Win" };
}
const emptyFieldIndexes = getEmptyFieldIndexes();
if (emptyFieldIndexes.length === 0 && !isValidMoveAvailable()) {
return { gameOver: true, gameOverText: "Game Over - You Lose" };
}
return { gameOver: false, gameOverText: "" };
}
/**
* Returns an array of empty field indexes
* @returns {array} An array of empty field indexes
*/
function getEmptyFieldIndexes() {
return grid
.flatMap((row, i) =>
row.map((cell, j) => (cell.value === 0 ? { x: i, y: j } : null))
)
.filter(Boolean);
}
/**
* Checks if there is a valid move available
* @returns {boolean} True if there is a valid move available, false otherwise
*/
function isValidMoveAvailable() {
return grid.some((row, i) =>
row.some(
(cell, j) =>
(j < 3 && cell.value === row[j + 1].value) || // Horizontal check
(i < 3 && cell.value === grid[i + 1][j].value) // Vertical check
)
);
}
If the game can go on, we enable the movement again and reset the combination statuses.
/**
* Resets the combined property of all cells
*/
function resetCombined() {
grid.forEach((row) => row.forEach((cell) => (cell.isCombined = false)));
}
However, if the game has been won or lost, we keep the movements blocked, and we draw the Game Over screen.
5. Game Over Display
Drawing the "Game Over" screen, we are going to need the same functions and attributes as for the main game grid and numbers.
const backgroundColor = "#542169";
const blurBackgroundColor = "#54216950";
/**
* Draws the game over screen
* @param {string} text The text to display
* @param {number} score The score to display
*/
function drawGameOver(text, score) {
const textWidth = context.measureText(text).width;
const scoreText = `+ ${score}`;
const scoreWidth = context.measureText(scoreText).width;
context.beginPath();
context.fillStyle = blurBackgroundColor;
context.roundRect(0, 0, CANVAS_SIZE, CANVAS_SIZE, 12);
context.fill();
context.beginPath();
context.fillStyle = backgroundColor;
context.roundRect(
CANVAS_SIZE / 2 - textWidth / 2 - gridPadding,
CANVAS_SIZE / 2 - FONT_SIZE / 2 - gridPadding / 2,
textWidth + gridPadding * 2,
FONT_SIZE + gridPadding * 2,
12
);
context.fill();
context.fillStyle = textColor;
context.font = `${FONT_SIZE}px sans-serif`;
context.fillText(
text,
CANVAS_SIZE / 2 - textWidth / 2,
CANVAS_SIZE / 2 + FONT_SIZE / 2
);
//Draw score
context.beginPath();
context.fillStyle = backgroundColor;
context.roundRect(
CANVAS_SIZE / 2 - scoreWidth / 2 - gridPadding,
CANVAS_SIZE / 2 -
FONT_SIZE / 2 -
gridPadding / 2 +
FONT_SIZE +
gridPadding * 4,
scoreWidth + gridPadding * 2,
FONT_SIZE + gridPadding * 2,
12
);
context.fill();
context.fillStyle = textColor;
context.font = `${FONT_SIZE}px sans-serif`;
context.fillText(
scoreText,
CANVAS_SIZE / 2 - scoreWidth / 2,
CANVAS_SIZE / 2 + FONT_SIZE / 2 + FONT_SIZE + gridPadding * 4
);
}
6. Extra
I have included two extra features in my project:
- Restart the game
- Score and best score counters
Restart game:
/**
* Restarts the game by resetting the score and the grid
*/
function restart() {
score = 0;
movementBlocked = false;
document.getElementById("score").innerHTML = score;
initGame();
drawState();
}
restart();
//Replace these root level calls with just restart()
//initGame();
//drawState();
Best score:
let bestScore = localStorage.getItem("bestScore") ?? 0;
/**
* Updates score and best score
*/
function updateScore() {
document.getElementById("score").innerHTML = score;
if (score > bestScore) {
bestScore = score;
localStorage.setItem("bestScore", score);
document.getElementById("bestScore").innerHTML = score;
}
}
function keyPressed(event) {
//...
if (moveAction) {
const gridBefore = copyGrid(grid);
moveAction();
updateScore(); //Add it here
drawState();
//...
}
document.getElementById("bestScore").innerHTML = bestScore;
What I Learned
Building 2048 with plain JavaScript and Canvas was a great exercise in applying fundamental rendering concepts in a real project. Even though I was only using a very few bits from the fundamental pieces of the Canvas API, I was amazed how far this alone would take me.
I am the kind of developer who tends to grab off-the-shelf solutions most of the time and trust others to know it better, but this journey made me realize how simple things can be done without having to reach for a library or a framework. I have a greater appreciation for my own work and efforts now.
This project was intentionally kept simple to focus on Canvas rendering, so there's still plenty of room for refinement in the game logic. That said, it was fun and rewarding to come up with the recursion solution for the puzzle nonetheless.
Conclusion
I hope this case study showed you how much you can achieve with just the Canvas basics. If you followed the previous article, this is a great way to reinforce what you’ve learned by seeing it applied in a full project.
If you’d like to check out the full code, it’s available here: