Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 4 min read

Building 2048 in JavaScript: One Merge Function, Four Directions

Build the sliding and merging logic, starting with a single row. Part of the Algorithms in JavaScript series. Take this row from a 2048 board: [2, 2, 4, 0] Slide it left. The result is: [4, 4, 0, 0] T

Building 2048 in JavaScript: One Merge Function, Four Directions

Build the sliding and merging logic, starting with a single row.

Part of the Algorithms in JavaScript series.

Take this row from a 2048 board:

[2, 2, 4, 0]

Slide it left. The result is:

[4, 4, 0, 0]

The first two tiles merge. The newly created 4 cannot merge again during the same move. If we keep merging until nothing else matches, we get the wrong board.

When I rebuilt 2048, the movement logic was the part I wanted to focus on. The function needs to answer this:

Given a board and a direction, compute the board after its tiles slide and merge.

We'll write the merge logic for a left move, then reuse it for the other three directions.

The board is just a matrix

Represent the board as an array of rows. A zero means an empty cell:

const board = [
  [2, 0, 2, 4],
  [4, 4, 0, 0],
  [2, 2, 2, 2],
  [0, 0, 0, 2]
];

The functions below assume a nonempty square board containing zeroes and valid tile values. They return a new board without changing the input.

For now, we're handling sliding and merging. We'll leave tile spawning, scoring, and rendering to the code that calls these functions.

Start with a single row

A left move has three steps:

  1. Remove the zeroes.
  2. Scan from left to right, merging equal neighboring tiles once.
  3. Pad the result with zeroes to restore the row's length.

For example:

[2, 0, 2, 4]  β†’  [2, 2, 4]  β†’  [4, 4]  β†’  [4, 4, 0, 0]

A left move removes zeroes, merges each pair once, and pads the row. A newly merged tile cannot merge again in the same move.

A left move removes zeroes, merges each pair once, and pads the row. A newly merged tile cannot merge again in the same move.

Here is the implementation:

function mergeRowLeft(row) {
  const nums = row.filter(n => n !== 0);
  const merged = [];

  for (let i = 0; i < nums.length; i++) {
    if (nums[i] === nums[i + 1]) {
      merged.push(nums[i] * 2);
      i++; // Both input tiles have now been consumed.
    } else {
      merged.push(nums[i]);
    }
  }

  while (merged.length < row.length) {
    merged.push(0);
  }

  return merged;
}

The extra i++ is doing the important work here: it skips the second tile in a merged pair. Results go into a separate array, so a newly created tile never re-enters the scan.

Using row.length for the padding also lets us use this function on a larger board.

Let's check a few rows before going further:

mergeRowLeft([2, 2, 2, 2]); // [4, 4, 0, 0]
mergeRowLeft([2, 2, 4, 0]); // [4, 4, 0, 0]
mergeRowLeft([2, 2, 2, 0]); // [4, 2, 0, 0]
mergeRowLeft([4, 0, 4, 4]); // [8, 4, 0, 0]
mergeRowLeft([0, 0, 0, 0]); // [0, 0, 0, 0]
mergeRowLeft([2, 4, 8, 16]); // [2, 4, 8, 16]

Look at the first and third cases. Four equal tiles produce two merged tiles; three equal tiles leave one unmerged tile. The scan order decides which pair gets merged.

LEFT: apply the operation to every row

A left move applies that function to each row:

function tiltLeft(board) {
  return board.map(row => mergeRowLeft(row));
}

The rows don't affect each other during a horizontal move.

RIGHT: reverse, merge, reverse back

Reverse the row, merge left, and reverse the result. The pair nearest the right edge merges first.

Reverse the row, merge left, and reverse the result. The pair nearest the right edge merges first.

Moving right uses the same rule, read from the opposite end:

function tiltRight(board) {
  return board.map(row =>
    mergeRowLeft([...row].reverse()).reverse()
  );
}

Don't drop the copy in [...row]: reverse() changes the array it receives. At the other end, we're reversing the fresh array returned by mergeRowLeft, so the original row stays untouched.

Direction also determines which pair merges first. Moving [2, 2, 2, 0] right produces [0, 0, 2, 4].

UP and DOWN: turn columns into rows

For a vertical move, we need to work on columns. Transposing the board turns them into rows:

Before         After transposing
2  0  4        2  8  0
8  2  0        0  2  4
0  4  2        4  0  2

Transposition turns columns into rows. UP uses a left move; DOWN uses a right move. Transpose again to restore the board.

Transposition turns columns into rows. UP uses a left move; DOWN uses a right move. Transpose again to restore the board.

Here's the transpose function:

function transpose(board) {
  return board[0].map((_, column) =>
    board.map(row => row[column])
  );
}

After transposing, each column runs left to right in the same order it originally ran top to bottom. We can therefore use a left move for UP and a right move for DOWN:

function tiltUp(board) {
  return transpose(tiltLeft(transpose(board)));
}

function tiltDown(board) {
  return transpose(tiltRight(transpose(board)));
}

Transpose the result once more to put the board back in its original orientation.

Put a small API around it

function tilt(board, direction) {
  switch (direction) {
    case 'LEFT':
      return tiltLeft(board);
    case 'RIGHT':
      return tiltRight(board);
    case 'UP':
      return tiltUp(board);
    case 'DOWN':
      return tiltDown(board);
    default:
      throw new Error('Invalid direction');
  }
}

Using the board from the beginning:

const nextBoard = tilt(board, 'LEFT');

console.log(nextBoard);
// [
//   [4, 4, 0, 0],
//   [8, 0, 0, 0],
//   [4, 4, 0, 0],
//   [2, 0, 0, 0]
// ]

We still have the original board if we want to compare states, support undo, or try another move.

Where this fits in the full game

This is enough to move the tiles, but there's still work around tilt to make a game. We need to detect whether the board changed, update the score, spawn a tile, and check the win and game-over conditions.

In the original game, a new tile appears only after a move changes the board. It is a 2 with 90% probability and a 4 with 10% probability. Merges add the resulting tile's value to the score. You can see these rules in the original game manager.

Tile spawning stays outside tilt, so the same board and direction always give us the same result. That makes movement easier to test: the next random tile won't interfere with the board we're checking.

Complexity

For an N Γ— N board, merging one row takes O(N) time. Processing N rows therefore takes O(NΒ²) time.

Reversing or transposing the board adds a constant number of passes over its cells, so each direction still takes O(NΒ²) time. This implementation uses O(NΒ²) space for the new board and intermediate arrays.

The standard 4 Γ— 4 board has a fixed size. The N Γ— N analysis tells us how the work grows if we make the board larger.

One rule, four directions

The part I like is that we haven't had to write the merge logic four times. Once it works for a row, reversal and transposition give us the remaining directions.

There's only one function to revisit if we get a merge wrong.

If you're trying this yourself, start with the row examples, especially [2, 2, 4, 0]. Get those working before adding the other directions.

Happy JavaScript-ing!

Game credit: Gabriele Cirulli's 2048.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.