Tetris Analyzer in Clojure - part 2

A short introduction to Clojure
Part 1

In my initial Clojure implementation of Tetris Analyzer I chose to represent the board as a one-dimensional vector, then in my second attempt I tried a two-dimensional vector.
The result for both was disapointingly ugly, and when it's ugly - then it's wrong!

Ugly code is often complex. The first met all my criterias of complex code: 
  • Code that is hard to read and understand
  • Code that is hard to change
  • When it's too much code
We will compare the rejected versions with the chosen one at the end of this post.

The new board model

After some thinking I realized that the board could be represented as a hash map where each key corresponds to a position on the board, stored as a vector "pair", [y x]:

Let's go through the improved solution and look at the first board related function in core.clj.

row->str

This function converts a board row to a string:
(defn- row->str [row]
  (apply str (map (fn [[_ piece]] (piece->char piece)) row)))
If we feed the inner map function with a board row, we get:
(map (fn [[_ piece]] (piece->char piece)) {[1 0] 9, [1 1] 0, [1 2] 1, [1 3] 2 })
;= (\# \- \I \Z)
The next two functions, apply and str, convert the list of characters to a string:
(apply str '(\# \- \I \Z))
;= "#-IZ"

board->str

This function converts a board into a printable string format. It uses the ->> macro which allows us to arrange the functions in the order they are executed:
(defn board->str [board width]
  (->> board
       sort
       (partition width)
       (map row->str)
       (clojure.string/join "\n")))
...which is equivalent to:
(defn board->str [board width]
  (clojure.string/join "\n" (map row->str (partition width (sort board)))))

It's a matter of taste which one you prefer. Let's try to explain this function, one step at a time, by calling it with a 5 x 2 board:
1. sort the board based on the order of the keys:
(sort { [0 1] 0, [0 0] 9, [1 2] 1, [0 3] 0, [0 4] 9,
        [1 1] 0, [1 0] 9, [0 2] 1, [1 3] 2, [1 4] 9 })
;=    ( [0 0] 9, [0 1] 0, [0 2] 1, [0 3] 0, [0 4] 9,
        [1 1] 9, [1 0] 0, [1 2] 1, [1 3] 2, [1 4] 9 )
2. partition into rows:
(partition 5 '( [0 0] 9, [0 1] 0, [0 2] 1, [0 3] 0, [0 4] 9 ,
                [1 1] 9, [1 0] 0, [1 2] 1, [1 3] 2, [1 4] 9 ))
;= ( ([[0 0] 9] [[0 1] 0] [[0 2] 1] [[0 3] 0] [[0 4] 9]) 
     ([[1 0] 9] [[1 1] 0] [[1 2] 1] [[1 3] 2] [[1 4] 9]) )
3. map each row to a string:
(map row->str '( ([[0 0] 9] [[0 1] 0] [[0 2] 1] [[0 3] 0] [[0 4] 9]) 
                 ([[1 0] 9] [[1 1] 0] [[1 2] 1] [[1 3] 2] [[1 4] 9])) )
;= ("#-I-#" "#-IZ#")
4. join the rows into a board, concatenate with "\n":
(clojure.string/join "\n" '("#-I-#" "#-IZ#"))
;= ("#-I-#\n#-IZ#")
And if printed, we get the "board":
#-I-#
#-IZ#

set-piece

The set-piece function can set a piece on a board:
(defn set-piece [board piece rotation x y]
  (apply assoc board (rotate-and-move-piece piece rotation x y)))

The function's test explains pretty well what it does:
;; Returns a new board with a piece set, e.g.:
;;
;;              board       piece rotation x y
;;              ----------- ----- -------- - -
;;   (set-piece empty-board    2      0    3 1)
;;
;;    piece = 2      piece Z
;;    rotation = 0   no rotation
;;    x,y = 3,1      position on the board
;;
(expect (str "#------#\n"
             "#--ZZ--#\n"
             "#---ZZ-#\n"
             "#------#\n"
             "#------#\n"
             "########")
        (board->str (set-piece empty-board 2 0 3 1) 8))

The inner form of the function produces a list with elements that has the same format as the board cells;  [y x] value:
(rotate-and-move-piece 6 1 4 2)
;= ([2 4] 6, [3 4] 6, [3 5] 6, [4 4] 6)
...the resulting list is used by apply and assoc to put a piece on the board. Note that the original board is left untouched and that a new board is returned. What makes this possible is the immutable collections in Clojure.

str->row

This function is defined as private by defn- as it's only used by new-board:
(defn- str->row [row y]
  (map-indexed #(vector [y %1] (char->piece %2)) row))
...and converts a board row to board cells:
(str->row "#-TZ#" 1) 
;= ([[1 0] 9] [[1 1] 0] [[1 2] 6] [[1 3] 2] [[1 4] 9])

The #( ) syntax is the shortcut form for anonymous functions in Clojure. The map-indexed function is similar to map except that it also offers an index to each element (0, 1, 2...here represented by %1.

new-board

The last function is used to create a new board:
(defn new-board
  ([] (new-board 12 21))
  ([rows] (into {} (mapcat #(str->row %1 %2) rows (range))))
  ([width height]
    (into {} (for [y (range height) x (range width)
                   :let [wall? (or (zero? x) (= x (dec width)) (= y (dec height)))]]
                   [[y x] (if wall? 9 0)]))))
This overloaded function can be called in three ways. If called with no arguments, we get a 10 x 20 board:
(board->str (new-board) 12)
;= "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "#----------#\n"
   "############"
The function board->str was added to get a more readable format in this example. The second way to call this function is by giving the board size:
(board->str (new-board 6 4) 6)
;= "#----#\n"
   "#----#\n"
   "#----#\n"
   "######"
The last way is to call it with a board position, which is done by the corresponding tests:
(expect { [0 0] 9, [0 1] 0, [0 2] 6, [0 3] 0, [0 4] 0, [0 5] 9,
          [1 0] 9, [1 1] 6, [1 2] 6, [1 3] 0, [1 4] 2, [1 5] 9,
          [2 0] 9, [2 1] 9, [2 2] 9, [2 3] 9, [2 4] 9, [2 5] 9 }
         (new-board ["#-T--#"
                     "#TT-Z#"
                     "######"]))
The new-board function uses these functions: forintomapcatrange, zero?, = and dec (for is actually a macro). It would take up too much space here to explain them all, especially as the official documentation is excellent with lot of examples!

The rejected design

I started by representing the board as a vector (the complete source can be found here). This seemed to be a good design choice, and was used in the C++ version.
Looping collections by index is a natural thing to do in imperative languages like C++ or Java. The problem is that it's not as natural in a functional language like Clojure.

A telling example is the set-piece function in the version that represents the board as a two dimensional vector:
(defn set-piece [board x y p piece]
  (reduce (fn [new-board [py xprow]]
          (assoc new-board py (apply assoc (new-board py) xprow))) board
        (map #(vector %2 (prow->xprow % x p)) piece (iterate inc 0))))

The code is complex and hard to read and understand. The new solution is much more elegant:
(defn set-piece [board piece rotation x y]
  (apply assoc board (rotate-and-move-piece piece rotation x y)))

The lesson from this is to choose your core models with care and change the design before it's too late!

Best regards,
Joakim Tengstrand

0 comments

Post a Comment