We have covered most of the design related development of HangMan game in our previous posts. In this post, we will be making the the game functional by managing the state of game in Flutter.
This is going to be a long post as we will be covering a lot of code in this part. By the end of this post, we will have a working version of the HangMan game.
Game Object States
For our game to run smoothly, we need to keep a few objects in memory.
- Whether the game has begun, is resuming or is completed.
- The word that is being guessed.
- List of letters user has tried for guessing.
- Next item to draw for the Stick Figure.
So for State management of our HangMan game, we will try out couple different options:
- Using ValueNotifier and ValueListenableBuilder
- Using Stream and StreamBuilder
But first we need a random word generator.
Generating Random Words For HangMan
For our game, we need a list of words which can be used to generate a random word for each new game. For the purpose of this tutorial, we will use the names of districts from Nepal as allowed random words.
import 'dart:math';
class GuessWordHelper {
var _allowedWords = [
'Bhojpur',
'Dhankuta',
'Ilam',
'Jhapa',
'Khotang',
'Morang',
'Okhaldhunga',
'Panchthar',
'Sankhuwasabha',
'Solukhumbu',
'Sunsari',
'Taplejung',
'Terhathum',
'Udayapur',
'Saptari',
'Siraha',
'Dhanusa',
'Mahottari',
'Sarlahi',
'Bara',
'Parsa',
'Rautahat',
'Sindhuli',
'Ramechhap',
'Dolakha',
'Bhaktapur',
'Dhading',
'Kathmandu',
'Kavrepalanchok',
'Lalitpur',
'Nuwakot',
'Rasuwa',
'Sindhupalchok',
'Chitwan',
'Makwanpur',
'Baglung',
'Gorkha',
'Kaski',
'Lamjung',
'Manang',
'Mustang',
'Myagdi',
'Nawalpur',
'Parbat',
'Syangja',
'Tanahun',
'Kapilvastu',
'Parasi',
'Rupandehi',
'Arghakhanchi',
'Gulmi',
'Palpa',
'Dang Deukhuri',
'Pyuthan',
'Rolpa',
'Eastern Rukum',
'Banke',
'Bardiya',
'Western Rukum',
'Salyan',
'Dolpa',
'Humla',
'Jumla',
'Kalikot',
'Mugu',
'Surkhet',
'Dailekh',
'Jajarkot',
'Kailali',
'Achham',
'Doti',
'Bajhang',
'Bajura',
'Kanchanpur',
'Dadeldhura',
'Baitadi',
'Darchula',
];
String generateRandomWord() {
var randomGenerator = Random();
var randomIndex = randomGenerator.nextInt(_allowedWords.length);
return _allowedWords[randomIndex].toUpperCase();
}
}
Here, the generateRandomWord
method of GuessWordHelper
class generates a random district.
Setup BLoC For Game State
Now we start by creating a BLoC for our HangMan game. A BLoC is simply a class which holds logic for the underlying widget.
We can handle the events like user tapping on buttons and icons and share the event response across the child widget using the BLoC object.
Learn More:
Start by creating an empty class GameStageBloc
.
class GameStageBloc {
}
Using ValueNotifier To Manage State
In our game, the first thing we want to maintain is the game state about whether it is in progress or completed states.
We will use ValueNotifier
for this purpose.
If you haven’t used ValueNotifier
before, checkout: Exploring ValueNotifier In Flutter
import 'package:flutter/widgets.dart';
import 'enum_collection.dart';
class GameStageBloc {
ValueNotifier<GameState> curGameState = ValueNotifier<GameState>(GameState.idle);
}
The GameState
is an enum:
enum GameState {
idle,
running,
failed,
succeded
}
Similarly, the next thing that we will need is the current word which is being guessed by the player.
ValueNotifier<String> curGuessWord = ValueNotifier<String>('');
Finally, we will also use ValueNotifier
to track the body parts which have been lost.
ValueNotifier<List<BodyPart>> lostParts = ValueNotifier<List<BodyPart>>([]);
Again, the BodyPart
is another enum:
enum BodyPart {
head,
body,
leftLeg,
rightLeg,
leftHand,
rightHand
}
Now for tracking the letters that the user have already guessed, we will use RxDart
library implementation of StreamController
.
Using StreamController To Manage State
First add reference to the rxdart
library by adding it’s dependency on the pubspec
file.
rxdart: 0.18.0
Next, in the GameStageBloc
create a controller and Stream
for guessedCharacters
list.
var _guessedCharactersController = BehaviorSubject<List<String>>();
Stream<List<String>> get guessedCharacters => _guessedCharactersController.stream;
Updating Game State
So far, we have setup different ValueListeners
and StreamBuilders
for managing the app state.
Next, we need to implement these helpers for maintaining game state.
Function To Create New Game
First, we need a function which will put everything in motion.
void createNewGame() {
curGameState.value = GameState.running;
lostParts.value.clear();
var guessWord = GuessWordHelper().generateRandomWord();
curGuessWord.value = guessWord;
_guessedCharactersController.sink.add([]);
}
The createNewGame
function sets the game state to running mode. It clears any game data that was created from previous run and generates a new word for a new game.
Function To Check If Word Guessed Correctly
Next, we need a function to check if player identified the puzzle word.
void _concludeGameOnWordGuessedCorrectly(List<String> guessedCharacters) {
//check if user identified all correct words
var allValuesIdentified = true;
var characters = curGuessWord.value.split('');
characters.forEach((letter) {
if(!guessedCharacters.contains(letter)) {
allValuesIdentified = false;
return;
}
});
if(allValuesIdentified) {
curGameState.value = GameState.succeded;
}
}
The _concludeGameOnWordGuessedCorrectly
function checks if all the letters of the guess word were correctly by the player. If the word has been identified, then:
curGameState.value = GameState.succeded;
Function To Update Guessed Characters
Next, we need to update the list of characters guessed by user so that we can update the state of pressed characters.
void updateGuessedCharacter(List<String> updatedGuessedCharacters) {
_guessedCharactersController.sink.add(updatedGuessedCharacters);
_concludeGameOnWordGuessedCorrectly(updatedGuessedCharacters);
}
Function To Track Body Parts Removed
Finally, to track the list of body parts that our Stick Figure has lost, we use the updateLostBodyParts
function.
void updateLostBodyParts() {
print('removing ');
if(!lostParts.value.contains(BodyPart.head)) {
print('head...');
lostParts.value.add(BodyPart.head);
return;
}
if(!lostParts.value.contains(BodyPart.body)) {
print('body...');
lostParts.value.add(BodyPart.body);
return;
}
if(!lostParts.value.contains(BodyPart.leftLeg)) {
print('left leg...');
lostParts.value.add(BodyPart.leftLeg);
return;
}
if(!lostParts.value.contains(BodyPart.rightLeg)) {
print('right left...');
lostParts.value.add(BodyPart.rightLeg);
return;
}
if(!lostParts.value.contains(BodyPart.leftHand)) {
print('left hand...');
lostParts.value.add(BodyPart.leftHand);
return;
}
if(!lostParts.value.contains(BodyPart.rightHand)) {
print('right hand...');
lostParts.value.add(BodyPart.rightHand);
// player has lost all body parts.
curGameState.value = GameState.failed;
return;
}
}
Here, we are removing a single body part at a time.
Tying Up Everything
Now that we have the functions ready in the GameStageBloc
, the next thing to do is fire up those functions whenever needed.
Update HangManPainter Class
We need to pass the instance of GameStage
bloc to our painter class and draw body parts based on the parts that have been lost by the player.
class HangManPainter extends CustomPainter {
final GameStageBloc _gameStageBloc;
double _headHeight = 32.0;
HangManPainter(this._gameStageBloc);
@override
void paint(Canvas canvas, Size size) {
var paint = Paint();
paint.color = Colors.grey;
paint.style = PaintingStyle.fill;
_drawFrame(canvas, size, paint);
_drawNoose(canvas, size, paint);
if(_gameStageBloc.lostParts.value.contains(BodyPart.head)) {
_drawHead(canvas, size, paint);
}
if(_gameStageBloc.lostParts.value.contains(BodyPart.body)) {
_drawBody(canvas, size, paint);
}
if(_gameStageBloc.lostParts.value.contains(BodyPart.leftLeg)) {
_drawLeg(canvas, size, paint, Limb.left);
}
if(_gameStageBloc.lostParts.value.contains(BodyPart.rightLeg)) {
_drawLeg(canvas, size, paint, Limb.right);
}
if(_gameStageBloc.lostParts.value.contains(BodyPart.leftHand)) {
_drawHand(canvas, size, paint, Limb.left);
}
if(_gameStageBloc.lostParts.value.contains(BodyPart.rightHand)) {
_drawHand(canvas, size, paint, Limb.right);
}
}
Update Puzzle Widget
Next, update the Puzzle
widget.
import 'package:flutter/material.dart';
import 'game_stage_bloc.dart';
class Puzzle extends StatefulWidget {
final String guessWord;
final GameStageBloc gameStageBloc;
const Puzzle(
{Key key, @required this.guessWord, @required this.gameStageBloc})
: super(key: key);
@override
State<StatefulWidget> createState() => _PuzzleState();
}
class _PuzzleState extends State<Puzzle> {
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: widget.gameStageBloc.guessedCharacters,
builder: (BuildContext ctxt,
AsyncSnapshot<List<String>> guessedLettersSnap) {
if (!guessedLettersSnap.hasData) return CircularProgressIndicator();
return Container(
child: Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: List.generate(widget.guessWord.length, (i) {
var letter = widget.guessWord[i];
var letterGuessedCorrectly = guessedLettersSnap.data.contains(letter);
return _buildSingleCharacterBox(letter, letterGuessedCorrectly);
})));
});
}
Widget _buildSingleCharacterBox(String letter, bool letterGuessedCorrectly) {
return Container(
height: 48.0,
width: 48.0,
decoration: BoxDecoration(
color: letterGuessedCorrectly ? Colors.limeAccent : Colors.white,
borderRadius: BorderRadius.circular(4.0)),
child: letterGuessedCorrectly
? Center(
child: Text(
letter,
style: _guessedCharacterStyle,
textAlign: TextAlign.center,
),
)
: null,
);
}
TextStyle _guessedCharacterStyle =
TextStyle(fontSize: 24, fontWeight: FontWeight.bold);
}
Update CharacterPicker Widget
Similarly, update CharacterPicker
widget.
import 'package:flutter/material.dart';
import 'package:hangman/game_stage_bloc.dart';
class CharacterPicker extends StatefulWidget {
final GameStageBloc gameStageBloc;
const CharacterPicker({Key key, @required this.gameStageBloc})
: super(key: key);
@override
State<StatefulWidget> createState() => _CharacterPickerState();
}
class _CharacterPickerState extends State<CharacterPicker> {
var _alphabets = 'A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z';
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
var alphabetArr = _alphabets.split(',');
return StreamBuilder(
stream: widget.gameStageBloc.guessedCharacters,
builder: (BuildContext ctxt, AsyncSnapshot<List<String>> guessedLettersSnap) {
if(!guessedLettersSnap.hasData) return CircularProgressIndicator();
return Container(
child: Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: List.generate(alphabetArr.length, (i) {
var letter = alphabetArr[i];
return _buildSingleCharacter(guessedLettersSnap.data, letter);
})));
});
}
Widget _buildSingleCharacter(List<String> guessedLetters, String letter) {
return GestureDetector(
onTap: () {
if(!guessedLetters.contains(letter)) {
guessedLetters.add(letter);
widget.gameStageBloc.updateGuessedCharacter(guessedLetters);
if(widget.gameStageBloc.curGuessWord.value.indexOf(letter) < 0) {
widget.gameStageBloc.updateLostBodyParts();
}
}
},
child: Container(
width: 32.0,
height: 32.0,
decoration: BoxDecoration(
color: guessedLetters.contains(letter)
? Colors.grey
: Colors.white,
borderRadius: BorderRadius.circular(4.0)),
child: Center(child: Text(letter)),
),
);
}
}
Update GameStage Widget
And finally, we update the GameStage
widget.
import 'package:flutter/material.dart';
import 'package:hangman/enum_collection.dart';
import 'package:hangman/game_stage_bloc.dart';
import 'package:hangman/hangman_painter.dart';
import 'package:hangman/puzzle.dart';
import 'character_picker.dart';
class GameStage extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return _GameStage();
}
}
class _GameStage extends State<GameStage> {
GameStageBloc _gameStageBloc;
@override
void initState() {
super.initState();
_gameStageBloc = GameStageBloc();
}
@override
void dispose() {
_gameStageBloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
var mediaQd = MediaQuery.of(context).size;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: [Colors.blue, Colors.red])),
padding: EdgeInsets.all(24.0),
width: mediaQd.width,
height: mediaQd.height,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Container(
width: 270,
height: mediaQd.height,
padding: EdgeInsets.symmetric(horizontal: 12.0, vertical: 12.0),
child: CustomPaint(
painter: HangManPainter(_gameStageBloc),
size: Size(
(270 - 24.0),
(mediaQd.height - 24.0),
),
),
),
Expanded(
child: Container(
child: ValueListenableBuilder(
valueListenable: _gameStageBloc.curGuessWord,
builder: (BuildContext ctxt, String guessWord, Widget child) {
if (guessWord == null || guessWord == '') {
return Center(
child: RaisedButton(
child: Text('Start New Game'),
onPressed: () {
_gameStageBloc.createNewGame();
},
));
}
return ValueListenableBuilder(
valueListenable: _gameStageBloc.curGameState,
builder: (BuildContext ctxt, GameState gameState,
Widget child) {
if (gameState == GameState.succeded) {
return Column(
mainAxisAlignment:
MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text('Well done! You got the right answer.',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 24.0)),
RaisedButton(
child: Text('Start New Game'),
onPressed: () {
_gameStageBloc.createNewGame();
},
)
]);
}
if (gameState == GameState.failed) {
return Column(
mainAxisAlignment:
MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text('Oops you failed!',
style: TextStyle(
// color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 24.0)),
RichText(
text: TextSpan(children: [
TextSpan(
text: 'The correct word was: ',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.black,
fontSize: 16.0)),
TextSpan(
text:
_gameStageBloc.curGuessWord.value,
style: TextStyle(
// color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 24.0))
]),
),
RaisedButton(
child: Text('Start New Game'),
onPressed: () {
_gameStageBloc.createNewGame();
},
)
]);
}
return Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
'Guess the correct district...',
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 16.0),
),
IconButton(
icon: Icon(
Icons.restore,
color: Colors.white,
size: 24.0,
),
onPressed: () {
_gameStageBloc.createNewGame();
},
),
],
),
CharacterPicker(
gameStageBloc: _gameStageBloc,
),
Puzzle(
guessWord: guessWord,
gameStageBloc: _gameStageBloc,
)
],
);
});
},
)),
)
],
)),
);
}
}
Conclusion
Pheww! Now that was quite a lot coding. But hey, it’s finally done.
We now have a working functional version of our own HangMan game that we built with Flutter.
If you followed the series along, you should have achieved a working HangMan game that you can take anywhere with you!

You must be logged in to post a comment.