In this post we will be creating a small game, 2048 in Elm. 2048 is a game created by gabrielecirulli which went viral in 2014. I chose 2048 because the game is simple which will help me/us understand Elm clearly. Also I didn’t want to make another todo app.
You can play here.
Whats the game?
- There are some number tiles on the board (4x4). The number on the tile will be a power of 2 with minimum being 2. See below image:
- You can press arrow keys: up, left, down, and right. This will trigger “merge” in the direction of the key-press. “merge” is the process of merging same number tiles into one and doubling its value. See below gif.
- The score is calculated by sum of number on tiles on the board, so you play to maximise your score by merging as many tiles as possible.
- A new tile (number 2) is introduced in the game at any random empty space with every key-press
- Game is over when no new tiles can be added, thats your final score (highest).
The Elm Architecture
Every Elm application follows the Elm Architecture, which is pretty straightforward.
- Model — the state of your application
- View — a way to turn your state into HTML
- Update — a way to update your state based on messages
We won’t deep dive into each of them, you can read more about Elm here.
Lets dive into building the game.
Building the game in Elm
Shaping Elm Architecture
Based on the game’s functioning, let’s start defining the architecture of our game from Elm’s perspective:
We have to store every tile’s data (number on the tiles and position of the tile). To store these tiles, we can introduce a matrix of
N being the number of rows/columns, here 4).
We can also store tiles as a list but matrix will be easy to render and visualize.
Since we kept our Model close to our View, in View we have to render this matrix into a matrix, with CSS.
In Update function we will regenerate the Model (state) depending upon the message it receives. Let’s look at different kind of messages we must have:
LeftMove: To enable merging leftwards
RightMove: To enable merging rightwards
UpMove: To enable merging upwards
DownMove: To enable merging downwards
Reset: For resetting the grid to the initial state
AddTile: To add a tile at any random available space
The structure of the game looks good, now we have to implement the handling of these messages.
Diving into our messages
LeftMovewill merge same number tiles on a row in left direction. Since the tiles to be merged are in same list, we can do map-reduce for rows and merge 2 tiles with the same number into one and double the number on the tile. Lets look at the code below which merges tiles in a list and returns the list:
mergeAndFillRow : List Cell -> List Cell mergeAndFillRow list = let updatedRow = mergeRow list in List.concat (updatedRow :: [ EmptyCell |> List.repeat ((list |> List.length) - (updatedRow |> List.length)) ] ) mergeRow : List Cell -> List Cell mergeRow list = case list of  ->  x1 :: xs -> if x1 == EmptyCell then mergeRow xs else case xs of  -> [ x1 ] x2 :: xs2 -> if x1 == x2 then mergeCell ( x1, x2 ) :: mergeRow xs2 else x1 :: mergeRow (x2 :: xs2)
mergeRow, we use recursion to reduce the list, and then in
mergeAndFillRowwe fill the list with empty tiles. Functional programming is fun!
LeftMovein the opposite direction. So we will reuse
mergeAndFillRow. How? By reversing the List before and after calling this function.
LeftMove, the merging operation is fairly easy since row elements are in a single list and therefore we can use
Listmethods to reduce and merge tiles, but in
UpMovecase, the tiles are in multi columns, which means different lists. Elementary Mathematics comes to our rescue. We can transpose the matrix and then call the function
Since Elm is a functional programming language there are no looping (for/while) constructs, and this makes transpose little challenging.
You can refer to the code below, uses recursion:
transposeMap : Grid -> Grid -> Grid transposeMap grid2 grid1 = case Array.get 0 grid1 of Just e -> transposeMap (grid2 |> transposeForIdx 0 e) (grid1 |> Array.slice 1 (grid1 |> Array.length)) Nothing -> grid2 transposeForIdx : Int -> Array Cell -> Grid -> Grid transposeForIdx idx list grid2 = case Array.get 0 list of Just e -> transposeForIdx (idx + 1) (list |> Array.slice 1 (list |> Array.length)) (case grid2 |> Array.get idx of Just l -> grid2 |> Array.set idx (l |> Array.push e) Nothing -> grid2 |> Array.push (Array.fromList [ e ]) ) Nothing -> grid2
Using the same technique as we did with
RightMove, we can first reverse the list, then transpose, call
mergeAndFillRow, transpose again, and finally reverse.
For this will create a list of available positions of tiles by traversing the matrix, and then randomly adding number tile 2 on any one of available positions.
Reset is initializing the matrix and sending
mergeCell is a common function which merges 2 tiles (Cell) and returns one tile:
swap : ( Cell, Cell ) -> ( Cell, Cell ) swap ( cell1, cell2 ) = ( cell2, cell1 ) mergeCell : ( Cell, Cell ) -> Cell mergeCell ( cell1, cell2 ) = case ( cell1, cell2 ) of ( Tile val1, Tile val2 ) -> Tile (val1 + val2) ( Tile val1, EmptyCell ) -> Tile val1 _ -> mergeCell (swap ( cell1, cell2 ))
Note: The above
mergeCell method fails when arguments are
EmptyCell, it gets stuck in infinite recursion. But since we never have that case, it never crashes.
For integrating key-presses and touch coordinates, we will use subscriptions.
You can look at the complete code here.