JavaScript Game Tutorial
How to Make 2048 in JavaScript: Grid, Tile Merging and Swipe Controls
2048 looks like it needs four different movement systems. It does not. The whole game rests on one function that slides and merges a single row, and everything else is reusing it from different directions.
Production context
This guide studies 2048, a published Supagames game. Repository source: games/lvl01/11-2048.html.
Play the Supagames 2048 for a minute and watch what happens to a row like 2, 2, 2, 2 when you press Left. Understanding that one move is the key to the code below, which follows the real game file line by line.
The game renders with plain DOM elements instead of a canvas, because tiles are text in boxes and CSS can style each value. You will need a container element for the grid and a score element.
1. Model the Board as a 4x4 Array of Numbers
The board is a two-dimensional array where 0 means an empty cell and any other number is a tile value. Rendering, moving and scoring all read from this array, so the DOM never becomes the source of truth.
A new game starts with two random tiles. Each new tile is a 2 nine times out of ten and a 4 otherwise, which matches the original game and keeps early moves predictable.
let grid = Array.from({ length: 4 }, () => Array(4).fill(0));
let score = 0;
function addRandom() {
const empty = [];
for (let r = 0; r < 4; r++)
for (let c = 0; c < 4; c++)
if (grid[r][c] === 0) empty.push({ r, c });
if (empty.length === 0) return;
const { r, c } = empty[Math.floor(Math.random() * empty.length)];
grid[r][c] = Math.random() > 0.9 ? 4 : 2;
}
function init() {
grid = Array.from({ length: 4 }, () => Array(4).fill(0));
score = 0;
addRandom();
addRandom();
render();
}
2. Write One Function That Slides and Merges a Row
This is the heart of 2048. Take one row, remove the zeros, then walk left to right and merge equal neighbours. After a merge, splice removes the absorbed tile so the next comparison starts after the merged pair. That is what stops a tile from merging twice in one move.
Trace it with 2, 2, 2, 2: the first pair becomes 4, the second pair becomes 4, and the result is 4, 4, 0, 0, not 8. Finally pad the row with zeros back to length four. The score increases by the value of every new merged tile.
function slide(row) {
const arr = row.filter((v) => v !== 0);
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] === arr[i + 1]) {
arr[i] *= 2;
score += arr[i];
arr.splice(i + 1, 1); // the absorbed tile disappears
}
}
while (arr.length < 4) arr.push(0);
return arr;
}
slide([2, 2, 2, 2]); // [4, 4, 0, 0]
slide([4, 0, 4, 8]); // [8, 8, 0, 0]
3. Reuse the Same Slide for All Four Directions
Moving left is slide on every row. Moving right is the same thing on a reversed row, reversed back afterwards. Up and down work on columns: read a column into a temporary array, slide it, and write it back. For down, read the column from the bottom.
Because all four directions call the same function, a merge bug can only exist in one place. When you test slide properly, every direction is correct.
function moveLeft() { grid = grid.map((row) => slide(row)); }
function moveRight() { grid = grid.map((row) => slide(row.reverse()).reverse()); }
function moveUp() {
for (let c = 0; c < 4; c++) {
const col = slide([grid[0][c], grid[1][c], grid[2][c], grid[3][c]]);
for (let r = 0; r < 4; r++) grid[r][c] = col[r];
}
}
function moveDown() {
for (let c = 0; c < 4; c++) {
const col = slide([grid[3][c], grid[2][c], grid[1][c], grid[0][c]]);
for (let r = 0; r < 4; r++) grid[3 - r][c] = col[r];
}
}
4. Only Add a Tile When the Move Changed the Board
If the player presses Left when every tile is already pushed left, nothing should happen. Spawning a tile anyway would punish a harmless key press. Copy the grid before the move, compare afterwards, and only continue when something changed.
Comparing with JSON.stringify is short and fast enough for 16 numbers. For larger boards you would compare cell by cell, but here readability wins.
function handleMove(dir) {
const before = JSON.stringify(grid);
if (dir === "left") moveLeft();
else if (dir === "right") moveRight();
else if (dir === "up") moveUp();
else moveDown();
if (JSON.stringify(grid) === before) return; // nothing moved
addRandom();
scoreEl.textContent = score;
render();
checkEnd();
}
5. Detect Game Over and the 2048 Tile
The game is over when there is no empty cell and no two equal tiles touch horizontally or vertically. Checking right and down neighbours for every cell covers all pairs without counting any pair twice.
Reaching 2048 should be celebrated once, not after every move that follows. A simple won flag remembers that the message was shown, so the player can keep going for 4096 in peace.
let won = false;
function canMove() {
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
if (grid[r][c] === 0) return true;
if (c < 3 && grid[r][c] === grid[r][c + 1]) return true;
if (r < 3 && grid[r][c] === grid[r + 1][c]) return true;
}
}
return false;
}
function checkEnd() {
if (!won && grid.some((row) => row.includes(2048))) {
won = true;
statusEl.textContent = "You made 2048! Keep going?";
}
if (!canMove()) statusEl.textContent = "Game over. Score: " + score;
}
6. Render Tiles From the Array
Rendering clears the grid container and creates one element per cell. The tile value goes into a data attribute, which lets CSS give each value its own colour with selectors like [data-val='2048'] without any colour logic in JavaScript.
Empty cells are rendered too, with no text, so the CSS grid keeps a stable 4 by 4 layout. Add a short transform transition in CSS if you want new tiles to pop in.
const gridEl = document.getElementById("grid2048");
function render() {
gridEl.innerHTML = "";
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
const tile = document.createElement("div");
tile.className = "tile";
tile.dataset.val = grid[r][c];
tile.textContent = grid[r][c] || "";
gridEl.appendChild(tile);
}
}
}
7. Add Keyboard and Swipe Input
Arrow keys map directly to handleMove. Call preventDefault for them, otherwise the page scrolls every time the player moves a tile. WASD support is one extra line and helps players on laptops.
On phones, compare the start and end of a touch and move along the longer axis. Ignore very short movements so a tap does not count as a swipe, and set touch-action to none on the grid so swipes do not scroll the page.
const keyMap = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down",
a: "left", d: "right", w: "up", s: "down" };
document.addEventListener("keydown", (e) => {
const dir = keyMap[e.key.length === 1 ? e.key.toLowerCase() : e.key];
if (!dir) return;
e.preventDefault();
handleMove(dir);
});
gridEl.style.touchAction = "none";
let start = null;
gridEl.addEventListener("touchstart", (e) => {
start = { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
}, { passive: true });
gridEl.addEventListener("touchend", (e) => {
if (!start) return;
const x = e.changedTouches[0].clientX - start.x;
const y = e.changedTouches[0].clientY - start.y;
start = null;
if (Math.max(Math.abs(x), Math.abs(y)) < 24) return;
handleMove(Math.abs(x) > Math.abs(y) ? (x > 0 ? "right" : "left") : (y > 0 ? "down" : "up"));
});
8. Build checklist
- Keep the board in a 4x4 number array and render from it.
- Put all merge logic in one slide function and test it with tricky rows.
- Build the other directions by reversing rows or reading columns.
- Spawn a new tile only when a move actually changed the board.
- Show the 2048 message once and detect game over with neighbour checks.
- Prevent arrow-key scrolling and add swipe controls for phones.
9. FAQ
Why does a row of four 2s become 4, 4 and not 8?
Each tile can merge only once per move. After two tiles merge, the new tile is skipped for the rest of that move, so 2, 2, 2, 2 becomes two 4s. They can merge into 8 on the next move.
How is the score calculated in 2048?
Every merge adds the value of the new tile to the score. Merging two 8s adds 16. That is why the score grows much faster than the largest tile.
Should I build 2048 with canvas or HTML elements?
HTML elements are the easier choice. Tiles are text in coloured boxes, CSS handles colours and animations, and the layout scales with the screen. Canvas only pays off if you want custom particle effects.
Who created the original 2048?
Gabriele Cirulli released 2048 as an open-source web game in March 2014. It was inspired by the games 1024 and Threes, and its simple rules made it one of the most cloned browser puzzles.