Edgardo Carreras | Blog

Tic Tac Toe in Clojure - Part 4 MiniMax Algorithm

August 26, 2021


Yesterday we started looking into implementing the Minimax Algorithm. We started with the scoring algorithm

(defn score [board player]
  (cond
    (= player (get-winning-player board))
    10
    (= (get-opponent player) (get-winning-player board))
    -10
    :else
    0)

But our scoring algorithm should return nil if the game isn’t over yet so we can keep recursing the minimax until the game is over.

(defn score-board [board player]
  (let [winning-player (get-winning-player board)]
    (cond
      (= player winning-player)
      10
      (= (get-opponent player) winning-player)
      -10
      (game-over? board)
      0
      :else
      nil)))

Ok now we have a proper scoring function. Let’s move ahead with the minimax implementation.

Looking again at the minimax pseudocode:

function minimax(node, depth, maximizingPlayer) is
    if depth = 0 or node is a terminal node then
        return the heuristic value of node
    if maximizingPlayer then
        value := −∞
        for each child of node do
            value := max(value, minimax(child, depth − 1, FALSE))
        return value
    else (* minimizing player *)
        value := +∞
        for each child of node do
            value := min(value, minimax(child, depth − 1, TRUE))
        return value

Ok, so it took me a while to wrap my head around this recursive algorithm. My initial algorithm didn’t implement the depth of the recursion as I was trying to figure out the requirement of depth I found out the hard way. Say the algorithm finds a winning play in the next play, without the depth implementation the minimax would score the immediate winning play the same as a potential winning play down the line.

Ok so let’s write some test for this.

(describe "minmax"
          (it "returns 10"
            (should= 10 (minimax
                          (assoc
                            empty-board
                            [0 1] X [0 2] X)
                          X
                          0
                          true)))
          (it "returns -10"
            (should= -10 (minimax
                           (assoc
                             empty-board
                             [0 0] nil [0 1] X [0 2] X
                             [1 0] O [1 1] O [1 2] X
                             [2 0] X [2 1] O [2 2] O)
                           X
                           0
                           false)))
          (it "returns -9"
            (should= -9 (minimax
                          (assoc
                            empty-board
                            [0 0] X [0 1] nil [0 2] O
                            [1 0] nil [1 1] O [1 2] nil
                            [2 0] X [2 1] nil [2 2] X)
                          O
                          0
                          true))))

Notice that if we score the board where X is about to lose we get a -10, if we score a board where X is about to win we get a 10. We also test that we score a -9 when O is about to lose in the next two turns.

Here’s our minimax code:

(defn minimax [board player depth maximizing?]
  (let [score (score-board board player)
        moves (get-empty-indexes board)]
    (cond
      (not (nil? score))
      score
      (true? maximizing?)
      (->> moves
           (map #(play board % player))
           (map #(minimax % player (inc depth) false))
           (apply max)
           (#(- % depth)))
      :else
      (->> moves
           (map #(play board % (get-opponent player)))
           (map #(minimax % player (inc depth) true))
           (apply min)
           (#(+ % depth))))))

Ok lets now create a function that allows us to use this function lets call it get-best-move.

Lets start with some sensible tests:

(describe "get-best-move"
          (it "should return best move"
            (should= [0 0] (get-best-move
                             (assoc
                               empty-board
                               [0 0] nil [0 1] X [0 2] X
                               [1 0] O [1 1] O)
                             X)))
          (it "should return best move"
            (should= [2 2] (get-best-move
                             (assoc
                               empty-board
                               [0 0] X [1 1] O)
                             X)))
          (it "should return best move"
            (should= [2 2] (time (get-best-move
                             empty-board
                             X)))))

In the test I check that the get-best-move not only returns the winning play but also the best defensive play.

Here is the implementation:

(defn get-best-move [board player]
  (loop [moves (get-empty-indexes board)
         scores {}]
    (cond
      (empty? moves)
      (key (apply max-key val scores))
      :else
      (recur
        (drop 1 moves)
        (assoc scores
          (first moves)
          (minimax
            (play board (first moves) player)
            player
            0
            false))))))

For each move available I score them and return the key with the maximum value. I really love how max-key made this so much easier.

So our algorithm works but… It is really slow when the board has a lot of empty spaces!

(time (get-best-move empty-board X))
;"Elapsed time: 154998.704009 msecs" 🤯🤯🤯🤯

Next we’ll look into how to optimize our algorithm. We’ll look at utility tree and alpha-beta pruning.

<3


Want to hear more from me?

Signup to my newsletter!

CarrerasDev Newsletter

A free email newsletter on how to create high-performing development teams.


Written by Edgardo Carreras.

© 2024, Edgardo Carreras