Riverpod State Management Example – Tic Tac Toe

We are going to show another example of Riverpod. This time we will create Tic Tac Toe. As shown earlier, we can easily create Tic Tac Toe with animation in Flutter. However, the state management was all over the place. In this blog post, we will show a better way to manage the state with Riverpod. Secondly, we are going to show another example of Flutter Freezed. Immutables can greatly help with reducing errors in your code, and it works great with Riverpod.

Setup the Project

Before we can start with coding, we are going to add some dependencies to the project. We will need Flutter Hooks and Hooks Riverpod for the state management. For the immutable objects, we will add Freezed Annotations to the dependencies. Furthermore, we have to add the Freezed dependency to the development dependencies to generate the code of the immutable objects.

dependencies:
  flutter:
    sdk: flutter
  freezed_annotation: ^0.14.0
  flutter_hooks: ^0.18.0
  hooks_riverpod: ^1.0.0-dev.6

dev_dependencies:
  build_runner:
  freezed: ^0.14.0

Do not forget to install the dependency, running the following command:

flutter pub get

Creating the Model

For the model, we are going to create three different immutables. One for the tiles, one for the game’s progress, and one that combines both to manage the state of the whole game. The tiles are pretty simple; they have an x and a y value representing the place on the board. With the @freezed annotation, the generator will generate the code for us. But, first, we create a factory method that will be picked up as a constructor for the Tile.

import 'package:freezed_annotation/freezed_annotation.dart';

part 'Tile.freezed.dart';

@freezed
class Tile with _$Tile {
  const factory Tile(int x, int y) = _Tile;
}

Freezed provides another feature, namely Union objects. With Union objects, we can easily create different types for the progress of the game. First, we will create an object, Finished for when the game is finished with the result of the game. Secondly, we will create an InProgress object, which means that the game is in progress.

import 'package:freezed_annotation/freezed_annotation.dart';

part 'Progress.freezed.dart';

@freezed
class Progress with _$Progress {
  factory Progress.finished(FinishedState winner) = Finished;
  factory Progress.inProgress() = InProgress;
}

enum FinishedState { CROSS, CIRCLE, DRAW }

Finally, we are going to create a GameState for the state of the game. The information we need:

  1. Which player clicked which tile: Map<Tile, PlayerType>
  2. The current player: PlayerType
  3. Whether the game is finished or not: Progress
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:freezed_riverpod_state/model/Progress.dart';

import 'PlayerType.dart';
import 'Tile.dart';

part 'GameState.freezed.dart';

@freezed
class GameState with _$GameState {
  factory GameState(
    Map<Tile, PlayerType> tiles,
    Progress progress, {
    @Default(PlayerType.CIRCLE) PlayerType currentPlayer,
  }) = _GameState;
}

Now that we have created our immutable objects, we can generate them with the following command:

flutter pub run build_runner build

Managing the Game

For managing the state of the game, we are going to introduce a StateNotifier. We already have an object, GameState, we are going to use as a state. The StateNotifier will notify consumers when the state has changed. In addition, we are going to introduce a toggle method that will toggle the selected player on the tile. Afterward, we will trigger the updates by mapping the tiles to a new map to detect changes and setting the current player to the next player.

import 'package:freezed_riverpod_state/model/FinishedState.dart';
import 'package:freezed_riverpod_state/model/GameState.dart';
import 'package:freezed_riverpod_state/model/PlayerType.dart';
import 'package:freezed_riverpod_state/model/Progress.dart';
import 'package:freezed_riverpod_state/model/Tile.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class GameStateNotifier extends StateNotifier<GameState> {
  GameStateNotifier(GameState state) : super(state) {
    Map<Tile, PlayerType> tiles = Map<Tile, PlayerType>();
    for (int x = 0; x < 3; x++) {
      for (int y = 0; y < 3; y++) {
        tiles.putIfAbsent(Tile(x, y), () => PlayerType.EMPTY);
      }
    }
    this.state = state.copyWith(tiles: tiles, progress: Progress.inProgress());
  }

  toggle(Tile tile) {
    state.tiles[tile] = state.currentPlayer;
    state = state.copyWith(
      currentPlayer: _nextPlayer(),
      progress: _determineProgress(),
      tiles: state.tiles.map((key, value) => MapEntry(key, value)),
    );
  }

  reset() {
    state = state.copyWith(
        currentPlayer: PlayerType.CIRCLE,
        progress: Progress.inProgress(),
        tiles:
            state.tiles.map((key, value) => MapEntry(key, PlayerType.EMPTY)));
  }

  Progress _determineProgress() {
    var finished = isFinished();
    if (finished == null) {
      return state.progress;
    }
    return Progress.finished(finished);
  }

  PlayerType _nextPlayer() {
    if (state.currentPlayer == PlayerType.CIRCLE) {
      return PlayerType.CROSS;
    }
    return PlayerType.CIRCLE;
  }

  FinishedState? isFinished() {
    if (_hasThreeInARow(PlayerType.CIRCLE)) {
      return FinishedState.CIRCLE;
    }
    if (_hasThreeInARow(PlayerType.CROSS)) {
      return FinishedState.CROSS;
    }
    if (state.tiles.entries
            .where((element) => element.value == PlayerType.EMPTY)
            .toList()
            .length ==
        0) {
      return FinishedState.DRAW;
    }
    return null;
  }

  bool _hasThreeInARow(PlayerType player) {
    var tiles = state.tiles.entries
        .where((element) => element.value == player)
        .map((e) => e.key)
        .toList();

    if (tiles.where((element) => element.y == element.x).toList().length == 3) {
      return true;
    }
    if (tiles.where((element) => 2 - element.y == element.y).toList().length ==
        3) {
      return true;
    }
    for (int i = 0; i < 3; i++) {
      if (tiles.where((tile) => tile.x == i).toList().length == 3) {
        return true;
      }
      if (tiles.where((tile) => tile.y == i).toList().length == 3) {
        return true;
      }
    }
    return false;
  }
}

Showing the Current State

Before we are going to start creating widgets, we are going to create two different consumers. First, one of the whole list of tiles and another one for the entry of a single tile. Next, we create the StateNotifierProvider _gameState, and from this one, we can derive the first provider we need. Finally, the second provider will be filled in the Widget itself.

final _gameState = StateNotifierProvider<GameStateNotifier, GameState>(
    (_) => GameStateNotifier(GameState(Map(), Progress.inProgress())));

To display the current tiles, we will use a Gridview with an axis count of three. To provide the current value to the child Widget, we are going to use a ScopedProvider. We can override ScopedProvider in the ProviderScope. Thus around each tile, we will wrap a new ProviderScope and update the value of the ScopedProvider. Doing this, we can use the ref.watch in the child Widget.

class Tiles extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final gameState = ref.watch(_gameState);
    return Container(
      child: GridView.count(
        physics: new NeverScrollableScrollPhysics(),
        padding: EdgeInsets.all(12),
        crossAxisCount: 3,
        crossAxisSpacing: 12,
        mainAxisSpacing: 12,
        children: gameState.tiles.entries
            .map<Widget>((entry) => TileWidget(entry))
            .toList(),
      ),
    );
  }
}

class TileWidget extends HookConsumerWidget {
  const TileWidget(this.tileEntry);

  final Duration duration = const Duration(milliseconds: 700);
  final MapEntry<Tile, PlayerType> tileEntry;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    switch (tileEntry.value) {
      case PlayerType.CROSS:
        return crossWidget();
      case PlayerType.CIRCLE:
        return circleWidget();
      case PlayerType.EMPTY:
        return emptyWidget(context, tileEntry.key);
    }
  }

  Widget emptyWidget(BuildContext context, Tile tile) {
    return GestureDetector(
        onTap: () => ref.watch(_gameState.notifier).toggle(tile),
        child: Container(
          color: Colors.green[600],
        ));
  }

  Widget crossWidget() {
    return Container(
      color: Colors.green[600],
      child: CustomPaint(
        painter: CrossPainter(),
      ),
    );
  }

  Widget circleWidget() {
    return Container(
      color: Colors.green[600],
      child: CustomPaint(
        painter: CirclePainter(),
      ),
    );
  }
}

To draw the circle and the cross, we will use a CustomPainter. In this blog post, you can find more information about CirclePainter and CrossPainter.

Adding Animations

To start with the animation, we can use the useAnimation hook to create an AnimationController. We want to start the animation when the value changes to a circle or cross. To do this, we can use another hook provided by Flutter Hooks, namely, useValueChanged. Here we watch the changes, and when the value is not empty, we start the animation. When the value becomes empty, we reset the animations again. This should only happen after we finished a game and we want to play again.

For the animation, we will be using an AnimatedBuilder. The AnimatedBuilder expects a controller. In the builder, we can then access the value of the controller we defined earlier. Since we passed extra parameters, the forward call on the controller moves the value from zero to a hundred during the duration we defined. We pass this value to the CirclePainter and CrossPainter, to draw only parts of the circle and cross till the percentage is a hundred.

class TileWidget extends HookConsumerWidget {
  const TileWidget(this.tileEntry);

  final Duration duration = const Duration(milliseconds: 700);
  final MapEntry<Tile, PlayerType> tileEntry;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final _controller = useAnimationController(
      duration: duration,
      upperBound: 100,
    );
    useValueChanged<PlayerType, Function(PlayerType, PlayerType)>(
        tileEntry.value, (_, __) {
      if (tileEntry.value == PlayerType.EMPTY) {
        _controller.reset();
      }
      if (tileEntry.value != PlayerType.EMPTY) {
        _controller.forward();
      }
    });

    switch (tileEntry.value) {
      case PlayerType.CROSS:
        return crossWidget(_controller);
      case PlayerType.CIRCLE:
        return circleWidget(_controller);
      case PlayerType.EMPTY:
        return emptyWidget(ref, tileEntry.key);
    }
  }

  Widget emptyWidget(WidgetRef ref, Tile tile) {
    return GestureDetector(
      onTap: () => ref.watch(_gameState.notifier).toggle(tile),
      child: Container(
        color: Colors.green[600],
      ),
    );
  }

  Widget crossWidget(AnimationController _controller) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) => Container(
        color: Colors.green[600],
        child: CustomPaint(
          painter: CrossPainter(_controller.value),
        ),
      ),
    );
  }

  Widget circleWidget(AnimationController _controller) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) => Container(
        color: Colors.green[600],
        child: CustomPaint(
          painter: CirclePainter(_controller.value),
        ),
      ),
    );
  }
}

Game Finished Dialog

We can now change the tiles to crosses and circles. However, when one of the players has three in a row, nothing happens. Let’s change that! For this, we are going to adjust the GameStateNotifier. When someone toggles, we will determine whether the game has finished or not and update the game’s progress based on the new condition.

class GameStateNotifier extends StateNotifier<GameState> {
  GameStateNotifier(GameState state) : super(state) {
    Map<Tile, PlayerType> tiles = Map<Tile, PlayerType>();
    for (int x = 0; x < 3; x++) {
      for (int y = 0; y < 3; y++) {
        tiles.putIfAbsent(Tile(x, y), () => PlayerType.EMPTY);
      }
    }
    this.state = state.copyWith(tiles: tiles, progress: Progress.inProgress());
  }

  toggle(Tile tile) {
    state.tiles[tile] = state.currentPlayer;
    state = state.copyWith(
      currentPlayer: _nextPlayer(),
      progress: _determineProgress(),
      tiles: state.tiles.map((key, value) => MapEntry(key, value)),
    );
  }

  reset() {
    state = state.copyWith(
        currentPlayer: PlayerType.CIRCLE,
        progress: Progress.inProgress(),
        tiles:
            state.tiles.map((key, value) => MapEntry(key, PlayerType.EMPTY)));
  }

  Progress _determineProgress() {
    var finished = isFinished();
    if (finished == null) {
      return state.progress;
    }
    return Progress.finished(finished);
  }

  PlayerType _nextPlayer() {
    if (state.currentPlayer == PlayerType.CIRCLE) {
      return PlayerType.CROSS;
    }
    return PlayerType.CIRCLE;
  }

  FinishedState? isFinished() {
    if (_hasThreeInARow(PlayerType.CIRCLE)) {
      return FinishedState.CIRCLE;
    }
    if (_hasThreeInARow(PlayerType.CROSS)) {
      return FinishedState.CROSS;
    }
    if (state.tiles.entries
            .where((element) => element.value == PlayerType.EMPTY)
            .toList()
            .length ==
        0) {
      return FinishedState.DRAW;
    }
    return null;
  }

  bool _hasThreeInARow(PlayerType player) {
    var tiles = state.tiles.entries
        .where((element) => element.value == player)
        .map((e) => e.key)
        .toList();

    if (tiles.where((element) => element.y == element.x).toList().length == 3) {
      return true;
    }
    if (tiles.where((element) => 2 - element.y == element.y).toList().length ==
        3) {
      return true;
    }
    for (int i = 0; i < 3; i++) {
      if (tiles.where((tile) => tile.x == i).toList().length == 3) {
        return true;
      }
      if (tiles.where((tile) => tile.y == i).toList().length == 3) {
        return true;
      }
    }
    return false;
  }
}

We can now create a provider that listens to the current state of the progress variable. Just like the animations, we can use the useValueChanged to check whether the game has finished. We only want to trigger a dialog when the game has finished, so we use the maybeWhen function provided by Flutter Freezed. When the game has finished, we can also access the winner of the Finished object since we know it is in the Finished state. Again, we will show a dialog, but only after a small delay since we want to finish the animation of the last click.

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final gameState = ref.watch(_gameState);
    useValueChanged<Progress, Function(Progress, Progress)>(gameState.progress,
        (progress, __) {
      gameState.progress.when(
          finished: (winner) => {triggerDialog(context, winner)},
          inProgress: () => {});
    });

  void triggerDialog(BuildContext context, FinishedState finishState) {
    Future.delayed(
      const Duration(milliseconds: 900),
      () => showDialog(
        context: context,
        barrierDismissible: false, // user must tap button!
        builder: (_) => FinishDialog(finishState),
      ),
    );
  }

In the dialog, we want to reset the game to the initial state. For this, we can add a reset function to the GameStateNotifier. There are three things we reset here:

  1. The player, to the circle player
  2. The progress, to in progress
  3. The state of each tile, to empty
  reset() {
    state = state.copyWith(
        currentPlayer: PlayerType.CIRCLE,
        progress: Progress.inProgress(),
        tiles:
            state.tiles.map((key, value) => MapEntry(key, PlayerType.EMPTY)));
  }

Finally, we can create a simple alert dialog. Based on the winner, we alternate the title and subtitle. On the button press, we can access the GameStateNotifier to reset the game. Furthermore, we close the dialog.

class FinishDialog extends HookConsumerWidget {
  final FinishedState _winner;

  FinishDialog(this._winner);

  String subtitle() {
    if (_winner == FinishedState.CROSS) {
      return "Cross won!";
    }
    if (_winner == FinishedState.CIRCLE) {
      return "Circle won!";
    }
    return "Nobody lost!";
  }

  String title() {
    if (_winner == FinishedState.DRAW) {
      return "We have no loser!";
    }
    return "We have a winner!";
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final provider = ref.watch(_gameState.notifier);
    return AlertDialog(
      title: Text(title()),
      content: SingleChildScrollView(
        child: ListBody(
          children: <Widget>[
            Text(subtitle()),
          ],
        ),
      ),
      actions: <Widget>[
        TextButton(
          child: Text('Play Again'),
          onPressed: () {
            provider.reset();
            Navigator.of(context).pop();
          },
        ),
      ],
    );
  }
}
Result of our implementation

Conclusion

After writing about tic tac toe with animations, we struggled with the state management. With this approach, all design approach is where it belongs, and all the business logic remains in the same place. The Widgets remain in control of the animations so that the business logic can remain simple! You can find the full code here on Github. If you still have any questions, remarks, or suggestions, do not hesitate to leave a comment or send a message!

4 Comments

  1. This needs to be updated to latest. I tried to do that, but I’m getting a lot of undefineds in the Tiles class (useProvider, useValueChanged, context.read).

    I’ve noodled over it for a few hours and have resolved some issues (HookWidget needs to be ConsumerHookWidget, build now takes a WidgetRef which is used in a ref.watch vs useProvider), but can’t get past the useValueChanged and context.read undefineds.

    If you update it, please ping me at the email address above – I’d be interested to see the changes.

    thanks,
    rickb

  2. Also, when trying to build it before upgrading, I got null-safety errors. Trying to upgrade the flutter sdk version didn’t help. There’s just a lot of dependency version issues, now.

  3. Ah. Some of the unresolveds were the result of my trying to use the latest riverpod_flutter (1.0.0-dev.5).

    Changing to 0.14.0+4 restored useProvider and context.read();

    However, I’m getting caught in null safety declaration hell with useValueChanged().

    rickb

Leave a Reply