Building a Tic-Tac-Toe app in Flutter

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:@overrideWidget 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.


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 boardThe 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 {.


idx, this.

onTap, this.


@override Widget build(BuildContext context) { return GestureDetector( onTap: _handleTap, child: Container( margin: const EdgeInsets.


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 thisfinal Function(int idx) onTap;void _handleTap() { // only send tap events if the field is empty if (playerSymbol == "") onTap(idx);}Artificial IntelligenceA 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 3×3, 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 implementedMove _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.

StorageFor 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.



snapshots();}/// Reactive getter for victory countint getVictoryCount(AsyncSnapshot<dynamic> snapshot) { if (snapshot.

hasData) { _documentCache = _getDocument(snapshot.

data); return _documentCache.

data[FIELD_COUNT]; } return -1;}/// Async setter for adding the victory countvoid addVictory() async { Firestore.


runTransaction((transaction) async { DocumentReference reference = _documentCache.

reference; DocumentSnapshot docSnapshot = await transaction.

get(reference); await transaction.


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.

ConclusionMy 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.


. More details

Leave a Reply