Watch the Screencast
Read the Source CodePlay the game Standalone
Coder X here,
Today we will build a multiplayer snake game. Players connect to a central server and eat green food dots to grow. We will use websockets to communicate between the browser and server.
One concern when using websockets is browser support. Sente is a library which addresses this concern by falling back to ajax when websockets are not available.
We will use Sente to collect player direction changes, and push out the new state of the world every time it updates. Let’s get started with a new ClojureScript project.
First we create the game client. We set up three namespaces:
- Main - the entry point that initializes our game
- Model - to store and manipulate the data for our game
- View - for components to be rendered on the page
Our game board will be a 20 by 20 matrix. We store our board and players in app-state. Players will have a current position, direction, length, and tail path. We need a ticker that updates the game world at regular intervals. Each update will be calculated by the step function.
(defn new-board [] (vec (repeat width (vec (repeat height nil))))) (defonce world (ref {:board (new-board) :players {}}))
Let’s see how we might draw the game board. For each position in the matrix, we may draw a snake body segment. We start a ticker that updates player positions, and some code to trim the tails of snakes as they move. Snakes are slithering.
Let’s adjust the look of the segments to taste. We need to handle keyboard input, so let’s add a listener that sets the next direction the snake should travel in. Great, we can steer the snake around now. Let’s add some eyes so it is obvious where the head is. We put two circle elements inside a group element that is translated to the player location.
(defn segment [uid i j me?] [:rect {:x (+ i 0.55) :y (+ j 0.55) :fill (subs uid 0 7) :stroke-width 0.3 :stroke (subs uid 7 14) :rx (if me? 0.4 0.2) :width 0.9 :height 0.9}]) (defn food [i j] [:circle {:cx (inc i) :cy (inc j) :r 0.45 :fill "lightgreen" :stroke-width 0.2 :stroke "green"}]) (defn pixel [uid i j my-uid] (if (= uid "food") [food i j] [segment uid i j (= my-uid uid)])) (defn eye [dx dy] [:circle {:cx (/ dx 2) :cy (/ dy 2) :r 0.2 :stroke "black" :stroke-width 0.05 :fill "red"}])
We are now ready to work on the server side of our game. Let’s copy the example code from the Sente website to set up our websockets. On the server side we have compojure routes to accept the socket. We’ll host these routes in a HTTP-kit server because HTTP-kit supports websockets.
Ring reload middleware is essential for development. It will watch the filesystem for changes in our code, and load them when we call the server. When we make changes, we don’t need to restart or send to our REPL integration. We just save our changes, and see the effect immediately.
The server is receiving events. The events are identified with a unique keyword. We dispatch handling of events with a multimethod. Our first custom handler will be for the :snakelake/dir event.
We’ll start by just printing the event we receive.
(defmethod event :snakelake/dir [{:as ev-msg :keys [event uid ?data]}] (let [[dx dy] ?data] (model/dir uid dx dy)))
Now we will move the model code we built up in our client code over to the server. Isn’t it convenient that we are using Clojure in both places? Instead of an atom, the server should use a ref to model world state. Refs provide transactional guarantees, and we want to handle concurrent user access.
When players connect, we’ll assign them a random colors as their identifier. The ticker will be a thread instead of an interval callback. We’ll broadcast the entire world every tick. On the client side, our model now gets replaced with whatever we hear from the server. Our client sends directions to the server, which updates the player state in the server model. Every tick, the world is updated and broadcast to all connected clients. When we run two browsers, we can see them both updating together.
(defn dir [dx dy] (chsk-send! [:snakelake/dir [dx dy]]))
At this point we have a multiplayer game. The server and client message handling code is very similar. Communication is expressed as event handling.
We need a host that will run our server process, not just serve static files. Heroku has a free hosting option, easy deployment steps, and excellent documentation. We define how the server should run our code in a Procfile. We’ll use java to execute an uberjar. An uberjar contains all our code, compiled Javascript, HTML resources, and dependencies. With this uberjar, the host can run the game loop process, serve files, and respond to requests.
:uberjar {:hooks [leiningen.cljsbuild] :aot :all :cljsbuild {:builds [{:id "min" :source-paths ["src" "prod"] :compiler {:main snakelake.main :output-to "resources/public/js/compiled/snakelake.js" :optimizations :advanced :pretty-print false}}]}}}
As this is a server process, we have the option to enable ahead of time compilation. Ahead of time compiled code starts faster, but it brings some weirdness. We need to pay careful attention to the Sente Lifecycle. Top level forms are executed during ahead of time compilation. It doesn’t make any sense to establish a socket server while compiling. So we need to reorganize our code a little bit to have a start function. Alternatively we could have chosen to avoid AOT completely..
We have our deployment unit, so let’s try running it locally. Making adjustments locally is faster than a full round trip deploy. Ok, things are looking good, so let’s deploy to the hosting provider.
Push the repository to Heroku, and our game is now live.
Setting up bidirectional communication is easy! The communication model is simple and concise.
Clojure lends itself to separation of concerns:
- Communication is event handling oriented.
- The model is data and transformations.
- The interface is a function of the data.
I hope you enjoy playing Snake Lake, and that you will build something even better.
Until next time,
Keep coding.