Flutter Tic-Tac-Toe Part 2: Animations, Score Tracker & UI Glow-Up (2026)

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.

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

1. What We’re Adding in Part 2

Feature Widget / API used What it does
Animated cell colour + borderAnimatedContainerSmoothly transitions cell background and border when marked
X/O symbol pop-inAnimatedScaleSymbol scales from 0 → 1 when a cell is first marked
Winning line highlight_winningCells listGolden border + amber background on the 3 winning cells
Tap ripple feedbackInkWell + MaterialMaterial ripple on empty cell taps
Clean grid linesCustomPaint + CustomPainterSingle crisp lines drawn over the grid, no cell borders needed
Persistent score trackerNew state variablesX wins / O wins / Draws survive round resets
Reusable scoreboardExtracted ScoreBoard widgetSeparate StatelessWidget, keeps main build() clean
Winning streaks + micro-copyStreak countersFun 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:

💡 The key design decision in Part 2
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:

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:

💡 How implicit animations work
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.linearConstant speed — robotic, rarely usedProgress bars
Curves.easeInStarts slow, finishes fastElements leaving the screen
Curves.easeOutStarts fast, finishes slowElements entering the screen
Curves.easeInOutSlow → fast → slow — very naturalGeneral-purpose UI transitions ✅
Curves.easeOutBackOvershoots slightly then settlesSymbol pop-in ✅
Curves.bounceOutBounces at the end — very playfulGame rewards, score counters
Curves.elasticOutSnaps past target and oscillatesDramatic entrances — use sparingly

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:

💡 AnimatedScale vs ScaleTransition
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:

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:

⚠️ Warning: Set crossAxisSpacing and mainAxisSpacing to 0
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:

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:

💡 AnimatedSwitcher inside ScoreBoard
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:

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)AlertDialogImportant event that deserves explicit acknowledgement and a Play Again choice
Scores resetSnackBarTransient confirmation — doesn’t block gameplay
New round startedSnackBar (optional)The board clearing is already visible — SnackBar adds optional context
Tapping a filled cellNothing (silent)The filled cell is visually obvious — feedback would be noise

13. Full Updated main.dart

Here is the complete Part 2 game — all enhancements combined into a single copy-paste-ready file:

14. Common Mistakes When Adding Polish

Mistake 1: Not passing winning cells to _buildCell — highlight never shows

Mistake 2: Resetting scores inside _initializeGame

Mistake 3: Forgetting IgnorePointer on CustomPaint overlay — cells stop responding

Mistake 4: Missing Material wrapper on InkWell — ripple invisible

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 DartPart 3
Easy / Medium / Unbeatable difficulty levelsPart 3
AI “thinking” delay with Future.delayedPart 3
Alpha-beta pruning explanationPart 3
Post Why it’s relevant
Part 1: Build a Flutter Tic-Tac-Toe from ScratchThe prerequisite — all code in this post builds on the working Part 1 game.
Flutter Widgets: Stateless vs StatefulEvery implicit animation widget in this post is driven by setState() — this is the foundational concept.
Flutter Dark Mode ToggleAdd dark mode to the game — the ColorScheme pattern in this guide makes it trivial to add after Part 2.
Flutter Bottom Navigation BarThe extracted ScoreBoard widget pattern here is the same composition technique used throughout nav bar apps.
Part 3: Unbeatable AI with Minimax AlgorithmThe next step — all animations and score tracking from Part 2 stay intact when adding the AI opponent.
Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply