Bloc 8.0 Tutorial for Flutter: Getting Started

Learn how to build a Wordle clone app in Flutter using one of the most robust state management libraries: Bloc 8.0. By Alejandro Ulate Fallas.

5 (4) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Handling Game State Changes

Now that you’ve brushed up on some of the core concepts, it’s time you dive into coding.

If you’ve used the plugin before, you might remember the new Events API introduced in v7.2.0. The motivation behind this change was predictability, but it also gave extra simplicity to event handling.

Open lib/presentation/bloc/game_event.dart and look at the different events you’ll process in GameBloc:

  1. LetterKeyPressed: An event performed when the user taps a key on the onscreen keyboard, PlingoKeyboard.
  2. GameStarted: An event triggered when a new game starts, either by opening the app or tapping Play Again when you win or lose the game.
  3. GameFinished: This event happens when the player guesses the correct word and wins or reaches the max attempts to guess and loses the game.

You’ll start by adding GameStarted. Open lib/presentation/bloc/game_bloc.dart and replace the constructor in line 40 with the following:

GameBloc(this._statsRepository)
    : super(GameState(
        guesses: emptyGuesses(),
      )) {
  on<GameStarted>(_onGameStarted);
}

This code lets GameBloc process GameStarted events. At this point, a compilation error should be showing in your editor since _onGameStarted isn’t yet defined. Fix that by replacing // TODO: Add logic for GameStarted with this code:

void _onGameStarted(
  GameStarted event,
  Emitter<GameState> emit,
) {
  print('Game has started!');
  final puzzle = nextPuzzle(puzzles);
  final guesses = emptyGuesses();
  emit(GameState(
    guesses: guesses,
    puzzle: puzzle,
  ));
}

This function defines the logic that should happen when a game starts. First, nextPuzzle defines a random puzzle from the list of available puzzles. Then, emptyGuesses generates a multidimensional array that handles the letters and words guessed in the form of a 5×5 matrix of Strings. Finally, you emit a new GameState with that information, which also updates the game’s UI.

Right now, GameBloc is ready to handle the start of a game, but that event isn’t added to GameBloc in the app. You’ll now write a few lines of code for that. Open lib/app/app.dart and replace the line below // TODO: Start game here! with:

GameBloc(ctx.read<GameStatsRepository>())..add(GameStarted()),

.. is the cascade operator that allows you to make a sequence of operations on the same object, i.e., GameBloc. The above code takes care of starting a game whenever the app starts.

Now, open lib/presentation/pages/main_page.dart, and replace the definition of onPressed below // TODO: Restart game here! with the following code:

onPressed: () => context.read<GameBloc>().add(const GameStarted()),

This onPressed gets called when the player wants to play again at the end of a game.

Build and run the project, and you’ll see no difference yet. However, check the debug console, and you’ll see that _onGameStarted got called because the print statement you added before is showing.

Logs displaying that GameStarted event has been added

Recognizing Key Presses

Now that the game has started, you’ll tackle the LetterKeyPressed event. Open game_bloc.dart again, and add the event handler on the constructor. The end result should look like this:

GameBloc(this._statsRepository)
    : super(GameState(
        guesses: emptyGuesses(),
      )) {
  on<GameStarted>(_onGameStarted);
  on<LetterKeyPressed>(_onLetterKeyPressed);
}

Now, replace // TODO: Add logic for LetterKeyPressed with the following code:

Future<void> _onLetterKeyPressed(
  LetterKeyPressed event,
  Emitter<GameState> emit,
) async {
  final guesses = addLetterToGuesses(state.guesses, event.letter);

  emit(state.copyWith(
    guesses: guesses,
  ));

  // TODO: check if the game ended.
}

With this code, every time you receive a LetterKeyPressed event, you add the letter to the current guesses by checking for the first empty slot and setting its value as equal to the letter pressed — this is what addLetterToGuesses does. Finally, you emit a new state with the new guess list and this, in turn, updates the UI via provider‘s helper, context.watch().

Like before, you need to add the event for GameBloc to process it. Open lib/presentation/widgets/plingo_key.dart and replace onTap with the following code:

onTap: () => context.read<GameBloc>().add(LetterKeyPressed(letter)),

Remember to add the corresponding imports for flutter_bloc and GameBloc at the top.

Build and run the app. Use the keyboard to type a couple of words like CRAVE, LINGO or MUMMY. This will give you a couple of hints about what the five-letter word is, and it might look like this:

Game board with guesses of crave, lingo and mummy

Great job! You’ve added a second event to the game. But, there’s one caveat to the current implementation: concurrency. You’ll tackle this problem next.

Transforming Events

To make this bug more noticeable, open game_bloc.dart and change _onLetterKeyPressed to emit a state in the future. Don’t forget to add the corresponding import 'dart:math'; at the top of the file. Here’s what the end result should look like:

Future<void> _onLetterKeyPressed(
  LetterKeyPressed event,
  Emitter<GameState> emit,
) async {
  final guesses = addLetterToGuesses(state.guesses, event.letter);

  final randGenerator = Random();
  final shouldHold = randGenerator.nextBool();
  await Future.delayed(Duration(seconds: shouldHold ? 2 : 0), () {
    emit(state.copyWith(
      guesses: guesses,
    ));
  });

  // TODO: check if the game ended.
}

Build and run again. Use the keyboard to type CRAZY.

Showcases a bug when handling events because of concurrency

Notice the bug now? Blocs treat events concurrently now instead of doing it sequentially. This means that if an event takes too long to complete, another one might be able to override the changes, leading to unexpected behaviors like the one in your app.

Blocs let you define the way to handle events by setting transformer in the event handler definition on the constructor.

Normally, defining your own transformer function could become difficult to maintain, so you should only do it when required. Luckily, a companion library called bloc_concurrency contains a set of opinionated transformer functions that allow you to handle events the way you want. Here’s a small list of the ones included in bloc_concurrency:

  • concurrent(): Process events concurrently.
  • sequential(): Process events sequentially.
  • droppable(): Ignore any events added while an event is processing.
  • restartable(): Process only the latest event and cancel previous event handlers.

For this project, bloc_concurrency is already added to the dependencies in your pubspec.yaml, so you can jump straight in using that. Open game_bloc.dart and add the following import statement at the top of the file:

import 'package:bloc_concurrency/bloc_concurrency.dart';

Then, change the corresponding event handler for LetterKeyPressed in the constructor and replace it with the following:

on<LetterKeyPressed>(_onLetterKeyPressed, transformer: sequential());

Using sequential() allows you to handle events in a sequence, which helps you avoid events from overriding one another.

Build and run the app again, and try to type CRAZY again.

Showcases the use of sequential event transformer to avoid concurrency bugs

As you can see, the events are now processed in the order you add them, despite some of them taking longer than their predecessors.

Now, it’s time you finish adding the last event that GameBloc processes. Add the event handler for GameFinished. It should look like this:

on<GameFinished>(_onGameFinished);

Then, replace // TODO: Add logic for GameFinished with the following code:

// 1
Future<void> _onGameFinished(
  GameFinished event,
  Emitter<GameState> emit,
) async {
  // 2
  await _statsRepository.addGameFinished(hasWon: event.hasWon);
  // 3
  emit(state.copyWith(
    status: event.hasWon ? GameStatus.success : GameStatus.failure,
  ));
}

Here’s a quick overview of the code you wrote:

  1. Notice that you’re using Future as a return value for _onGameFinished since Bloc supports this return type.
  2. Then, _statsRepository.addGameFinished interacts with local storage for updating the different statistics.
  3. Finally, you emit a new state with the corresponding game result: success for winning or failure for losing.

As a final step, replace _onLetterKeyPressed with the following code:

Future<void> _onLetterKeyPressed(
  LetterKeyPressed event,
  Emitter<GameState> emit,
) async {
  final puzzle = state.puzzle;
  final guesses = addLetterToGuesses(state.guesses, event.letter);

  // 1
  emit(state.copyWith(
    guesses: guesses,
  ));

  // 2
  final words = guesses
      .map((guess) => guess.join())
      .where((word) => word.isNotEmpty)
      .toList();

  final hasWon = words.contains(puzzle);
  final hasMaxAttempts = words.length == kMaxGuesses &&
      words.every((word) => word.length == kWordLength);
  if (hasWon || hasMaxAttempts) {
    add(GameFinished(hasWon: hasWon));
  }
}

With this code, you’re doing two things:

  1. emit is no longer wrapped with a Future like it was before.
  2. Determining if the game ended after the guesses have updated. You do this by checking if the user guessed the word puzzle — Plingo’s win condition — or if the user reached the max number of attempts without guessing the correct word. If the game meets either condition, then you add a new GameFinished event.

Build and run the app again, and try to win the game. If you lose, just tap Play Again to give it another try. You’ll now see a winning message when you guess the puzzle or a losing message if you reach the max attempts without guessing correctly. Here’s what they look like:

Plingo with winning message

Plingo with losing message