davidanaya.io

Simon implementation in Flutter

January 27, 2019 | 12 Minute Read

back-to-the-flutter

Over the next three articles I will show how I implemented the game Simon in Flutter. The game is currently available in Google Play under the name Simon Says. I think it makes for an interesting use case for Flutter because I wanted to use reactive programming for it and Flutter has excellent support for it. I will share the full version of the game in my github account in the last article of the series.

Simon is an electronic game of memory skill in which the device creates a series of tones and lights and requires a user to repeat the sequence. If the user succeeds, the series becomes progressively longer and more complex. wikipedia

How does the game work?

The mechanics of the game are pretty straightforward. The device consists of four buttons, each one with a particular color and sound, which will be activated in a random sequence that the user will then need to replicate. The game is played in rounds, and in each round the length of the sequence is increased in one.

You can check this video if you need more details.

What does the game look like?

From a user perspective, the game is very simple: a black canvas and four colored buttons which will be highlighted and also produce a sound when pressed. We also want some way to show the current round and to indicate whose turn it is.

simon-game-play

What are the basics?

These are the things that I cover in this article:

  • UI elements: board, buttons, status bar and score.
  • App architecture: widgets, models and how they interact together. The final version of the application has some extra elements and features that I don’t explain here, as the overlay screen used before and after the game.

Architecture

simon-game-play

As you can see, GameBloc is the heart of the game. It will be the source of truth for our data and will use streams and sinks to communicate with the presentation layer (widgets). Also, I defined a few simple models to represent the data and encapsulate some business logic. Let’s start with them.

This is how I understand the game in pseudocode in an imperative way:

state = SimonSays
gamePlays = []
while (state != GameOver) {
  play = generateRandomPlay()
  gamePlays.add(play)
  gamePlays.forEach(simonPlays$.add)
  state = UserSays
  userPlayIndex = 0
  userPlays.listen((play) {
    if (!isValidPlay(play, userPlayIndex)) state = GameOver
    else id (userPlayIndex == gamePlays.length) state = SimonPlays
    userPlayIndex++
  })
}

Now we need to implement this using reactive programming. Let’s do it!

Data models

import 'package:flutter/material.dart';

class SimonColor {
  final Color primary;
  final Color accent;
  final String soundFileName;

  SimonColor(this.primary, {@required this.accent, this.soundFileName});
}

SimonColor encapsulates information needed for each button in the game; the color used normally and when pressed, and also the name of the file with the related sound.

import 'package:simon_says/src/models/constants.dart';

class GamePlay {
  final GameColor play;
  bool _isLastPlay = true;
  bool _isFailedPlay = false;

  bool get isLastPlay => _isLastPlay;

  bool get isFailedPlay => _isFailedPlay;

  GamePlay(this.play, {bool isLastPlay, bool isFailedPlay})
      : _isLastPlay = isLastPlay ?? true,
        _isFailedPlay = isFailedPlay ?? false;

  void setAsNotLastPlay() {
    _isLastPlay = false;
  }
}

GamePlay represents a single play (color) plus some extra information that I use to check if it’s the last play in the sequence and if it was successful or not. For the complete sequence of plays in the game, I use GamePlays. Other than the list of plays I have some functions to deal with the contents of the list and also validate user plays agains the sequence.

import 'dart:math';
import 'dart:collection';

import 'package:simon_says/src/models/constants.dart';
import 'package:simon_says/src/models/game_play.dart';

class GamePlays {
  List<GamePlay> _simonPlays = [];
  // we use -1 as we increase it always before checking the user play
  int _userPlayIndex = -1;

  UnmodifiableListView<GamePlay> get simonPlays =>
      UnmodifiableListView(_simonPlays);

  void clear() {
    _simonPlays.clear();
  }

  void newSimonPlay() {
    _userPlayIndex = -1;
    _addSimonPlay();
  }

  bool validateUserPlay(GameColor play) {
    _userPlayIndex++;
    return _isValidUserPlay(play);
  }

  bool isUserTurnFinished() {
    return _userPlayIndex == _simonPlays.length - 1;
  }

  GameColor getFailedPlay() {
    return _simonPlays[_userPlayIndex].play;
  }

  bool _isValidUserPlay(GameColor play) {
    return _simonPlays[_userPlayIndex].play == play;
  }

  void _addSimonPlay() {
    if (!_isEmpty()) {
      _simonPlays[_simonPlays.length - 1].setAsNotLastPlay();
    }
    _simonPlays.add(_generateRandomPlay());
  }

  bool _isEmpty() {
    return _simonPlays.length == 0;
  }

  GamePlay _generateRandomPlay() {
    var number = Random().nextInt(GameColor.values.length);
    return GamePlay(GameColor.values[number]);
  }
}

Finally, GameState holds the current state in the game, plus some extra information about the duration and the round which I use for the score when the game is over.

class GameState {
  static const Intro = 'Intro';
  static const GameOver = 'GameOver';
  static const SimonSays = 'SimonSays';
  static const UserSays = 'UserSays';

  final String state;
  final Duration time;
  final int round;

  final isBestScore;

  GameState(this.state, this.time, this.round, {isBestScore})
      : this.isBestScore = isBestScore ?? false;

  int get score => round;

  bool get isPlayState =>
      state == GameState.UserSays || state == GameState.SimonSays;

  int get duration => time.inSeconds;
}

The board

The board is fairly simple, I just draw the buttons and include a rounded container in the middle to display the current round. Please note that I’m using the stream score$ to reactively change the score value during the game.

import 'package:flutter/material.dart';

import 'package:simon_says/src/bloc/bloc_provider.dart';
import 'package:simon_says/src/models/constants.dart';
import 'package:simon_says/src/models/game_state.dart';
import 'package:simon_says/src/widgets/no_game_info_overlay.dart';
import 'package:simon_says/src/widgets/round_score.dart';
import 'package:simon_says/src/widgets/simon_button.dart';

class SimonBoard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final bloc = BlocProvider.of(context).gameBloc;
    return StreamBuilder(
        stream: bloc.state$,
        builder: (context, snapshot) {
          if (!snapshot.hasData) {
            return CircularProgressIndicator();
          }
          return Stack(children: [_buildButtons(), RoundScore()]);
        });
  }

  Widget _buildButtons() {
    return Container(
        padding: EdgeInsets.fromLTRB(12.0, 12.0, 12.0, 4.0),
        color: Colors.black,
        child: Column(children: <Widget>[_buildTopRow(), _buildBottomRow()]));
  }

  Widget _buildTopRow() {
    return _buildRow(GameColor.green, GameColor.red);
  }

  Widget _buildBottomRow() {
    return _buildRow(GameColor.yellow, GameColor.blue);
  }

  Widget _buildRow(GameColor color1, GameColor color2) {
    return Expanded(
      child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            _buildButton(color1),
            _buildButton(color2),
          ]),
    );
  }

  Widget _buildButton(GameColor color) {
    return Expanded(child: SimonButton(color));
  }
}

The buttons

There is some complexity to the SimonButton widget, but for this first iteration I stripped down everything related to the game itself so that we can focus on the design and the animation. At this point, only the user can interact with the button, but that’s not wired to GameBloc.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart';

import 'package:simon_says/src/models/constants.dart';
import 'package:simon_says/src/models/simon_color.dart';

enum Tap { up, down }

class SimonButton extends StatefulWidget {
  final GameColor gameColor;
  final SimonColor simonColor;

  SimonButton(this.gameColor) : simonColor = gameColors[gameColor];

  @override
  _SimonButtonState createState() => _SimonButtonState();
}

class _SimonButtonState extends State<SimonButton>
    with TickerProviderStateMixin {
  final buttonPadding = 12.0;
  final buttonPaddingAccent = 24.0;

  Animation<double> _animation;
  AnimationController _animationController;

  PublishSubject<Tap> _userTap$;

  StreamSubscription<Tap> _userTapSubs;

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

    // initialize animation controllers for the button
    _animationController = AnimationController(
        duration: Duration(milliseconds: buttonAnimationMs), vsync: this);
    _animation = Tween(begin: buttonPadding, end: buttonPaddingAccent).animate(
        CurvedAnimation(parent: _animationController, curve: Curves.linear));

    // creates a new subject to deal with user taps
    _userTap$ = PublishSubject<Tap>();
  }

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

    _userTapSubs = _userTap$.listen((Tap tap) => _handleUserTap(tap));
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: _animation,
        builder: (context, child) {
          return Container(
            child: _buildButton(
                color: _animation.value == buttonPadding
                    ? _getPrimaryColor()
                    : _getAccentColor()),
            color: Colors.black,
            padding: EdgeInsets.all(_animation.value),
          );
        });
  }

  Widget _buildButton({Color color}) {
    return GestureDetector(
      onTapDown: _handleTapDown,
      onTapUp: _handleTapUp,
      child: Container(
          decoration: BoxDecoration(
              color: color,
              borderRadius: BorderRadius.all(Radius.circular(10.0)))),
    );
  }

  void _handleUserTap(Tap tap) {
    if (tap == Tap.down) {
      _animationController.forward();
    } else {
      _animationController.reverse();
    }
  }

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

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

  Color _getPrimaryColor() {
    return widget.simonColor.primary;
  }

  Color _getAccentColor() {
    return widget.simonColor.accent;
  }
}

Notice how I use the AnimationController to change the padding on the button when the user taps on it to give the feeling of it being pressed. Also, I used the current value in the padding on every render during the animation to chose the primary color or the accent color so it seems as if the button is highlighted.

In my next article I will focus on the GameBloc and wiring it to the buttons. Thanks for reading!