davidanaya.io

Simon implementation in Flutter II

February 10, 2019 | 12 Minute Read

back-to-the-flutter

In this set of articles I explain how to implement a version of the electronic game Simon in a fully reactive way with Flutter.

In my previous article I presented the architecture, the mechanics and the widgets that I use for the board and the buttons. In this article I explain how to wire together the presentational layer (widgets) with the business logic (GameBloc) using the BLoC pattern architecture, which I also introduced in one of my previous articles.

The MVC is a widely known pattern so separate data models from business logic and presentational components, and the BLoC pattern actually represents the same idea but with an extra element of reactiveness. What we want to do, instead of trying to control the flow of events, is react to those events happening and making sure that when we get the event we are ready for it.

THE BLOC

Instead of going over GameBloc class as a whole, I think it will be easier to understand by looking at each type of event independently and try to understand how they work.

state$

  final BehaviorSubject<GameState> _state$ = BehaviorSubject<GameState>(
      seedValue: GameState(GameState.Intro, Duration(milliseconds: 0), 0));
  Stream<GameState> get state$ => _state$.stream.distinct();

  GameBloc(this._soundPlayer) {
    state$.listen(_stateHandler);

    ...
  }

  void startGame() {
    _setRound(0);
    _gamePlays.clear();
    _gameTimeStart = DateTime.now();
    _state$.add(_newGameState(GameState.SimonSays));
  }

  void _stateHandler(GameState state) {
    if (state.state == GameState.SimonSays) {
      _simonSaysHandler();
    }
  }

  ...
}

We will use the state to communicate the state of the game. We already check the GameState model. The GameBloc is the only one allowed to change the state, so we need a Subject to emit new values and we expose it with a getter. Then we will also listen to the observable so that whenever there is a change in the state we react to it.

In fact, the only state that we need to react to is SimonSays, because that indicates that we need to stream the current sequence of the events. In case it’s UserSays or GameOver there will be another event (user pressing the button or starting the game) that will trigger another action.

simonPlay$


  final _simonPlay$ = PublishSubject<GamePlay>();

  Stream<GamePlay> get simonPlay$ =>
      _simonPlay$.stream.concatMap((play) => Observable.timer(
          play,
          Duration(milliseconds: simonPlayDelayMs)));

  GameBloc(this._soundPlayer) {
    ...

    Observable(simonPlay$)
        .where((play) => play.isLastPlay)
        .delay(Duration(milliseconds: lastPlayDelayMs))
        .listen(_lastSimonPlayHandler);

    ...
  }

  void _stateHandler(GameState state) {
    if (state.state == GameState.SimonSays) {
      _simonSaysHandler();
    }
  }

  _simonSaysHandler() {
    _setRound(_round + 1);
    _gamePlays.newSimonPlay();
    _gamePlays.simonPlays.forEach(_simonPlay$.add);
  }

  void _lastSimonPlayHandler(GamePlay play) {
    _state$.add(_newGameState(GameState.UserSays));
  }

  ...
}

We already saw how we are reacting to the state being SimonSays. In that case, _simonSaysHandler() will increment the current round, generate a new random play, and will emit a series of events, one for each play in the sequence.

We want to control the pace of those events, so instead of just streaming them as they come, we will use the concatMap operator, which will take each event and generate a new observable, but keeping the order (we don’t want to mess with the sequence). This new observable can be anything, but in our case we will use a timer to delay it.

Also, we need to control when we emit the last play in the sequence to change the state of the game to UserSays. We do that by listening to simonPlay$ and, if the current play being emitted is the last one, executing _lastSimonPlayHandler(), which in turn changes the state to UserSays.

round$

  int _round = 0;
  BehaviorSubject<int> _round$ = BehaviorSubject<int>(seedValue: 0);
  Stream<int> get round$ => _round$.distinct();

  ...

  void _setRound(int round) {
    _round = round;
    _round$.add(_round);
  }

  ...
}

The value emitted will be controlled only by GameBloc, so we also protect this with a getter to expose only the value when it changes. We also have a private helper function _setRound() to increase the round and emit a value, which will be used a few times in different functions.

userPlay


  final _userPlayController = StreamController<GameColor>();

  Sink<GameColor> get userPlay => _userPlayController.sink;

  GameBloc(this._soundPlayer) {
    ...

    _userPlayController.stream.listen(_userPlayHandler);

    ...
  }

  void _userPlayHandler(GameColor play) async {
    if (_gamePlays.validateUserPlay(play)) {
      if (_gamePlays.isUserTurnFinished()) {
        Timer(Duration(milliseconds: lastPlayDelayMs),
            () => _state$.add(_newGameState(GameState.SimonSays)));
      }
    } else {
      final gameState = GameState(GameState.GameOver, _gameDuration, _round);
      _state$.add(gameState);
      _setRound(0);
    }
  }

  GameState _newGameState(String state) {
    return GameState(state, _gameDuration, _round);
  }

  ...
}

In this case it will be the buttons the ones to emit events, so we expose a sink and we listen to it in the constructor. Whenever a new value is received, we execute _userPlayHandler(). In here we basically validate if the user play is valid and if it’s the last one in the sequence and we change the state accordingly (GameOver if the user failed, SimonSays if it was the last play) or we just do nothing.

The buttons

Now for the buttons, which will be the widgets that will interact the most with GameBloc. We already presented a basic version of the buttons in the previous article, but now we will wire them to the bloc. I have removed in this case the information that is not relevant.

class SimonButton extends StatefulWidget {
  ...
}

class _SimonButtonState extends State<SimonButton>
    with TickerProviderStateMixin {
  ...

  PublishSubject<Tap> _userTap$;

  StreamSubscription<GamePlay> _simonPlaySubs;
  StreamSubscription<Tap> _userTapSubs;

  @override
  void initState() {
    super.initState();
    ...

    _userTap$ = PublishSubject<Tap>();
  }

  @override
  didChangeDependencies() {
    super.didChangeDependencies();

    var bloc = BlocProvider.of(this.context).gameBloc;

    // subscribe to simon plays and animate button
    _simonPlaySubs = Observable(bloc.simonPlay$)
        .where((gamePlay) => gamePlay.play == widget.gameColor)
        .listen((play) => _handleSimonPlay(play));

    // subscribe to user plays and animate only if it's the user turn
    _userTapSubs = _userTap$
        .withLatestFrom(bloc.state$, (tap, state) => [tap, state])
        .where((data) => data[1].state == GameState.UserSays)
        .map((data) => data[0] as Tap)
        .listen((Tap tap) =>
            _handleUserTap(tap, bloc.userPlay));
  }

  @override
  void dispose() {
    super.dispose();
    _simonPlaySubs.cancel();
    _userTapSubs.cancel();
  }

  @override
  Widget build(BuildContext context) {
    ...
  }

  Widget _buildButton({Color color}) {
    ...
  }

  void _handleUserTap(Tap tap, Sink<GameColor> userPlay) {
    if (tap == Tap.down) {
      _animationController.forward();
    } else {
      userPlay.add(widget.gameColor);
      _animationController.reverse();
    }
  }

  void _handleSimonPlay(GamePlay play) {
    _animationController.forward();
    Timer(
        Duration(milliseconds: buttonAnimationMs), () {
      _animationController.reverse();
    });
  }

  void _handleTapDown(TapDownDetails tapDetails) {
    _userTap$.add(Tap.down);
  }

  void _handleTapUp(TapUpDetails tapDetails) {
    _userTap$.add(Tap.up);
  }

  ...
}

The idea here is that it’s not enough that the button reacts to user inputs, it also has to communicate those inputs to GameBloc; that’s why _handleTapDown() and _handleTapUp() generate a new event in the internal subject _userTap$.

We initialize _userTap$ in initState(), but we need to use didChangeDependencies() to get a reference to the bloc. If try to do this inside initState() we will get an error because the InheritedWidget that our bloc provider uses internally might not be fully initialized. On the other hand, didChangeDependencies() is executed right after initState() and we are certain that our bloc will be available at this point.

Now that we have our bloc, we can access both state$ and simonPlay$. The first one we need it to filter the user interactions with the button, so that only when the state is UserSays we allow the user to press the buttons.

_userTapSubs = _userTap$
  .withLatestFrom(bloc.state$, (tap, state) => [tap, state])
  .where((data) => data[1].state == GameState.UserSays)
  .map((data) => data[0] as Tap)
  .listen((Tap tap) =>
    _handleUserTap(tap, bloc.userPlay));

This is what is happening here. For each tap, get the last value in the state and do not continue unless the state equals UserSays. Then take only the tap event (we don’t need the state anymore) and, for each event that got this far, execute _handleUserTap() with the tap event, which will start or reverse the animation (depending on the event, tap down or tap up) and the sink to communicate with GameBloc.

For simonPlay$ we have a similar process.

_simonPlaySubs = Observable(bloc.simonPlay$)
  .where((gamePlay) => gamePlay.play == widget.gameColor)
  .listen((play) =>
    _handleSimonPlay(play));

We want to filter the events, so only if the play affects this button (same color) we want to react to it. In that case, _handleSimonPlay() will start the animation to activate the button, and after some time will reverse it to give the effect of a tap up (or button released).

That’s all!

As you can see there are not that many events to account for, but event with some extra events that I’m using for other small features the code in GameBloc and SimonButton is under 200 lines.

In the last article in the series I will explain how to add sounds to our game, which will make a huge difference for the user experience. Thanks for reading!