The goal of this tutorial is to get some practice using ClojureScript with the HTML Canvas element, including JavaScript interoperability. The byproduct is that we build something that might be useful. We have access to the browser, and I find creating something visual to aid in the learning process I am going to walk through how to build a gameboard. At the end of this tutorial you will be able to use ClojureScript with the HTML Canvas element to create an 8x8 Checkers board.
This walkthrough is targeted primarily at people that are beginners to advanced-beginners with ClojureScript. It does assumes a very basic knowledge of Clojure/ClojureScript, even if you are very new you should be able to get through it by following along and looking things up as we go. I’ve found this post to be especially helpful as a JS interop reference.
I chose to use leiningen for this tutorial although it isn't required (see https://github.com/clojure/clojurescript/wiki/Quick-Start). I also am using lein-figwheel, it automatically reloads our ClojureScript code in the browser. It also gives you a REPL to fiddle with your live application as it's running.
Let’s start by creating a new ClojureScript project with leinegen. If you don’t have leiningen, go here and follow the installation instructions.
-
lein new figwheel gameboard
-
cd gameboard
-
Open up the
gameboard
project in your editor. -
Run
lein figwheel
from your terminal.If everything went well the last three lines of your terminal should look like this:
Prompt will show when fig wheel connects to your application To quit, type: :cljs/quit cljs.user=>
If this didn’t work see the troubleshooting section.
-
In your text editor open
./resources/public/templates/index.html
. -
Immediately after the
<body>
tag add<canvas id=“canvas”></canvas>
and remove the<div id=“app”>
and it’s contents. -
In your text editor open
./src/gameboard/core.cljs
and remove all of the code below(enable-console-print!)
. -
Open up a browser and navigate to http://localhost:3449.
The page will be blank, but by opening the inspector or viewing the source you should be able to see our
<canvas id=“canvas”></canvas>
element in the page source. Now our app and workflow is pretty well set up so that we can start drawing on the canvas.
If this didn’t work for you go through these steps:
- Make sure you have at least version 1.7 of java. Check this by running
java -version
. - Make sure leiningen is setup correctly. Check this by running
lein
. - Remove and go through the steps again. Remove the gameboard directory, and start from step one on the instructions above.
-
In your text editor, open
./src/gameboard/core.cljs
. -
At the top of your file in the namespace
(ns ...)
section replace(:require)
with(:require [goog.dom :as dom])
. This will give us access to the dom methods in the Google Closure library which you have access to in to any ClojureScript project. -
Add the following line to the bottom of your file:
(def ctx (.getContext (dom/getElement "canvas") "2d"))
We are defining a Var
ctx
that stores a reference to the canvas context. We access the canvas context by using some ClojureScript interop.(.getContext (dom/getElement "canvas") "2d")
The line above uses a bit of syntactic sugar to save us a set of parens, and I prefer the look of it over the long-hand version below.
(. (dom/getElement "canvas") (getContext "2d"))
Using
(.function Object arg)
is how you call methods on objects when they have been already defined in JavaScript somewhere. If you are familiar with JavaScript it might be useful to see the rough equivalent in JavaScript.var ctx = goog.dom.getElement('canvas').getContext("2d");
All of the drawing methods use the canvas context and not the canvas element directly. This is because there are multiple contexts
2d
,webgl
, andwebgl2
. We are going to be using the2d
context throughout this tutorial since we are only drawing 2d shapes. We are fetching the2d
context viagetContext
for our canvas element, and storing it inctx
for easy access later. -
Let's draw a simple box.
(.beginPath ctx) (.rect ctx 0 0 50 50) (set! (.-fillStyle ctx) "white") (.fill ctx) (set! (.-lineWidth ctx) 0.5) (set! (.-strokeStyle ctx) "black") (.stroke ctx)
It should look like the image below:
There's four concepts to get down in this bit of code.
(.beginPath ctx)
beginPath tells the renderer that we aren't continuing on any previous path, we want to start a new one with the context we give it.
(.rect ctx 0 0 50 50)
rect takes four args x, y, width, and height. What we have at this point is the invisible skeleton of our rectangle. As in, there is nothing to see yet.
(set! (.-fillStyle ctx) "white") (.fill ctx)
We are now seeing a new function
set!
and the JavaScript interop method call, but instead of the method being preceded with a.
we see.-
. This means that we are not callingfillStyle
as a method, but we are accessingfillStyle
as a property. This might now start to make sense what we are doing withset!
. We are getting thefillStyle
property with.-fillStyle
and then wrapping it in aset!
to update the value of thefillStyle
property. If you are new to Clojure/ClojureScript the!
at the end of the method means that we are changing state.The equivalent in JavaScript would look like this:
ctx.fillStyle = "white";
Setting the
fillStyle
doesn't mean that the fill will be rendered, we are explicitly telling it to draw the fill with thefill
method on the context.(set! (.-lineWidth ctx) 0.5) (set! (.-strokeStyle ctx) "black") (.stroke ctx)
The previous section of code isn't anything new or particularly interesting. We just covered setting properties. As is done here with the
.lineWidth
and the.strokeStyle
. This code is another example of the set and render style that we saw earlier, where all of the settings are defined, and then thestroke
method is called to actually render the stroke.Now that we have a single box, we are on our way to making a full game board. It will just be a square tile grid, for games like Checkers, Chess, and Stratego.
There is one thing that bugs me about the square that we have drawn. On the right side, and bottom side of the square it appears to be a fuzzy line. I tried fixing it with setting a narrow
lineWidth
as we did above, but that didn't seem to completely fix it. I did find a solution for it, but it isn't a very satisfying one.Add this line above the
(.beginPath ctx)
line:(.setTransform ctx 1, 0, 0, 1, 0.5, 0.5)
Notice that fuzziness is gone? I found the fix here, and yes, it does feel like a hack, but there doesn't seem to be a better way around it. The
setTransform
method takes six parameters, we care about two of them, the last two. As you can see we add 0.5px offset to both the x and y axis.
To make a board all we really need to do is call the code that we used to draw the first square for whatever size board we want. There isn't that much work in refactoring our existing code into something that is reusable in the way we described.
-
Let's wrap our code up in a function called
draw-tile!
. It should take an x and y value for where our tile should start being rendered. I also want to pull the size of the tile out into a Var so we can reuse it.Our code should look something like this:
(def ctx (.getContext (dom/getElement "canvas") "2d")) (def tile-size 50) (.setTransform ctx 1, 0, 0, 1, 0.5, 0.5) (defn draw-tile! [x y] (.beginPath ctx) (.rect ctx x y tile-size tile-size) (set! (.-fillStyle ctx) "white") (.fill ctx) (set! (.-lineWidth ctx) 0.5) (set! (.-strokeStyle ctx) "black") (.stroke ctx)) (draw-tile! 0 0)
The refactor we just did should result in the exact same square we had on the screen previously.
-
Now we need to write a function that draws a bunch of tiles to make up our board.
(defn draw-board! [w h] (mapv (fn [y] (mapv (fn [x] (draw-tile! (* tile-size x) (* tile-size y))) (range 0 w))) (range 0 h)))
This function takes a width and a height argument for how many tiles wide and tall you want your game board. In the function body, it is two
mapv
functions,mapv
applies the function to each item in the collection, and returns a vector of the result in a non-lazy fashion. We use two ranges to give us all of the integer values from 0 to the size of our width or height. -
Using our new
draw-board!
function we can draw a game board of any size. Draw a 3x3 board, and you should notice one issue we need to take care of.We have a cutoff board because of the limit on our canvas size. The easy way to fix this (and for now is fine) is to just set a height and width attribute on the canvas element. You may try to do this in your CSS file but it will stretch your canvas. A more robust way is to dynamically set the canvas size based on the width and height of your board.
(defn draw-board! [w h] (set! (.-height (dom/getElement "canvas")) (+ 1 (* h tile-size))) (set! (.-width (dom/getElement "canvas")) (+ 1 (* w tile-size))) (mapv (fn [y] (mapv (fn [x] (draw-tile! (* tile-size x) (* tile-size y))) (range 0 w))) (range 0 h)))
The two lines we added at the top of the method set the width and height properties based on the size of the gameboard. You may also be wondering why we are adding 1 to our width and height, unfortunately this is because of our offset we set to get the lines to render sharp.
I also noticed that there was some fuzziness when the grid of tiles was rendered. To fix this I moved the
setTransform
function inside of thedraw-board!
function.Your board should look like this:
The standard Checkers board is an 8x8 grid of tiles. The tiles are colored cycling between two colors.
-
Let's start by making an 8x8 grid with our existing
draw-board!
function.(draw-board! 8 8)
-
To implement the checker pattern, the first thought that I have is to use the
x
andy
values of ourmapv
functions to figure out if the tile should be red or black. Knowing that we will have those values, we can right a function that takes in anx
andy
and will tell us if that tile should be red or black.(defn tile-color [x y] (if (= (even? x) (even? y)) "red" "black"))
This function takes an
x
andy
value and checks to see if each value is even, then compares the result of whether the even-ness ofx
andy
are the same.[0,0]
both would be true, return "red",[0, 1]
x
would be true andy
would be false, return "black". -
Now we need to change our
draw-tile!
function so that it takes an additional argumentcolor
, and when we draw the rectangle we need to pass in that color.(defn draw-tile! [x y color] (.setTransform ctx 1, 0, 0, 1, 0.5, 0.5) (.beginPath ctx) (.rect ctx x y tile-size tile-size) (set! (.-fillStyle ctx) color) (.fill ctx) (set! (.-lineWidth ctx) 0.5) (set! (.-strokeStyle ctx) "black") (.stroke ctx))
We only needed to change two lines, but I included the whole function to give context. We added the argument, and you can see we reference that argument when we set the
fillStyle
. -
In the
draw-board!
function we need to use the value ofx
andy
when we are calling ourdraw-tile!
function. Sincedraw-tile!
takes a third argument we can use thetile-color
function.tile-color
takes in thex
andy
values of the current iteration and will return "red" or "black" which will get passed on to thedraw-tile!
function and become the fill color for our tile.(defn draw-board! [w h] (set! (.-height (dom/getElement "canvas")) (+ 1 (* h tile-size))) (set! (.-width (dom/getElement "canvas")) (+ 1 (* w tile-size))) (mapv (fn [y] (mapv (fn [x] (draw-tile! (* tile-size x) (* tile-size y) (tile-color x y))) (range 0 w))) (range 0 h)))
-
The last thing that we need to do is change the size of our board from a 3x3 to an 8x8.
(draw-board! 8 8)
In this tutorial we used ClojureScript with JavaScript interop to draw a game board with the HTML Canvas element. I am hoping that this will be the first part in a series on making an interactive board game with ClojureScript. I am only slightly ahead of this tutorial working on a personal project to do just that. Hopefully we have met the goal which was to get some practice using ClojureScript, Canvas, and doing some interop with JavaScript.