2048 Tile Merge Game — Free HTML CSS JS Snippet

Tile Merge 2048 Puzzle · Games · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

slideLine() single-pass merge algorithm that guarantees each tile merges at most once per move
cloneBoardValues() + boardsEqual() diffing to detect no-op moves and skip spawning/scoring on illegal keypresses
Absolutely positioned tiles with CSS transition on top/left for a smooth slide-and-merge glide animation
Distinct .spawn scale-in and .merged pop keyframe animations for new versus combined tiles
Direction-agnostic line processing: rows/columns optionally reversed so left/right/up/down share one merge function
Touch swipe detection via touchstart/touchend coordinate delta with a minimum-distance threshold
Persistent best score via localStorage.setItem, read back on load and updated live whenever the current score exceeds it
Win-without-stopping: hasWon flag shows a one-time 2048 banner while leaving the board fully playable afterward

About this UI Snippet

Tile Merge 2048 Puzzle — Single-Merge-Per-Move Logic, Absolute-Positioned Tile Animation & Swipe Input

Screenshot of the Tile Merge 2048 Puzzle snippet rendered live

2048 looks simple to clone but has a surprisingly sharp edge case that trips up most quick implementations: a tile must only merge once per move. If three equal tiles slide together — say three 2s in a row — the naive approach of repeatedly combining adjacent equal pairs left-to-right can accidentally merge the resulting 4 with the third 2 in the same pass, producing an 8 out of thin air. This snippet's slideLine() function avoids that bug entirely by processing each line with a single forward pass and an index that jumps by two positions the instant a merge happens, guaranteeing no merged tile is ever reconsidered for a second merge within the same move.

How a single move is processed

Each arrow key or swipe gesture triggers move(direction), which reduces the 2D board to four independent 1D lines — either the four rows (for left/right) or the four columns (for up/down) — and runs slideLine() on each one after optionally reversing it so every direction can reuse the same left-aligned slide-and-merge logic. slideLine() first filters out empty cells, then walks the remaining values left to right: if the current tile's value equals the next tile's value, it emits one merged tile worth double and advances the index by two; otherwise it emits the tile unchanged and advances by one. The result is padded back out to four slots with nulls representing empty space, then written into a fresh board array.

Detecting whether a move actually changed anything

Because arrow keys fire even when a move is illegal (for example pressing left when everything is already pressed against the left wall), the snippet snapshots the board's values before and after via cloneBoardValues() and boardsEqual(). A new tile only spawns, and the score/localStorage update only happens, when the comparison shows the board actually changed — this matches the real 2048 rule that a no-op keypress does not consume a turn.

Animated, absolutely positioned tiles

Rather than re-laying out a DOM grid every move, each tile is an absolutely positioned div inside .g2048-tiles, and its left/top pixel offsets are computed from its row/column index and the live cell size (recalculated on resize via cellSizePx()). A CSS transition on top/left/transform means simply changing those style properties on each render produces a smooth glide animation for free, while freshly spawned tiles get a .spawn class that plays a scale-in keyframe animation and merged tiles get a .merged class that plays a brief pop/bounce.

Ace-free but not gimmick-free: soft rules that matter

Score accumulates by the value of every merge, not just a flat point per move, matching the original 2048 scoring rule. Reaching a 2048 tile triggers a one-time win overlay but does not lock the board — play continues seamlessly afterward, exactly like the original game, tracked with a hasWon flag that prevents the win banner from firing again on subsequent moves. Game over is detected only when the board is completely full and no two horizontally or vertically adjacent cells share a value, meaning every remaining move would be a no-op. The best score persists across sessions using localStorage.setItem('g2048-best-score', ...), read back on load so returning players see their all-time high immediately.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to walk through why slideLine()'s single forward pass with an index that jumps by two on a merge is sufficient to prevent the classic double-merge bug — tracing through a three-in-a-row example (2, 2, 4) by hand alongside the AI is the fastest way to really understand it. It's also worth asking the assistant to add features on top: an undo-last-move button that snapshots the board before each move, a move counter or moves-per-minute stat, or an animated score increment that counts up rather than snapping instantly, similar to the merge pop animation already in the CSS. You could also ask it to compare this line-reduction approach for handling all four directions against an alternative that transposes the matrix for vertical moves, and discuss the tradeoffs of each.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a playable 2048 tile-merge puzzle game in plain HTML, CSS, and JavaScript on a 4x4 grid — no frameworks, no libraries.

Requirements:
- Arrow keys (and swipe gestures on touch devices) slide every tile as far as possible in the pressed direction.
- Adjacent tiles of equal value merge into one tile of double the value, but each individual tile must only be allowed to participate in one merge per move — sliding three equal tiles together must produce one merged tile and one leftover tile, never a double-merged result.
- After any move that actually changes the board state, spawn a new tile in a random empty cell: 90% chance of value 2, 10% chance of value 4. A keypress that would not change the board must not spawn a tile or add to the score.
- Track a running score that increases by the value of every merge (not a flat per-move point), and persist the best-ever score across page reloads using localStorage.
- Detect game over: the board is full and no two horizontally or vertically adjacent tiles share a value, meaning no legal move remains.
- Detect reaching a 2048-value tile as a win condition, show a win indicator, but allow the player to keep playing past it toward higher values.
- Animate tile movement and merging with CSS transitions rather than having tiles jump instantly to their new position.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Move tiles with arrows or swipePress an Arrow key on desktop, or swipe in any direction on a touch screen (a 24px minimum swipe distance in touchend prevents accidental taps from triggering a move). All tiles slide as far as possible in that direction.
  2. 2
    Merge equal tilesWhen two tiles of the same value collide during a slide, they combine into one tile of double the value, and your score increases by that new value. Each tile can only take part in one merge per move — three 2s sliding together become a 4 and a leftover 2, never an 8.
  3. 3
    Watch a new tile spawnAfter any move that actually changes the board, spawnTile() places a new 2 (90% chance) or 4 (10% chance) in a random empty cell, matching the original 2048 spawn distribution.
  4. 4
    Reach 2048 to win, or keep goingThe first time a 2048 tile appears on the board, an overlay announces the win but the board stays interactive — hasWon prevents the banner from re-triggering, so you can keep merging toward 4096 and beyond.
  5. 5
    Recognise game overWhen the board is completely full and no adjacent pair of tiles (horizontally or vertically) shares a value, no move can change the board any further and a Game Over overlay appears showing your final score.
  6. 6
    Track your best score and start freshYour highest-ever score persists in localStorage under g2048-best-score and displays in the Best box at all times. Click "New Game" (or "Try Again" on the overlay) to reset the board to two starting tiles.

Real-world uses

Common Use Cases

Teaching array-reduction and 1D-line abstraction techniques
The trick of reducing four different move directions down to one slideLine() function by transposing/reversing rows and columns is a genuinely useful abstraction pattern applicable well beyond games — anywhere a 2D grid operation can be decomposed into repeated 1D passes. This snippet is a clean, self-contained example of that technique for students studying array manipulation.
Portfolio piece demonstrating edge-case-aware game logic
The single-merge-per-move bug is a well-known trap that separates a superficial 2048 clone from a correct one. Including this snippet in a portfolio, alongside an explanation of how slideLine() specifically prevents double-merging, demonstrates attention to subtle correctness details that reviewers and interviewers notice.
Idle-time puzzle embedded in a site or internal tool
A fully playable, dependency-free 2048 is an easy drop-in for a 404 page, waiting-room screen, or "break room" section of an internal dashboard. Its localStorage best-score tracking gives repeat visitors a reason to come back and beat their own record, similar to the replay incentive built into the Minesweeper Puzzle Game snippet.
Reference for animated absolutely-positioned tile/card layouts
The pattern of computing pixel left/top offsets from a logical grid index and letting a CSS transition animate the difference is reusable for any UI that needs smooth repositioning — Kanban cards, draggable dashboard widgets, or reflowing image galleries — without needing a JavaScript animation library.
Starting point for larger boards or alternate merge rules
Because SIZE is a single top-level constant driving the grid template, empty-board generation, and win/loss checks, this snippet is a practical base for a 5x5 or 6x6 variant, or for experimenting with alternate spawn probabilities, a undo-last-move feature, or a move counter.
Mobile-first touch gesture game for casual play
The swipe detection with a minimum-distance threshold makes this genuinely comfortable to play one-handed on a phone, without the accidental-move problem that plagues 2048 clones with overly sensitive touch handling.

Got questions?

Frequently Asked Questions

slideLine() processes each line with a single left-to-right pass using an index i. When values[i] equals values[i+1], it emits one merged tile and jumps i forward by 2, skipping past both source tiles entirely. Because the loop never looks backward at a tile it already emitted, a freshly merged tile can never be compared against the next tile in the same pass, which is exactly the bug (2+2+4 collapsing into 8) that this logic is structured to avoid.

move() computes the resulting board and compares it against a snapshot taken before the move using boardsEqual(). If nothing changed — for example pressing left when all tiles are already flush against the left edge — no new tile spawns and no score is added, matching the original 2048's rule that an illegal move does not consume a turn.

The best score is written to localStorage under the key g2048-best-score every time the current score surpasses it, and read back into the best variable on page load via localStorage.getItem. To reset it, run localStorage.removeItem("g2048-best-score") in the browser console and reload, or change BEST_KEY in the JS to start tracking a fresh key.

No. The first time a 2048-value tile appears, checkWin() sets a hasWon flag and shows a one-time overlay announcing the win, but the board remains fully interactive — you can dismiss the overlay-adjacent game state and keep merging toward 4096, 8192, and beyond, exactly like the original 2048. The hasWon flag ensures the win banner only fires once per game.

checkGameOver() first checks whether any cell is empty (getEmptyCells().length > 0); if so, the game continues. If the board is completely full, it scans every cell for a horizontally or vertically adjacent neighbour with the same value — if none exists anywhere on the board, no slide in any direction could produce a merge, so isGameOver is set and the Game Over overlay appears with the final score.