The board

The Field widget represents a single cell on the board. It knows how to draw itself based on the index of the cell. It does not need to keep any state so it’s a StatelessWidget.

class Field extends StatelessWidget { ... Field({this.idx, this.onTap, this.playerSymbol});



... @override

Widget build(BuildContext context) {

return GestureDetector(

onTap: _handleTap,

child: Container(

margin: const EdgeInsets.all(0.0),

decoration: BoxDecoration(

border: _determineBorder()

),

child: Center(

child: Text(playerSymbol, style: TextStyle(fontSize: 50))

),

),

);

}

}

Field class gets the index which represents a particular cell on the board and it gets the symbol that should be printed, which is either empty, X or O. Using the index it knows which borders it should draw:

/// Returns a border to draw depending on this field index.

Border _determineBorder() {

Border determinedBorder = Border.all();



switch(idx) {

case 0:

determinedBorder = Border(bottom: _borderSide, right: _borderSide);

break;

case 1:

determinedBorder = Border(left: _borderSide, bottom: _borderSide, right: _borderSide);

break;

case 2:

determinedBorder = Border(left: _borderSide, bottom: _borderSide);

break;

case 3:

determinedBorder = Border(bottom: _borderSide, right: _borderSide, top: _borderSide);

break;

case 4:

determinedBorder = Border(left: _borderSide, bottom: _borderSide, right: _borderSide, top: _borderSide);

break;

case 5:

determinedBorder = Border(left: _borderSide, bottom: _borderSide, top: _borderSide);

break;

case 6:

determinedBorder = Border(right: _borderSide, top: _borderSide);

break;

case 7:

determinedBorder = Border(left: _borderSide, top: _borderSide, right: _borderSide);

break;

case 8:

determinedBorder = Border(left: _borderSide, top: _borderSide);

break;

}



return determinedBorder;

}

Using the GestureDetector widget we can listen for tap events and send them to our parent widget for processing. We don’t send tap events for fields which are already taken.

// dart allows referencing methods like this

final Function(int idx) onTap; void _handleTap() {

// only send tap events if the field is empty

if (playerSymbol == "")

onTap(idx);

}

Artificial Intelligence

A classic Tic-Tac-Toe is a simple game that doesn’t require any advanced AI techniques to build a competent computer player. The algorithm used in the app is called the Minimax algorithm.

How the AI determines the next move is by taking a look at the current board and then plays against itself on a separate board by trying all the possible moves until the game ends. It can then determine which moves result in victories and which moves result in losses. Of course, the AI will then pick one of the moves that can potentially win the game.

In other words, if we assume a win has a positive score, and a loss has a negative score, the AI tries to maximize its score while minimizing the score of the opposing player, repeated for all moves. Hence the name Minimax (or Maximin).

In this particular game, you can always play to avoid a loss by at least getting a draw. Because of this, the AI cannot be beaten if it plays optimally.

Although the board is 3x3, we use an array of 9 fields with indices 0–8 which will simplify the implementation somewhat. Index 0 represents the top left cell, index 1 represents the top middle cell and so on.

Implementation is done using recursion. Our stopping condition is that the game has ended:

/// Returns the best possible score for a certain board condition.

/// This method implements the stopping condition.

int _getBestScore(List<int> board, int currentPlayer) {

int evaluation = Utils.evaluateBoard(board);



if (evaluation == currentPlayer)

return WIN_SCORE;



if (evaluation == DRAW)

return DRAW_SCORE;



if (evaluation == Utils.flipPlayer(currentPlayer)) {

return LOSE_SCORE;

}



return _getBestMove(board, currentPlayer).score;

}

If we evaluate the board and find that one of the players has won or that the game has resulted in a draw, we stop the recursion and return the score. The score is positive if the current player has won, it’s 0 if it’s a draw and it’s negative if the opposing player won.

Next, we need to try all legal moves and determine their scores:

/// This is where the actual Minimax algorithm is implemented

Move _getBestMove(List<int> board, int currentPlayer) { // try all possible moves

List<int> newBoard; // will contain our next best score

Move bestMove = Move(score: -10000, move: -1);



for(int currentMove = 0; currentMove < board.length; currentMove++) { if (!Utils.isMoveLegal(board, currentMove)) continue;



// we need a copy of the initial board so we don't pollute our real board

newBoard = List.from(board);



// make the move

newBoard[currentMove] = currentPlayer;



// solve for the next player

// what is a good score for the opposite player is opposite of good score for us

int nextScore = -_getBestScore(newBoard, Utils.flipPlayer(currentPlayer));



// check if the current move is better than our best found move

if (nextScore > bestMove.score) {

bestMove.score = nextScore;

bestMove.move = currentMove;

}

}



return bestMove;

}

After we get a score for a particular move, we can compare it against other moves and if it results in a better score, then we mark this move as the best one found so far. Since we are trying all possible legal moves, we can be sure that the move we chose is the optimal one.

There is a minor difference between the implementation and the official Minimax algorithm and that is we always use the max function and never really use min. You can notice that we flip the sign on the score when the function returns. This allows us to always just maximize the score and simplify implementation, that’s the only reason. This variant is called the Negamax algorithm.