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 N
xN
(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
LeftMove
LeftMove
will 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
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
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)
In
mergeRow
, we use recursion to reduce the list, and then inmergeAndFillRow
we fill the list with empty tiles. Functional programming is fun!RightMove
RightMove
isLeftMove
in the opposite direction (Thanks, I think). So we will reusemergeAndFillRow
. How? By reversing theList
before and after calling this function.UpMove
For
RightMove
, andLeftMove
, the merging operation is fairly easy since row elements are in a single list and therefore we can useList
methods to reduce and merge tiles, but inUpMove
case, 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 functionmergeAndFillRow
to 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:
transposeMap
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
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
DownMove
Using the same technique as we did with
RightMove
, we can: reverse the list, transpose, callmergeAndFillRow
, transpose again, and finally reverse.AddTile
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
Reset is initializing the matrix and sending
AddTile
message.
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.