Build a Flutter Tic-Tac-Toe Game from Scratch — Part 1: Core Game Logic (2026)

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.

📚 This is Part 1 of a 3-part series
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
📋 Prerequisites

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 + setStateEvery tap updates the board, player indicator, and game status
GridView.builderRenders the 3×3 board lazily from a flat list of 9 strings
GestureDetectorDetects taps on each cell and passes the cell index to game logic
AlertDialog + showDialogShows the win/draw result after the game ends
initStateSets up the initial board state before the first frame
Dart List.everyDetects draws by checking whether all 9 cells are filled

2. Project Setup

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
boardList<String>Which cells hold “X”, “O”, or “”Every valid tap
currentPlayerStringWhose turn it is — “X” or “O”After each valid move
winnerString?null while playing, “X” or “O” when wonWhen a winning line is detected
isDrawboolWhether all 9 cells are filled with no winnerWhen all cells fill with no winner

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:

Use initState for one-time setup, and extract it into a helper _initializeGame() so the same logic can be called when resetting:

💡 Why extract _initializeGame()?
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:

💡 GridView.builder vs 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:

💡 GestureDetector vs InkWell
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:

🚨 Critical order of operations
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:

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:

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:

💡 What is addPostFrameCallback?
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

12. Full main.dart — Copy-Paste Ready

Here is the complete, runnable game. Every concept from the sections above comes together here:

13. Common Beginner Mistakes

Mistake 1: Allowing a tap to overwrite an existing move

Mistake 2: Switching player before checking for the winner

Mistake 3: Calling showDialog inside setState

Mistake 4: Forgetting to reset winner and isDraw on new game

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 animationPart 2
Score tracker across rounds❌ Win count resets on every new gamePart 2
Custom player names❌ Players are always “X” and “O”Part 2
Dark mode / theme switcher❌ Fixed blue theme onlyPart 2
Single-player AI opponent❌ 2-player only on same devicePart 3
Minimax algorithm❌ No AIPart 3
💡 Before moving to Part 2
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.
Post Why it’s relevant
Flutter Widgets: Stateless vs StatefulThe prerequisite — every game mechanic depends on StatefulWidget and setState().
Flutter Layout: Row, Column, Flex and ExpandedThe game screen uses Column and Expanded — this guide explains how they size themselves.
Flutter ListView.builder: Complete GuideGridView.builder works identically to ListView.builder — if you understand one you understand both.
Flutter Dark Mode TogglePart 2 of this series adds a theme switcher to the game — this post covers exactly how to implement it.
Hot Reload vs Hot RestartSpeeds up development — understanding when to hot reload vs restart saves time while iterating on game logic.
Show 2 Comments

2 Comments

Leave a Reply