2048 in Clojurescript
2048 is a pretty simple matching game that massively took off a couple of years ago. The original game was implemented in a weekend so it makes it a good candidate to replicate in clojurescript. I’ve taken a similar approach to tic-tac-toe but have also added in tests and spec to round things off.
Game state
Seeing as we’re using clojure spec, let’s start with the definitions for the game board.
(s/def ::piece
(s/with-gen
(s/and int?
#(> (.indexOf [0 2 4 8 16 32 64 128 256 1024 2048] %) -1))
#(s/gen #{0 2 4 8 16 32 64 128 256 1024 2048})))
(s/def ::row
(s/coll-of ::piece :kind vector? :count 4))
(s/def ::board
(s/and (s/coll-of ::row :kind vector? :count 4)
#(apply = (map count %))))
(s/def ::score
(s/and int? pos?))
The playing pieces in 2048 are powers of 2 between 0 and 2048. Some versions of the game allow you to play beyond 2048 to get the highest score but for simplicity’s sake I’ll end the game at “victory”.
The row is a collection of 4 pieces and the game baord is a collection of 4 rows.
Finally, the score is just defined as a positive integer.
(defonce app-state
(reagent/atom
{:board (generate-board)
:score 0
:victory false
:defeat false}))
The global state is a map in a reagent atom that contains the game board (generated at start up), the current score and whether victory or defeat states have been reached.
(defn generate-board []
[[0 0 0 0]
[0 0 0 0]
[0 0 2 0]
[0 0 0 0]])
The generate-board
function just returns a basic game board with one piece already placed.
Rendering the board
Rendering the game board is almost identical to how it was done in tic-tac-toe, after all they are both simply elements layed out in a grid. The only real difference is that this is slightly simpler as there is no click handler to implement, we’re going to rely on keyboard input. I hava added a reset button to the top bard to make sure you can quickly get your next fix of the game.
(defn page [state]
(let [b (:board @state)]
[:div
[:nav {:class "navbar navbar-default"} 2048
[:span (:score @state)]
[:span {:on-click #(reset)} "Reset"]]
[:div {:id "board"}
(render-board b)]
(when (:victory @state)
[:div "Victory"])
(when (:defeat @state)
[:div {:id "defeat"} "Defeat"])]))
(defn render-cell [y row]
(map-indexed
(fn [a x]
[:span {:class (str "cell n" x)
:key (str "cell" a y)}
(if (zero? x)
" "
x)])
row))
(defn render-board [board]
(map-indexed
(fn [y x]
[:div {:class "row"
:key (str "row" y)}
(render-cell y x)])
board))
Game logic
First a quick recap of the rules of 2048.
- The user picks a direction (↑,↓,← or →).
- All of the pieces move in that direction, filling any blank spaces.
- If 2 pieces of the same value meet, the combine and take the sum of their values as their new value. This only happens once per piece per turn.
- If there are no free spaces on the board, and no possible merges then the game is over.
- If a piece of value 2048 is on the board then the player is victorious.
- After every turn a new piece of value 2 is added to a blank space.
There are several different challenges in there and I think it’s useful to break them down.
Moving
The first problem we’ve got is that there are 4 directions to move in. Given the data structure that we’ve used for the game board it should be obvious that some of these directions are going to be easier to process than others. I want to create a slightly more abstract concept of the move and then have each different direction build on that. Let’s start with looking at just one row of the board.
If I have the row [0 2 0 4]
after I move to the right that should be [0 0 2 4]
. I could achieve this by some kind of loop and looking ahead of the piece to see if there’s space to move, but my gut feeling is that will require a lot of index management and just feels wrong. How about a more functional approach?
The approach I am going to take is to take the vector [0 2 0 4]
, filter out all the 0
so it becomes [2 4]
and then pad the front of the vector with 0
to get it back to the full length as [0 0 2 4]
.
(defn push-right [row]
(let [tail (filter pos? row)
shift (- (count row) (count tail))
padding (map (fn [x] 0) (range 0 shift))]
(vec (concat padding tail))))
(s/fdef push-right
:args (s/cat :row ::row)
:ret ::row
:fn #(= (filter zero? (:ret %))
(take (count (filter zero? (-> % :args :row))) (:ret %))))
I’ve called this function push-right
. It will also work on any sized game board in case we want the option of changing that in the future.
Now the pieces are all together let’s do the merging in a separate step:
(defn merge-right
"from the right - merge 2 adjacent cells, setting the outermost one to 0"
[row]
(loop [[x & xs] (reverse row)
merged false
output []]
(cond
(nil? x) (reverse output)
(true? merged) (recur xs false (concat output [0]))
(= x (first xs)) (recur xs true (concat output [(* 2 x)]))
:else (recur xs false (concat output [x])))))
Here we loop through the row to remove any pairs. One slight quirk is that because I want to always merge the rightmost of a group of numbers, i.e. [0 2 2 2]
becomes [0 0 2 4]
and not [0 0 4 2]
, it’s easier to reverse and then unreverse the row.
The logic is broken down simply as:
- if the current piece is different to the next piece and there hasn’t just been a merge add it to straight to the output vector and carry on the loop.
- if the current piece is the same as the next piece then add twice it’s value to the output vector and set the merged flag to true.
- if the merged flag is true then add
0
to the output vector. - when we run out of value reverse the output vector so it’s back to its original direction and return it.
A keen eyed reader might spot that we don’t actually get the final output from this step. The vector [0 2 2 2]
becomes [0 2 0 4]
, introducing 0
back in to the middle. To get round this we can do another push-right
after the merge, giving us a row-move
function like:
(defn row-move [row]
(-> row
push-right
merge-right
push-right))
Now we just need to take this and use it to build moving functions for each direction:
(defn move-board-right [board]
(map row-move board))
(defn move-board-left [board]
(->> board
(map reverse)
move-board-right
(map reverse)))
(defn move-board-down [board]
(->> board
(apply map list)
move-board-right
(apply map list)))
(defn move-board-up [board]
(->> board
(apply map list)
(map reverse)
move-board-right
(map reverse)
(apply map list)))
Moving the board right is easy, it’s just mapping the row move function across all rows of the board. Likewise with left we just need to reverse and then un-reverse all the rows. To move the board down we have to transpose the board using (apply map list)
and then untranspose it. Moving the board up is the same as down but with a reverse and unreverse. All of the difficult logic about moving stays exactly the same for each direction, with only some data shuffling to get the input into the right format.
Adding a piece
Once a move has been completed a new piece is added to the board in an empty space. First we define a helper function called get-empty-spaces
to produce a list of co-ordinates of the empty spaces on the board.
(defn get-empty-spaces [board]
(filter some?
(mapcat identity
(map-indexed
(fn [i x]
(map-indexed
(fn [j y] (when (zero? y)
(list i j)))
x))
board))))
The output of this is a list of 2 element lists, e.g. ((0 1) (2 2) (2 3))
. The neat thing about this is that any of those 2 element lists can be passed into a function like assoc-in
to directly reference the cell we’re talking about.
(defn add-piece
"count the number of 0s on the board and add at least one number"
[board]
(let [spaces (get-empty-spaces board)]
(assoc-in (vec (map vec board)) (rand-nth spaces) 2)))
(s/fdef add-piece
:args (s/cat :board ::board)
:ret ::board)
To actually add the piece to the board we first get the list of empty spaces, then using rand-nth
one of them is just picked at random. The output of that is passed straight into assoc-in
and the cell set to 2
. The only slight quirk here is the application of vec
to the board and its rows. At some point in the logic above the board becomes ’lazy’, meaning assoc-in
won’t work.
Scoring
The score in 2048 is the sum of all the pieces that have been merged / 2, i.e. if you merge two 2
s to create a 4
you get 2 points. I’m sure there are a number of ways of tackling this. I took the approach of comparing before and after board states.
(defn get-score [old new]
(let [a (frequencies (flatten old))
b (frequencies (flatten new))]
(reduce +
(filter pos?
(map (fn [x] (/
(* (first x)
(- (last x)
(get a (first x) 0)))
2))
b)))))
(s/fdef get-score
:args (s/cat :old ::board :new ::board)
:ret ::score)
We take the original and the post-move boards and flatten them into one long list. We then pass that list into the frequencies
function. This produces a map of each unique item and the number of times they appear.
The new board is passed into the map function. For each element in it we look up in the original board and retrieve the original count, or 0 if the piece wasn’t there, (get a (first x) 0)
. We then subtract this from the frequency of the piece in the new board, and divide by 2. We filter out any negative numbers, as these represent the pieces that have been merged from and then sum the result. The output here is the incremental score from this turn.
End states
Victory checking is nice and simple. All we have to do is check for the presence of 2048
anywhere in the game board:
(defn victory? [board]
(some true?
(map (fn [x] (some #(= 2048 %) x)) board)))
Defeat is a bit harder, although it can be checked from the accumulation of previous functions.
(defn defeat? [board]
(cond
(pos? get-empty-spaces) false
(and
(= (move-board-up board) board)
(= (move-board-down board) board)
(= (move-board-right board) board)
(= (move-board-left board) board)) true
:else false))
Firstly if there are any empty spaces then it can’t be defeat. We then do a test move in every direction and check whether the post test move board is the same as the current board. If there are any differences then there is a possible move and it’s not defeat. If they are all the same, however, there’s nothing left for the player to do and it’s defeat.
Attaching the input
With all the logic in place the game just needs to be tied to user input. The player uses the arrow keys on their keyboard to define the direction of movement. Here’s a simple map of the key codes to their symbols.
(def codename
{37 :left
38 :up
39 :right
40 :down
32 :space})
Each of those symbols is then mapped to an input to the game turn function move
, which is explained a bit lower down:
(def action
{:right #(move :right)
:left #(move :left)
:up #(move :up)
:down #(move :down)})
Finally, a handler for key presses is added in. This is added as an event listener in the main initialisation function of the app.
(defn handle-keydown [e]
(let [b (:board @app-state)]
(when-let [f (action (codename (.-keyCode e)))]
(.preventDefault e)
(swap! app-state assoc :board (f)))))
The Game Turn
(defn move
([direction]
(move direction (:board @app-state)))
([direction board]
(let [b
(case direction
:right (move-board-right board)
:left (move-board-left board)
:up (move-board-up board)
:down (move-board-down board))]
(cond
(victory? b) (do (swap! app-state assoc :victory true)
(swap! app-state update :score + (get-score board b))
b)
(defeat? b) (do (swap! app-state assoc :defeat true)
(swap! app-state update :score + (get-score board b))
b)
(not= b board) (do
(swap! app-state update :score + (/ (get-score board b) 2))
(add-piece b))
:else board))))
This function ties everything together. It’s pretty straight forward, taking the keyboard input and triggering the appropriate board move functions. It then checks for victory, defeat and if there’s a successful move updates the score and adds a piece.
Conclusion
I’ve left out a tiny bit of scaffold code. The full code is available on my github. There are also some unit tests up there. In coding this it’s actually the first time that I’ve ever completed the game, and have now done multiple times. Clojure really comes into its own again here as the game is purely about manipulation of data.