Today you will learn how to build your own clone of the 2048 game in React. What makes this article unique is that we will focus on creating delightful animations. Aside from React, we will use TypeScript and we'll make some CSS transitions using LESS. We are only going to use modern React interfaces such as hooks and the Context API. This article contains a few external resources such as: 2048 Game (GitHub Pages) Animation Examples for 2048 (GitHub Pages) Source Code (GitHub) ...and a YouTube video. It took me more than a month to prepare this tutorial, so it would mean the world to me if you watch it, smash the like button, and subscribe to my channel. Thank you! 2048 Game Rules In this game, the player must combine tiles containing the same numbers until they reach the number 2048. The tiles can contain only integer values starting from 2, and that are a power of two, like 2, 4, 8, 16, 32, and so on. Ideally, the player should reach the 2048 tile within the smallest number of steps. The board has dimension of 4 x 4 tiles, so that it can fit up to 16 tiles. If the board is full, and there is no possible move to make like merging tiles together - the game is over. While creating this tutorial I took shortcuts to focus on the game mechanics and animations. What did I get rid of? In our example, the game always creates a new tile with the number 2. But in the proper version it should generate a random number (either 2 or 4 - to make it harder to play). Also, we will not handle wins and loses. You can play after you complete the number 2048, and nothing happens when the board is unsolvable - you need to click the reset button. I skipped the scoring. If you want you can implement those missing features on your own. Just fork my repository and implement it on your own setup. The Project Structure The application contains the following elements: Board (component) – responsible for rendering tiles. It exposes one hook called . useBoard Grid (component) – renders 4x4 grid. Tile (component) – responsible for all animations related to the tile, and rendering the tile itself. Game (component) – combines all elements above together. It includes a hook which is responsible for enforcing the game rules and constrains. useGame How to Build the Tile Component We want to invest more time in animations, so I will start the story from the Tile component. In the end, this component is responsible for all animations in the game. There are only two fairly simple animations in 2048 – tile highlighting, and sliding it across the board. We can handle those animations with CSS transitions by declaring the following styles: { // ... : transform; : ; : (1); } .tile transition-property transition-duration 100ms transform scale At the current moment I defined only one transition that will highlight the tile when it is created or merged. We will leave it like that for now. Let's consider how the Tile meta data is supposed to look, so we can easily use it. I decided to call it since we don't want to have the name conflict with other entities such as the Tile component: TileMeta TileMeta = { id: ; position: [ , ]; value: ; mergeWith?: ; }; type number number number number number – the unique identifier of the tile. It is important so that the React DOM doesn't re-render all tiles from scratch on every change. Otherwise, we would see all tiles highlighted on every action of the player. id – the position of the tile on the board. It is an array with two elements, the and coordinate (the possible values are - in both cases). position x y 0 3 – the tile value. Only the power of two, starting from . value 2 – (optional) the id of the tile which is going to absorb the current tile. If it is present, the tile should be merged into another tile, and disappear. mergeWith How to Create and Merge Tiles We want to somehow highlight that the tile changed after the player's action. I think the best way would be changing the tile's scale to indicate that a new tile has been created or one has been changed. Tile = { [scale, setScale] = useState( ); prevValue = usePrevProps< >(value); isNew = prevCoords === ; hasChanged = prevValue !== value; shallAnimate = isNew || hasChanged; useEffect( { (shallAnimate) { setScale( ); setTimeout( setScale( ), ); } }, [shallAnimate, scale]); style = { transform: , }; ( <div className={ } style={style}> {value} < export const ( ) => { value, position }: Props const 1 const number const undefined const const => () if 1.1 => () 1 100 const `scale( )` ${scale} return `tile tile- ` ${value} /div> ); }; To trigger the animation, we need to consider two cases: a new tile – the previous value will be . null the tile changed the value – the previous value will be different than the current one. And the result is the following: You might've noticed that I'm using a custom hook called . It helps to track the previous values of the component properties (props). usePrevProps I could use references to retrieve the previous values but it would clutter up my component. I decided to extract it into a standalone hook, so the code is readable, and I can use this hook in other places. If you want to use it in your project, just copy this snippet: { useEffect, useRef } ; usePrevProps = <K = > { ref = useRef<K>(); useEffect( { ref.current = value; }); ref.current; }; import from "react" /** * `usePrevProps` stores the previous value of the prop. * * @param {K} value * @returns {K | undefined} */ export const any ( ) => value: K const => () return How to Slide Tiles Across the Board The game will look janky without animated sliding of tiles across the board. We can easily create this animation by using CSS transitions. The most convenient will be to use properties responsible for positioning, such as and . So we need to modify our CSS styles to look like this: left top { : absolute; // ... : left, top, transform; : , , ; : (1); } .tile position transition-property transition-duration 250ms 250ms 100ms transform scale Once we've declared the styles, we can implement the logic responsible for changing a tile's position on the board. Tile = { [boardWidthInPixels, tileCount] = useBoard(); useEffect( { }, [shallAnimate, scale]); positionToPixels = { (position / tileCount) * (boardWidthInPixels ); }; style = { top: positionToPixels(position[ ]), left: positionToPixels(position[ ]), transform: , zIndex, }; }; export const ( ) => { value, position, zIndex }: Props const // ... => () // ... const ( ) => position: number return as number const 1 0 `scale( )` ${scale} // ... As you can see, the equation in the function needs to know the position of the tile, the total amount of tiles per row and column, and the total board length in pixels (width or height – same, it is a square). The calculated value is passed down to the HTML element as an inline style. positionToPixels Wait a minute... but what about the hook and property? useBoard zIndex allows us to access the properties of the board within the children components without passing them down. The Tile component needs to know the width and total count of tiles to find the right spot on the board. Thanks to React Context API we can share properties across multiple layers of components without polluting their properties (props). useBoard is a CSS property that defines the order of tile on the stack. In our case it is the id of the tile. As you can see on the gif below, the tile can be stacked on each other, so the enabled us to specify which one will be on top. zIndex zIndex How to Build the Board Another important part of the game is the Board. The component is responsible for rendering the grid and tiles. Board It seems like the Board has duplicated business logic with Tile component, but there is a small difference. The Board holds information about its size (width and height), and number of columns and rows. It's the opposite of the Tile which only knows its own position. Props = { tiles: TileMeta[]; tileCountPerRow: ; }; Board = { containerWidth = tileTotalWidth * tileCountPerRow; boardWidth = containerWidth + boardMargin; tileList = tiles.map( ( <Tile key={ } {...restProps} zIndex={id} /> )); ( <div className= style={{ width: boardWidth }}> <BoardProvider containerWidth={containerWidth} tileCountPerRow={tileCountPerRow}> <div className= >{tileList}< > < div> ); }; type number const ( ) => { tiles, tileCountPerRow = 4 }: Props const const const ( ) => { id, ...restProps } `tile- ` ${id} return "board" "tile-container" /div> <Grid / /BoardProvider> </ The Board component uses the to distribute the width of the tile container and amount of tiles per row and column to all tiles and the grid component. BoardProvider BoardContext = React.createContext({ containerWidth: , tileCountPerRow: , }); Props = { containerWidth: ; tileCountPerRow: ; children: ; }; BoardProvider = ({ children, containerWidth = , tileCountPerRow = , }: Props) => { ( <BoardContext.Provider value={{ containerWidth, tileCountPerRow }}> {children} < const 0 4 type number number any const 0 4 return /BoardContext.Provider> ); }; The uses the React Context API to propagate properties down to every child. If any component needs to use any value available on the provider it can retrieve it by calling the hook. BoardProvider useBoard I am going to skip this topic since I spoke more about it in my . If you wish to learn more about them you can watch it. video on Feature Toggles in React useBoard = { { containerWidth, tileCount } = useContext(BoardContext); [containerWidth, tileCount] [ , ]; }; const => () const return as number number How to Build the Game Component Now we can specify the rules of game, and expose the interface to play the game. I am going to start with the navigation since it will help you understand why the game logic is implemented that way. { useThrottledCallback } ; Game = { [tiles, moveLeft, moveRight, moveUp, moveDown] = useGame(); handleKeyDown = { e.preventDefault(); (e.code) { : moveLeft(); ; : moveRight(); ; : moveUp(); ; : moveDown(); ; } }; throttledHandleKeyDown = useThrottledCallback( handleKeyDown, animationDuration, { leading: , trailing: } ); useEffect( { .addEventListener( , throttledHandleKeyDown); { .removeEventListener( , throttledHandleKeyDown); }; }, [throttledHandleKeyDown]); <Board tiles={tiles} tileCountPerRow={ } />; }; import from "use-debounce" const => () const const ( ) => e: KeyboardEvent // disables page scrolling with keyboard arrows switch case "ArrowLeft" break case "ArrowRight" break case "ArrowUp" break case "ArrowDown" break // protects the reducer from being flooded with events. const true false => () window "keydown" return => () window "keydown" return 4 As you can see, the game logic will be handled by the hook that exposes the following properties and methods: useGame – an array of tiles available on the board. It uses the TileMeta type described above. tiles – a function that slides all tiles to the left side of the board. moveLeft – a function that slides all tiles to the right side of the board. moveRight – a function that slides all tiles to the top of the board. moveUp – a function that slides all tiles to the bottom of the board. moveDown We use the callback to prevent players from flooding the game with tons of moves at the same time. Basically, the player needs to wait until the animation is complete before they can trigger another move. throttledHandleKeyDown This mechanism is called throttling. I decided to use the hook from the package. useThrottledCallback use-debounce How to Use the useGame Hook I mentioned above that the Game component will be handling the rules of the game as well. We are going to extract the game logic into a hook rather than writing it directly onto the component (since we don't want to clutter the code). The hook is based on the hook which is a built-in hook within React. I will start by defining the shape of the reducer's state. useGame useReducer TileMap = { [id: ]: TileMeta; } State = { tiles: TileMap; inMotion: ; hasChanged: ; byIds: []; }; type number type boolean boolean number The state contains the following fields: – a hash table responsible for storing tiles. The hash table makes it easy to lookup entries by their keys, so it is a perfect match for us since we want to find tiles by their ids. tiles – an array containing all ids in the expected order (that is, ascending). We must keep the right order of tiles, so React doesn't re-render the whole board every time we change the state. byIds – keeps track of tile changes. If nothing has changed the new tile will not be generated. hasChange – determines if tiles are still moving. If they are, the new tile will not be generated until the motion is completed. inMotion Actions requires to specify the actions that are supported by this reducer. useReducer Action = | { : ; tile: TileMeta } | { : ; tile: TileMeta } | { : ; source: TileMeta; destination: TileMeta } | { : } | { : }; type type "CREATE_TILE" type "UPDATE_TILE" type "MERGE_TILE" type "START_MOVE" type "END_MOVE" What are those actions responsible for? – creates a new tile, and appends it into the tiles hash table. It changes the flag to since this action is always triggered when a new tile is appended to the board. CREATE_TILE hasChange false – updates an existing tile. It doesn't modify the id which is important to keep the animations working. We will use it to reposition the tile and change its value (during merges). Also, it changes the flag to . UPDATE_TILE hasChange true – merges a source tile into a destination tile. After this operation, the destination tile will change its value (the value of the source tile will be added into it). And it will remove the source tile from the tiles table and array. MERGE_TILE byIds – tells the reducer it should expect multiple actions, so it must wait until all animations are complete before it will be able to generate a new tile. START_MOVE – tells the reducer all actions were completed, and it can safely create a new tile. END_MOVE If you don't understand why we defined those actions, don't worry – now we are going to implement a hook which will hopefully shed some light on it. How to Implement the Hook Let's look into the function which is responsible for a player's moves. We will focus on the move left only since the other ones are almost the same. moveLeftFactory = { retrieveTileIdsByRow = { tileMap = retrieveTileMap(); tileIdsInRow = [ tileMap[tileIndex * tileCount + ], tileMap[tileIndex * tileCount + ], tileMap[tileIndex * tileCount + ], tileMap[tileIndex * tileCount + ], ]; nonEmptyTiles = tileIdsInRow.filter( id !== ); nonEmptyTiles; }; calculateFirstFreeIndex = ( tileIndex: , tileInRowIndex: , mergedCount: , _: ) => { tileIndex * tileCount + tileInRowIndex - mergedCount; }; move.bind( , retrieveTileIdsByRow, calculateFirstFreeIndex); }; moveLeft = moveLeftFactory(); const => () const ( ) => rowIndex: number const const 0 1 2 3 const ( ) => id 0 return const number number number number return return this const As you can see, I decided to bind two callbacks to the function. This technique is called the inversion of control – so the consumer of the function will be able to inject their own values into the executed function. move If you don't know how works you should learn about it because it is a very common question on job interviews. bind The first callback called is responsible of finding all non-empty tiles available in a row (for horizontal moves – left or right). If the player does the vertical (up or down) moves we will look for all tiles in a column. retrieveTileIdsByRow The second callback called finds the closest position to the board's border based on the given parameters such as tile index, index of the tile in row or column, number of merged tiles, and the maximum possible index. calculateFirstFreeIndex Now we are going to look into the business logic of the function. I've explained the code of this function in the comments. The algorithm might be a bit complex, and I believe It will be easier to understand it if I document the code line by line: move RetrieveTileIdsByRowOrColumnCallback = []; CalculateTileIndex = ( tileIndex: , tileInRowIndex: , mergedCount: , maxIndexInRow: ) => ; move = ( retrieveTileIdsByRowOrColumn: RetrieveTileIdsByRowOrColumnCallback, calculateFirstFreeIndex: CalculateTileIndex ) => { dispatch({ : }); maxIndex = tileCount - ; ( tileIndex = ; tileIndex < tileCount; tileIndex += ) { availableTileIds = retrieveTileIdsByRowOrColumn(tileIndex); previousTile: TileMeta | ; mergedTilesCount = ; availableTileIds.forEach( { currentTile = tiles[tileId]; ( previousTile !== && previousTile.value === currentTile.value ) { tile = { ...currentTile, position: previousTile.position, mergeWith: previousTile.id, } TileMeta; throttledMergeTile(tile, previousTile); previousTile = ; mergedTilesCount += ; updateTile(tile); } tile = { ...currentTile, position: indexToPosition( calculateFirstFreeIndex( tileIndex, nonEmptyTileIndex, mergedTilesCount, maxIndex ) ), } TileMeta; previousTile = tile; (didTileMove(currentTile, tile)) { updateTile(tile); } }); } setTimeout( dispatch({ : }), animationDuration); }; type ( ) => tileIndex: number number type number number number number number const // new tiles cannot be created during motion. type "START_MOVE" const 1 // iterates through every row or column (depends on move kind - vertical or horizontal). for let 0 1 // retrieves tiles in the row or column. const // previousTile is used to determine if tile can be merged with the current tile. let undefined // mergeCount helps to fill gaps created by tile merges - two tiles become one. let 0 // interate through available tiles. ( ) => tileId, nonEmptyTileIndex const // if previous tile has the same value as the current one they should be merged together. if undefined const as // delays the merge by 250ms, so the sliding animation can be completed. // previous tile must be cleared as a single tile can be merged only once per move. undefined // increment the merged counter to correct position for the consecutive tiles to get rid of gaps 1 return // else - previous and current tiles are different - move the tile to the first free space. const as // previous tile becomes the current tile to check if the next tile can be merged with this one. // only if tile has changed its position will it be updated if return // wait until the end of all animations. => () type "END_MOVE" The complete code of this hook has more than 400 lines of code, so instead of pasting it here I decided to keep it on GitHub – so please . review the complete code there Summary I hope you enjoyed my tutorial. This time I decided to focus on the essence of the topic rather than building basic React and CSS, so I skipped those basic parts. I believe it makes this article easier to digest. Any feedback or questions? ! Shout at me on twitter If you found this article helpful please share it, so more developers can learn from it. Occasionally I , and it would be great if you subscribe to my channel, hit the like button, and drop a comment under your favourite video. publish videos on my YouTube channel Stay tuned! This article was first published at: https://www.freecodecamp.org/news/how-to-make-2048-game-in-react/