In this post I 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 mechanics are simple which will help us understand Elm clearly. Also, I didn’t want to make another todo app.
You can play here.
The Game Mechanics?
- 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 above 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).
Simple eh!
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:
Model
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 NxN (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.
View
Since we kept our Model close to our View, in View we have to render this matrix into a matrix, with CSS.
Update
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 leftwardsRightMove: To enable merging rightwardsUpMove: To enable merging upwardsDownMove: To enable merging downwardsReset: For resetting the grid to the initial stateAddTile: 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
LeftMoveLeftMovewill 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:mergeAndFillRowmergeAndFillRow : List Cell -> List Cell mergeAndFillRow list = let updatedRow = mergeRow list in List.concat (updatedRow :: [ EmptyCell |> List.repeat ((list |> List.length) - (updatedRow |> List.length)) ] )mergeRowmergeRow : 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)In
mergeRow, we use recursion to reduce the list, and then inmergeAndFillRowwe fill the list with empty tiles. Functional programming is fun!RightMoveRightMoveisLeftMovein the opposite direction (Thanks, I think). So we will reusemergeAndFillRow. How? By reversing theListbefore and after calling this function.UpMoveFor
RightMove, andLeftMove, the merging operation is fairly easy since row elements are in a single list and therefore we can useListmethods to reduce and merge tiles, but inUpMovecase, the tiles are in multi columns, meaning items are in different lists. Here, elementary Mathematics comes to our rescue. We can transpose the matrix and then call the functionmergeAndFillRowto implementUpMove.
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:
transposeMaptransposeMap : 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 -> grid2transposeForIdxtransposeForIdx : 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 -> grid2DownMoveUsing the same technique as we did with
RightMove, we can: reverse the list, transpose, callmergeAndFillRow, transpose again, and finally reverse.AddTileFor 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.
ResetReset is initializing the matrix and sending
AddTilemessage.
Merging cells:
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 and EmptyCell, it gets stuck in infinite recursion. But since we never have that case, it never crashes.
Subscriptions
For integrating key-presses and touch coordinates, we will use subscriptions.
Game is ready, now time for icing on the cake.
Adding CSS
I have replicated same color codes as in the original game.
You can look at the complete code here.
