Your Tic-Tac-Toe game is playable and polished. The logic is solid (Part 1), the animations and score tracker are in place (Part 2). The one thing missing: something to play against when there is no second human around.
Part 3 adds a full single-player mode with three difficulty levels. Easy is trivially beatable — it plays random moves. Medium adds defensive awareness: it wins when it can, blocks when it must. Hard is unbeatable: it uses the minimax algorithm to explore every possible future game state and always pick the optimal move.
Everything from Parts 1 and 2 stays intact. This post is purely additive: a new mode screen, two enums, three AI functions, and a small delay to make the AI feel like it’s thinking.
Part 1: Core game logic — StatefulWidget, GridView, win/draw detection, AlertDialog
Part 2: Animations, score tracker, CustomPaint, InkWell, winning-cell highlight
Part 3 (this post): Single-player AI — Easy, Medium, and Hard (minimax)
- Complete Part 1 and Part 2 first — this post adds AI on top of the working polished game
- Comfortable with Dart functions and recursion — minimax calls itself repeatedly
- Understand StatefulWidget and setState() — AI moves trigger the same setState flow as human moves
- Know Navigator.push — used for the new game mode screen
1. What Makes an AI Unbeatable?
Classic Tic-Tac-Toe is a two-player, zero-sum game: one player’s gain is always the other’s loss. That makes it a perfect fit for the minimax algorithm, which works by assuming both players always play optimally and then pre-computing the best possible move from any board position.
The reason minimax produces an unbeatable AI is simple: it considers every possible future game before making a move. If the human plays perfectly, the best outcome the AI can guarantee is a draw. If the human makes any mistake, minimax finds and exploits it immediately.
| Difficulty | Strategy | Can you beat it? | Algorithm |
|---|---|---|---|
| Easy | Picks a random empty cell | Yes, almost always | dart:math Random |
| Medium | Wins if it can, blocks if it must, else random | Yes, with the right sequence | 1-move lookahead |
| Hard | Exhaustive game-tree search | No — best you can do is draw | Minimax |
2. Step 1 — GameMode and Difficulty Enums
Before any AI logic, add two enums at the top of main.dart. Enums make the code self-documenting and prevent magic strings like 'vsAi' or 'hard' scattered through the logic:
// Describes whether the game is local 2-player or vs the AI
enum GameMode {
twoPlayers,
vsAi,
}
// Controls which AI strategy is used
enum Difficulty {
easy, // random moves
medium, // win > block > random
hard, // minimax — unbeatable
}
// Why enums instead of strings or ints?
// • String: 'hrad' compiles fine but breaks at runtime — typo-prone
// • Int: 0/1/2 has no meaning at the call site
// • Enum: Difficulty.hard is self-documenting and the compiler catches typos
Update TicTacToePage to accept these as constructor parameters:
class TicTacToePage extends StatefulWidget {
const TicTacToePage({
super.key,
this.gameMode = GameMode.twoPlayers, // default: 2-player for backward compat
this.difficulty = Difficulty.hard, // default difficulty when vs AI
});
final GameMode gameMode;
final Difficulty difficulty;
@override
State<TicTacToePage> createState() => _TicTacToePageState();
}
// In the State, add:
class _TicTacToePageState extends State<TicTacToePage> {
// ... all Part 2 fields ...
// AI configuration — read from widget properties
late final GameMode gameMode;
late final Difficulty difficulty;
static const String _humanPlayer = 'X'; // human always plays X
static const String _aiPlayer = 'O'; // AI always plays O
bool _isAiThinking = false; // blocks taps during AI turn
@override
void initState() {
super.initState();
gameMode = widget.gameMode;
difficulty = widget.difficulty;
_initializeGame();
}
}
3. Step 2 — GameModeScreen: Choose 2P or vs AI
Add a GameModeScreen as the new entry point. This is a simple StatefulWidget (needed for the difficulty selector) that navigates to TicTacToePage with the chosen configuration:
// Update MyApp to point to GameModeScreen instead of TicTacToePage
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Tic-Tac-Toe',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const GameModeScreen(), // ← changed from TicTacToePage
);
}
}
// ─────────────────────────────────────────────────────────────────
class GameModeScreen extends StatefulWidget {
const GameModeScreen({super.key});
@override
State<GameModeScreen> createState() => _GameModeScreenState();
}
class _GameModeScreenState extends State<GameModeScreen> {
Difficulty _selectedDifficulty = Difficulty.hard;
void _startGame(GameMode mode) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TicTacToePage(
gameMode: mode,
difficulty: _selectedDifficulty,
),
),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Tic-Tac-Toe'),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Title block
const Text(
'❌ ⭕',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 56, letterSpacing: 16),
),
const SizedBox(height: 8),
const Text(
'Flutter Tic-Tac-Toe',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold),
),
const SizedBox(height: 48),
// 2-player button
FilledButton.icon(
onPressed: () => _startGame(GameMode.twoPlayers),
icon: const Icon(Icons.people),
label: const Text('2 Players', style: TextStyle(fontSize: 18)),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
),
const SizedBox(height: 16),
// vs AI button
FilledButton.icon(
onPressed: () => _startGame(GameMode.vsAi),
icon: const Icon(Icons.smart_toy),
label: const Text('vs AI', style: TextStyle(fontSize: 18)),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: scheme.secondary,
),
),
const SizedBox(height: 32),
// Difficulty selector (only relevant for vs AI)
const Text(
'AI Difficulty',
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
_DifficultySelector(
selected: _selectedDifficulty,
onChanged: (d) => setState(() => _selectedDifficulty = d),
),
],
),
),
);
}
}
4. Step 3 — Difficulty Selector UI
The difficulty selector uses Material 3’s SegmentedButton — a clean three-way toggle that maps perfectly to Easy / Medium / Hard:
// Extracted as a reusable widget for cleanliness
class _DifficultySelector extends StatelessWidget {
const _DifficultySelector({
required this.selected,
required this.onChanged,
});
final Difficulty selected;
final ValueChanged<Difficulty> onChanged;
@override
Widget build(BuildContext context) {
return SegmentedButton<Difficulty>(
// SegmentedButton requires useMaterial3: true in ThemeData
segments: const [
ButtonSegment(
value: Difficulty.easy,
label: Text('Easy'),
icon: Icon(Icons.sentiment_satisfied),
),
ButtonSegment(
value: Difficulty.medium,
label: Text('Medium'),
icon: Icon(Icons.sentiment_neutral),
),
ButtonSegment(
value: Difficulty.hard,
label: Text('Hard'),
icon: Icon(Icons.sentiment_very_dissatisfied),
),
],
selected: {selected}, // Set<Difficulty> with one item
onSelectionChanged: (newSelection) => onChanged(newSelection.first),
);
}
}
// DropdownButton alternative (if you prefer or need wider compatibility):
DropdownButton<Difficulty>(
value: _selectedDifficulty,
items: const [
DropdownMenuItem(value: Difficulty.easy, child: Text('Easy (Random)')),
DropdownMenuItem(value: Difficulty.medium, child: Text('Medium (Defensive)')),
DropdownMenuItem(value: Difficulty.hard, child: Text('Hard (Unbeatable)')),
],
onChanged: (v) { if (v != null) setState(() => _selectedDifficulty = v); },
)
5. Step 4 — Easy AI: Random Moves
The Easy AI scans for all empty cells and picks one at random using Dart’s dart:math library. It is completely trivial to beat — it will miss wins and ignore threats — but it is a useful building block because both Medium and Hard fall back to it when no better move exists:
import 'dart:math';
final Random _random = Random();
// Returns the index of a random empty cell, or -1 if the board is full
int _pickRandomMove(List<String> board) {
final List<int> available = [
for (int i = 0; i < board.length; i++)
if (board[i].isEmpty) i,
];
if (available.isEmpty) return -1;
return available[_random.nextInt(available.length)];
}
// How dart:math Random works:
// _random.nextInt(3) returns 0, 1, or 2 with equal probability
// _random.nextInt(available.length) returns a valid index into 'available'
// available[that index] gives a real board position
6. Step 5 — Medium AI: Win, Block, Random
Medium difficulty adds a one-move lookahead: simulate each possible move on a copy of the board, check if it creates a win, and act accordingly. The key rule: never mutate the real board inside the AI logic — always work on List.from(board):
// A standalone winner check that works on any board copy
// (Reuses the same _winningCombos constant from Parts 1 and 2)
bool _checkWinnerOnBoard(List<String> b, String player) {
for (final combo in _winningCombos) {
if (b[combo[0]] == player &&
b[combo[1]] == player &&
b[combo[2]] == player) {
return true;
}
}
return false;
}
bool _isBoardFull(List<String> b) => b.every((cell) => cell.isNotEmpty);
// Medium: three-phase decision
int _pickMediumMove(List<String> board, String ai, String human) {
// Phase 1: Win if possible
for (int i = 0; i < board.length; i++) {
if (board[i].isEmpty) {
final temp = List<String>.from(board); // ← copy, never mutate real board
temp[i] = ai;
if (_checkWinnerOnBoard(temp, ai)) return i;
}
}
// Phase 2: Block the human from winning
for (int i = 0; i < board.length; i++) {
if (board[i].isEmpty) {
final temp = List<String>.from(board);
temp[i] = human;
if (_checkWinnerOnBoard(temp, human)) return i;
}
}
// Phase 3: No immediate threat — play randomly
return _pickRandomMove(board);
}
// Why this order matters:
// If you check blocking BEFORE winning, the AI might miss its own winning move
// to block a non-existent threat. Always try to win first.
Medium only looks one move ahead. You can beat it by creating a fork: a board position where you have two different ways to win simultaneously. Medium will block one but can’t block both. Setting up a fork requires thinking 2–3 moves ahead, which Medium cannot do. Hard (minimax) blocks all forks.
7. Step 6 — Minimax Explained: How the Hard AI Thinks
Before writing any minimax code, understand the mental model. Minimax builds a complete decision tree of the game and scores every leaf node (terminal game state). It then propagates those scores back up the tree, with each level choosing the best move for the player whose turn it is.
// Minimax decision tree — simplified to 2 moves deep:
//
// Current board: X has 2 in a row, O's turn
//
// [O's turn — minimizing]
// / |
// Move A Move B Move C
// (O doesn't (O doesn't (O blocks
// block) block) X's win)
//
// X wins next X wins next Game continues
// Score: -10 Score: -10 Score: 0 or +10
//
// Minimax sees Move C gives the best (least bad) outcome for O.
// It picks Move C — block the human.
//
// Scoring terminal states:
// +10 = AI (O) wins
// -10 = Human (X) wins
// 0 = Draw
//
// At a MAXIMIZING level (AI's turn): pick the child with the HIGHEST score
// At a MINIMIZING level (Human's turn): pick the child with the LOWEST score
The algorithm is recursive. It stops recursing when it hits a terminal state (win, loss, or full board), scores it, and returns that score up the call stack. Each level then picks the max or min of its children’s scores:
// Minimax trace on a 3-move-from-end board:
//
// minimax(board, isMaximizing=true) ← AI's turn, wants to MAXIMISE
// ├─ try cell 2: minimax(board2, false) ← Human's turn, wants to MINIMISE
// │ ├─ try cell 5: X wins → return -10
// │ └─ try cell 7: draw → return 0
// │ human picks min(-10, 0) = -10, returns -10
// └─ try cell 5: minimax(board5, false)
// └─ try cell 2: AI wins → return +10
// human picks min(10) = 10, returns +10
//
// AI picks max(-10, 10) = 10 → plays cell 5 — guaranteed win!
8. Step 7 — Implementing Minimax in Pure Dart
The implementation has three parts: base cases (when to stop recursing), the maximizing branch (AI’s turn), and the minimizing branch (human’s turn). Each recursive call works on a fresh copy of the board — this is the most important rule:
int _minimax(
List<String> board,
bool isMaximizing, // true = AI's turn (wants high score), false = human's turn (wants low score)
String ai,
String human,
) {
// ── Base cases: check for terminal states FIRST ───────────────────────────────
if (_checkWinnerOnBoard(board, ai)) return 10; // AI wins → best outcome
if (_checkWinnerOnBoard(board, human)) return -10; // Human wins → worst outcome
if (_isBoardFull(board)) return 0; // Draw → neutral
// ── AI's turn: try every empty cell, keep the highest score ───────────────────
if (isMaximizing) {
int best = -1000; // start lower than any real score
for (int i = 0; i < board.length; i++) {
if (board[i].isEmpty) {
final temp = List<String>.from(board); // ← COPY, never mutate the original
temp[i] = ai;
final score = _minimax(temp, false, ai, human); // human moves next
if (score > best) best = score;
}
}
return best;
// ── Human's turn: try every empty cell, keep the lowest score ────────────────
} else {
int best = 1000; // start higher than any real score
for (int i = 0; i < board.length; i++) {
if (board[i].isEmpty) {
final temp = List<String>.from(board);
temp[i] = human;
final score = _minimax(temp, true, ai, human); // AI moves next
if (score < best) best = score;
}
}
return best;
}
// Recursion terminates because each call fills one more cell.
// After at most 9 levels, the board is full and a base case fires.
}
Never do
board[i] = ai inside minimax. Always create a copy first: final temp = List<String>.from(board); temp[i] = ai;. If you mutate the real board, recursive calls overwrite each other’s work, scores become meaningless, and the AI either crashes or plays randomly. This is the single most common minimax bug.
9. Step 8 — Choosing the Best Move
_minimax evaluates a board position and returns a score. A separate wrapper function iterates all empty cells, calls _minimax for each, and returns the index of the cell with the highest score:
// Returns the board index of the best move for the AI
int _pickBestMoveMinimax(List<String> board, String ai, String human) {
int bestScore = -1000;
int bestMove = -1;
for (int i = 0; i < board.length; i++) {
if (board[i].isEmpty) {
final temp = List<String>.from(board);
temp[i] = ai; // try this move
// After AI places, it's the human's turn next (isMaximizing=false)
final score = _minimax(temp, false, ai, human);
if (score > bestScore) {
bestScore = score;
bestMove = i; // remember which cell gave this score
}
}
}
return bestMove;
}
// Worked example on an empty board:
// All 9 cells are evaluated. The centre (index 4) and corners score highest
// because they belong to the most winning lines. Minimax will pick one of those.
//
// On a nearly-full board with one winning move:
// Only 1-2 cells need evaluating. Minimax immediately returns +10 for the win
// and that move is selected. Total time: microseconds.
10. Step 9 — AI Thinking Delay with Future.delayed
Without a delay, the AI responds so fast after a human move that both marks appear to happen simultaneously — the human’s tap and the AI’s response are in the same setState. A 400ms pause makes the AI feel deliberate and gives players time to see their own move first:
void _maybeTriggerAiMove() {
// Only trigger if we are in AI mode AND it is now the AI's turn
if (gameMode != GameMode.vsAi || currentPlayer != _aiPlayer) return;
// Don't trigger if the game just ended
if (winner != null || isDraw) return;
setState(() => _isAiThinking = true); // block human taps during AI turn
// Wait 400ms, then compute and apply the AI move
Future.delayed(const Duration(milliseconds: 400), _makeAiMove);
}
void _makeAiMove() {
// Guard: game might have ended during the delay (shouldn't happen, but safe)
if (winner != null || isDraw) {
setState(() => _isAiThinking = false);
return;
}
// Choose move based on current difficulty
final int moveIndex;
switch (difficulty) {
case Difficulty.easy:
moveIndex = _pickRandomMove(board);
case Difficulty.medium:
moveIndex = _pickMediumMove(board, _aiPlayer, _humanPlayer);
case Difficulty.hard:
moveIndex = _pickBestMoveMinimax(board, _aiPlayer, _humanPlayer);
}
if (moveIndex == -1) {
setState(() => _isAiThinking = false);
return;
}
setState(() {
board[moveIndex] = _aiPlayer;
_checkWinner();
if (winner == null) {
_checkDraw();
if (!isDraw) _switchPlayer();
}
_isAiThinking = false; // re-enable human taps
});
}
11. Step 10 — Wiring All Three Levels into _handleTap
The updated _handleTap adds two new guard conditions: ignore taps when the AI is thinking, and ignore taps on the AI’s turn (so the human can’t play for the AI). After a valid human move, it calls _maybeTriggerAiMove() to chain the AI response:
void _handleTap(int index) {
// Original Part 1 guards
if (board[index] != '' || winner != null || isDraw) return;
// Part 3 additions: block taps while AI is computing or on AI's turn
if (_isAiThinking) return;
if (gameMode == GameMode.vsAi && currentPlayer == _aiPlayer) return;
setState(() {
board[index] = currentPlayer;
_checkWinner();
if (winner == null) {
_checkDraw();
if (!isDraw) {
_switchPlayer();
_maybeTriggerAiMove(); // ← chain AI move if it's now AI's turn
}
}
});
}
// Guard evaluation order matters:
// 1. board[index] != '' — prevent overwriting
// 2. winner != null — game already over
// 3. isDraw — game already over
// 4. _isAiThinking — AI is mid-computation
// 5. currentPlayer == AI — it's not the human's turn
// All 5 must pass before a tap is processed
If you skip the
_isAiThinking flag, a fast tap during the 400ms delay will fire _handleTap successfully — placing the human’s symbol on what should be the AI’s turn. The flag prevents this race condition and ensures turns alternate correctly even when the human taps rapidly.
12. Bonus: Alpha-Beta Pruning
Minimax on a 3×3 board is fast — it takes microseconds. But on a 4×4 board, the game tree grows exponentially (more cells, more branching) and plain minimax starts to lag. Alpha-beta pruning is an optimisation that cuts branches of the tree that cannot possibly affect the final decision.
// How alpha-beta pruning works:
//
// alpha = best score the MAXIMIZER (AI) is guaranteed so far
// beta = best score the MINIMIZER (human) is guaranteed so far
//
// If beta <= alpha at any point, neither player would rationally choose
// this branch — stop exploring it immediately ("prune" it).
int _minimaxAB(
List<String> board,
bool isMaximizing,
String ai,
String human,
int alpha, // ← new parameter
int beta, // ← new parameter
) {
// Same base cases
if (_checkWinnerOnBoard(board, ai)) return 10;
if (_checkWinnerOnBoard(board, human)) return -10;
if (_isBoardFull(board)) return 0;
if (isMaximizing) {
int best = -1000;
for (int i = 0; i < board.length; i++) {
if (board[i].isEmpty) {
final temp = List<String>.from(board);
temp[i] = ai;
final score = _minimaxAB(temp, false, ai, human, alpha, beta);
if (score > best) best = score;
if (best > alpha) alpha = best; // update alpha
if (beta <= alpha) break; // ← prune: no need to check further
}
}
return best;
} else {
int best = 1000;
for (int i = 0; i < board.length; i++) {
if (board[i].isEmpty) {
final temp = List<String>.from(board);
temp[i] = human;
final score = _minimaxAB(temp, true, ai, human, alpha, beta);
if (score < best) best = score;
if (best < beta) beta = best; // update beta
if (beta <= alpha) break; // ← prune
}
}
return best;
}
}
// Initial call:
// _minimaxAB(board, false, ai, human, -1000, 1000)
//
// For 3×3 Tic-Tac-Toe: pruning rarely changes speed noticeably.
// For 4×4 or larger: pruning can reduce nodes explored by up to 50%.
13. Full main.dart — Copy-Paste Ready
The complete Part 3 file combines everything: enums, GameModeScreen, all AI helpers, and the updated TicTacToePage with the full Part 2 polish intact:
import 'dart:math';
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
// ── Enums ────────────────────────────────────────────────────────────────
enum GameMode { twoPlayers, vsAi }
enum Difficulty { easy, medium, hard }
// ── App ───────────────────────────────────────────────────────────────────
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Tic-Tac-Toe',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const GameModeScreen(),
);
}
// ── Game Mode Screen ───────────────────────────────────────────────────────────
class GameModeScreen extends StatefulWidget {
const GameModeScreen({super.key});
@override
State<GameModeScreen> createState() => _GameModeScreenState();
}
class _GameModeScreenState extends State<GameModeScreen> {
Difficulty _diff = Difficulty.hard;
void _start(GameMode mode) => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TicTacToePage(gameMode: mode, difficulty: _diff),
),
);
@override
Widget build(BuildContext context) {
final s = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Tic-Tac-Toe'), centerTitle: true),
body: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('❌ ⭕', textAlign: TextAlign.center,
style: TextStyle(fontSize: 56, letterSpacing: 16)),
const SizedBox(height: 8),
const Text('Flutter Tic-Tac-Toe', textAlign: TextAlign.center,
style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold)),
const SizedBox(height: 48),
FilledButton.icon(
onPressed: () => _start(GameMode.twoPlayers),
icon: const Icon(Icons.people),
label: const Text('2 Players', style: TextStyle(fontSize: 18)),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () => _start(GameMode.vsAi),
icon: const Icon(Icons.smart_toy),
label: const Text('vs AI', style: TextStyle(fontSize: 18)),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: s.secondary),
),
const SizedBox(height: 28),
const Text('AI Difficulty', textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 10),
SegmentedButton<Difficulty>(
segments: const [
ButtonSegment(value: Difficulty.easy, label: Text('Easy'), icon: Icon(Icons.sentiment_satisfied)),
ButtonSegment(value: Difficulty.medium, label: Text('Medium'), icon: Icon(Icons.sentiment_neutral)),
ButtonSegment(value: Difficulty.hard, label: Text('Hard'), icon: Icon(Icons.sentiment_very_dissatisfied)),
],
selected: {_diff},
onSelectionChanged: (s) => setState(() => _diff = s.first),
),
],
),
),
);
}
}
// ── AI helpers (standalone functions, not inside State) ──────────────────────────
final Random _rng = Random();
const List<List<int>> _winningCombos = [
[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6],
];
bool _checkWinnerOnBoard(List<String> b, String p) =>
_winningCombos.any((c) => b[c[0]] == p && b[c[1]] == p && b[c[2]] == p);
bool _isBoardFull(List<String> b) => b.every((c) => c.isNotEmpty);
int _pickRandom(List<String> b) {
final av = [for (int i = 0; i < b.length; i++) if (b[i].isEmpty) i];
return av.isEmpty ? -1 : av[_rng.nextInt(av.length)];
}
int _pickMedium(List<String> b, String ai, String hu) {
for (int i = 0; i < b.length; i++) {
if (b[i].isEmpty) { final t = List<String>.from(b)..setAll(i,[ai]); if (_checkWinnerOnBoard(t, ai)) return i; }
}
for (int i = 0; i < b.length; i++) {
if (b[i].isEmpty) { final t = List<String>.from(b)..setAll(i,[hu]); if (_checkWinnerOnBoard(t, hu)) return i; }
}
return _pickRandom(b);
}
int _minimax(List<String> b, bool max, String ai, String hu) {
if (_checkWinnerOnBoard(b, ai)) return 10;
if (_checkWinnerOnBoard(b, hu)) return -10;
if (_isBoardFull(b)) return 0;
if (max) {
int best = -1000;
for (int i = 0; i < b.length; i++) {
if (b[i].isEmpty) {
final t = List<String>.from(b); t[i] = ai;
final s = _minimax(t, false, ai, hu); if (s > best) best = s;
}
}
return best;
} else {
int best = 1000;
for (int i = 0; i < b.length; i++) {
if (b[i].isEmpty) {
final t = List<String>.from(b); t[i] = hu;
final s = _minimax(t, true, ai, hu); if (s < best) best = s;
}
}
return best;
}
}
int _pickBestMove(List<String> b, String ai, String hu) {
int best = -1000, move = -1;
for (int i = 0; i < b.length; i++) {
if (b[i].isEmpty) {
final t = List<String>.from(b); t[i] = ai;
final s = _minimax(t, false, ai, hu);
if (s > best) { best = s; move = i; }
}
}
return move;
}
// ── CustomPainter (from Part 2) ────────────────────────────────────────────────────
class GridPainter extends CustomPainter {
const GridPainter();
@override
void paint(Canvas canvas, Size size) {
final p = Paint()..color = const Color(0xFF90A4AE)..strokeWidth = 3..strokeCap = StrokeCap.round..style = PaintingStyle.stroke;
final double w = size.width, h = size.height;
canvas.drawLine(Offset(w/3,0), Offset(w/3,h), p);
canvas.drawLine(Offset(2*w/3,0), Offset(2*w/3,h), p);
canvas.drawLine(Offset(0,h/3), Offset(w,h/3), p);
canvas.drawLine(Offset(0,2*h/3), Offset(w,2*h/3), p);
}
@override bool shouldRepaint(_) => false;
}
// ── ScoreBoard (from Part 2) ────────────────────────────────────────────────────────
class ScoreBoard extends StatelessWidget {
const ScoreBoard({super.key, required this.scoreX, required this.scoreO, required this.scoreDraws, this.onReset});
final int scoreX, scoreO, scoreDraws;
final VoidCallback? onReset;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
decoration: BoxDecoration(color: Colors.grey.shade50, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey.shade200)),
child: Row(
children: [
Expanded(child: _t('Player X', scoreX, Colors.blue.shade700)),
_d(), Expanded(child: _t('Draws', scoreDraws, Colors.grey.shade600)),
_d(), Expanded(child: _t('Player O', scoreO, Colors.red.shade600)),
if (onReset != null) IconButton(icon: const Icon(Icons.refresh, size: 18), onPressed: onReset, color: Colors.grey.shade400),
],
),
);
Widget _t(String l, int s, Color c) => Column(mainAxisSize: MainAxisSize.min, children: [
Text(l, style: TextStyle(fontWeight: FontWeight.w600, color: c, fontSize: 12)),
const SizedBox(height: 4),
AnimatedSwitcher(duration: const Duration(milliseconds: 300),
child: Text('$s', key: ValueKey(s), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold))),
]);
Widget _d() => Container(width:1, height:36, color: Colors.grey.shade200, margin: const EdgeInsets.symmetric(horizontal:8));
}
// ── Main Game Page ────────────────────────────────────────────────────────────────
class TicTacToePage extends StatefulWidget {
const TicTacToePage({super.key, this.gameMode = GameMode.twoPlayers, this.difficulty = Difficulty.hard});
final GameMode gameMode;
final Difficulty difficulty;
@override
State<TicTacToePage> createState() => _TicTacToePageState();
}
class _TicTacToePageState extends State<TicTacToePage> {
late List<String> board;
String currentPlayer = 'X';
String? winner;
bool isDraw = false;
List<int> _winningCells = [];
int _scoreX = 0, _scoreO = 0, _scoreDraws = 0;
int _streakX = 0, _streakO = 0;
bool _isAiThinking = false;
static const String _humanPlayer = 'X';
static const String _aiPlayer = 'O';
@override void initState() { super.initState(); _initializeGame(); }
void _initializeGame() {
board = List.filled(9, '');
currentPlayer = 'X'; winner = null; isDraw = false; _winningCells = [];
}
void _handleTap(int index) {
if (board[index] != '' || winner != null || isDraw) return;
if (_isAiThinking) return;
if (widget.gameMode == GameMode.vsAi && currentPlayer == _aiPlayer) return;
setState(() {
board[index] = currentPlayer;
_checkWinner();
if (winner == null) {
_checkDraw();
if (!isDraw) { _switchPlayer(); _maybeTriggerAiMove(); }
}
});
}
void _switchPlayer() => currentPlayer = currentPlayer == 'X' ? 'O' : 'X';
void _checkWinner() {
for (final c in _winningCombos) {
if (board[c[0]].isNotEmpty && board[c[0]] == board[c[1]] && board[c[0]] == board[c[2]]) {
winner = board[c[0]]; _winningCells = List.from(c);
_updateScores(); _showDialog(_buildMsg()); return;
}
}
}
void _checkDraw() {
if (board.every((c) => c.isNotEmpty) && winner == null) {
isDraw = true; _updateScores(); _showDialog(_buildMsg());
}
}
void _updateScores() {
if (winner == 'X') { _scoreX++; _streakX++; _streakO = 0; }
else if (winner == 'O') { _scoreO++; _streakO++; _streakX = 0; }
else { _scoreDraws++; _streakX = 0; _streakO = 0; }
}
String _buildMsg() {
if (winner == 'X') {
if (_streakX >= 3) return 'X is unstoppable! 🔥 Streak: $_streakX';
if (_streakX == 2) return 'X wins again! 🔥';
return 'X wins! 🎉';
} else if (winner == 'O') {
final ai = widget.gameMode == GameMode.vsAi;
if (_streakO >= 3) return ai ? 'The AI is on a roll! 🤖🔥' : 'O is unstoppable! 🔥';
return ai ? 'AI wins this round! 🤖' : 'O wins! 🎉';
} else {
return _scoreDraws >= 3 ? 'Another draw! 🤔' : "It's a draw! 🤝";
}
}
void _showDialog(String msg) {
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog<void>(
context: context, barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Game Over'),
content: Text(msg, style: const TextStyle(fontSize: 18)),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK')),
FilledButton(
onPressed: () { Navigator.of(ctx).pop(); _resetGame(); },
child: const Text('Play Again'),
),
],
),
);
});
}
void _resetGame() => setState(() => _initializeGame());
void _resetScores() {
setState(() { _scoreX = _scoreO = _scoreDraws = 0; _streakX = _streakO = 0; });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Scores reset'), duration: Duration(seconds: 2),
behavior: SnackBarBehavior.floating, width: 160));
}
void _maybeTriggerAiMove() {
if (widget.gameMode != GameMode.vsAi || currentPlayer != _aiPlayer) return;
if (winner != null || isDraw) return;
setState(() => _isAiThinking = true);
Future.delayed(const Duration(milliseconds: 400), _makeAiMove);
}
void _makeAiMove() {
if (winner != null || isDraw) { setState(() => _isAiThinking = false); return; }
final int move;
switch (widget.difficulty) {
case Difficulty.easy: move = _pickRandom(board);
case Difficulty.medium: move = _pickMedium(board, _aiPlayer, _humanPlayer);
case Difficulty.hard: move = _pickBestMove(board, _aiPlayer, _humanPlayer);
}
if (move == -1) { setState(() => _isAiThinking = false); return; }
setState(() {
board[move] = _aiPlayer;
_checkWinner();
if (winner == null) { _checkDraw(); if (!isDraw) _switchPlayer(); }
_isAiThinking = false;
});
}
BoxDecoration _cellDeco(int i) {
final w = _winningCells.contains(i), v = board[i];
if (w) return BoxDecoration(color: Colors.amber.shade100, borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.amber.shade700, width: 3));
if (v == 'X') return BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.blue.shade300, width: 1.5));
if (v == 'O') return BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.red.shade300, width: 1.5));
return BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade300, width: 1));
}
Widget _symbol(String v) => AnimatedScale(
scale: v.isEmpty ? 0.0 : 1.0,
duration: const Duration(milliseconds: 200), curve: Curves.easeOutBack,
child: Text(v, style: TextStyle(fontSize: 52, fontWeight: FontWeight.bold,
color: v == 'X' ? Colors.blue.shade700 : Colors.red.shade600)),
);
Widget _cell(int i) => Material(
color: Colors.transparent, borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: () => _handleTap(i), borderRadius: BorderRadius.circular(10),
splashColor: Colors.blue.withOpacity(0.15),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250), curve: Curves.easeInOut,
decoration: _cellDeco(i), child: Center(child: _symbol(board[i])),
),
),
);
@override
Widget build(BuildContext context) {
final isVsAi = widget.gameMode == GameMode.vsAi;
String status; Color statusColor;
if (_isAiThinking) { status = 'AI is thinking…'; statusColor = Colors.orange; }
else if (winner != null) { status = '$winner wins! 🎉'; statusColor = winner == 'X' ? Colors.blue.shade700 : Colors.red.shade600; }
else if (isDraw) { status = "Draw! 🤝"; statusColor = Colors.grey.shade700; }
else if (isVsAi && currentPlayer == _aiPlayer) { status = 'AI’s turn'; statusColor = Colors.red.shade600; }
else { status = "Your turn (${isVsAi ? 'you are X' : 'Player $currentPlayer'})"; statusColor = Colors.blue.shade700; }
return Scaffold(
appBar: AppBar(
title: Text(isVsAi ? 'vs AI (${widget.difficulty.name})' : '2 Players'),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ScoreBoard(scoreX: _scoreX, scoreO: _scoreO, scoreDraws: _scoreDraws, onReset: _resetScores),
const SizedBox(height: 16),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(status, key: ValueKey(status),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: statusColor)),
),
const SizedBox(height: 16),
Expanded(
child: LayoutBuilder(
builder: (_, c) => Stack(
children: [
GridView.builder(
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisSpacing: 0, mainAxisSpacing: 0),
itemCount: 9, itemBuilder: (_, i) => _cell(i),
),
IgnorePointer(child: CustomPaint(
size: Size(c.maxWidth, c.maxHeight), painter: const GridPainter())),
],
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _resetGame,
icon: const Icon(Icons.refresh),
label: const Text('Play Again', style: TextStyle(fontSize: 16)),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
),
),
],
),
),
);
}
}
14. Common Beginner Mistakes
Mistake 1: Mutating the board inside minimax — wrong scores, wrong moves
// ❌ Wrong — mutates the real board, recursive calls overwrite each other
int _minimax(List<String> board, bool max, String ai, String hu) {
for (int i = 0; i < board.length; i++) {
if (board[i].isEmpty) {
board[i] = ai; // ← corrupts the real board!
final score = _minimax(board, false, ai, hu);
board[i] = ''; // manual undo — error-prone
}
}
}
// ✅ Correct — always work on a fresh copy per recursive call
int _minimax(List<String> board, bool max, String ai, String hu) {
for (int i = 0; i < board.length; i++) {
if (board[i].isEmpty) {
final temp = List<String>.from(board); // ← independent copy
temp[i] = ai; // ← mutate the copy only
final score = _minimax(temp, false, ai, hu);
}
}
}
Mistake 2: AI moves on the human’s turn — no guard in _handleTap
// ❌ Wrong — human can tap during AI's turn, placing their symbol on AI's move
void _handleTap(int index) {
if (board[index] != '' || winner != null || isDraw) return;
// No AI turn guard — human can tap on 'O's turn
}
// ✅ Correct — two AI-specific guards
void _handleTap(int index) {
if (board[index] != '' || winner != null || isDraw) return;
if (_isAiThinking) return; // ← guard 1
if (widget.gameMode == GameMode.vsAi
&& currentPlayer == _aiPlayer) return; // ← guard 2
// ...proceed with human move
}
Mistake 3: Wrong isMaximizing initial value in _pickBestMove
// ❌ Wrong — passing isMaximizing=true after AI places its symbol
// means minimax thinks IT is the maximizer on the next ply too
final temp = List<String>.from(board); temp[i] = ai;
final score = _minimax(temp, true, ai, human); // ← wrong
// ✅ Correct — after AI places its symbol, it's the HUMAN's turn next
// Human is the minimizer, so isMaximizing=false
final temp = List<String>.from(board); temp[i] = ai;
final score = _minimax(temp, false, ai, human); // ← correct
Mistake 4: Checking win/block in wrong order in Medium AI
// ❌ Wrong — checks blocking before winning
// AI might block a human threat instead of taking its own winning move
int _pickMedium(List<String> b, String ai, String hu) {
// Block first (wrong order)
for (int i = 0; i < b.length; i++) { ... if (human can win) return i; }
// Win second
for (int i = 0; i < b.length; i++) { ... if (ai can win) return i; }
}
// ✅ Correct — always win first, then block
int _pickMedium(List<String> b, String ai, String hu) {
for (int i = 0; i < b.length; i++) { ... if (ai can win) return i; } // 1. Win
for (int i = 0; i < b.length; i++) { ... if (human can win) return i; } // 2. Block
return _pickRandom(b); // 3. Random
}
15. Series Wrap-Up: Where to Go from Here
You have now built a complete Flutter game from scratch across three posts. Here is what the series covered end-to-end:
| Post | Core concepts learned |
|---|---|
| Part 1: Core game logic | StatefulWidget, setState, GridView.builder, GestureDetector, win/draw detection, AlertDialog, addPostFrameCallback |
| Part 2: Animations & polish | AnimatedContainer, AnimatedScale, Curves, InkWell, CustomPaint, score tracking, widget extraction, SnackBar vs AlertDialog |
| Part 3: AI (this post) | Enums, Navigator.push with args, SegmentedButton, dart:math, 1-move lookahead, minimax recursion, alpha-beta pruning, Future.delayed |
Where to go next:
| Challenge | What you will learn |
|---|---|
| Expand to a 4×4 board | Why minimax slows down, how alpha-beta pruning helps, depth-limited search |
| Persist scores with SharedPreferences | Async data storage, the same pattern as dark mode persistence |
| Add dark mode | ColorScheme roles, ThemeMode — covered in our dark mode guide |
| Apply minimax to another game | Connect Four, Reversi — same algorithm, larger state space, heuristic evaluation functions |
| Explore the Flutter Casual Games Toolkit | Flutter’s official game templates and audio packages for more complex casual games |
16. Related Posts
| Post | Why it’s relevant |
|---|---|
| Part 1: Build a Flutter Tic-Tac-Toe from Scratch | The foundation — all game logic and the _winningCombos constant used throughout this post. |
| Part 2: Animations, Score Tracker & UI Glow-Up | The polish layer — AnimatedContainer, ScoreBoard, CustomPaint, and InkWell all carried forward here. |
| Flutter Widgets: Stateless vs Stateful | The core mental model for why _isAiThinking must be a state variable and how setState drives AI move rendering. |
| Flutter Navigation: Push, Pop, and Named Routes | The Navigator.push() pattern used by GameModeScreen to open the game with the chosen configuration. |
| Flutter Dark Mode Toggle | The next natural enhancement — the game’s ColorScheme already supports it; just add a themeMode toggle. |
