Test store. Prices are the planned list prices stored as test data, license terms are drafts, and payments go to a Stripe test-mode account. No real money moves.

2048 Game in HTML5: Full JavaScript Code and Tutorial

This 2048 game is one HTML file of about 260 lines: a 4 × 4 grid of tile objects, a move function that slides tiles and merges each pair once, smooth slide and pop animations, keyboard and swipe controls, a win message at 2048 and a game over check. Play it above, copy the code into 2048.html and open it in any browser. It's MIT-licensed.

Runs in your browser in a sandbox · 261 lines · no downloads

2048 is a sliding puzzle on a 4 × 4 grid: every move pushes all tiles to one side, equal tiles merge into their sum, and a new 2 or 4 appears. The rules take ten seconds to learn, which makes the HTML5 game code for 2048 a great lesson in grid logic. This page gives you a complete version, then explains the one tricky part (merging each pair exactly once) and how to animate tiles without changing the rules.

How to run it

No server, build step or library is needed:

  1. Press Copy code above (or Download .html).
  2. Paste it into a new file called 2048.html.
  3. Double-click the file. Press an arrow key or swipe to start.

Use the arrow keys or WASD on a keyboard, or swipe on a phone or with the mouse. R starts a new game at any time. You can also paste the code into the HTML game code runner and change a setting, such as SIZE for a 5 × 5 board.

How the code works

The game is one <canvas> and one script, drawn with the Canvas 2D API. It has five parts: settings, state, the move function, drawing with animation, and input. Unlike an action game, nothing changes on its own: the board only changes when the player moves, and the animation loop just shows the result.

1. Settings and colours

The settings at the top control the board size, the goal tile and the length of each animation. The tile colours are a lookup table from value to a background and text colour, so restyling the game means editing one object.

2048.html
const SIZE = 4;                          // 4 × 4 board
const GOAL = 2048;
const SLIDE = 0.1;                       // seconds for tiles to slide
const POP = 0.14;                        // seconds for merged tiles to pulse and new tiles to grow
const HUD = 76;                          // room above the board for the title and scores

2. State: a grid of tile objects

Many 2048 tutorials store the board as a grid of plain numbers. That works for the rules, but then there's no way to know where a tile came from, so tiles can't slide smoothly. Here, each cell holds a small object (or null for empty) with its value, its current row and column, and where it was before the last move.

js
// A new 2 (or, one time in ten, a 4) on a random empty cell.
function addRandomTile() {
  const empty = [];
  for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) if (!grid[r][c]) empty.push([r, c]);
  if (!empty.length) return;
  const [r, c] = empty[Math.floor(Math.random() * empty.length)];
  grid[r][c] = { value: Math.random() < 0.9 ? 2 : 4, r, c, fromR: r, fromC: c, kind: "new" };
}

Listing the empty cells first and then picking one guarantees the new tile always lands on a free cell, with no retry loop. The game is in one of four states: ready (start screen), playing, won (a pause to celebrate reaching 2048) and over.

3. The move: slide, then merge each pair once

Every move is a direction (dr, dc): (0, -1) is left, (1, 0) is down, and so on. move() visits every tile, slides it through empty cells as far as it can go, then looks at the next cell along. If that cell holds a tile with the same value that hasn't merged yet this move, the two become one tile worth double.

js
  const order = [...Array(SIZE).keys()], back = [...order].reverse();   // [0, 1, 2, 3] and [3, 2, 1, 0]
  const rows = dr > 0 ? back : order, cols = dc > 0 ? back : order;
  let moved = false, madeGoal = false;
  for (const r of rows) for (const c of cols) {
    const t = grid[r][c];
    if (!t) continue;
    let nr = r, nc = c;
    while (inside(nr + dr, nc + dc) && !grid[nr + dr][nc + dc]) { nr += dr; nc += dc; }   // slide into empty cells
    const next = inside(nr + dr, nc + dc) ? grid[nr + dr][nc + dc] : null;
    grid[r][c] = null;
    if (next && next.value === t.value && !next.merged) {
      // Merge: both tiles slide into next's cell (as ghosts), and one doubled tile replaces them.
      ghosts.push({ value: t.value, fromR: r, fromC: c, r: next.r, c: next.c }, next);
      grid[next.r][next.c] = { value: t.value * 2, r: next.r, c: next.c, fromR: next.r, fromC: next.c, kind: "merged", merged: true };
      score += t.value * 2;
      if (t.value * 2 === GOAL && !won) madeGoal = true;
      moved = true;
    } else {
      grid[nr][nc] = t;
      t.r = nr; t.c = nc;
      if (nr !== r || nc !== c) moved = true;
    }
  }

The score goes up by the value of every new tile, which is the usual 2048 scoring rule.

4. Why the visiting order and the merged flag matter

Two small details make the rules come out right, and they're where most first attempts go wrong.

Order. When moving left, the code visits columns 0, 1, 2, 3; when moving right, 3, 2, 1, 0. The tile nearest the wall moves first, so every later tile slides up against a tile that's already in its final place. Visit them the other way round and tiles stop early, leaving gaps.

Merge once. The new tile from a merge carries merged: true, and a merged tile can't merge again in the same move. Without that flag, a row of 2 2 4 moved left would become 8 (2 + 2 = 4, then 4 + 4 = 8) instead of the correct 4 4. With it, 2 2 2 2 becomes 4 4 and 4 _ 4 8 becomes 8 8, as players expect.

If no tile moved, move() returns early: pressing towards a wall that's already packed doesn't add a new tile. Otherwise a new 2 or 4 appears, the best score updates, and the game checks for a win or a loss.

5. Winning and losing

Reaching 2048 for the first time switches to the won state, which shows a message and waits; a tap or Space lets you keep going for 4096 and beyond. The game is lost when the board is full and no two neighbours match, which canMove() checks by comparing each tile with the one to its right and the one below it:

js
function canMove() {
  for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) {
    const t = grid[r][c];
    if (!t) return true;
    if (c + 1 < SIZE && grid[r][c + 1] && grid[r][c + 1].value === t.value) return true;
    if (r + 1 < SIZE && grid[r + 1][c] && grid[r + 1][c].value === t.value) return true;
  }
  return false;
}

Checking right and down from every cell covers every neighbouring pair exactly once, so there's no need to check left and up as well.

6. Animation that never touches the rules

The grid always holds the final result of the last move. Animation is purely a drawing concern: anim counts the seconds since the last move, and draw() uses it to place each tile somewhere between fromR, fromC and r, c. Tiles that merged away are kept for one move in the ghosts list, so you can see both halves slide together before the doubled tile pops.

js
  const p = Math.min(1, anim / SLIDE), ease = 1 - (1 - p) * (1 - p);
  const q = Math.min(1, Math.max(0, (anim - SLIDE) / POP));
  const lerp = (a, b) => a + (b - a) * ease;
  if (p < 1) for (const g of ghosts) drawTile(g.value, lerp(g.fromR, g.r), lerp(g.fromC, g.c), 1);
  for (const row of grid) for (const t of row) {
    if (!t) continue;
    if (t.kind === "slide") drawTile(t.value, lerp(t.fromR, t.r), lerp(t.fromC, t.c), 1);
    else if (p >= 1 && t.kind === "merged") drawTile(t.value, t.r, t.c, 1 + 0.15 * Math.sin(Math.PI * q));
    else if (p >= 1 && t.kind === "new") drawTile(t.value, t.r, t.c, 0.3 + 0.7 * q);
  }

The slide takes 0.1 seconds with an ease-out curve, then merged tiles pulse to 115% and new tiles grow in over 0.14 seconds. Because the rules never wait for the animation, fast players aren't slowed down: a new move simply starts every tile from where the grid says it is. requestAnimationFrame drives the loop, and anim advances in real seconds, so the animation lasts the same time on every screen.

7. One input function for everything

Arrow keys, WASD, swipes, taps, Space and Enter all call input(dr, dc), where (0, 0) means "no direction". That one function decides what an input means in each state: start the game, make a move, keep going after 2048, or start again after a loss. It also ignores input for 0.6 seconds after a message appears, so a quick run of key presses can't skip the game over or win screen before you've read it.

Make it work on phones

Swipes are the natural way to play 2048 on a phone, and pointer events let the same code handle a finger, a pen or a mouse drag. The code records where the pointer went down and compares it with where it came up:

js
let swipe = null;
canvas.addEventListener("pointerdown", (e) => { swipe = { x: e.clientX, y: e.clientY }; });
canvas.addEventListener("pointerup", (e) => {
  if (!swipe) return;
  const dx = e.clientX - swipe.x, dy = e.clientY - swipe.y;
  swipe = null;
  if (Math.max(Math.abs(dx), Math.abs(dy)) < 30) input(0, 0);          // a tap
  else if (Math.abs(dx) > Math.abs(dy)) input(0, Math.sign(dx));       // a sideways swipe
  else input(Math.sign(dy), 0);                                        // an up or down swipe
});

A movement under 30 pixels counts as a tap; anything longer is a swipe in its strongest direction, so a slightly diagonal swipe still does what the player meant. touch-action: none in the CSS stops the browser from scrolling the page or pulling to refresh while you swipe. The board is the largest square that fits under the score bar, capped at 520 pixels, and the canvas buffer is scaled by the device pixel ratio for sharp numbers. On a 390 × 640 phone the board is 366 pixels wide. For more on touch controls, see our guide to HTML5 mobile game development.

Ideas to extend it

  • Add an undo button: copy the tile values and score before each move and restore them on Z.
  • Try a 3 × 3 or 5 × 5 board by changing SIZE, and adjust GOAL to match.
  • Add a "moves" counter and a timer for speed runs.
  • Play a soft tone on each merge, pitched by the tile value, with the Web Audio API.
  • Replace the numbers with a themed sequence: planets, gems or dishes, as many merge games do.
  • Add obstacles: a stone tile that never moves or merges.
  • Write a simple hint button that tries all four moves and suggests the one that keeps the largest tile in a corner.

From prototype to a game you can sell

This file is a solid base for learning grid logic, a class project or a quick jam game. A merge game that players pay for, or that a portal will accept, needs much more around the same rules:

This 2048 fileA production HTML5 game
ContentOne endless boardLevels, goals, a theme, collections, daily challenges
Art and soundColoured squares with numbers, no audioOriginal art, animation, music and effects
SavesScore and board lost on reloadProgress, unlocks and settings saved on the device
InputKeyboard, mouse and swipeTouch, mouse, keyboard and gamepad, tested on each
PlatformsDesktop and phone browsersPortal SDK hooks, ads, languages, app wrappers
QualityTested by hand and in a sandboxUnit tests and cross-browser end-to-end tests
CodeOne scriptA typed project with tuning in data files and docs
LicenceMIT, do anythingA commercial licence that covers your use

That's the gap our merge games fill. Gem Forge is the closest to this page: a 2048-style sliding merge game, built on Phaser 4.2.1, where minerals merge into crystals, gemstones and artifacts that change how the board behaves, with campaign, endless and daily modes. Pocket Planet Merge and Merge Café Story take the merge idea in other directions: dropping planets and running a café. Each ships as a TypeScript project with tuning in JSON data files and a README that covers install, build, test and deploy.

You can also browse all our HTML5 games with source code, or go back to the free HTML game code collection for more tutorials like this one.

The complete code

One file, no libraries. Save it as 2048.html and open it in any browser. Free to use under the MIT license: copy it, change it, ship it.

2048.html
<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<!-- 2048: a sliding-tile number puzzle in one HTML5 canvas file. Free to use, change and ship (MIT license). -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>2048 (HTML5 canvas sliding-tile puzzle)</title>
<style>
  html, body { margin: 0; height: 100%; background: #0d1220; overflow: hidden; touch-action: none; }
  canvas { display: block; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
// ----- Settings -----
const SIZE = 4;                          // 4 × 4 board
const GOAL = 2048;
const SLIDE = 0.1;                       // seconds for tiles to slide
const POP = 0.14;                        // seconds for merged tiles to pulse and new tiles to grow
const HUD = 76;                          // room above the board for the title and scores
// Tile colours [background, text] by value. Anything above 2048 uses BIG.
const TILE = {
  2: ["#36466f", "#e8edff"], 4: ["#3f5f93", "#e8edff"], 8: ["#1c7a8c", "#ffffff"], 16: ["#14a085", "#ffffff"],
  32: ["#3fb950", "#ffffff"], 64: ["#a3d139", "#17220a"], 128: ["#f4c430", "#2a1f00"], 256: ["#f5a524", "#2a1600"],
  512: ["#ee7b30", "#ffffff"], 1024: ["#e5484d", "#ffffff"], 2048: ["#d6409f", "#ffffff"],
};
const BIG = ["#8e4ec6", "#ffffff"];
const COLORS = { bg: "#0d1220", board: "#18213a", empty: "#212c48", text: "#e8edff", dim: "#8c97b8", box: "#18213a" };

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
let boardX = 0, boardY = 0, boardSize = 400;

// ----- Sizing: the largest square board that fits under the score bar, sharp on high-DPI -----
function resize() {
  const dpr = window.devicePixelRatio || 1;
  const W = window.innerWidth, H = window.innerHeight;
  canvas.style.width = W + "px";
  canvas.style.height = H + "px";
  canvas.width = Math.round(W * dpr);
  canvas.height = Math.round(H * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  boardSize = Math.max(200, Math.floor(Math.min(W - 24, H - 24 - HUD, 520)));
  boardX = Math.floor((W - boardSize) / 2);
  boardY = Math.floor((H - boardSize - HUD) / 2) + HUD;
}

// ----- Game state -----
// grid[r][c] holds a tile object or null. A tile remembers where it was before the last move
// (fromR, fromC) so drawing can slide it; the grid itself is always the true, final position.
let grid, ghosts, score, best = 0, state, won, anim = 0, shownAt = 0, last = 0;

function reset() {
  grid = Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
  ghosts = [];
  score = 0;
  won = false;
  addRandomTile();
  addRandomTile();
  anim = SLIDE;                          // skip the slide; just grow the two starting tiles
  state = "ready";                       // ready → playing → over (with a "won" pause at 2048)
}

// A new 2 (or, one time in ten, a 4) on a random empty cell.
function addRandomTile() {
  const empty = [];
  for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) if (!grid[r][c]) empty.push([r, c]);
  if (!empty.length) return;
  const [r, c] = empty[Math.floor(Math.random() * empty.length)];
  grid[r][c] = { value: Math.random() < 0.9 ? 2 : 4, r, c, fromR: r, fromC: c, kind: "new" };
}

const inside = (r, c) => r >= 0 && r < SIZE && c >= 0 && c < SIZE;

// Slide every tile towards (dr, dc), merging equal pairs once per move. Returns true if anything moved.
function move(dr, dc) {
  // Start this move from where every tile is now, even if the last animation hasn't finished.
  ghosts = [];
  for (const row of grid) for (const t of row) if (t) { t.fromR = t.r; t.fromC = t.c; t.kind = "slide"; t.merged = false; }

  // Visit the tiles nearest the far wall first, so each tile stops against one that has already moved.
  const order = [...Array(SIZE).keys()], back = [...order].reverse();   // [0, 1, 2, 3] and [3, 2, 1, 0]
  const rows = dr > 0 ? back : order, cols = dc > 0 ? back : order;
  let moved = false, madeGoal = false;
  for (const r of rows) for (const c of cols) {
    const t = grid[r][c];
    if (!t) continue;
    let nr = r, nc = c;
    while (inside(nr + dr, nc + dc) && !grid[nr + dr][nc + dc]) { nr += dr; nc += dc; }   // slide into empty cells
    const next = inside(nr + dr, nc + dc) ? grid[nr + dr][nc + dc] : null;
    grid[r][c] = null;
    if (next && next.value === t.value && !next.merged) {
      // Merge: both tiles slide into next's cell (as ghosts), and one doubled tile replaces them.
      ghosts.push({ value: t.value, fromR: r, fromC: c, r: next.r, c: next.c }, next);
      grid[next.r][next.c] = { value: t.value * 2, r: next.r, c: next.c, fromR: next.r, fromC: next.c, kind: "merged", merged: true };
      score += t.value * 2;
      if (t.value * 2 === GOAL && !won) madeGoal = true;
      moved = true;
    } else {
      grid[nr][nc] = t;
      t.r = nr; t.c = nc;
      if (nr !== r || nc !== c) moved = true;
    }
  }
  if (!moved) return false;
  addRandomTile();
  anim = 0;
  best = Math.max(best, score);
  if (madeGoal) { won = true; state = "won"; shownAt = performance.now(); }
  else if (!canMove()) { state = "over"; shownAt = performance.now(); }
  return true;
}

// The game is over when the board is full and no two neighbours match.
function canMove() {
  for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) {
    const t = grid[r][c];
    if (!t) return true;
    if (c + 1 < SIZE && grid[r][c + 1] && grid[r][c + 1].value === t.value) return true;
    if (r + 1 < SIZE && grid[r + 1][c] && grid[r + 1][c].value === t.value) return true;
  }
  return false;
}

// Every key, swipe and tap comes through here. (0, 0) means "no direction": a tap, Space or Enter.
function input(dr, dc) {
  const recent = performance.now() - shownAt < 600;   // ignore presses right after a message appears
  if (state === "over") {
    if (dr || dc || recent) return;
    reset();
    state = "playing";
    return;
  }
  if (state === "won") {
    if (recent) return;
    state = canMove() ? "playing" : "over";            // keep going past 2048
    return;
  }
  if (state === "ready") state = "playing";
  if (dr || dc) move(dr, dc);
}

// ----- Drawing -----
function roundRect(x, y, w, h, r, color) {
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
  ctx.fill();
}

function text(str, x, y, size, color, weight = 700) {
  ctx.font = `${weight} ${Math.round(size)}px system-ui, sans-serif`;
  ctx.fillStyle = color;
  ctx.fillText(str, x, y);
}

// Draws one tile at a fractional grid position (row, col), scaled around its centre.
function drawTile(value, row, col, scale) {
  const gap = boardSize * 0.03, size = (boardSize - gap * (SIZE + 1)) / SIZE;
  const x = boardX + gap + col * (size + gap), y = boardY + gap + row * (size + gap);
  const s = size * scale, [bg, fg] = TILE[value] || BIG;
  roundRect(x + (size - s) / 2, y + (size - s) / 2, s, s, s * 0.1, bg);
  const digits = String(value).length;
  text(String(value), x + size / 2, y + size / 2 + size * 0.03, s * (digits <= 2 ? 0.46 : digits === 3 ? 0.38 : digits === 4 ? 0.3 : 0.24), fg, 800);
}

function draw() {
  const W = window.innerWidth, H = window.innerHeight;
  ctx.fillStyle = COLORS.bg;
  ctx.fillRect(0, 0, W, H);

  // Score bar: title on the left, score and best boxes on the right.
  const top = boardY - HUD, boxW = Math.min(96, boardSize * 0.26), boxH = 50;
  ctx.textBaseline = "middle";
  ctx.textAlign = "left";
  text("2048", boardX, top + HUD / 2 - 6, Math.min(44, boardSize * 0.11), COLORS.text, 800);
  ctx.textAlign = "center";
  [["SCORE", score], ["BEST", best]].forEach(([label, n], i) => {
    const x = boardX + boardSize - (2 - i) * boxW - (1 - i) * 8;
    roundRect(x, top + 6, boxW, boxH, 8, COLORS.box);
    text(label, x + boxW / 2, top + 20, 11, COLORS.dim, 700);
    text(String(n), x + boxW / 2, top + 40, n > 99999 ? 15 : 19, COLORS.text, 800);
  });

  // Board and empty cells.
  roundRect(boardX, boardY, boardSize, boardSize, boardSize * 0.03, COLORS.board);
  for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) {
    const gap = boardSize * 0.03, size = (boardSize - gap * (SIZE + 1)) / SIZE;
    roundRect(boardX + gap + c * (size + gap), boardY + gap + r * (size + gap), size, size, size * 0.1, COLORS.empty);
  }

  // Tiles. p: slide progress 0 → 1 (eased); q: pop progress after the slide.
  const p = Math.min(1, anim / SLIDE), ease = 1 - (1 - p) * (1 - p);
  const q = Math.min(1, Math.max(0, (anim - SLIDE) / POP));
  const lerp = (a, b) => a + (b - a) * ease;
  if (p < 1) for (const g of ghosts) drawTile(g.value, lerp(g.fromR, g.r), lerp(g.fromC, g.c), 1);
  for (const row of grid) for (const t of row) {
    if (!t) continue;
    if (t.kind === "slide") drawTile(t.value, lerp(t.fromR, t.r), lerp(t.fromC, t.c), 1);
    else if (p >= 1 && t.kind === "merged") drawTile(t.value, t.r, t.c, 1 + 0.15 * Math.sin(Math.PI * q));
    else if (p >= 1 && t.kind === "new") drawTile(t.value, t.r, t.c, 0.3 + 0.7 * q);
  }

  if (state === "playing") return;
  roundRect(boardX, boardY, boardSize, boardSize, boardSize * 0.03, state === "ready" ? "rgba(8, 12, 22, 0.82)" : "rgba(8, 12, 22, 0.9)");
  const cx = boardX + boardSize / 2, cy = boardY + boardSize / 2, small = Math.max(14, boardSize * 0.042);
  const lines = {
    ready: ["Slide the tiles", "Join equal numbers to reach 2048", "Arrows, WASD or swipe", "Tap or press an arrow to start"],
    won: ["You made 2048!", `Score ${score}`, "Bigger tiles are still out there", "Tap or press Space to keep going"],
    over: ["No moves left", `Score ${score}`, `Best ${best}`, "Tap or press Space to play again"],
  }[state];
  text(lines[0], cx, cy - boardSize * 0.16, Math.max(24, boardSize * 0.085), COLORS.text, 800);
  text(lines[1], cx, cy - boardSize * 0.03, small, COLORS.text, 500);
  text(lines[2], cx, cy + boardSize * 0.05, small, COLORS.dim, 500);
  text(lines[3], cx, cy + boardSize * 0.18, small, TILE[128][0], 700);
}

// ----- Main loop: the game only changes on input; the loop runs the animations -----
function loop(now) {
  anim += Math.min(0.1, (now - last) / 1000);
  last = now;
  draw();
  requestAnimationFrame(loop);
}

// ----- Input: keyboard, swipe and tap -----
const DIRS = { ArrowUp: [-1, 0], KeyW: [-1, 0], ArrowDown: [1, 0], KeyS: [1, 0], ArrowLeft: [0, -1], KeyA: [0, -1], ArrowRight: [0, 1], KeyD: [0, 1] };
window.addEventListener("keydown", (e) => {
  const d = DIRS[e.code];
  if (d) { e.preventDefault(); input(d[0], d[1]); }
  else if (e.code === "Space" || e.code === "Enter") { e.preventDefault(); input(0, 0); }
  else if (e.code === "KeyR") { reset(); state = "playing"; }   // new game at any time
});

let swipe = null;
canvas.addEventListener("pointerdown", (e) => { swipe = { x: e.clientX, y: e.clientY }; });
canvas.addEventListener("pointerup", (e) => {
  if (!swipe) return;
  const dx = e.clientX - swipe.x, dy = e.clientY - swipe.y;
  swipe = null;
  if (Math.max(Math.abs(dx), Math.abs(dy)) < 30) input(0, 0);          // a tap
  else if (Math.abs(dx) > Math.abs(dy)) input(0, Math.sign(dx));       // a sideways swipe
  else input(Math.sign(dy), 0);                                        // an up or down swipe
});
canvas.addEventListener("pointercancel", () => { swipe = null; });

window.addEventListener("resize", resize);
reset();
resize();
requestAnimationFrame((t) => { last = t; loop(t); });
</script>
</body>
</html>

Frequently asked questions

Is this 2048 game code free to use, including commercially?

Yes. This file was written from scratch for this page and is MIT-licensed: you can copy it, change it and use it in personal or commercial projects, as long as you keep the licence line at the top. The sliding-tile idea itself is widely used; the original 2048 by Gabriele Cirulli is also open source under the MIT license.

How do I put this 2048 game on my website?

Upload 2048.html to your web host and link to it, or show it inside a page with an <iframe>. The board resizes to the frame. Our guide to embedding an HTML5 game in a website covers WordPress, Wix and Google Sites.

How do I save the best score between visits?

Save best with localStorage.setItem after each move and read it when the page loads. Wrap both calls in try/catch, because some browsers and sandboxed frames (like the player on this page) block storage. To save the whole board, store the tile values as a 4 × 4 array of numbers and rebuild the tile objects from it.

How do I add sound to the 2048 game?

Create an AudioContext the first time the player presses a key or swipes (browsers block audio before that), then play a short oscillator tone in move() whenever a merge happens. Raising the pitch with the merged value makes big merges feel bigger.

Why does a row of 2, 2, 4 become 4, 4 and not 8?

Each tile can merge only once per move. The two 2s merge into a 4, and that new 4 can't merge again until the next move. The merged flag in move() enforces this; remove it and you'll see the difference straight away.

We make and sell HTML5 game source code; links to our games are marked as ours.