Building a Tic-Tac-Toe app in Flutter

Dario Miličić
11 min readFeb 11, 2019

--

Introduction

As you may well be aware, Flutter is a cross-platform framework for developing mobile apps that has recently reached 1.0. After following it for a while and reading more about it I decided to try for myself and see if the development experience is as good as advertised.

In short, yes, I was very satisfied with how easy it is to build an app using it, especially how easy it is to build the UI. This was very satisfying since I worked for years as an Android developer where it was always much more work to implement custom UIs and animations than seemed necessary.

Also, since this is my first attempt at building a Flutter app from scratch, I will probably not do things the “best” way and I might be missing out on some idioms and best practices in Flutter/Dart. Feel free to post possible improvements in the comments, this should be a learning experience for everyone. :)

In this article, I use Flutter to build a small Tic-Tac-Toe app with a simple AI, although one that should not be possible to beat! I will highlight my experiences in building the user interface, the AI, how I stored some game information and how it all connects in a simple MVP architecture.

So let’s get started.

Architecture

As I come from the Android world where MVP (Model-View-Presenter) and similar architectures are more prevalent, I decided to apply it here to see how well it fits. Although it is a very elementary MVP implementation, I found that it kept the code somewhat clean.

Widgets (Views) were only responsible for drawing itself and passing touch events to the Presenters. Presenters handled these touch events and contained the actual business logic. Models/Repositories were only responsible for handling data.

It’s always a good idea to separate your business logic from the UI as it helps with testing, keeping the code clean and making developers stay sane. It seems that UI code in Flutter can get very large very quickly with nesting widgets so it can easily increase cognitive complexity if it gets bundled with business logic.

For more advanced architectures like BLoC, I recommend taking a look at this video.

User Interface

This is where Flutter is supposed to shine the most. And shine it does! I have worked somewhat with React, Angular, similar UI frameworks and of course Android, but in my opinion, this was the most enjoyable experience I have had when doing front-end work. The main reason was how fast the UI updated after a change in the code.

Let’s start with the home screen which is simple enough. We’ll have a welcoming message, a button to start a new game and a small statistic at the bottom, which was implemented to get a feel for working with online storage solutions like Cloud Firestore (shown later).

A very simple home screen.

Let’s also take a look at the code behind this:

@override
Widget build(BuildContext context) {

return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
// Welcome text Text("Welcome to Flutter Tic Tac Toe!", style: TextStyle(fontSize: 20),), // New game button Center(
child: ButtonTheme(
minWidth: 200,
height: 80,
child: RaisedButton(
shape: RoundedRectangleBorder(
side: BorderSide(color: Colors.amber, width: 2),
borderRadius: BorderRadius.all(Radius.circular(100)),
),
color: Colors.amber,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => GamePage(widget.title))
);
},
child: Text("New game!", style: TextStyle(fontSize: 20),),
),
),
),
// Win statistic widget StreamBuilder(
stream: _presenter.buildVictoriesStream(),
builder: (context, snapshot) {
var playerCount = _presenter.getVictoryCountFromStream(snapshot);
if (playerCount <= 0) {
return Text("No AI wins yet!", style: TextStyle(fontSize: 15));
}

return Text("Number of AI wins: $playerCount", style: TextStyle(fontSize: 15));
}),

],
),
);
}

This is pretty much basic Flutter stuff when building a stateful widget apart from the bottom section. The home screen widget is stateful because the bottom statistic about the number of wins is updated in real-time. If any other player loses to the AI while you have the app open, this number should be updated almost immediately.

Flutter is designed to use streams very well which makes real-time changes on the UI really easy. I especially like the StreamBuilder widget which allows your UI elements to listen to events and rebuild themselves if necessary without manually notifying your views. I’ve used it here to update the victory count on the home screen. StreamBuilder is especially useful when using the BLoC architecture as can be seen in this video.

Please note that I decided not to use the Flutter’s localization framework for this app so all of the strings are hardcoded. It’s not recommended but you will forgive me :)

The only other screen we have is the game screen. It’s a simple screen which displays the grid on which the game is played on and shows a corresponding dialog when the game ends.

It’s not the prettiest UI but will serve our purpose :)

This screen consists of just the board and a message up top. A dialog pops up when the game ends.

class GamePage extends StatefulWidget {

final String title;

GamePage(this.title);

@override
GamePageState createState() => GamePageState();
}
class GamePageState extends State<GamePage> {...GamePageState() {
this._presenter = GamePresenter(_movePlayed, _onGameEnd);
}
@override
Widget build(BuildContext context) {

return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(60),
child: Text("You are playing as X", style: TextStyle(fontSize: 25),),
),
Expanded(
child: GridView.count(
crossAxisCount: 3,
// generate the widgets that will display the board
children: List.generate(9, (idx) {
return Field(idx: idx, onTap: _movePlayed, playerSymbol: getSymbolForIdx(idx));
}),
),
),
],
),
);
}
}

The GamePage widget is the one that talks to our game logic, which is primarily our AI. This happens through the GamePresenter which is where the AI code and UI code connect. We provide the presenter with the necessary callbacks so the UI knows when to update itself.

The core method that drives UI updates is the following:

void _movePlayed(int idx) {
setState(() {
board[idx] = _currentPlayer;

if (_currentPlayer == Ai.HUMAN) {
// switch to AI player
_currentPlayer = Ai.AI_PLAYER;
_presenter.onHumanPlayed(board);

} else {
_currentPlayer = Ai.HUMAN;
}
});
}

This method is invoked when a human player taps on an empty field. It changes the board state and notifies our presenter that the human has played and it’s the computer’s turn to play. When the computer determines it’s move then this exact method is invoked again as a callback.

On the presenter side, this is the main game logic:

void onHumanPlayed(List<int> board) async {

// evaluate the board after the human player
int evaluation = Utils.evaluateBoard(board);
if (evaluation != Ai.NO_WINNERS_YET) {
onGameEnd(evaluation);
return;
}

// calculate the next move, could be an expensive operation
int aiMove = await Future(() => _aiPlayer.play(board, Ai.AI_PLAYER));

// do the next move
board[aiMove] = Ai.AI_PLAYER;

// evaluate the board after the AI player move
evaluation = Utils.evaluateBoard(board);
if (evaluation != Ai.NO_WINNERS_YET)
onGameEnd(evaluation);
else
showMoveOnUi(aiMove);
}

It is an async method because the AI calculations can be time-consuming and we don’t want to stop our frames from rendering. In this particular method, we will wait asynchronously for the AI to make its move before we notify the UI that we want to show that move on the screen.

Futures and async, await features are very useful in Dart and Flutter and it’s recommended to get a good understanding of how it works.

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.

Storage

For storage, I decided to use a cloud-based solution so I don’t have to implement my own backend. Google is pushing Firebase integration with Flutter a lot, so I decided to see how easy it is to implement. Sure enough, the tutorials were easy to find but the experience was not smooth. Apparently, I picked the wrong day to use Flutter plugins.

After a few bumps, I managed to make it work. The app counts the number of victories against all players and stores that number in the cloud. The number is updated in real-time, which means that if another player loses against the AI while your app is open, you will see the number of victories on the home screen increase without refreshing the page.

The code for handling data is in the GameInfoRepository, this repository is only accessed through Presenters as I don’t like having UI classes deal with database/network specific code:

Stream getVictoryStream() {
return Firestore.instance.collection(VICTORIES_DOC_NAME).snapshots();
}

/// Reactive getter for victory count
int getVictoryCount(AsyncSnapshot<dynamic> snapshot) {
if (snapshot.hasData) {
_documentCache = _getDocument(snapshot.data);
return _documentCache.data[FIELD_COUNT];
}

return -1;
}

/// Async setter for adding the victory count
void addVictory() async {
Firestore.instance.runTransaction((transaction) async {
DocumentReference reference = _documentCache.reference;
DocumentSnapshot docSnapshot = await transaction.get(reference);
await transaction.update(docSnapshot.reference, {
FIELD_COUNT: docSnapshot.data[FIELD_COUNT] + 1,
});
});
}

This is mostly Firestore specific code so I won’t go into much detail here. The tutorial I linked has all the information regarding how this code works.

Conclusion

My main takeaway from the experience of building apps is that Flutter would be my framework of choice moving on. It’s a major improvement over Android UI development in my opinion. Especially when you consider that a single code base works on both iOS and Android.

I did not build the same app in Android to directly compare the complexity of development, but I know from experience that I would have to mess a lot more with XML layouts to get the UI right. And then the process would have to be repeated for iOS as well.

Although it was a bumpy ride, I still believe the developer experience was more enjoyable. I really didn’t like having to wait a few minutes after every small change in Android for a rebuild on my device. I would often lose focus during the wait and my thoughts would drift elsewhere. This is not the case anymore in Flutter and is the primary reason I was drawn to the framework.

It’s easier to stay focused in Flutter because the wait time for applying changes is so small, I felt I could be in the “zone” for longer. It makes me excited about future projects and how my productivity might increase.

The app is available on Google Play here and all the source code for the app is available here. Feel free to play around with it, modify it and post your thoughts in the comments. Since this post is supposed to be a learning experience for everyone, I will appreciate all the constructive feedback. :)

--

--