3

(note: this is not CS homework)

I've tried implementing the minimax game-tree searching algorithm in Coffeescript, and continue to get errors with my algorithm. There appear to be 2 main issues: 1) the algorithm itself doesn't seem to return the proper value during alpha-beta pruning (obviously the bigger issue), and 2) my gameboard object, represented by an array of 9 integers, seems to be attached to the DOM, making duplication of the board and passing it as a parameter to the recursive calls of the minimax search function problematic.

There are 3 classes: board, bot (where the minimax algorithm lives), and game. Be aware that a new Game is initiated upon load (debugging alerts popup accordingly) and that a board is mocked with preconfigured plays to ease debugging.

You'll note that I've tried three separate attempts at a minimax solution (been banging my head against this for weeks in my spare time), the latter two are now commented out. In my final solution I've been following this pseudocode.

index.html

<!DOCTYPE html>
<!--[if lt IE 7]>      <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
<!--[if IE 7]>         <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
<!--[if IE 8]>         <html class="no-js lt-ie9"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <title>Tic Tac Toe | JMS</title>
    <meta name="description" content="Tic-tac-toe using the minimax algorithm">
    <meta name="viewport" content="width=device-width">

    <link rel="stylesheet" href="css/normalize.min.css">
    <link rel="stylesheet" href="css/main.css">

    <script src="js/vendor/modernizr-2.6.2-respond-1.1.0.min.js"></script>
  </head>
  <body>

    <h1 id="output">empty</h1>

    <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js"></script>
    <script>window.jQuery || document.write('<script src="js/vendor/jquery-1.9.0.min.js"><\/script>')</script>

    <!-- <script src="js/main.js"></script> -->
    <script src="js/rules.js"></script>
    <script src="js/board.js"></script>
    <script src="js/game.js"></script>
    <script src="js/bot.js"></script>


    <!-- <script>
      var _gaq=[['_setAccount','UA-XXXXX-X'],['_trackPageview']];
      (function(d,t){var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
      g.src=('https:'==location.protocol?'//ssl':'//www')+'.google-analytics.com/ga.js';
      s.parentNode.insertBefore(g,s)}(document,'script'));
    </script> -->
  </body>
</html>

board.coffee

class Board 
  constructor: ->
    @spaces = [0,1,2,3,4,5,6,7,8]

  reset: ->
    @spaces = [0,1,2,3,4,5,6,7,8]

  setSpace: (index, side) ->
    console.log "board.setSpace: played at index #{index} with side #{side}"
    @spaces[index] = side
    $('#output').text(@spaces)

  setSpaces: (array) ->
    @spaces = array

  getSpace: (index) ->
    @spaces[index]

  getSpaces: ->
    @spaces

bot.coffee

class Bot
  constructor: (side) ->
    console.log "created a new bot!"
    @name = "Gandalf"
    @infinity = 99
    @side = side


  calculateMove: (board) ->
    console.log "Bot.calculateMove with #{board.getSpaces()}"
    debugger 

    isBoardEmpty = (board) -> # works
      console.log "Bot.calculateMove: board is #{board.getSpaces()}"
      boardSpaces = board.getSpaces()
      for space in boardSpaces
        console.log "Bot.calculateMove: checking if space #{space} is empty"
        if typeof space is "string" 
          console.log "Bot.calculateMove: board isn't empty"
          return false
      return true

    return 4 if isBoardEmpty(board)

    boardCopy = jQuery.extend({}, board) # this copies the board 
                                         # but still refers to the same spaces 
                                         # in the copy. Necessary?
    console.log "about to call Bot.move"
    move = @search(boardCopy, @side, 0, -@infinity, +@infinity)

    return move

  search: (board, side, depth, alpha, beta)->
    # needs to return the index of the move
    debugger

    ##### TRY 1 #####
    #################    
    value = @nodeValue(board, side)
    if value isnt 0
      if value > 0 then return value - depth else return value + depth

    otherside = if side is 'X' then 'O' else 'X'
    moves = @generateMoves board


    boardSpaces = board.getSpaces()
    boardCopy = new Board()
    boardCopy.setSpaces(boardSpaces) 


    if side is 'O'
      for move in moves
        boardCopy.setSpace(move, side)
        score = @search(boardCopy, otherside, depth + 1, beta, alpha)
        alpha = score if score > alpha
        @undoMove(boardCopy, move)
        return alpha if alpha >= beta

    if side is 'X'
      for move in moves
        boardCopy.setSpace(move, side)
        score = @search(boardCopy, otherside, depth + 1, beta, alpha)
        beta = score if score < beta
        @undoMove(boardCopy, move)
        return beta if alpha >= beta

    ##### TRY 2 #####
    #################    
    # value = @nodeValue(board, side)
    # console.log "Bot.search: depth is #{depth}"
    # console.log "Bot.search: value is #{value}"

    # if value isnt 0
    #   if value > 0 then return value - depth else return value + depth

    # moves = @generateMoves board

    # return value if moves.length is 0

    # otherside = if side is 'X' then 'O' else 'X'

    # for move in moves
    #   console.log "Bot.search: #{move} in moves" 

    #   boardSpaces = board.getSpaces()
    #   boardCopy = new Board()
    #   boardCopy.setSpaces(boardSpaces) # This could be rolled into a optional argument 
    #                                    # on the board constructor

    #   @makeMove(boardCopy, move, side)
    #   potentialAlpha = -@search(board, otherside, depth + 1, -beta, -alpha)
    #   @undoMove(boardCopy, move)  # THINK ITS SOMETHING WITH THE BOARD BEING USED
    #   break if beta <= alpha

    #   if potentialAlpha > alpha
    #     alpha = potentialAlpha
    #     if depth is 0
    #       bestMove = move

    # if depth isnt 0 then return alpha else return bestMove


    ##### TRY 3 #####
    #################
    # value = @nodeValue(board, side)
    # console.log "Bot.search: depth is #{depth}"
    # console.log "Bot.search: value is #{value}"

    # if value isnt 0
    #   if value > 0 then return value - depth else return value + depth

    # moves = @generateMoves board

    # return value if moves.length is 0 # ?

    # otherside = if side is 'X' then 'O' else 'X'

    # boardSpaces = board.getSpaces()
    # boardCopy = new Board()
    # boardCopy.setSpaces(boardSpaces) # This could be rolled into a optional argument 
    #                                  # on the board constructor
    # if side is 'O'
    #   for move in moves

    #     boardCopy.setSpace(move, side)
    #     score = @search(boardCopy, otherside, depth + 1, beta, alpha)
    #     alpha = score if score > alpha
    #     return alpha if alpha >= beta
    #     break if beta > alpha

    # else
    #   for move in moves
    #     boardCopy.setSpace(move, side)
    #     score = @search(boardCopy, otherside, depth + 1, beta, alpha)
    #     beta = score if score < beta
    #     return beta if alpha >= beta
    #     break if alph > beta


  nodeValue: (board, side) ->
    console.log "Bot.nodeValue: board is #{board.getSpaces()} and side is #{side}"
    gameResult = checkGameOver board
    console.log "Bot.nodeValue: gameResult is #{gameResult}"
    if gameResult is false or gameResult is 'tie'
      console.log "returning 0 for nodeValue"
      return 0 
    else if gameResult is side
      console.log "returning #{@infinity} for nodeValue"
      return @infinity
    else
      console.log "returning #{-@infinity} for nodeValue"
      return -@infinity

  generateMoves: (board) ->
    console.log "Bot.generateMoves: board is #{board.getSpaces()}"
    moves = []
    boardSpaces = board.getSpaces()

    for space in boardSpaces
      if typeof space is "number"
        moves.push(space)

    console.log "Bot.generateMoves: moves are #{moves}"
    return moves

  makeMove: (board, move, side) ->
    console.log "makeMove: board before makeMove with move #{move} is #{board.getSpaces()}"
    board.setSpace(move, side)
    # board[move] = side
    console.log "makeMove: board after makeMove is #{board.getSpaces()}"

    return board                                   #####

  undoMove: (board, move) ->

    console.log board.getSpaces()
    boardSpaces = board.getSpaces()
    board.setSpace(move, move)
    console.log board.getSpaces()

    return board 

# minimax = (player, board) ->

# minimax (player, board) ->
#   winner if gameOver(currentPosition)

rules.coffee

checkGameOver = (board) ->
  opportunities = 8
  result        = false

  check = (space) ->
    board.getSpace(space)

  winningCombinations = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],
                         [1,4,7],[2,5,8],[0,4,8],[2,4,6]]

  for combo in winningCombinations
    firstPlay       = combo[0]
    secondPlay      = combo[1]
    thirdPlay       = combo[2]

    if typeof check(firstPlay) is "string" and 
    typeof check(secondPlay) is "string" and 
    typeof check(thirdPlay) is "string"
      opportunities -= 1
      console.log "checkGameOver: opportunities decreased to #{opportunities}"

    if check(firstPlay) is check(secondPlay) is check(thirdPlay)
      alert "Winner is #{board.getSpace(firstPlay)}"
      return result = board.getSpace(firstPlay)

    if opportunities is 0
      alert "tie"
      return result = "tie"

  return result

game.coffee

class Game
  constructor: ->
    @board = new Board()
    $('#output').text(@board.getSpaces())

  new: ->
    @board.reset()
    @result = false
    @side = "X"
    @bot = new Bot "O"
    @moves = 0

  firstTurn: ->
    # Production
    # space = prompt "What space are you playing?"
    # @makeMove space

    # Testing
    @board.setSpaces ['X',1,2,3,'O',5,6,7,8] # Testing – Bot should output 1 for calMove
    @makeMove 2

  makeMove: (space) =>
    @board.setSpace(space, @side)
    @moves += 1
    @concludeTurn()

  listenForMove: ->
    space = prompt "What space are you playing?"
    @makeMove space

  concludeTurn: ->
    @result = checkGameOver @board
    console.log "Result is #{@result}"

    if @result is 'X' or @result is 'O' or @result is 'tie'
      alert "Game.concludeTurn: game is over, heading into gameOver"
      return @gameOver @result 
    @changeTurn()

  changeTurn: ->
    @side = if @side is 'X' then 'O' else 'X'
    console.log "in change turn, side is now #{@side}"
    @listenForMove() if @side is 'X'

    console.log "Game.changeTurn: bot (#{@bot}) is about to calc move"
    @makeMove(@bot.calculateMove @board) if @side is 'O'

    # placement = @bot.calculateMove @board if @side is 'O'
    # console.log placement
    # @board.setSpace(placement, 'O') # give a secondary argument to makeMove and remove
    # @concludeTurn()


  gameOver: (winner) ->
    console.log "Game.concludeTurn: winner is #{@result}"
    return
    # answer = prompt "Winner is #{winner}! Would you like to play again?"
    # @playAgain answer

  # playAgain: (answer) ->
  #   alert "Suite yourself" if answer is false
  #   @new() if answer is true


# check if game is over
# check moves
#   if even then human
# else 
#   bot
# 
# increment moves?

# reset grid
# turn = "X"
# move = 0

g = new Game()
g.new()
g.firstTurn()

Very grateful for any help.

Jeff Smith
  • 248
  • 1
  • 7
  • 1
    That's an enormous chunk of code. You're going to have a hard time getting answers if you can't narrow down the problem a bit. – Aaron Dufour Mar 05 '13 at 22:00
  • To debug this, do both minimax and alpha-beta search at every level. Log the data at every level, then analyze it when there is a discrepancy between the two results to see where the alpha-beta logic failed. I assume you have plain minimax working - if not, get that working first. – 500 - Internal Server Error Mar 05 '13 at 22:03
  • yeah that's a lot of code to work with for anyone looking to help – jcollum Mar 11 '13 at 15:54

1 Answers1

0

Question #1:

Your try #1 is missing return value if moves.length is 0 right after you calculate the moves. When I tested your try #2 it worked flawlessly. I had to do a lot of work around that code to make the whole thing work, but it has nothing to do with the minimax code in try #2. In your bot.coffee, please disable your other 2 tries and only enable try #2. Then replace game.coffee with the below.

class Game
  constructor: ->
    @board = new Board()
    $('#output').text(@board.getSpaces())

  new: ->
    @board.reset()
    @result = false
    @side = "X"
    @bot = new Bot "O"
    @moves = 0

  firstTurn: ->
    # Production
    space = prompt "What space are you playing?"
    if space is null
      return
    @makeMove space

    # Testing
    #@board.setSpaces ['X',1,2,3,'O',5,6,7,8] # Testing – Bot should output 1 for calMove
    #@makeMove 2

  makeMove: (space) =>
    @board.setSpace(space, @side, true)
    @moves += 1
    @concludeTurn()

  listenForMove: ->
    space = prompt "What space are you playing?"
    if space is null
      return
    @makeMove space

  concludeTurn: ->
    @result = checkGameOver @board
    #alert "Result is #{@result}"

    if @result is 'X' or @result is 'O' or @result is 'tie'
      alert "Game.concludeTurn: game is over, heading into gameOver"
      return @gameOver @result 
    @changeTurn()

  changeTurn: ->
    @side = if @side is 'X' then 'O' else 'X'
    console.log "in change turn, side is now #{@side}"
    if @side is 'X'
      @listenForMove()
    else if @side is 'O'
      console.log "Game.changeTurn: bot (#{@bot}) is about to calc move"
      @makeMove(@bot.calculateMove @board)

    # placement = @bot.calculateMove @board if @side is 'O'
    # console.log placement
    # @board.setSpace(placement, 'O') # give a secondary argument to makeMove and remove
    # @concludeTurn()


  gameOver: (winner) ->
    console.log "Game.concludeTurn: winner is #{@result}"
    answer = prompt "Winner is #{winner}! Would you like to play again?"
    @playAgain answer

  playAgain: (answer) ->
    alert "Suite yourself" if answer is false
    @new() if answer is true


# check if game is over
# check moves
#   if even then human
# else 
#   bot
# 
# increment moves?

# reset grid
# turn = "X"
# move = 0

g = new Game()
g.new()
g.firstTurn()

And board.coffee becomes:

class Board 
  constructor: ->
    @spaces = [0,1,2,3,4,5,6,7,8]

  reset: ->
    @spaces = [0,1,2,3,4,5,6,7,8]

  setSpace: (index, side, print = false) ->
    #console.log "board.setSpace: played at index #{index} with side #{side}"
    @spaces[index] = side
    if print
      $('#output').text(@spaces)

  setSpaces: (array) ->
    @spaces = array

  getSpace: (index) ->
    @spaces[index]

  getSpaces: ->
    @spaces

Question #2:

The gameboard object isn't bound to the DOM, but you're printing it out every time you're making a move. And since you're using instances of it to calculate your minimax values, you're always printing to the screen, when you're not supposed to. So I've changed board.coffee above to just add a print parameter to work around that. I don't like the way you've written the whole thing, it's totally inefficient on all fronts and I would never write it that way, but the questions aren't about efficiency so I'll leave it at that.

Rikkles
  • 3,372
  • 1
  • 18
  • 24