2048 LLD: One Merge Function Wearing Four Costumes
A low-level design walkthrough of the 2048 game: the merge-once rule everyone breaks, why all four directions are one mergeLeft function, win and lose detection, and the complete implementation.
"Design 2048." It looks like the friendliest game in the LLD queue — a 4×4 grid, tiles that slide, numbers that double. Then candidates start coding and the trap springs: four directions means four copies of the slide logic, the merge rule turns out to have a subtlety that breaks every first attempt, and somewhere in there a random tile spawner quietly makes the whole thing untestable.
All three problems share one elegant escape, and it's the reason this game is worth an article: the entire game is one function — mergeLeft on a single row — and every direction is just a different way of reading the lines. Build that function correctly once, prove it with tests, and the rest of 2048 assembles itself around it.
Let's start nowhere near a computer
Take four coins of equal value and line them up on the table: 2 2 2 2. Now sweep them all to the left, stacking equal neighbours as they collide. What do you get?
If you said "one stack of 8" — that's the bug, and you've just failed the interview's hidden test. Watch real 2048: the left pair merges into a 4, the right pair merges into a 4, and the two 4s — freshly made this very move — do not merge with each other. You get 4 4 · ·.
A tile merges at most once per sweep. That single sentence is the game's actual specification, and it's the one everybody discovers by failing.
Where you've already met this machine
- The merge-once rule is a batch-processing rule. "Apply each effect once per pass, don't let outputs re-trigger inputs" — the same discipline that stops double-applied bank transactions and infinite trigger loops.
- The direction trick is normalization. Reduce four cases to one canonical case plus transforms — the same move as sorting two endpoints before comparing intervals, or canonicalizing URLs before deduping.
- The spawn is the Snake & Ladder die again — randomness that must arrive through the constructor, or nothing about this game can ever be asserted in a test.
Step 1 — Sentences first
- The board is 4×4; tiles hold powers of two.
- A move slides every tile as far as it can go in one direction; equal neighbours merge into their sum.
- A tile may merge at most once per move.
- After every move that changes the board, a new tile (a 2, occasionally a 4) appears in a random empty cell.
- The game is won when 2048 appears, and lost when no move can change the board.
Sentence four hides a beloved bug: press LEFT on an already-left-packed board, and nothing may spawn. An idle move is a no-op — punish it with a spawn and you've broken the game's economy.
Step 2 — Non-functional requirements
2048 is a deterministic, single-player puzzle — there's no server, no concurrency, no datastore to pick. So the non-functional requirements aren't the distributed-systems checklist; they're the qualities of a class — how well the logic behaves, not how it scales across machines:
- Correctness. The slide-and-merge rule must be exact: each tile merges at most once per move, a move that changes nothing spawns no tile, the game is won at 2048 and lost only when no move can change the board. This is the whole game; get it wrong and nothing else matters.
- Performance. A move touches the board once — O(n²) over the cells, really O(n) per row — with no rescans, and the "can the board still move?" check only runs when the board is full.
- Extensibility. Board size, win target, and spawn rule should be parameters or seams, not hard-coded
4s scattered through the logic. - Testability. The RNG for spawns is injected so any game is reproducible, and one move is a pure transform you can assert on without standing up a grid.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | one pure mergeLeft holds the merge-once rule; move spawns only when the board changed — Steps 4, 6 |
| Performance | each move reads and writes every line exactly once; the lose-scan runs only on a full board — Step 6 |
| Extensibility | directions reduce to line-reading, so board shape lives in one place; spawn is a swappable seam — Step 5 |
| Testability | mergeLeft is static and pure; the Random arrives through the constructor — Steps 4, 6 |
Every trade-off below is chosen to keep one of these.
Step 3 — Nouns → almost nothing
Board → an int[][]. Tile → an int (0 = empty; no Tile class — Tic-Tac-Toe's lesson again). Direction → an enum. Status → an enum. Game → the one class. The entire cast:
public enum Direction { LEFT, RIGHT, UP, DOWN }
public final class Game2048 {
public enum Status { PLAYING, WON, LOST }
private final int[][] grid = new int[4][4];
private final Random random; // injected — the spawn must be scriptable
private Status status = Status.PLAYING;
}Which structure — and why. The board is a plain int[][], not a grid of Tile objects — and that's the load-bearing choice. A tile carries no behaviour and no identity worth tracking; it's a number, so an int (with 0 for empty) is the tile. That keeps a move a cheap array walk rather than a graph of objects to chase (Performance), and it makes the whole board a 16-int snapshot you can copy, compare, or preset in a test in one line (Testability). The Random is a field, not a new Random() call buried in spawn() — injected through the constructor so a test can script exactly which cell gets which tile (Testability again). Directions are an enum so the four cases stay an exhaustive switch the compiler checks, which is what makes adding logic in one place safe later (Extensibility).
Step 4 — The hero function: mergeLeft on one row
Forget the grid. Forget directions. Solve this and only this: given one row, push everything left and merge equal neighbours once.
The implementation reads like the rule itself — walk the non-zero tiles; if the one you're carrying equals the last one you placed and that one hasn't already merged, double it; otherwise place and move on:
/** The whole game, for one row: slide left, merge equal neighbours once each. */
static int[] mergeLeft(int[] row) {
int[] out = new int[row.length];
int write = 0;
int lastUnmerged = 0; // the placed tile still eligible to merge
for (int value : row) {
if (value == 0) continue;
if (value == lastUnmerged) {
out[write - 1] = value * 2; // merge into the tile we just placed…
lastUnmerged = 0; // …and retire it: merged tiles merge no further
} else {
out[write++] = value;
lastUnmerged = value;
}
}
return out;
}Trace [2, 2, 2, 2] through it: place 2 — merge into 4, retire — place 2 — merge into 4, retire → [4, 4, 0, 0]. The lastUnmerged = 0 line is the merge-once rule; delete it and you've recreated the classic bug. Trace [4, 2, 2, 0] too: the 4 is placed, the 2 doesn't match it, the second 2 merges → [4, 4, 0, 0] — the 4 never doubled.
This function is pure, static, and takes an array — which means it's exhaustively testable in isolation, before a grid or a direction even exists. That's not an accident; it's the design.
Step 5 — Four directions, zero new logic
Now the costume trick. A RIGHT move is a LEFT move on a reversed row. An UP move is a LEFT move on a column read top-down. DOWN: column read bottom-up. So the grid code never merges anything — it just decides how to read and write lines:
private int[] readLine(Direction direction, int i) {
int[] line = new int[4];
for (int j = 0; j < 4; j++) {
line[j] = switch (direction) {
case LEFT -> grid[i][j];
case RIGHT -> grid[i][3 - j];
case UP -> grid[j][i];
case DOWN -> grid[3 - j][i];
};
}
return line;
}writeLine is the mirror image. Four directions, one algorithm, no duplication — and when the interviewer asks "what if the board were 5×5, or hexagonal?", your answer is "mergeLeft doesn't change; only the line-reading does," which is the whole point of the costume.
Step 6 — A move, end to end
public synchronized boolean move(Direction direction) {
if (status != Status.PLAYING) {
throw new IllegalStateException("game over: " + status);
}
boolean changed = false;
for (int i = 0; i < 4; i++) {
int[] line = readLine(direction, i);
int[] merged = mergeLeft(line);
if (!Arrays.equals(line, merged)) changed = true;
writeLine(direction, i, merged);
}
if (changed) {
spawn();
updateStatus();
}
return changed;
}The highlighted guard carries three rules at once: no spawn on an idle move, no status re-check on an idle move, and a truthful return value so a UI can shake the screen on a wasted keypress. One if, three behaviors — that's what extracting mergeLeft bought.
The spawn, with the Snake & Ladder lesson applied — random came through the constructor, so tests can script exactly where and what appears:
private void spawn() {
List<int[]> empty = new ArrayList<>();
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++)
if (grid[r][c] == 0) empty.add(new int[] {r, c});
if (empty.isEmpty()) return;
int[] cell = empty.get(random.nextInt(empty.size()));
grid[cell[0]][cell[1]] = random.nextInt(10) == 0 ? 4 : 2; // 10% fours
}
private void updateStatus() {
boolean full = true;
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++) {
if (grid[r][c] == 2048) { status = Status.WON; return; }
if (grid[r][c] == 0) full = false;
}
if (!full) return;
for (int r = 0; r < 4; r++) // full board: any merge left?
for (int c = 0; c < 4; c++) {
if (c < 3 && grid[r][c] == grid[r][c + 1]) return;
if (r < 3 && grid[r][c] == grid[r + 1][c]) return;
}
status = Status.LOST;
}Note when LOST can possibly happen: only right after a spawn fills the last
hole — which is why updateStatus() runs after spawn(), and why a full
board isn't lost yet if any two neighbours match. Getting this ordering right
is the lose-detection question.
Step 7 — Patterns?
The question: what varies? For the core game — nothing. One board, one rule set: plain code, like Tic-Tac-Toe. The honest seams, if asked: the spawn policy (always-2? evil mode that spawns the worst tile?) is a Strategy waiting to happen, and an undo feature is Memento — snapshot the grid before each move. Name them; don't build them.
Step 8 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the qualities from Step 2 — that's what designing to the non-functional requirements looks like, even for a puzzle with no network in sight.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
int[][] board, int tiles | a Tile class per cell | a move stays a flat array walk; the whole board copies and compares in one line | Performance |
one mergeLeft, four line-reads | four copies of the slide code | the rule lives in exactly one tested place; directions can't drift apart | Correctness |
spawn + status only when changed | spawn after every keypress | an idle move is a true no-op, and the lose-scan never runs on a non-full board | Correctness |
injected Random | new Random() inside spawn | any game replays exactly; the spawn becomes scriptable in a test | Testability |
| direction reduces to line-reading | hard-coded 4s per direction | board shape lives in one switch, so a 5×5 is a read change, not a rewrite | Extensibility |
Growth — when the board gets bigger. The natural "scaling" question here isn't replicas or shards; it's what happens at 5×5, or a different win target? The answer is the costume design paying off: mergeLeft doesn't change at all — it already takes a row of any length — and only readLine/writeLine and the bounds learn the new size. Make the size a constructor parameter and the hard-coded 4s become one field; the merge logic, the rule everyone gets wrong, never gets touched again.
The complete implementation
package dev.fiveyear.game2048;
public enum Direction { LEFT, RIGHT, UP, DOWN }package dev.fiveyear.game2048;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
public final class Game2048 {
public enum Status { PLAYING, WON, LOST }
private final int[][] grid;
private final Random random;
private Status status = Status.PLAYING;
public Game2048(Random random) {
this.random = random;
this.grid = new int[4][4];
spawn(); // the game opens with two tiles
spawn();
}
Game2048(int[][] preset, Random random) { // package-private: tests script exact boards
this.grid = preset;
this.random = random;
}
public synchronized boolean move(Direction direction) {
if (status != Status.PLAYING) {
throw new IllegalStateException("game over: " + status);
}
boolean changed = false;
for (int i = 0; i < 4; i++) {
int[] line = readLine(direction, i);
int[] merged = mergeLeft(line);
if (!Arrays.equals(line, merged)) changed = true;
writeLine(direction, i, merged);
}
if (changed) {
spawn();
updateStatus();
}
return changed;
}
/** The whole game, for one row: slide left, merge equal neighbours once each. */
static int[] mergeLeft(int[] row) {
int[] out = new int[row.length];
int write = 0;
int lastUnmerged = 0;
for (int value : row) {
if (value == 0) continue;
if (value == lastUnmerged) {
out[write - 1] = value * 2;
lastUnmerged = 0;
} else {
out[write++] = value;
lastUnmerged = value;
}
}
return out;
}
private int[] readLine(Direction direction, int i) {
int[] line = new int[4];
for (int j = 0; j < 4; j++) {
line[j] = switch (direction) {
case LEFT -> grid[i][j];
case RIGHT -> grid[i][3 - j];
case UP -> grid[j][i];
case DOWN -> grid[3 - j][i];
};
}
return line;
}
private void writeLine(Direction direction, int i, int[] line) {
for (int j = 0; j < 4; j++) {
switch (direction) {
case LEFT -> grid[i][j] = line[j];
case RIGHT -> grid[i][3 - j] = line[j];
case UP -> grid[j][i] = line[j];
case DOWN -> grid[3 - j][i] = line[j];
}
}
}
private void spawn() {
List<int[]> empty = new ArrayList<>();
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++)
if (grid[r][c] == 0) empty.add(new int[] {r, c});
if (empty.isEmpty()) return;
int[] cell = empty.get(random.nextInt(empty.size()));
grid[cell[0]][cell[1]] = random.nextInt(10) == 0 ? 4 : 2;
}
private void updateStatus() {
boolean full = true;
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++) {
if (grid[r][c] == 2048) { status = Status.WON; return; }
if (grid[r][c] == 0) full = false;
}
if (!full) return;
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++) {
if (c < 3 && grid[r][c] == grid[r][c + 1]) return;
if (r < 3 && grid[r][c] == grid[r + 1][c]) return;
}
status = Status.LOST;
}
public synchronized Status status() {
return status;
}
public synchronized int tile(int row, int col) {
return grid[row][col];
}
}And the hero function earning its keep:
Game2048.mergeLeft(new int[] {2, 2, 2, 2}); // [4, 4, 0, 0] — never [8]
Game2048.mergeLeft(new int[] {4, 2, 2, 0}); // [4, 4, 0, 0] — the 4 stays a 4
Game2048.mergeLeft(new int[] {2, 0, 2, 4}); // [4, 4, 0, 0] — gaps close, then merge
Game2048.mergeLeft(new int[] {2, 4, 2, 4}); // [2, 4, 2, 4] — nothing to do
Game2048 game = new Game2048(new Random());
game.move(Direction.LEFT); // true if anything slid or mergedThe interview corner
Clarify before you code: Always 4×4? Does the game stop at 2048 or continue? What's the 2-vs-4 spawn ratio (the real game is 90/10)?
The follow-up ladder:
- "Add scoring." Make
mergeLeftreturn a small record — the row and the points (merges are the only scoring events). Pure function, richer return. - "Add undo." Snapshot grid + score before each effective move — a Memento in a deque. The board is 16 ints; don't overthink it.
- "The UI needs animations." Return merge events (from, to, value) instead of just the final row — purity made this an extension, not a rewrite.
- "5×5? Hexagonal?"
mergeLeftdoesn't change; only line reading does. Saying that sentence with confidence is the whole point of the costume design. - "Build an AI." Expectimax over (your move × random spawn) — and your pure, gridless
mergeLeftis already the simulator it needs.
Mistakes that fail the round: four copies of the slide logic; the merge cascade ([2,2,4] becoming [8]); spawning a tile after an idle move.
Where to go from here
The design on a card: one pure function holds every rule, directions are costumes over line-reading, idle moves are no-ops, and the spawn is injected randomness — scriptable, like every dependency should be.
- Add scoring — each merge adds its result to a score;
mergeLeftreturns a tiny record instead of an array. Twenty minutes, no redesign. - Add undo — snapshot the grid per move (Memento). Notice how cheap that is because the state is one array.
- Next in the queue: Minesweeper — where the interesting move is a flood-fill, and the lesson is recursion with discipline.
- Landed here first? The method behind all of these: A Rookie's Guide to LLD.
And next time you absent-mindedly swipe four twos together on your phone, watch them become two fours — and appreciate the single line of code, lastUnmerged = 0, that's keeping the universe honest.