Stian Eikeland bio photo

Stian Eikeland

Developer, hacker, techno-foodie, diver, hobby-photographer. Does consultancy work from own company. Lives in Bergen, Norway.

Twitter Github Flickr Vimeo Instagram 500px

Clojure 2048 at the dojo

I've attended Bergen CodingDojo a few times this year. BCD is a monthly meet-up here in Bergen where developers meet for kata-solving, pizza and usually a few beers afterwards. Number of people showing up varies a lot, but usually there's around 10 - ranging from students to people who has been programming professionally for many years.

Generally it's quite fun, lot's of different katas, some easy, some hard, and some really hard (traveling santa problem, I'm looking at you..) considering the 2-3 hours time frame. The dojo usually occupies Miles' offices in Bergen.

Yesterday we did 2048, you know, the viral game that's been all over the internets lately. If you haven't seen it, just try it.

Paired with Andreas (@olsenius), we tried to solve it in Clojure, which I think it's a nice language for this particular problem (can't go wrong with a Lisp when the task is to manipulate a list of numbers based on a set of rules..). Used midje for tests, a testing framework that's growing on me, liking it more and more. Midje has this neat functionality for doing top-down developement in a convenient way, didn't really get much use for that here, expect for a tiny bit of mocking, but overall it's pretty nice. Only itch I have is that the test-runners (both repl and cli) can be a bit slow picking up file changes - and it seems to be missing support for .cljx-files (proposed file extension for clojure sources that are both for the jvm (.clj) and for clojurescript/javascript (.cljs)) - but I haven't really looked into it.

The clone we implemented, click image to play :)

(fact "slides to left"
  (slide :left [n n n n
                n n 2 n
                n n n n
                n n n n])
  => [n n n n
      2 n n n
      n n n n
      n n n n])

The tests look like the above, using midje. The rest are available at github, with the source code.

Implemented the core game rules first. Tried to exploit symmetry as much as possible - implemented the sliding and adding of numbers for one direction only - left, and then just rotated the board for down, right and up.

(ns twenty48.core)

(def ^:private dir->rot {:left 0
                         :down 1
                         :right 2
                         :up 3})

(defn- filter-not-nil [row]
  (filter identity row))

(defn- nil-pad-row [row]
  (take 4 (concat row (repeat nil))))

(defn- add-pairs [row]
  (loop [r row
         output []]
    (if (empty? r) output
        (let [cur (first r)
              next (second r)]
          (if (= cur next)
            (recur (drop 2 r) (conj output (* 2 cur)))
            (recur (drop 1 r) (conj output cur)))))))

(defn- rotate-right [board]
  (apply mapv #(into [] %&)
         (reverse board)))

(defn- rotate-board [n board]
  (nth (iterate rotate-right board) n))

(defn- random-element []
  (rand-nth [2 2 2 4]))

(defn- get-empty-positions [board]
  (reduce-kv (fn [s k v] (if (nil? v) (conj s k) s))

(defn- assoc-on-random-empty-pos [board elem]
  (let [pos (rand-nth (get-empty-positions board))]
    (assoc board pos elem)))

(defn create-game []
  (-> (repeat 16 nil)
      (assoc-on-random-empty-pos (random-element))
      (assoc-on-random-empty-pos (random-element))))

(defn slide [direction board]
  (->> (partition 4 board)
       (rotate-board (dir->rot direction))
       (map filter-not-nil)
       (map add-pairs)
       (map nil-pad-row)
       (rotate-board (- 4 (dir->rot direction)))
       (apply concat)

(defn move [direction board]
  (let [newboard (slide direction board)]
    (if (= board newboard) board
        (assoc-on-random-empty-pos newboard (random-element)))))

Since we had a bit of time left over after finishing the game logic, we decided to make a quick and dirty frontend. Stole the original game's CSS, and whacked out some HTML using Reagent. Didn't have time for any animations. Oh, and symlinked the core.clj file to core.cljs, that way it's available both for jvm-target (and testrunner) and for js-target (didn't have time to look into .cljx).

  (:require [reagent.core :as r :refer [atom]]
            [twenty48.core :as game]))

(def game-state (atom (game/create-game)))

(def keycode->direction {38 :up
                         40 :down
                         37 :left
                         39 :right})

(defn Cell [cell]
   (when cell [:div {:class (str "tile tile-" cell)}
               [:div.tile-inner (str cell)]])])

(defn Row [row]
   (map Cell row)])

(defn Grid []
   (map Row (partition 4 @game-state))])

(defn handle-keys [event]
  (when-let [key (keycode->direction (.-keyCode event))]
    (swap! game-state (partial game/move key))))

(defn ^:export run []
  (.addEventListener js/window "keydown" handle-keys)
  (r/render-component [Grid] (.getElementById js/document "game")))

That's the view, using Reagent. Reagent is a minimalistic clojurescript interface to React.js (you know - the hypermegasuperspeed-DOM-rendering-library from facetube that all the frontend people are hyped about these days). Reagent uses hiccup-style syntax, and has this neat feature where they provide their own version of atom. So if you already isolate your program state to an atom (which you often do in clojure), reagent automatically renders your changes using react and the shadow-DOM trick every time your state changes. Neat, love the idea of the reactive-atom.

Oh, and apparently it is quite a bit faster than the plain JavaScript version of react (at least it is for Om) - persistent/immutable data-structures enable lightning-fast change detection (reference equality checks are faaast). I love the fact that something that is considered really slow and bad for performance can enable lightning-fast implementations of something a few abstraction levels up the stack - apparently even blew the mind of the author of Om - he didn't expect it.

All in all, a fun little kata. The code is all available on github. Takes impressively little code to knock out something like this using clojure and react I think. Total just around 70 lines for game logic and view. Nice!