Building a small game is one of the best ways to level up as a Flutter developer. Static layouts are forgiving — you can get them right without fully understanding state. A game forces you to think clearly about how data and UI are connected, because every tap changes the board and Flutter must know exactly what to rebuild.
Tic-Tac-Toe is the perfect first Flutter game. It is small enough to finish in a single session, visual enough to be satisfying, and complex enough to exercise every core Flutter concept: StatefulWidget, setState, GridView.builder, GestureDetector, win detection, and AlertDialog. No packages. No external state management. Just Flutter and pure Dart.
Part 1 (this post): Core game logic — StatefulWidget, GridView, win/draw detection, AlertDialog
Part 2: Animations, score tracking, and a polished UI — coming soon
Part 3: Single-player AI with the minimax algorithm — coming soon
- Comfortable with StatefulWidget and setState() — the entire game runs on these
- Understand Flutter Layout: Row, Column, and Expanded — used for the game screen structure
- Familiar with ListView.builder patterns — GridView.builder works the same way
- Flutter SDK installed and a project you can run — see our Flutter Tutorial for Beginners
1. Why Tic-Tac-Toe Is a Perfect First Flutter Game
Unlike a static layout, a game has state that changes. Each tap must update the board, switch the current player, and possibly end the game — and every one of those changes must be reflected in the UI immediately. That maps exactly to how StatefulWidget and setState work.
Tic-Tac-Toe hits the sweet spot: complex enough to teach real patterns, simple enough to finish in one sitting. By the end of this post you will have a fully playable 2-player game running on a phone or emulator — built entirely with core Flutter widgets and pure Dart.
| Flutter concept | Where it appears in this game |
|---|---|
StatefulWidget + setState | Every tap updates the board, player indicator, and game status |
GridView.builder | Renders the 3×3 board lazily from a flat list of 9 strings |
GestureDetector | Detects taps on each cell and passes the cell index to game logic |
AlertDialog + showDialog | Shows the win/draw result after the game ends |
initState | Sets up the initial board state before the first frame |
Dart List.every | Detects draws by checking whether all 9 cells are filled |
2. Project Setup
# Create a new Flutter project
flutter create flutter_tic_tac_toe
cd flutter_tic_tac_toe
# Open in VS Code (or Android Studio)
code .
Open lib/main.dart and delete everything. You will replace it completely with the game code. The entire implementation lives in one file — no packages, no extra files needed for this first version.
3. Why This Game Needs StatefulWidget
A StatelessWidget is immutable — once built, it never changes. That is fine for a logo, a settings label, or any UI where the data is fixed. A game is the opposite: its data changes with every tap.
Here is every piece of data that changes during a Tic-Tac-Toe game — and why it must live in a StatefulWidget:
| State variable | Type | What it tracks | Changes when |
|---|---|---|---|
board | List<String> | Which cells hold “X”, “O”, or “” | Every valid tap |
currentPlayer | String | Whose turn it is — “X” or “O” | After each valid move |
winner | String? | null while playing, “X” or “O” when won | When a winning line is detected |
isDraw | bool | Whether all 9 cells are filled with no winner | When all cells fill with no winner |
// StatelessWidget — immutable, build() only ever runs once with the same data
class MyLabel extends StatelessWidget {
@override
Widget build(BuildContext context) => const Text('Hello');
// Can never show "Goodbye" — no mutable state
}
// StatefulWidget — mutable state lives in the State object
// setState() triggers a rebuild with the new data
class TicTacToePage extends StatefulWidget {
@override
State<TicTacToePage> createState() => _TicTacToePageState();
}
class _TicTacToePageState extends State<TicTacToePage> {
List<String> board = List.filled(9, ''); // ← changes on every tap
String currentPlayer = 'X'; // ← changes after every move
String? winner; // ← set when someone wins
bool isDraw = false; // ← set when board fills up
// setState() here triggers Flutter to call build() again with new data
}
4. Modelling the Game State
The board is stored as a flat list of 9 strings rather than a 2D array. This maps directly to GridView.builder‘s single integer index and keeps the code simpler:
// The board as a flat list — index maps directly to cell position:
//
// 0 │ 1 │ 2
// ───┼───┼───
// 3 │ 4 │ 5
// ───┼───┼───
// 6 │ 7 │ 8
//
// board[0] = top-left, board[4] = centre, board[8] = bottom-right
List<String> board = List.filled(9, ''); // all cells start empty
// After player X taps the centre cell:
board[4] = 'X'; // board is now ['', '', '', '', 'X', '', '', '', '']
Use initState for one-time setup, and extract it into a helper _initializeGame() so the same logic can be called when resetting:
@override
void initState() {
super.initState();
_initializeGame(); // ← called once when the widget is first inserted
}
// Extracted helper — called from initState AND from _resetGame()
// This keeps initialization in one place (DRY principle)
void _initializeGame() {
board = List.filled(9, '');
currentPlayer = 'X';
winner = null;
isDraw = false;
}
If you inline the setup directly in
initState(), you have to duplicate it in _resetGame(). Extracting it into one method means “start a fresh game” is always one call — no matter whether it’s the first load or the tenth replay. This is the DRY (Don’t Repeat Yourself) principle in practice.
5. Building the 3×3 Board with GridView.builder
GridView.builder works like ListView.builder but in two dimensions. It builds items lazily and uses a gridDelegate to control how many columns to use. For a 3×3 board, SliverGridDelegateWithFixedCrossAxisCount with crossAxisCount: 3 is exactly right:
Expanded(
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 3 columns = 9 cells = 3×3 grid
crossAxisSpacing: 6.0, // horizontal gap between cells (the "lines")
mainAxisSpacing: 6.0, // vertical gap between cells (the "lines")
),
itemCount: 9, // exactly 9 cells
itemBuilder: (context, index) => _buildCell(index),
// GridView.builder calls _buildCell(0) through _buildCell(8)
// only when each cell becomes visible — same lazy behaviour as ListView.builder
),
)
They work identically — both receive
(BuildContext context, int index) in their builder callback and only build visible items. The only difference is GridView.builder requires a gridDelegate that says how many columns to use. The crossAxisSpacing and mainAxisSpacing create the gaps between cells that act as the grid lines.
6. Building Each Cell with _buildCell
Each cell is a GestureDetector wrapping a styled Container. The container shows “X”, “O”, or nothing depending on the board state at that index:
Widget _buildCell(int index) {
final cellValue = board[index]; // '', 'X', or 'O'
return GestureDetector(
onTap: () => _handleTap(index), // pass the cell index to game logic
child: AnimatedContainer( // AnimatedContainer transitions smoothly
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: _getCellColor(index), // colour changes when cell is filled
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.blueGrey.shade300, width: 1.5),
boxShadow: cellValue.isNotEmpty
? [BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(2, 2))]
: null,
),
child: Center(
child: Text(
cellValue,
style: TextStyle(
fontSize: 52,
fontWeight: FontWeight.bold,
// X is blue, O is red — easy to tell apart
color: cellValue == 'X' ? Colors.blue.shade700 : Colors.red.shade600,
),
),
),
),
);
}
// Helper: returns a slightly different background based on the cell's contents
Color _getCellColor(int index) {
if (board[index] == 'X') return Colors.blue.shade50;
if (board[index] == 'O') return Colors.red.shade50;
return Colors.grey.shade100; // empty cell
}
GestureDetector detects taps without any visual feedback. InkWell adds a Material ripple effect when placed inside a Material widget. Both work here — this tutorial uses GestureDetector to keep the focus on game logic. Part 2 of this series will upgrade the cells to use InkWell with proper ripple animations.
7. Handling Taps with _handleTap
This is the heart of the game. Every tap goes through _handleTap, which enforces the rules and updates state. The order of operations inside setState matters — get it wrong and you report the wrong winner:
void _handleTap(int index) {
// Guard clause — ignore the tap if:
// 1. The cell is already filled (don't overwrite moves)
// 2. Someone already won
// 3. The game already ended in a draw
if (board[index] != '' || winner != null || isDraw) {
return;
}
setState(() {
// Step 1: Place the current player's symbol
board[index] = currentPlayer;
// Step 2: Check if this move wins the game
_checkWinner();
// Step 3: Only check draw and switch player if no one won
if (winner == null) {
_checkDraw();
if (!isDraw) {
// Step 4: Switch to the other player
_switchPlayer();
}
}
// ⚠️ IMPORTANT: Always check winner BEFORE switching player.
// If you switch first, _checkWinner would report the wrong player.
});
}
void _switchPlayer() {
currentPlayer = currentPlayer == 'X' ? 'O' : 'X';
}
Always follow this sequence inside
setState:1. Write the symbol to the board
2. Check for a winner (uses
currentPlayer — must still be the same player)3. Check for a draw
4. Switch player (only if game is still ongoing)
If you switch the player before checking for a winner,
winner will be set to the next player’s symbol, not the one who actually won. This is the most common logic bug in beginner Tic-Tac-Toe implementations.
8. Win Detection — The 8 Winning Lines
There are exactly 8 ways to win Tic-Tac-Toe: 3 rows, 3 columns, and 2 diagonals. Each is stored as a list of the three board indexes that form that line:
// 0 │ 1 │ 2
// ───┼───┼───
// 3 │ 4 │ 5
// ───┼───┼───
// 6 │ 7 │ 8
static const List<List<int>> _winningCombos = [
[0, 1, 2], // top row ───────
[3, 4, 5], // middle row ───────
[6, 7, 8], // bottom row ───────
[0, 3, 6], // left column │
[1, 4, 7], // mid column │
[2, 5, 8], // right column │
[0, 4, 8], // diagonal
[2, 4, 6], // diagonal /
];
void _checkWinner() {
for (final combo in _winningCombos) {
final a = combo[0];
final b = combo[1];
final c = combo[2];
// Three conditions must all be true:
// 1. The first cell is not empty
// 2. All three cells contain the same symbol
if (board[a] != '' && board[a] == board[b] && board[a] == board[c]) {
winner = board[a]; // 'X' or 'O'
_showResultDialog('$winner wins! 🎉');
return; // stop checking — we already found a winner
}
}
}
9. Draw Detection with List.every
A draw occurs when all 9 cells are filled and no one has won. Dart’s List.every checks a condition against every element in a list — perfect for this:
void _checkDraw() {
// board.every((cell) => cell != '') returns true
// only if every single cell in the list is non-empty
if (board.every((cell) => cell != '') && winner == null) {
isDraw = true;
_showResultDialog("It's a draw! 🤝");
}
}
// How List.every works:
// board = ['X', 'O', 'X', 'O', 'X', 'O', 'O', 'X', 'O']
// board.every((cell) => cell != '')
// → checks cell[0]='X' ≠ '' ✅, cell[1]='O' ≠ '' ✅, ... all 9 pass → true
// board = ['X', 'O', 'X', '', 'X', 'O', 'O', 'X', 'O']
// → cell[3]='' fails immediately → false (game not over yet)
10. Showing the Result with AlertDialog
When the game ends, show an AlertDialog with the result. There is one important gotcha: calling showDialog directly inside a setState callback triggers a “setState() called during build” exception. The fix is to schedule it after the current frame using addPostFrameCallback:
// ❌ Wrong — calling showDialog inside setState triggers:
// "setState() or markNeedsBuild() called during build"
setState(() {
winner = board[a];
showDialog(...); // ← Flutter is still building — this crashes
});
// ✅ Correct — schedule the dialog AFTER the current build frame completes
void _showResultDialog(String message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// This runs after the current frame has fully rendered
showDialog<void>(
context: context,
barrierDismissible: false, // user must tap OK — can't dismiss by tapping outside
builder: (context) {
return AlertDialog(
title: const Text('Game Over'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
);
},
);
});
}
WidgetsBinding.instance.addPostFrameCallback schedules a callback to run after the current layout and paint cycle finishes. Think of it as “do this after Flutter has finished drawing the current frame.” It is the standard solution for any code that needs to happen after a build — showing dialogs, scrolling to a position, or focusing a text field.
11. Resetting the Game
void _resetGame() {
setState(() {
_initializeGame(); // ← resets all 4 state variables at once
});
// Flutter rebuilds the widget with a fresh empty board
}
// ❌ Common mistake: forgetting to reset winner and isDraw
void _resetGameBuggy() {
setState(() {
board = List.filled(9, '');
currentPlayer = 'X';
// winner and isDraw are still set from the last game!
// The game appears stuck — no taps register because of the guard clause
});
}
// ✅ Correct: _initializeGame() resets all 4 variables together
12. Full main.dart — Copy-Paste Ready
Here is the complete, runnable game. Every concept from the sections above comes together here:
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(),
);
}
}
class TicTacToePage extends StatefulWidget {
const TicTacToePage({super.key});
@override
State<TicTacToePage> createState() => _TicTacToePageState();
}
class _TicTacToePageState extends State<TicTacToePage> {
late List<String> board;
String currentPlayer = 'X';
String? winner;
bool isDraw = false;
static const List<List<int>> _winningCombos = [
[0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
[0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
[0, 4, 8], [2, 4, 6], // diagonals
];
@override
void initState() {
super.initState();
_initializeGame();
}
void _initializeGame() {
board = List.filled(9, '');
currentPlayer = 'X';
winner = null;
isDraw = false;
}
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] != '' && board[a] == board[b] && board[a] == board[c]) {
winner = board[a];
_showResultDialog('$winner wins! 🎉');
return;
}
}
}
void _checkDraw() {
if (board.every((cell) => cell != '') && winner == null) {
isDraw = true;
_showResultDialog("It's a draw! 🤝");
}
}
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(); // Play Again from the dialog
},
child: const Text('Play Again'),
),
],
),
);
});
}
void _resetGame() {
setState(() {
_initializeGame();
});
}
Color _getCellColor(int index) {
if (board[index] == 'X') return Colors.blue.shade50;
if (board[index] == 'O') return Colors.red.shade50;
return Colors.grey.shade100;
}
Widget _buildCell(int index) {
return GestureDetector(
onTap: () => _handleTap(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: _getCellColor(index),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.blueGrey.shade300, width: 1.5),
boxShadow: board[index].isNotEmpty
? [const BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(2, 2))]
: null,
),
child: Center(
child: Text(
board[index],
style: TextStyle(
fontSize: 52,
fontWeight: FontWeight.bold,
color: board[index] == 'X' ? Colors.blue.shade700 : Colors.red.shade600,
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
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,
backgroundColor: scheme.primaryContainer,
),
body: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Status text
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
statusText,
key: ValueKey(statusText),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
),
const SizedBox(height: 28),
// The 3×3 board
Expanded(
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 6.0,
mainAxisSpacing: 6.0,
),
itemCount: 9,
itemBuilder: (context, index) => _buildCell(index),
),
),
const SizedBox(height: 20),
// Play Again button
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)),
),
),
],
),
),
);
}
}
13. Common Beginner Mistakes
Mistake 1: Allowing a tap to overwrite an existing move
// ❌ Wrong — no guard, any cell can be tapped at any time
void _handleTap(int index) {
setState(() {
board[index] = currentPlayer; // overwrites X with O if tapped again!
});
}
// ✅ Correct — guard at the top ignores invalid taps
void _handleTap(int index) {
if (board[index] != '' || winner != null || isDraw) return;
setState(() { board[index] = currentPlayer; ... });
}
Mistake 2: Switching player before checking for the winner
// ❌ Wrong — switches player first, then checks winner
// If X plays the winning move, currentPlayer is now 'O' when _checkWinner runs
// → winner gets set to 'O' even though X won
setState(() {
board[index] = currentPlayer;
_switchPlayer(); // ← too early!
_checkWinner(); // reports 'O' as winner when 'X' actually won
});
// ✅ Correct — check winner first, switch player last
setState(() {
board[index] = currentPlayer;
_checkWinner(); // currentPlayer is still the player who made the move
if (winner == null) {
_checkDraw();
if (!isDraw) _switchPlayer();
}
});
Mistake 3: Calling showDialog inside setState
// ❌ Wrong — showDialog during build throws an exception
setState(() {
winner = board[a];
showDialog(...); // "setState() called during build"
});
// ✅ Correct — schedule it after the current frame
void _showResultDialog(String message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog(...); // runs after build completes
});
}
Mistake 4: Forgetting to reset winner and isDraw on new game
// ❌ Wrong — board clears but winner is still 'X' from last game
// _handleTap's guard clause sees winner != null → all taps are ignored → stuck
void _resetGame() {
setState(() {
board = List.filled(9, '');
currentPlayer = 'X';
// winner and isDraw not reset!
});
}
// ✅ Correct — _initializeGame resets all 4 variables at once
void _resetGame() {
setState(() => _initializeGame());
}
14. What’s Next in Part 2
Part 1 is intentionally minimal — it focuses entirely on core state management and game logic. Here is what Part 1 does not do yet, and what Part 2 will add:
| Feature | Status in Part 1 | Covered in |
|---|---|---|
| Win line highlight animation | ❌ Cells just show X/O with no animation | Part 2 |
| Score tracker across rounds | ❌ Win count resets on every new game | Part 2 |
| Custom player names | ❌ Players are always “X” and “O” | Part 2 |
| Dark mode / theme switcher | ❌ Fixed blue theme only | Part 2 |
| Single-player AI opponent | ❌ 2-player only on same device | Part 3 |
| Minimax algorithm | ❌ No AI | Part 3 |
Make sure Part 1’s game is working correctly. Test every edge case: can you tap a filled cell? Does the right player win? Does a draw trigger correctly? Does Play Again fully reset the game? Only move to Part 2 once the logic is solid — animations and a score tracker built on top of broken logic are much harder to debug.
15. Related Posts
| Post | Why it’s relevant |
|---|---|
| Flutter Widgets: Stateless vs Stateful | The prerequisite — every game mechanic depends on StatefulWidget and setState(). |
| Flutter Layout: Row, Column, Flex and Expanded | The game screen uses Column and Expanded — this guide explains how they size themselves. |
| Flutter ListView.builder: Complete Guide | GridView.builder works identically to ListView.builder — if you understand one you understand both. |
| Flutter Dark Mode Toggle | Part 2 of this series adds a theme switcher to the game — this post covers exactly how to implement it. |
| Hot Reload vs Hot Restart | Speeds up development — understanding when to hot reload vs restart saves time while iterating on game logic. |

Pingback: Polish Your Flutter Tic-Tac-Toe: Animations, Score Tracker & UI Glow-Up (No Packages)
Pingback: Flutter Tic-Tac-Toe Part 3: Single-Player AI with Minimax Algorithm