How to make a 2048 game with React

The author of this tutorial focused on animation. He used the hooks of the React library, its Context API, as well as TypeScript and LESS. At the end you will find links to the game, its code and animation demo. We tell the details under the cut, while we start Frontend development course

Game Rules 2048

The numbers on the tiles are only powers of two, starting with 2. The player matches tiles with the same numbers. The numbers are added up until it comes to 2048. The player must get to the tile with the number 2048 in the least number of steps.

If the board is full and there is no way to make a move, for example to merge tiles together, the game is over.

For the purposes of this article, I focused on game mechanics and animation and neglected the details:

  • The number on the new tile is always 2, but in the full game it is random.

  • You can play even after 2048, and if there are no moves left on the board, then nothing will happen. To start over, press the reset button.

  • One last thing: points are not counted.

Project structure

The app consists of these React components:

  • Board responsible for rendering tiles. Uses one hook called useBoard

  • Grid renders a 4×4 grid.

  • Tile responsible for all tile-related animations and rendering of the tile itself.

  • Game combines all the elements above and includes a hook useGameresponsible for enforcing the rules and restrictions of the game.

How to make a tile component

In this project, I want to devote more time to animation, so I start my story with the Tile component. It is he who is responsible for all the animations. In 2048, there are two simple animations – selecting a tile and moving it around the board. We can write them using CSS transitions:

.tile {
  // ...
  transition-property: transform;
  transition-duration: 100ms;
  transform: scale(1);
}

I have only defined one transition to highlight the tile when it is created or merged with another tile. Let’s leave it that way for now.

Let’s see what the Tile metadata should look like in order to work with it easily. I decided to name the metadata type TileMeta: I don’t want his name to conflict with others, for example Tile:

type TileMeta = {
  id: number;
  position: [number, number];
  value: number;
  mergeWith?: number;
};
  • id – unique identifier for the tile. It is needed to prevent DOM React from redrawing all tiles from the very beginning with every change. Otherwise, we will see the tiles highlighting on each player’s action.

  • position – the position of the tiles on the board. It is an array with two elements, i.e. coordinates x and y and values ​​from 0 to 3.

  • value Is the number on the tile.

  • mergeWith – an optional identifier for the tile that will absorb the current one. If it exists, then the tile should merge with the other tile and disappear.

How to create and merge tiles

Somehow it should be noted that the tile changed after the player’s action. I think the best way is to change the scale of the tile. Changing the scale will show that a new tile has been created or another has been modified:

export const Tile = ({ value, position }: Props) => {
  const [scale, setScale] = useState(1);

  const prevValue = usePrevProps<number>(value);

  const isNew = prevCoords === undefined;
  const hasChanged = prevValue !== value;
  const shallAnimate = isNew || hasChanged;

  useEffect(() => {
    if (shallAnimate) {
      setScale(1.1);
      setTimeout(() => setScale(1), 100);
    }
  }, [shallAnimate, scale]);

  const style = {
    transform: `scale(${scale})`,
  };

  return (
    <div className={`tile tile-${value}`} style={style}>
      {value}
    </div>
  );
};

To start an animation, there are two cases to consider:

  • a new tile is created – the previous value will be null;

  • the tile changes the value – the previous value will be different from the current one.

And here’s the result:

You may have noticed that I am working with a custom hook usePrevProps… It helps keep track of the previous values ​​of component properties (props).

I could use links, but they are cumbersome, so I decided to separate the code into a separate hook for the sake of readability and in order to reuse the hook. If you want to use it in your project, just copy the snippet below:

import { useEffect, useRef } from "react";

/**
 * `usePrevProps` stores the previous value of the prop.
 *
 * @param {K} value
 * @returns {K | undefined}
 */
export const usePrevProps = <K = any>(value: K) => {
  const ref = useRef<K>();

  useEffect(() => {
    ref.current = value;
  });

  return ref.current;
};

How to move tiles across the board

Without animated movement of tiles across the board, the game will look sloppy. These animations are easy to create with CSS transitions. And it will be most convenient to use the positioning properties, for example left and top… Let’s change the CSS this way:

.tile {
  position: absolute;
  // ...
  transition-property: left, top, transform;
  transition-duration: 250ms, 250ms, 100ms;
  transform: scale(1);
}

Having declared styles, we can write the logic for changing the position of the tile:

export const Tile = ({ value, position, zIndex }: Props) => {
  const [boardWidthInPixels, tileCount] = useBoard();
  // ...

  useEffect(() => {
    // ...
  }, [shallAnimate, scale]);

  const positionToPixels = (position: number) => {
    return (position / tileCount) * (boardWidthInPixels as number);
  };

  const style = {
    top: positionToPixels(position[1]),
    left: positionToPixels(position[0]),
    transform: `scale(${scale})`,
    zIndex,
  };

  // ...
};

As you can see, the expression in positionToPixels must know the position of the tile, the total number of tiles in a row and column, and the total length of the board in pixels. The computed value is passed to the HTML element as an inline style. But what about the hook useBoard and property zIndex?

  • Property useBoard allows you to access board properties inside child components without passing them below. To find the right spot on the board, the Tile component needs to know the width and total number of tiles. Thanks to the React Context API, we can share properties between multiple component layers without polluting their props.

  • zIndex Is a CSS property that determines the order of the tiles. In our case it is id tiles. The picture below shows that the tiles can be stacked on top of each other. Property zIndex allows you to specify which tile is at the top.

How to make a board

Another important part of the game is the board. The Board component is responsible for rendering the grid and tiles. The Board seems to duplicate the logic of the Tile component, but there is a slight difference. Board stores information about its size (width and height), as well as the number of columns and rows. This is the opposite of a tile that only knows its own position:

type Props = {
  tiles: TileMeta[];
  tileCountPerRow: number;
};

const Board = ({ tiles, tileCountPerRow = 4 }: Props) => {
  const containerWidth = tileTotalWidth * tileCountPerRow;
  const boardWidth = containerWidth + boardMargin;

  const tileList = tiles.map(({ id, ...restProps }) => (
    <Tile key={`tile-${id}`} {...restProps} zIndex={id} />
  ));

  return (
    <div className="board" style={{ width: boardWidth }}>
      <BoardProvider containerWidth={containerWidth} tileCountPerRow={tileCountPerRow}>
        <div className="tile-container">{tileList}</div>
        <Grid />
      </BoardProvider>
    </div>
  );
};

Board uses BoardProvider to distribute the tile container width and the number of tiles per row and column between all tiles and the grid component:

const BoardContext = React.createContext({
  containerWidth: 0,
  tileCountPerRow: 4,
});

type Props = {
  containerWidth: number;
  tileCountPerRow: number;
  children: any;
};

const BoardProvider = ({
  children,
  containerWidth = 0,
  tileCountPerRow = 4,
}: Props) => {
  return (
    <BoardContext.Provider value={{ containerWidth, tileCountPerRow }}>
      {children}
    </BoardContext.Provider>
  );
};

To pass properties to all child components, BoardProvider uses the React Context API. If any component needs to use some value available in the provider, it can do so by calling the hook useBoard

I’ll skip this topic: I covered it in more detail in my video about Feature Toggles in React. If you want to know more about them, you can watch it:

const useBoard = () => {
  const { containerWidth, tileCount } = useContext(BoardContext);

  return [containerWidth, tileCount] as [number, number];
};

Game component

Now you can set the rules of the game and open the interface for the game. I’m going to start with navigation: this will help you understand why the logic of the game is implemented this way:

import { useThrottledCallback } from "use-debounce";

const Game = () => {
  const [tiles, moveLeft, moveRight, moveUp, moveDown] = useGame();

  const handleKeyDown = (e: KeyboardEvent) => {
  	// disables page scrolling with keyboard arrows
    e.preventDefault();
  
    switch (e.code) {
      case "ArrowLeft":
        moveLeft();
        break;
      case "ArrowRight":
        moveRight();
        break;
      case "ArrowUp":
        moveUp();
        break;
      case "ArrowDown":
        moveDown();
        break;
    }
  };

  // protects the reducer from being flooded with events.
  const throttledHandleKeyDown = useThrottledCallback(
    handleKeyDown,
    animationDuration,
    { leading: true, trailing: false }
  );

  useEffect(() => {
    window.addEventListener("keydown", throttledHandleKeyDown);

    return () => {
      window.removeEventListener("keydown", throttledHandleKeyDown);
    };
  }, [throttledHandleKeyDown]);

  return <Board tiles={tiles} tileCountPerRow={4} />;
};

As you can see, the game logic will be handled by a hook. useGamewhich presents the following properties and methods:

  • tiles Is an array of tiles available on the board. Used here TileMeta, which was discussed above.

  • moveLeft moves all tiles to the left side of the board.

  • moveRight moves all tiles to the right side of the board.

  • moveUp moves all tiles to the top of the board.

  • moveDown moves all tiles to the bottom of the board.

We work with a callback throttledHandleKeyDownto prevent the player from performing multiple movements at the same time.

Before the player can trigger another movement, he needs to wait for the animation to complete. This mechanism is called a throttling decorator. For him, I decided to use the hook useThrottledCallback package use-debounce

How to work with useGame

I mentioned above that the Game component handles the rules of the game. I don’t want to clutter the code, so we will not write the logics directly into the component, but extract it into a hook. useGame… This hook is based on the built-in hook in React useReducer… Let’s start by defining the shape of the reducer state:

type TileMap = { 
  [id: number]: TileMeta;
}

type State = {
  tiles: TileMap;
  inMotion: boolean;
  hasChanged: boolean;
  byIds: number[];
};

State useReducer contains the following fields:

  • tiles Is a hash table responsible for storing tiles. It makes it easy to find records by their keys, so it’s perfect: we want to find tiles by their IDs.

  • byIds Is an array containing all identifiers in ascending order. We need to keep the tiles in the correct order so that React doesn’t redraw the entire board every time we change state.

  • hasChange keeps track of changes to tiles. If nothing has changed, no new tile is created.

  • inMotion indicates if tiles are moving. If this is the case, then a new tile is not created until the end of the movement.

Action games

useReducer you need to specify the actions that are supported by this hook:

type Action =
  | { type: "CREATE_TILE"; tile: TileMeta }
  | { type: "UPDATE_TILE"; tile: TileMeta }
  | { type: "MERGE_TILE"; source: TileMeta; destination: TileMeta }
  | { type: "START_MOVE" }
  | { type: "END_MOVE" };

What are these actions responsible for?

  • CREATE_TILE creates a new tile and adds it to the tile hash table. Flag hasChange changes to false : This action is always triggered when a new tile is added to the board.

  • UPDATE_TILE updates existing tiles; does not change it idwhich is important for the animation to work. Let’s use this action to change the position of the tile and its value (during the merge). Also UPDATE_TILE changes the flag hasChange on true

  • MERGE_TILE combines the source tile and the destination tile. After this operation, the destination tile will change its value, that is, the value of the original tile will be added to it. The original tile is removed from the tile and array table byIds

  • START_MOVE tells the reducer to wait for multiple actions, which means it must wait for all animations to complete before it can generate a new tile.

  • END_MOVE informs the reducer that all actions are completed and he can create a new tile.

You can write the logic of this reducer yourself or copy mine:

Reducer
type TileMap = { 
  [id: number]: TileMeta;
}

type State = {
  tiles: TileMap;
  inMotion: boolean;
  hasChanged: boolean;
  byIds: number[];
};

type Action =
  | { type: "CREATE_TILE"; tile: TileMeta }
  | { type: "UPDATE_TILE"; tile: TileMeta }
  | { type: "MERGE_TILE"; source: TileMeta; destination: TileMeta }
  | { type: "START_MOVE" }
  | { type: "END_MOVE" };

const initialState: State = {
  tiles: {},
  byIds: [],
  hasChanged: false,
  inMotion: false,
};

const GameReducer = (state: State, action: Action) => {
  switch (action.type) {
    case "CREATE_TILE":
      return {
        ...state,
        tiles: {
          ...state.tiles,
          [action.tile.id]: action.tile,
        },
        byIds: [...state.byIds, action.tile.id],
        hasChanged: false,
      };
    case "UPDATE_TILE":
      return {
        ...state,
        tiles: {
          ...state.tiles,
          [action.tile.id]: action.tile,
        },
        hasChanged: true,
      };
    case "MERGE_TILE":
      const {
        [action.source.id]: source,
        [action.destination.id]: destination,
        ...restTiles
      } = state.tiles;
      return {
        ...state,
        tiles: {
          ...restTiles,
          [action.destination.id]: {
            id: action.destination.id,
            value: action.source.value + action.destination.value,
            position: action.destination.position,
          },
        },
        byIds: state.byIds.filter((id) => id !== action.source.id),
        hasChanged: true,
      };
    case "START_MOVE":
      return {
        ...state,
        inMotion: true,
      };
    case "END_MOVE":
      return {
        ...state,
        inMotion: false,
      };
    default:
      return state;
  }
};

If you don’t understand why we defined these actions, don’t worry – we are now implementing a hook that I hope will explain everything.

How to implement a hook

Let’s look at the function that is responsible for the player’s moves. Let’s focus only on the move to the left: the rest of the moves are practically the same.

const moveLeftFactory = () => {
    const retrieveTileIdsByRow = (rowIndex: number) => {
      const tileMap = retrieveTileMap();

      const tileIdsInRow = [
        tileMap[tileIndex * tileCount + 0],
        tileMap[tileIndex * tileCount + 1],
        tileMap[tileIndex * tileCount + 2],
        tileMap[tileIndex * tileCount + 3],
      ];

      const nonEmptyTiles = tileIdsInRow.filter((id) => id !== 0);
      return nonEmptyTiles;
    };

    const calculateFirstFreeIndex = (
      tileIndex: number,
      tileInRowIndex: number,
      mergedCount: number,
      _: number
    ) => {
      return tileIndex * tileCount + tileInRowIndex - mergedCount;
    };

    return move.bind(this, retrieveTileIdsByRow, calculateFirstFreeIndex);
  };
  
  const moveLeft = moveLeftFactory();

It can be seen that I decided to bind two callbacks to the move function. This technique is called inversion of control – so the consumer of the function can substitute eigenvalues ​​into the function being executed.

If you don’t know how it works bind, you should know about it. This question is often asked in interviews.

Kolbeck retrieveTileIdsByRow responsible for finding all non-empty tiles available in the row (for moving left or right). If the player moves up or down, we will look for all the tiles in the column.

Kolbeck calculateFirstFreeIndex, finds the position closest to the board border based on the given parameters such as the index of the tile, the index of the tile in a row or column, the number of tiles merged, and the maximum possible index.

Let’s look at the logic of the move function. I explained its code in the comments. The algorithm can be a little tricky, so I figured line-by-line comments would help me understand it:

RetrieveTileIdsByRowColumnCallback
type RetrieveTileIdsByRowOrColumnCallback = (tileIndex: number) => number[];

  type CalculateTileIndex = (
    tileIndex: number,
    tileInRowIndex: number,
    mergedCount: number,
    maxIndexInRow: number
  ) => number;

  const move = (
    retrieveTileIdsByRowOrColumn: RetrieveTileIdsByRowOrColumnCallback,
    calculateFirstFreeIndex: CalculateTileIndex
  ) => {
    // new tiles cannot be created during motion.
    dispatch({ type: "START_MOVE" });

    const maxIndex = tileCount - 1;

    // iterates through every row or column (depends on move kind - vertical or horizontal).
    for (let tileIndex = 0; tileIndex < tileCount; tileIndex += 1) {
      // retrieves tiles in the row or column.
      const availableTileIds = retrieveTileIdsByRowOrColumn(tileIndex);

      // previousTile is used to determine if tile can be merged with the current tile.
      let previousTile: TileMeta | undefined;
      // mergeCount helps to fill gaps created by tile merges - two tiles become one.
      let mergedTilesCount = 0;

      // interate through available tiles.
      availableTileIds.forEach((tileId, nonEmptyTileIndex) => {
        const currentTile = tiles[tileId];

        // if previous tile has the same value as the current one they should be merged together.
        if (
          previousTile !== undefined &&
          previousTile.value === currentTile.value
        ) {
          const tile = {
            ...currentTile,
            position: previousTile.position,
            mergeWith: previousTile.id,
          } as TileMeta;

          // delays the merge by 250ms, so the sliding animation can be completed.
          throttledMergeTile(tile, previousTile);
          // previous tile must be cleared as a single tile can be merged only once per move.
          previousTile = undefined;
          // increment the merged counter to correct position for the consecutive tiles to get rid of gaps
          mergedTilesCount += 1;

          return updateTile(tile);
        }

        // else - previous and current tiles are different - move the tile to the first free space.
        const tile = {
          ...currentTile,
          position: indexToPosition(
            calculateFirstFreeIndex(
              tileIndex,
              nonEmptyTileIndex,
              mergedTilesCount,
              maxIndex
            )
          ),
        } as TileMeta;

        // previous tile becomes the current tile to check if the next tile can be merged with this one.
        previousTile = tile;

        // only if tile has changed its position will it be updated
        if (didTileMove(currentTile, tile)) {
          return updateTile(tile);
        }
      });
    }

    // wait until the end of all animations.
    setTimeout(() => dispatch({ type: "END_MOVE" }), animationDuration);
  };

Complete code useGame contains more than 400 lines.

You can continue your study of modern web development in our courses:

Professions and courses

Data Science and Machine Learning

Python, web development

Mobile development

Java and C #

From the basics to the depth

And:

Article links

Similar Posts

Leave a Reply