Friday, August 21, 2015

A Clojure Snake Game

I recently decided to write a Snake game in Clojure as a small project. In the game you have a snake that grows as it consumes food. The goal of the game is to make the snake as long as possible without self-intersecting. I got the idea to do it on a Saturday morning and by that night I was done. Most of the coding was done in the evening since I was doing family activities all day. All told, this was probably a 3 hour project. The entire program is 75 lines of code. That's awesome! This includes both the JVM and JavaScript targets via Clojure and ClojureScript.
Here's the game:

Here's how you play:
  • Use your arrow keys or w, a, s, d to change the snake's direction. Note that you probably will need to click on the canvas first to gain focus.
  • When the snake hits a green "food" pill it grows one unit longer.
  • When the snake intersects itself it resets to its original length.
  • Your score is the length of your snake.
The Program
Here's the program. Read past it for my observations and comments.

(ns snake.core
  (:require [quil.core :as q #?@(:cljs [:include-macros true])]
            [quil.middleware :as m]))

(def world { :width 100 :height 100 :food-amount 1000 })

(defn gen-food [] [(rand-int (world :width)) (rand-int (world :width))])

(defn replenish-food [initial amount]
  (loop [food initial] (if (>= (count food) amount) food (recur (conj food (gen-food))))))

(defn wrap [i m]
  (loop [x i] (cond (< x 0) (recur (+ x m)) (>= x m) (recur (- x m)) :else x)))

(defn grow-snake [{:keys [snake velocity] :as state}]
  (let [[px py] (map + (peek snake) velocity)]
    (assoc state :snake (conj snake [(wrap px (world :width)) (wrap py (world :height))]))))

(defn eat [{:keys [snake food] :as state}]
  (if-let [pellet (food (peek snake))]
    (-> state (update :food disj pellet))
    (-> state (update :snake subvec 1))))

(defn reset? [{:keys [snake] :as state}]
  (if (apply distinct? snake)
    (assoc state :snake [(peek snake)])))

(defn setup []
  (do (q/smooth)
      (q/frame-rate 30)
      {:snake [[50 50]] :velocity [1 0] :food (replenish-food #{} (world :food-amount))}))

(defn draw [{ :keys [snake food] }]
  (let [w (/ (q/width) (world :width))
        h (/ (q/height) (world :height))]
      (q/stroke-cap :round)
      (q/stroke-join :round)
      (q/background 0 0 0)

      (q/fill 0 255 0)
      (q/stroke 0 255 0)
      (doseq [[x y] food](q/rect (* w x) (* h y) w h))

      (q/fill 255 0 0)
      (q/stroke 255 0 0)
      (doseq [[x y] snake](q/rect (* w x) (* h y) w h))

      (q/fill 0 255 255)
      (q/text (str "Score: " (count snake)) 10 15))))

(defn launch-sketch [{:keys[width height host]}]
    :title "Snake"
    :setup setup
    :update #(-> % grow-snake eat (update :food replenish-food (world :food-amount)) reset?)
    :draw draw
    (fn [{ :keys [velocity] :as state} { :keys [key key-code] }]
      (case key
        (:a :left) (if (not= [1 0] velocity) (assoc state :velocity [-1 0]) state)
        (:d :right) (if (not= [-1 0] velocity) (assoc state :velocity [1 0]) state)
        (:w :up) (if (not= [0 1] velocity) (assoc state :velocity [0 -1]) state)
        (:s :down) (if (not= [0 -1] velocity) (assoc state :velocity [0 1]) state)
    :middleware [m/fun-mode]
    :size [width height]
    #?@(:cljs [:host host])))

;#?(:clj (launch-sketch { :width 400 :height 400 }))

#?(:cljs (defn ^:export launch-app[host width height]
           (launch-sketch { :width width :height height :host host})))

Observations and Comments
The state in this program is represented by a simple data structure containing a vector describing the snake, a vector representing the snake's velocity, and a set of coordinates representing the locations of food. You can see where I create this at the end of the setup function.There are also some constants in the world def.

To update the state, this program makes use of the common Clojure pattern of "threading state." Basically, you write your functions so that program state is passed in as an argument and a modified, updated version of the program state is returned. Functions with a single action or concern are written in this manner and then chained to form more complicated behaviors. It makes your program very easy to reason about. In this program you can see where this is done in the update method:

#(-> % grow-snake eat (update :food replenish-food (world :food-amount)) reset?)

For those unfamiliar with Clojure, I am using the "thread first" macro (The arrow). The # creates an anonymous function with the % as the passed in argument. The arrow takes the argument and feeds it through each function in succession (grow-snake, then eat, then updating food, and so on.).

At the program level, the Quil library handles passing state to each relevant function for processing. In "fun-mode" (functional mode), Quil functions hand you the initial state for modification in methods for program setup, update, and input. For drawing, state is passed in and there is no function output since you will draw your state to the screen. In other applications you can follow this same pattern of state management using Clojure's amazing concurrency primitives (atoms, agents, and refs). In fact Quil is just using an atom under the hood.

Other minor details:
  • There's a commented out form (;#?(:clj (launch-sketch { :width 400 :height 400 }))) towards the end. Uncomment this if you want to launch the file from a REPL. I leave it commented so that it doesn't launch when I do a cljsbuild.
  • For some reason, the (:gen-class) directive doesn't seem to have any effect in the cljc file, so I have a separate launcher.clj that defines a main method for uberjar builds. Clone the project if you want to see what I mean.
You can clone the project here.

Clojure continues to amaze me by repeatedly enabling me to do so much in such a small amount to time and code. Simple ideas, such as representing your domain as data structures vs. classes and the ability to thread your state throughout your program via functions make development in Clojure rapid and productive. Nowadays, whenever I get an itch to try out a new program or concept, I just sit down and model the domain as data and then start writing functions to manipulate the data. Before I know it, I end up with a complete program. It's a very powerful and fun way to write applications.

No comments:

Post a Comment