Your Part 1 Tic-Tac-Toe game works perfectly. Every tap updates the board, the right player wins, draws are detected, and the Play Again button resets everything. But if you open it next to a production app, the difference is obvious — it looks like a prototype.
Part 2 fixes that. We are adding everything that makes a game feel polished: cells that animate when tapped, a pop-in effect for X and O symbols, a golden highlight on the winning line, a scoreboard that survives round resets, crisp CustomPaint grid lines, and InkWell ripple feedback. None of this requires a single package — all of it uses Flutter’s built-in animation and painting APIs.
The game logic from Part 1 stays completely untouched. Everything in this post is additive.
Part 1: Core game logic — StatefulWidget, GridView, win/draw detection, AlertDialog
Part 2 (this post): AnimatedContainer, score tracker, CustomPaint, InkWell, winning-cell highlight
Part 3: Single-player AI with the minimax algorithm
- Complete Part 1 first — this post assumes the working game from Part 1 is your starting point
- Understand StatefulWidget and setState() — animations here are all state-driven
- Familiar with Flutter Layout: Row, Column, Expanded — the scoreboard uses these directly
1. What We’re Adding in Part 2
| Feature | Widget / API used | What it does |
|---|---|---|
| Animated cell colour + border | AnimatedContainer | Smoothly transitions cell background and border when marked |
| X/O symbol pop-in | AnimatedScale | Symbol scales from 0 → 1 when a cell is first marked |
| Winning line highlight | _winningCells list | Golden border + amber background on the 3 winning cells |
| Tap ripple feedback | InkWell + Material | Material ripple on empty cell taps |
| Clean grid lines | CustomPaint + CustomPainter | Single crisp lines drawn over the grid, no cell borders needed |
| Persistent score tracker | New state variables | X wins / O wins / Draws survive round resets |
| Reusable scoreboard | Extracted ScoreBoard widget | Separate StatelessWidget, keeps main build() clean |
| Winning streaks + micro-copy | Streak counters | Fun dynamic result messages based on consecutive wins |
2. New State Fields
Before touching any UI code, add these new variables to _TicTacToePageState. The game logic fields from Part 1 are untouched — these sit alongside them:
class _TicTacToePageState extends State<TicTacToePage> {
// ── Part 1 fields (unchanged) ─────────────────────────────────────────
late List<String> board;
String currentPlayer = 'X';
String? winner;
bool isDraw = false;
// ── Part 2: winning line highlight ───────────────────────────────────
List<int> _winningCells = []; // indexes of the 3 winning cells
// ── Part 2: persistent score tracker ─────────────────────────────────
int _scoreX = 0;
int _scoreO = 0;
int _scoreDraws = 0;
// ── Part 2: winning streaks ───────────────────────────────────────────
int _streakX = 0;
int _streakO = 0;
// initState and _initializeGame stay exactly the same as Part 1
// _initializeGame() ONLY resets board/currentPlayer/winner/isDraw/_winningCells
// It does NOT reset scores or streaks — those survive round resets
void _initializeGame() {
board = List.filled(9, '');
currentPlayer = 'X';
winner = null;
isDraw = false;
_winningCells = []; // ← new: clear winning highlight
}
}
There are now two distinct concepts of “reset”: round reset (board, players, winner, winning cells) and session reset (scores, streaks).
_initializeGame() handles only the round reset. A separate _resetScores() method handles the session reset. Mixing them would wipe the scoreboard every time a new round starts — a common beginner mistake.
3. Step 1 — Track the Winning Cell Indexes
The winning highlight needs to know which three cells formed the winning line. Extend _checkWinner to store them in _winningCells when a win is found:
void _checkWinner() {
for (final combo in _winningCombos) {
final a = combo[0], b = combo[1], c = combo[2];
if (board[a].isNotEmpty && board[a] == board[b] && board[a] == board[c]) {
winner = board[a];
_winningCells = List<int>.from(combo); // ← NEW: store the winning indexes
_updateScoresAndStreaks(); // ← NEW: update score before dialog
_showResultDialog(_buildResultMessage());
return;
}
}
}
void _checkDraw() {
if (board.every((cell) => cell != '') && winner == null) {
isDraw = true;
_updateScoresAndStreaks(); // ← also update on draw
_showResultDialog(_buildResultMessage());
}
}
// Called once per game end — increments the right score and streak
void _updateScoresAndStreaks() {
if (winner == 'X') {
_scoreX++;
_streakX++;
_streakO = 0; // opponent's streak resets
} else if (winner == 'O') {
_scoreO++;
_streakO++;
_streakX = 0;
} else if (isDraw) {
_scoreDraws++;
_streakX = 0; // draws break both streaks
_streakO = 0;
}
}
4. Step 2 — AnimatedContainer: Cells That React
AnimatedContainer is Flutter’s “implicit animation” widget for the Container. When any of its decoration properties change (colour, border, border radius) inside a setState call, Flutter automatically animates from the old value to the new one over the specified duration. No AnimationController needed.
Replace the Container in _buildCell with AnimatedContainer and pass it properties derived from the cell’s current state:
// Helper: returns visual decoration based on cell state
BoxDecoration _cellDecoration(int index) {
final bool isWinning = _winningCells.contains(index);
final String value = board[index];
// Three visual states: empty, marked (X or O), winning cell
Color bgColor;
Color borderColor;
double borderWidth;
double radius;
if (isWinning) {
bgColor = Colors.amber.shade100; // golden highlight for winning line
borderColor = Colors.amber.shade700;
borderWidth = 3.0;
radius = 16.0; // rounder corners on winning cells
} else if (value == 'X') {
bgColor = Colors.blue.shade50;
borderColor = Colors.blue.shade300;
borderWidth = 1.5;
radius = 10.0;
} else if (value == 'O') {
bgColor = Colors.red.shade50;
borderColor = Colors.red.shade300;
borderWidth = 1.5;
radius = 10.0;
} else {
bgColor = Colors.grey.shade100; // empty cell
borderColor = Colors.blueGrey.shade200;
borderWidth = 1.0;
radius = 8.0;
}
return BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: borderColor, width: borderWidth),
boxShadow: value.isNotEmpty
? [BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 4, offset: const Offset(2, 2))]
: null,
);
}
Widget _buildCell(int index) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _handleTap(index),
borderRadius: BorderRadius.circular(10),
splashColor: Colors.blue.withOpacity(0.15),
highlightColor: Colors.blue.withOpacity(0.05),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250), // ← animation duration
curve: Curves.easeInOut, // ← animation easing
decoration: _cellDecoration(index), // ← changes trigger animation
child: Center(
child: _buildAnimatedSymbol(board[index]),
),
),
),
);
}
AnimatedContainer remembers its previous decoration. When setState() causes _buildCell to run again with new decoration values, Flutter intercepts the difference and interpolates between old and new over 250ms. You never manually start or stop the animation — changing state is all it takes.
5. Step 3 — Curves: Making Animations Feel Right
The curve parameter on any implicit animation widget controls the pacing of the animation — how fast or slow it moves at the start, middle, and end of its duration. Same duration, completely different feel:
| Curve | Feel | Best for |
|---|---|---|
Curves.linear | Constant speed — robotic, rarely used | Progress bars |
Curves.easeIn | Starts slow, finishes fast | Elements leaving the screen |
Curves.easeOut | Starts fast, finishes slow | Elements entering the screen |
Curves.easeInOut | Slow → fast → slow — very natural | General-purpose UI transitions ✅ |
Curves.easeOutBack | Overshoots slightly then settles | Symbol pop-in ✅ |
Curves.bounceOut | Bounces at the end — very playful | Game rewards, score counters |
Curves.elasticOut | Snaps past target and oscillates | Dramatic entrances — use sparingly |
// Experiment with curves to find the right feel:
// Cell background change — natural, no drama
AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut, // ← smooth and neutral
...
)
// Symbol pop-in — slightly bouncy for a game feel
AnimatedScale(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack, // ← overshoots slightly, feels satisfying
...
)
// Winning cell highlight — slightly slower to draw attention
AnimatedContainer(
duration: const Duration(milliseconds: 350),
curve: Curves.bounceOut, // ← celebratory bounce on win
...
)
6. Step 4 — AnimatedScale: Symbol Pop-In Effect
AnimatedContainer handles colour and border, but it cannot animate transforms like scale. AnimatedScale is a separate implicit animation widget that transitions the scale of its child whenever the value changes in setState.
The trick: when a cell is empty, scale is 0.0 (invisible). When a player marks it, scale becomes 1.0 and the symbol smoothly grows into place:
Widget _buildAnimatedSymbol(String value) {
// Scale 0.0 when empty (invisible), 1.0 when marked (full size)
final double targetScale = value.isEmpty ? 0.0 : 1.0;
return AnimatedScale(
scale: targetScale,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack, // slight overshoot = satisfying game feel
child: Text(
value,
style: TextStyle(
fontSize: 52,
fontWeight: FontWeight.bold,
color: value == 'X' ? Colors.blue.shade700 : Colors.red.shade600,
),
),
);
}
// How this works:
// Empty cell: scale=0.0 → Text is rendered but scaled to invisible
// Player taps: setState sets board[index] = 'X'
// _buildAnimatedSymbol runs again with value='X' → scale=1.0
// AnimatedScale sees scale changed from 0.0→1.0 and animates the transition
// The symbol appears to "pop" into existence over 200ms
AnimatedScale is an implicit animation widget — you just change the scale value in setState and it animates automatically. ScaleTransition is an explicit animation widget that requires you to manage an AnimationController manually. For this game, AnimatedScale is simpler and sufficient. Explicit animations (Part 3) are the right choice when you need precise control over timing, loops, or reversals.
7. Step 5 — InkWell: Material Ripple on Tap
Part 1 used GestureDetector which detects taps with no visual feedback. Replacing it with InkWell inside a Material widget adds the standard Material ripple that users expect from interactive elements. The Material wrapper is required — without it, the ripple has nowhere to paint:
// ❌ Part 1 — no tap feedback
GestureDetector(
onTap: () => _handleTap(index),
child: Container(...),
)
// ✅ Part 2 — Material ripple on tap
Material(
color: Colors.transparent, // ← transparent so AnimatedContainer colour shows through
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: () => _handleTap(index),
borderRadius: BorderRadius.circular(10), // ← clips ripple to rounded corners
splashColor: Colors.blue.withOpacity(0.15), // ← ripple colour
highlightColor: Colors.blue.withOpacity(0.05),
child: AnimatedContainer(
// ... same as before
),
),
)
// Note: only show ripple on empty cells — marking a filled cell does nothing
// This is already handled by _handleTap's guard clause:
// if (board[index] != '' || winner != null || isDraw) return;
8. Step 6 — CustomPaint: Crisp Grid Lines
Part 1’s grid lines came from borders on each Container cell. This creates doubled borders (each cell draws its own edge) and looks slightly uneven. CustomPaint draws a single crisp line once, directly on a canvas, for a cleaner result.
CustomPaint is a widget that gives you a raw Canvas to draw on during Flutter’s paint phase. You provide a CustomPainter subclass that implements paint(Canvas canvas, Size size). The canvas has a coordinate system matching the widget’s size, so you can calculate exact positions from the dimensions Flutter provides:
// 1. Define the painter — draws 4 lines (2 vertical + 2 horizontal)
class GridPainter extends CustomPainter {
final Color lineColor;
final double strokeWidth;
final double cornerRadius; // rounded line caps
const GridPainter({
this.lineColor = const Color(0xFF78909C), // blueGrey.shade400
this.strokeWidth = 3.0,
this.cornerRadius = 0,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = lineColor
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round // rounded line ends look nicer
..style = PaintingStyle.stroke;
final double w = size.width;
final double h = size.height;
final double gapH = w / 3; // horizontal cell width
final double gapV = h / 3; // vertical cell height
// Vertical dividers at 1/3 and 2/3 of width
canvas.drawLine(Offset(gapH, 0), Offset(gapH, h), paint);
canvas.drawLine(Offset(2 * gapH, 0), Offset(2 * gapH, h), paint);
// Horizontal dividers at 1/3 and 2/3 of height
canvas.drawLine(Offset(0, gapV), Offset(w, gapV), paint);
canvas.drawLine(Offset(0, 2 * gapV), Offset(w, 2 * gapV), paint);
}
@override
bool shouldRepaint(GridPainter oldDelegate) =>
oldDelegate.lineColor != lineColor ||
oldDelegate.strokeWidth != strokeWidth;
// Return true only when properties change — avoids unnecessary repaints
}
// 2. Overlay the painter on top of the GridView using a Stack
// IgnorePointer ensures the CustomPaint layer doesn't intercept cell taps
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
GridView.builder(
padding: EdgeInsets.zero, // ← remove padding so grid lines align
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 0, // ← set to 0: CustomPaint draws the gaps
mainAxisSpacing: 0,
),
itemCount: 9,
itemBuilder: (context, index) => _buildCell(index),
),
IgnorePointer( // ← lets taps pass through to cells below
child: CustomPaint(
size: Size(constraints.maxWidth, constraints.maxHeight),
painter: GridPainter(),
),
),
],
);
},
),
),
When using
CustomPaint for grid lines, set both spacing values to 0 on the GridView.builder. If you keep the Part 1 spacing of 6.0, the drawn lines won’t align with the actual gaps between cells. The CustomPainter divides the canvas into equal thirds, which only matches the grid when there is no spacing.
9. Step 7 — Persistent Score Tracker
The scores are already being updated in _updateScoresAndStreaks() from Step 1. Now add a method to reset only scores (separate from resetting the board), and wire it up to an optional button:
// Reset only the board, not the scores
void _resetGame() {
setState(() {
_initializeGame(); // board, currentPlayer, winner, isDraw, _winningCells
// _scoreX, _scoreO, _scoreDraws are NOT touched here
});
}
// Reset only the scores (e.g., long-press the scoreboard or a menu button)
void _resetScores() {
setState(() {
_scoreX = 0;
_scoreO = 0;
_scoreDraws = 0;
_streakX = 0;
_streakO = 0;
});
}
// ── Why separation matters ────────────────────────────────────────────────
// After 5 rounds, X has won 3, O has won 1, 1 draw.
// Player taps Play Again → _resetGame() runs → board clears, scores stay.
// Player taps "Reset Scores" → _resetScores() runs → scores clear, board stays.
// Combining them would wipe the scoreboard on every new round — nobody wants that.
10. Step 8 — Extracting a Reusable ScoreBoard Widget
As the main widget grows, keeping all UI inside a single build() method makes it hard to read. Extracting the scoreboard into its own StatelessWidget is good Flutter architecture: the parent passes data down, the child only renders it. The parent state never bleeds into the child’s rendering logic:
// A self-contained StatelessWidget — receives scores as parameters, renders them
class ScoreBoard extends StatelessWidget {
const ScoreBoard({
super.key,
required this.scoreX,
required this.scoreO,
required this.scoreDraws,
this.onResetScores,
});
final int scoreX;
final int scoreO;
final int scoreDraws;
final VoidCallback? onResetScores; // optional reset button
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Row(
children: [
Expanded(child: _tile('Player X', scoreX, Colors.blue.shade700)),
_divider(),
Expanded(child: _tile('Draws', scoreDraws, Colors.grey.shade600)),
_divider(),
Expanded(child: _tile('Player O', scoreO, Colors.red.shade600)),
if (onResetScores != null)
IconButton(
icon: const Icon(Icons.refresh, size: 18),
onPressed: onResetScores,
tooltip: 'Reset scores',
color: Colors.grey.shade400,
),
],
),
);
}
Widget _tile(String label, int score, Color color) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: TextStyle(fontWeight: FontWeight.w600, color: color, fontSize: 13)),
const SizedBox(height: 4),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
'$score',
key: ValueKey(score), // key change triggers AnimatedSwitcher
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
),
],
);
}
Widget _divider() => Container(
width: 1,
height: 40,
color: Colors.grey.shade200,
margin: const EdgeInsets.symmetric(horizontal: 8),
);
}
// Usage in the main widget's build():
ScoreBoard(
scoreX: _scoreX,
scoreO: _scoreO,
scoreDraws: _scoreDraws,
onResetScores: _resetScores,
)
The score numbers use
AnimatedSwitcher with a ValueKey(score). When a score increments, the key changes, which tells Flutter this is a new widget — triggering a crossfade between the old and new number. Without the key, Flutter sees the same widget type and doesn’t animate the change.
11. Step 9 — Winning Streaks and Fun Micro-Copy
A small personality touch: dynamic result messages that react to winning streaks. The streak counters are already updated in _updateScoresAndStreaks(). Now use them to pick the right message:
String _buildResultMessage() {
if (winner == 'X') {
if (_streakX >= 3) return 'X is unstoppable! 🔥🔥🔥 Streak: $_streakX';
if (_streakX == 2) return 'X wins again! 🔥 Two in a row!';
return 'X wins! 🎉 Nice move!';
} else if (winner == 'O') {
if (_streakO >= 3) return 'O is dominating! 🔥🔥🔥 Streak: $_streakO';
if (_streakO == 2) return 'O is on a roll! 🔥 Back-to-back wins!';
return 'O wins! 🎉 Well played!';
} else {
// Draw variants based on total draws this session
if (_scoreDraws >= 3) return 'Another draw! Are you two the same person? 🤔';
if (_scoreDraws == 2) return 'Draw again! Getting competitive 🤝';
return "It's a draw! So close! 🤝";
}
}
// The improved AlertDialog using this message:
void _showResultDialog(String message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Game Over'),
content: Text(message, style: const TextStyle(fontSize: 18)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
_resetGame();
},
child: const Text('Play Again'),
),
],
),
);
});
}
12. Step 10 — SnackBar vs AlertDialog: UX Decisions
Now that the game has more events, it is worth understanding which feedback mechanism to use when:
| Event | Use | Why |
|---|---|---|
| Game ends (win or draw) | AlertDialog | Important event that deserves explicit acknowledgement and a Play Again choice |
| Scores reset | SnackBar | Transient confirmation — doesn’t block gameplay |
| New round started | SnackBar (optional) | The board clearing is already visible — SnackBar adds optional context |
| Tapping a filled cell | Nothing (silent) | The filled cell is visually obvious — feedback would be noise |
// SnackBar example — scores reset confirmation
void _resetScores() {
setState(() {
_scoreX = 0; _scoreO = 0; _scoreDraws = 0;
_streakX = 0; _streakO = 0;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Scores reset'),
duration: Duration(seconds: 2),
behavior: SnackBarBehavior.floating, // ← floats above the bottom bar
width: 160,
),
);
}
13. Full Updated main.dart
Here is the complete Part 2 game — all enhancements combined into a single copy-paste-ready file:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
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 TicTacToePage(),
);
}
}
// ── CustomPainter for crisp grid lines ──────────────────────────────────────
class GridPainter extends CustomPainter {
final Color lineColor;
final double strokeWidth;
const GridPainter({this.lineColor = const Color(0xFF90A4AE), this.strokeWidth = 3});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = lineColor
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
final double w = size.width, h = size.height;
canvas.drawLine(Offset(w / 3, 0), Offset(w / 3, h), paint);
canvas.drawLine(Offset(2 * w / 3, 0), Offset(2 * w / 3, h), paint);
canvas.drawLine(Offset(0, h / 3), Offset(w, h / 3), paint);
canvas.drawLine(Offset(0, 2 * h / 3), Offset(w, 2 * h / 3), paint);
}
@override
bool shouldRepaint(GridPainter old) => old.lineColor != lineColor;
}
// ── Reusable ScoreBoard widget ───────────────────────────────────────────────
class ScoreBoard extends StatelessWidget {
const ScoreBoard({
super.key,
required this.scoreX,
required this.scoreO,
required this.scoreDraws,
this.onResetScores,
});
final int scoreX, scoreO, scoreDraws;
final VoidCallback? onResetScores;
@override
Widget build(BuildContext context) {
return 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: _tile('Player X', scoreX, Colors.blue.shade700)),
_div(), Expanded(child: _tile('Draws', scoreDraws, Colors.grey.shade600)),
_div(), Expanded(child: _tile('Player O', scoreO, Colors.red.shade600)),
if (onResetScores != null)
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reset scores',
color: Colors.grey.shade400,
onPressed: onResetScores,
),
],
),
);
}
Widget _tile(String label, int score, Color color) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: TextStyle(fontWeight: FontWeight.w600, color: color, fontSize: 12)),
const SizedBox(height: 4),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text('$score', key: ValueKey(score),
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
),
],
);
Widget _div() => Container(
width: 1, height: 36,
color: Colors.grey.shade200,
margin: const EdgeInsets.symmetric(horizontal: 8),
);
}
// ── Main game ────────────────────────────────────────────────────────────────
class TicTacToePage extends StatefulWidget {
const TicTacToePage({super.key});
@override
State<TicTacToePage> createState() => _TicTacToePageState();
}
class _TicTacToePageState extends State<TicTacToePage> {
// Part 1 game logic
late List<String> board;
String currentPlayer = 'X';
String? winner;
bool isDraw = false;
// Part 2 additions
List<int> _winningCells = [];
int _scoreX = 0, _scoreO = 0, _scoreDraws = 0;
int _streakX = 0, _streakO = 0;
static 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],
];
@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;
setState(() {
board[index] = currentPlayer;
_checkWinner();
if (winner == null) {
_checkDraw();
if (!isDraw) _switchPlayer();
}
});
}
void _switchPlayer() =>
currentPlayer = currentPlayer == 'X' ? 'O' : 'X';
void _checkWinner() {
for (final combo in _winningCombos) {
final a = combo[0], b = combo[1], c = combo[2];
if (board[a].isNotEmpty && board[a] == board[b] && board[a] == board[c]) {
winner = board[a];
_winningCells = List<int>.from(combo);
_updateScoresAndStreaks();
_showResultDialog(_buildResultMessage());
return;
}
}
}
void _checkDraw() {
if (board.every((c) => c != '') && winner == null) {
isDraw = true;
_updateScoresAndStreaks();
_showResultDialog(_buildResultMessage());
}
}
void _updateScoresAndStreaks() {
if (winner == 'X') { _scoreX++; _streakX++; _streakO = 0; }
else if (winner == 'O') { _scoreO++; _streakO++; _streakX = 0; }
else if (isDraw) { _scoreDraws++; _streakX = 0; _streakO = 0; }
}
String _buildResultMessage() {
if (winner == 'X') {
if (_streakX >= 3) return 'X is unstoppable! 🔥🔥🔥 Streak: $_streakX';
if (_streakX == 2) return 'X wins again! 🔥 Two in a row!';
return 'X wins! 🎉 Nice move!';
} else if (winner == 'O') {
if (_streakO >= 3) return 'O is dominating! 🔥🔥🔥 Streak: $_streakO';
if (_streakO == 2) return 'O is on a roll! 🔥 Back-to-back!';
return 'O wins! 🎉 Well played!';
} else {
if (_scoreDraws >= 3) return 'Another draw! Are you two the same person? 🤔';
return "It's a draw! So close! 🤝";
}
}
void _showResultDialog(String message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Game Over'),
content: Text(message, 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,
),
);
}
BoxDecoration _cellDecoration(int index) {
final bool isWinning = _winningCells.contains(index);
final String v = board[index];
if (isWinning) return BoxDecoration(color: Colors.amber.shade100, borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.amber.shade700, width: 3.0));
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.0));
}
Widget _buildAnimatedSymbol(String value) => AnimatedScale(
scale: value.isEmpty ? 0.0 : 1.0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
child: Text(value, style: TextStyle(
fontSize: 52, fontWeight: FontWeight.bold,
color: value == 'X' ? Colors.blue.shade700 : Colors.red.shade600,
)),
);
Widget _buildCell(int index) => Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: () => _handleTap(index),
borderRadius: BorderRadius.circular(10),
splashColor: Colors.blue.withOpacity(0.15),
highlightColor: Colors.blue.withOpacity(0.05),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
decoration: _cellDecoration(index),
child: Center(child: _buildAnimatedSymbol(board[index])),
),
),
);
@override
Widget build(BuildContext context) {
String statusText;
Color statusColor;
if (winner != null) {
statusText = '$winner wins! 🎉';
statusColor = winner == 'X' ? Colors.blue.shade700 : Colors.red.shade600;
} else if (isDraw) {
statusText = "It's a draw! 🤝";
statusColor = Colors.grey.shade700;
} else {
statusText = "Player $currentPlayer's turn";
statusColor = currentPlayer == 'X' ? Colors.blue.shade700 : Colors.red.shade600;
}
return Scaffold(
appBar: AppBar(
title: const Text('Flutter Tic-Tac-Toe'),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ScoreBoard(
scoreX: _scoreX, scoreO: _scoreO, scoreDraws: _scoreDraws,
onResetScores: _resetScores,
),
const SizedBox(height: 20),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(statusText,
key: ValueKey(statusText),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: statusColor)),
),
const SizedBox(height: 20),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) => Stack(
children: [
GridView.builder(
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisSpacing: 0, mainAxisSpacing: 0,
),
itemCount: 9,
itemBuilder: (_, i) => _buildCell(i),
),
IgnorePointer(
child: CustomPaint(
size: Size(constraints.maxWidth, constraints.maxHeight),
painter: 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 Mistakes When Adding Polish
Mistake 1: Not passing winning cells to _buildCell — highlight never shows
// ❌ Wrong — _winningCells is set but _buildCell only reads board[index]
void _checkWinner() {
// ... winner = board[a]; but _winningCells is never set
}
Widget _buildCell(int index) {
// decoration never turns golden — _winningCells is always []
}
// ✅ Correct — _checkWinner sets _winningCells, _buildCell reads it
void _checkWinner() {
// ...
winner = board[a];
_winningCells = List<int>.from(combo); // ← must set this too
}
Widget _cellDecoration(int index) {
final bool isWinning = _winningCells.contains(index); // ← reads it
if (isWinning) return BoxDecoration(color: Colors.amber.shade100, ...);
// ...
}
Mistake 2: Resetting scores inside _initializeGame
// ❌ Wrong — scores reset every time Play Again is tapped
void _initializeGame() {
board = List.filled(9, '');
currentPlayer = 'X';
winner = null;
isDraw = false;
_winningCells = [];
_scoreX = 0; _scoreO = 0; _scoreDraws = 0; // ← wiped on every new round!
}
// ✅ Correct — scores only reset when the player explicitly requests it
void _initializeGame() {
board = List.filled(9, '');
currentPlayer = 'X';
winner = null;
isDraw = false;
_winningCells = [];
// Scores stay — only _resetScores() clears them
}
Mistake 3: Forgetting IgnorePointer on CustomPaint overlay — cells stop responding
// ❌ Wrong — CustomPaint sits on top and absorbs all tap events
// Cells below it never receive onTap
Stack(
children: [
GridView.builder(...),
CustomPaint(painter: GridPainter()), // ← intercepts taps!
],
)
// ✅ Correct — IgnorePointer passes all pointer events through to the GridView
Stack(
children: [
GridView.builder(...),
IgnorePointer( // ← lets taps pass through
child: CustomPaint(painter: GridPainter()),
),
],
)
Mistake 4: Missing Material wrapper on InkWell — ripple invisible
// ❌ Wrong — ripple has no Material to paint on, nothing visible
InkWell(
onTap: () => _handleTap(index),
child: AnimatedContainer(...),
)
// ✅ Correct — Material provides the ink layer for the ripple
Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _handleTap(index),
child: AnimatedContainer(...),
),
)
15. What’s Next in Part 3
The game now looks and feels polished. The last piece is making it playable alone. Part 3 adds a single-player mode with an AI opponent that uses the minimax algorithm — a recursive technique that makes the computer explore every possible future game state and pick the optimal move. With perfect minimax play on a 3×3 board, the computer never loses.
| Feature | Covered in |
|---|---|
| Game mode screen (2-player vs vs Computer) | Part 3 |
| Minimax algorithm in pure Dart | Part 3 |
| Easy / Medium / Unbeatable difficulty levels | Part 3 |
| AI “thinking” delay with Future.delayed | Part 3 |
| Alpha-beta pruning explanation | Part 3 |
16. Related Posts
| Post | Why it’s relevant |
|---|---|
| Part 1: Build a Flutter Tic-Tac-Toe from Scratch | The prerequisite — all code in this post builds on the working Part 1 game. |
| Flutter Widgets: Stateless vs Stateful | Every implicit animation widget in this post is driven by setState() — this is the foundational concept. |
| Flutter Dark Mode Toggle | Add dark mode to the game — the ColorScheme pattern in this guide makes it trivial to add after Part 2. |
| Flutter Bottom Navigation Bar | The extracted ScoreBoard widget pattern here is the same composition technique used throughout nav bar apps. |
| Part 3: Unbeatable AI with Minimax Algorithm | The next step — all animations and score tracking from Part 2 stay intact when adding the AI opponent. |