One of these days, a friend of mine posted about his experience writing the “Mastermind” game in React (in portuguese only). The game is quite simple:

  1. You have six possible colors
  2. A combination of 4 colors is chosen randomly (they can be repeated – for example, blue,blue,blue,blue is a valid combination) – you have to guess that number
  3. You have up to 10 guesses of 4 colors. For each color on the right position, you win a “black” point. For each color in the wrong position, you win a “white” point
  4. If you can’t guess on the 10th try, you loose.

So, first, we’ll create a shadow-cljs app – create a package.json file, fill it with {"name": "Mastermind"}, then run npm install shadow-cljs. Traditional stuff.

Then, we’ll create the shadow-cljs.edn file. It’ll only contain a single target (:browser), opening up a dev-http server so we can serve our code, and we’ll add reagent library dependency. I also added the material-ui dependency, but you don’t really need it for the code. Now, running npx shadow-cljs watch browser will start a webserver at port 3000, and we can start to develop things.

First things first: add an index.html so we can render our code on the web browser. Then, I added the first code: to shuffle colors:

(def colors (->> [:red :green :blue :yellow :pink :gray]
                 cycle
                 (take (* 4 6))))

(defn shuffle-colors []
  (->> colors shuffle (take 4) vec))

What this will do is simple: we have 6 colors, so we’ll create a vector, repeat it, and take only 4 * 6 elements. This will be our “color seed” – the next step is just to shuffle this “seed”, take 4 elements, and convert to vector. And that’s all. This code will also be used to generate our first “game state”:

(defn new-game-state []
  (let [colors (shuffle-colors)]
    {:colors colors
     :old-guesses []
     :current-guess []}))

Everything should be self-explanatory. Now, comes the part to check if our guess is correct:

(defn compare-guess [colors guess]
  (let [results (map (fn [color guess] (when-not (= color guess) [color guess]))
                     colors guess)
        colors-f (frequencies (map first results))
        guess-f (frequencies (map second results))]
    {:white (->> (dissoc guess-f nil)
                 (map (fn [[color freq]] (min freq (get colors-f color 0))))
                 (reduce +))
     :black (get colors-f nil 0)}))

This could be a little hard to understand, so let’s dive deeper into it: we’ll generate a “results” – it’s a pair that contains or nil (when both colors are equal) or a pair [right-color guessed-color]. Then, we’ll calculate the frequencies of the first element of the pair (the shuffled colors), and the frequencies of the second (the guessed colors). Notice that:
1. If both colors are equal, frequencies will accumulate the result on nil
2. If they are not, we’ll have a map like {:red 2}, saying that we guessed 2 reds out of order, or that there are 2 reds on the shuffled colors that were out of order
3. For :black, we just need to get the number of times that nil appeared – it doesn’t matter if we get from colors-f or guess-f
4. For :white, we’ll check for each guessed color (excluding the ones that we got right on the right position – that’s what (dissoc guess-f nil) is doing) if there’s an equivalent on the shuffled colors. If there is, we’ll get the lower number; if not, we’ll return 0

These are the game rules. So far, everything is side-effect free (except for the shuffle), and immutable. For the view, we’ll have to define some side-effects code:

(defonce state (r/atom {}))

(defn- add-color-to-guess! [state color]
  (swap! state update :current-guess
         #(cond-> % (< (count %) 4) (conj color))))

(defn- reset-guess! []
  (swap! state assoc :current-guess []))

(defn- send-guess! [state]
  (let [current-guess (:current-guess @state)
        colors (:colors @state)
        result (rules/compare-guess colors current-guess)]
    (when (and (= 4 (count current-guess)))
      (swap! state
             #(-> %
                  (assoc :current-guess [])
                  (update :old-guesses conj {:result result :guess current-guess}))))))

(defn- new-game! []
  (reset! state (rules/new-game-state)))

Reagent works by defining an r/atom – it’s a reference type that can be changed, and when it does change, Reagent will update the view with the new state. The only thing we need, now, is to ensure that this state is rendered correctly on the view – for example, this is the code for the color button (when you click the color button to add to your guess):

(defn- buttons [state color]
  ^{:key color}
  [:> Button {:variant "contained"
              :disable-elevation true
              :style {:width "6em" :height "2.5em" :background-color (get-color color)}
              :on-click (fn [_] (add-color-to-guess! state color))}])

Just for the record, this is the code fragment that, given the state, renders the color buttons and the colors we’re adding to the guess:

(defn- color-circle
  ([idx color]
   [:div.circle {:key idx :style {:background-color (get-color color)}}])
  ([idx color size]
   [:div.circle {:key idx :style {:background-color (get-color color)
                                  :width size
                                  :height size}}]))

(defn- index [state]
 ... lots of code here ...
 [:div.cols {:style {:margin-top "5em" :height "3em"}}
  (map color-circle (range) (:current-guess @state))]
 [:div.cols {:style {:width "35em" :justify-content "space-between"}}
  (map #(buttons state %) (keys get-color))]
 ... also more code here ...

You can check the whole source on my github. It’s really just two source files, including some tests I made to check if the game rules are correct! It’s easy, testable, and as Reagent takes care of all side-effects of rendering things on the view, we could theoretically even test our handlers – just by checking if, by passing a specific state, another correct state would be returned! This probably takes a lot of the burden that’s trying to test UI code!

The game

Below, the code above (embedded on this page):


0 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

%d bloggers like this: