2 Comments

Building Better Board Games with Clojure and Gorilla REPL

A while back, my six-year-old daughter and I were on one of our regular daddy-daughter date nights when we decided to create a board game. We sat at a restaurant table with paper, crayons, and a small collection of Dungeons and Dragons trinkets. To my surprise, we built a fun little game that my daughter named Turtle Burglars.

Note about the name: I have no idea, kids are silly. Only after we named it did we add any sort of stealing to the game.
Note about the name: I have no idea, kids are silly. Only after we named it did we add any sort of stealing to the game.

The next day, I decided that I should get the game printed and boxed as a gift. While designing the game, we played quite a few times. Each time we played, we tweaked numbers, crossed off things, and added new rules. But if I was going to print the game, I needed to nail down exact values that would be fair and fun. So I decided to build a model that could be run thousands of times to validate assumptions.

Before I get into those, let’s look at the rules of Turtle Burglars.

The Game

  • Goal: Cross the finish line with some number of tokens to win.
  • Action: Each player moves by rolling a die.
  • Land on a number: Take/lose that number of tokens.
  • Land on a jail: Lose a turn.
  • Land on a steal: Roll die and steal that many tokens from any player.
  • Stop at the shortcut: Decide on the next turn whether or not to pay a number of tokens to take that path.

The Model

The game is simple, but I wanted to validate a few assumptions, such as:

  • Does losing a turn cause you to lose the game?
  • Do players ever need to take a second lap?
  • How many tokens should be required to finish the game?
  • How many tokens should the shortcut cost?
  • How many squares should the shortcut bypass?

I first turned to Ruby to build a code model that could play games of Turtle Burglars. The code was about 200 LOC and had no clear way to start aggregating results about the games. I definitely could have kept going, but a coworker (Eric) recommended a Clojure talk by Mark Bastian that derailed my first approach. I highly recommend watching this talk if you are new to functional programming.

In the talk, Mark does a great job of describing the different approaches of object-oriented design vs. functional programming design for data-heavy applications. In his example, he actually builds a board game in Clojure! I decided that Turtle Burglars would be the perfect learning ground for Clojure (and it was).

Eric also taught me about a neat tool called Gorilla REPL. Gorilla is an in-browser REPL that can do basic charting. It can also store the commands and charts for later.

Scroll to the bottom to see the modeled Clojure code.
You can also view sample usage of my Turtle Burglars library via Gorilla in PDF Form.

The Results

In-development game board.
In-development game board.

Using my Clojure model and Gorilla, I can now run 10,000 games in seconds and quickly analyze the results. As expected, the numbers we originally started with almost never caused a second lap and heavily emphasized NOT taking the shortcut. We also found that whoever goes first has a huge advantage, so we added a slight penalty for going first. By tweaking the board/token counts/winning conditions, we built the game we wanted before printing. Thanks, Clojure/Gorilla!

I highly recommend using these tools for modeling and proving out any system, not just your board games. I’ve included my Clojure library for Turtle Burglars below. It’s far from perfect as this was my first Clojure project, but I hope it can help show how easy it can be to model complex systems in Clojure and how informative it can be to analyze that model with Gorilla.

(ns turtles.core
  (:require [clojure.pprint :refer [pprint]]))

(def tokens-to-win 12)
(def tokens-to-take-shortcut 10)
(def tokens-to-start 1)
(def num-players 4)
(def shortcut-cost 4)
(def sided-die 6)
(def all-players [:red :blue :green :purple])

(defn spy [x]
  (println x)
  x)

(defn roll-die []
  (inc (rand-int sided-die)))


(defn make-player [i color] 
  {:color color
   :tokens i #_(if (zero? i) 0 tokens-to-start)
   :skip-next-turn false
   :tile :tile1
   :has-won false
   :lost-turns 0
   :laps 0
   :take-shortcut (constantly (rand-nth [true false]))})

(defn make-players [num-players] 
  {:pre [(<= 2 num-players (count all-players))]}
  (map-indexed make-player (take num-players all-players)))

(defn make-tile 
  ([] (make-tile :empty 0))
  ([tile-type] (make-tile tile-type 0))
  ([tile-type value] {:tile-type tile-type :value value}) )

(defmulti forward-from (fn [game t _ _] (get-in game [:board :tiles t :tile-type])))

(defmethod forward-from :empty [game tile player n]
  (if (zero? n)
    (assoc-in game [:players player :tile] tile)
    (let [next-tile (first (get-in game [:board :graph tile]))]
      (forward-from game next-tile player (dec n)))))

(defmethod forward-from :token-change [game tile player n]
  (if (zero? n)
    (let [delta (get-in game [:board :tiles tile :value])]
      (-> game
        (update-in [:players player :tokens] + delta)
        (update-in [:players player :tokens] max 0)
        (assoc-in [:players player :tile] tile)))
    (let [next-tile (first (get-in game [:board :graph tile]))]
      (forward-from game next-tile player (dec n)))))

(defmethod forward-from :lose-a-turn [game tile player n]
  (if (zero? n)
    (let [delta (get-in game [:board :tiles tile :value])]
      (-> game
        (assoc-in [:players player :skip-next-turn] true)
        (update-in [:players player :lost-turns] inc)
        (assoc-in [:players player :tile] tile)))
    (let [next-tile (first (get-in game [:board :graph tile]))]
      (forward-from game next-tile player (dec n)))))

(defmethod forward-from :shortcut-option [game tile player n]
  (assoc-in game [:players player :tile] tile))

(defmethod forward-from :steal [game tile player n]
  (if (zero? n)
    (let [target-player (apply max-key :tokens (-> game :players (dissoc player) vals))
          roll (roll-die)
          takable (min roll (:tokens target-player))]
      (-> game
        (assoc-in [:players player :tile] tile)
        (update-in [:players player :tokens] + takable)
        (update-in [:players player :took] conj takable)
        (update-in [:players (:color target-player) :tokens] - takable)
        (update-in [:players (:color target-player) :taken] conj takable)))
    (let [next-tile (first (get-in game [:board :graph tile]))]
      (forward-from game next-tile player (dec n)))))

(defmethod forward-from :start-finish [game tile player n]
  (if (> (get-in game [:players player :tokens]) tokens-to-win)
    (assoc-in game [:players player :has-won] true)
    (if (zero? n)
      (assoc-in game [:players player :tile] tile)
      (let [next-tile (first (get-in game [:board :graph tile]))]
        (-> game
          (update-in [:players player :laps] inc)
          (forward-from next-tile player (dec n)))))))

(defn make-board [] 
  {:graph {
           :tile1 [:tile2]
           :tile2 [:tile3]
           :tile3 [:tile4]
           :tile4 [:tile5]
           :tile5 [:tile6]
           :tile6 [:tile7]
           :tile7 [:tile8]
           :tile8 [:tile9]
           :tile9 [:tile10]
           :tile10 [:tile11]
           :tile11 [:tile12]
           :tile12 [:tile13]
           :tile13 [:tile14]
           :tile14 [:tile15]
           :tile15 [:tile16]
           :tile16 [:tile17, :tile22]
           :tile17 [:tile18]
           :tile18 [:tile19]
           :tile19 [:tile20]
           :tile20 [:tile21]
           :tile21 [:tile22]
           :tile22 [:tile23]
           :tile23 [:tile24]
           :tile24 [:tile25]
           :tile25 [:tile26]
           :tile26 [:tile1]
           } 
   :tiles {
           :tile1 (make-tile :start-finish) 
           :tile2 (make-tile :token-change 1)
           :tile3 (make-tile)
           :tile4 (make-tile :token-change -1)
           :tile5 (make-tile)
           :tile6 (make-tile :token-change 2)
           :tile7 (make-tile)
           :tile8 (make-tile)
           :tile9 (make-tile :token-change 3)
           :tile10 (make-tile)
           :tile11 (make-tile :lose-a-turn)
           :tile12 (make-tile :token-change -2)
           :tile13 (make-tile :steal)
           :tile14 (make-tile :token-change 2)
           :tile15 (make-tile)
           :tile16 (make-tile :shortcut-option shortcut-cost)
           :tile17 (make-tile :lose-a-turn)
           :tile18 (make-tile)
           :tile19 (make-tile :token-change -1)
           :tile20 (make-tile :token-change 3)
           :tile21 (make-tile)
           :tile22 (make-tile :steal)
           :tile23 (make-tile :token-change -1)
           :tile24 (make-tile)
           :tile25 (make-tile :token-change 2)
           :tile26 (make-tile)
           }})

(defn make-game []
  (let [the-players (make-players num-players)] 
    {:board (make-board)
     :players (zipmap (map :color the-players) the-players)
     :turn 0
     :next-players (cycle (map :color the-players))}))

(defn display-game [game]
  (pprint {:turn (:turn game) :players (:players game)}))

(defn move-player [game current-player moves] 
  (let [tile (get-in game [:players current-player :tile])
        next-tile (first (get-in game [:board :graph tile]))
        player (get-in game [:players current-player])]
    (if (and (= :shortcut-option (:tile-type tile))
             (:take-shortcut player)
             (<= (:value tile) (:tokens player)))
      (forward-from (update-in game [:players current-player :tokens] - (:value tile))
                    (last (get-in game [:board :graph tile]))
                    current-player
                    (dec moves))
      (forward-from game
                    next-tile
                    current-player
                    (dec moves)))))

(defn roll-and-move [game current-player] 
  (move-player game current-player (roll-die)))

(defn play-player [game current-player] 
  (if (get-in game [:players current-player :skip-next-turn])
    (assoc-in game [:players current-player :skip-next-turn] false)
    (roll-and-move game current-player)))

(defn find-first [f coll]
  (first (filter f coll)))

(defn find-winner [game]
  (find-first :has-won (vals (:players game))))

(defn has-winner? [game]
  (some :has-won (vals (:players game))))

(defn play-turn [game] 
  (let [ current-player (first (:next-players game)) ]
    (-> game
        (assoc :previous-game game)
        (play-player current-player)
        (update :next-players rest)
        (update :turn inc))))

(defn play-game [] 
  (loop [game (make-game)]
    (if (not (has-winner? game))
      (recur (play-turn game)) 
      game)))

(defn avg [xs]
  (/ (reduce + xs) (count xs)))

(defn -main
  [& args]
  ;; (display-game (play-game)))
  ;; (display-game (first (take 5 (map play-game)))))
  ;; (display-game (last (repeatedly 10000 #(play-game)))))
  (spy (float (avg (map :turn (repeatedly 100 #(play-game)))))))