Next up, we will implement a Hangman game using ZIO, as an illustrative example of how we can develop purely functional applications in Scala with the help of this library.
For reference, you can see a Github repository with the complete code at this link. By the way, the implementation of the game is based on this repository created by John De Goes, author of the ZIO library, where you can find several interesting exercises.
A Hangman game consists of taking a word at random and giving the player the possibility of guessing letters until the word is completed. The rules are as follows:
So, our implementation of the Hangman game should work as follows:
We will define our application as a sbt project, the build.sbt file will contain the dependencies of our project:
val scalaVer = "2.13.4"
val zioVersion = "1.0.4"
lazy val compileDependencies = Seq(
"dev.zio" %% "zio" % zioVersion
) map (_ % Compile)
lazy val settings = Seq(
name := "zio-hangman",
version := "1.0.0",
scalaVersion := scalaVer,
libraryDependencies ++= compileDependencies
)
lazy val root = (project in file("."))
.settings(settings)
As you can see, we will work with Scala 2.13.4 and with ZIO 1.0.4.
Firstly, we must define our domain model. For this we will define some classes within the file com/example/package.scala.
As a first step, we can define a Name class that represents the player’s name:
final case class Name(name: String)
As you can see, we define Name as a case class instead of simply defining it as a normal class. Using case classes gives us a lot of benefits, such as:
Also, we define the class as final so that it cannot be extended by other classes. On the other hand, we see the Name class simply encapsulates a String that represents the user’s name. We could leave this model as it is, however there’s a problem. It allows us to create Name objects with an empty String, which is unwanted. So, to prevent this, we can apply a functional design technique called smart constructor, which simply consists of defining a method in the companion object of the Name class that allows us to do the respective validations before creating an instance. So, we would have something like this:
final case class Name(name: String)
object Name {
def make(name: String): Option[Name] =
if (!name.isEmpty) Some(Name(name)) else None
}
As you can see, the make method corresponds to our smart constructor, and it checks if the received String is empty or not. If it is not empty, it returns a new Name, but is encapsulated within a Some, and if it is empty it returns a None. The important thing to highlight here is that the make method is a pure function, because it is total, deterministic, the output only depends on the String input and it does not produce side effects.
Now, there are some things we can improve in our implementation. For example, it is still possible to directly call the Name constructor with empty Strings. To avoid this, we can make it private:
final case class private Name(name: String)
object Name {
def make(name: String): Option[Name] =
if (!name.isEmpty) Some(Name(name)) else None
}
Thus, the only way to construct Name objects is by calling the Name.make method, at least that can be expected., However we are still able to construct Name objects with empty Strings by using the Name.apply method. Besides that, there is also another problem that we must solve, which is that thanks to the copy method that is automatically generated for case classes, after having created a valid Name object using Name.make, we could then generate an invalid object generating a copy with an empty String, and nothing would stop us from doing that. To avoid these issues, we can define the class as a sealed abstract instead of final, which means apply and copy won’t be generated:
sealed abstract case class Name private (name: String)
object Name {
def make(name: String): Option[Name] =
if (!name.isEmpty) Some(new Name(name) {}) else None
}
In this way, we are completely sure that any Name object in our application will always be valid.
Similarly, we can define a case class called Guess, which represents any letter guessed by the player:
sealed abstract case class Guess private (char: Char)
object Guess {
def make(str: String): Option[Guess] =
Some(str.toList).collect {
case c :: Nil if c.isLetter => new Guess(c.toLower) {}
}}
As you can see, we have used the same technique of defining a smart constructor, which receives a String entered by the player, and checks if it only consists of one letter. If that is not the case (for example when the String entered by the player consists of several characters or when entering a number or a symbol) None is returned.
Then we can define another case class called Word, which represents the word to be guessed by the player:
sealed abstract case class Word private (word: String) {
def contains(char: Char) = word.contains(char)
val length: Int = word.length
def toList: List[Char] = word.toList
def toSet: Set[Char] = word.toSet
}
object Word {
def make(word: String): Option[Word] =
if (!word.isEmpty && word.forall(_.isLetter)) Some(new Word(word.toLowerCase) {})
else None
}
Again we have a smart constructor that verifies that a word is not empty and contains only letters, not numbers or symbols. Additionally this class contains some useful methods (they are all pure functions, by the way):
Next, we define another case class called State, which represents the internal state of the application, which includes:
sealed abstract case class State private (name: Name, guesses: Set[Guess], word: Word) {
def failuresCount: Int = (guesses.map(_.char) -- word.toSet).size
def playerLost: Boolean = failuresCount > 5
def playerWon: Boolean = (word.toSet -- guesses.map(_.char)).isEmpty
def addGuess(guess: Guess): State = new State(name, guesses + guess, word) {}
}
object State {
def initial(name: Name, word: Word): State = new State(name, Set.empty, word) {}
}
Similarly, we have a smart constructor State.initial, which allows us to instantiate the initial state of the game with the name of the player and the word that has to be guessed, obviously with an empty Set of guessed letters. On the other hand, State includes some useful methods:
Finally, we define a GuessResult model that represents the result obtained by the player after guessing a letter. This result can be one of the following:
We can represent this with an enumeration, as follows:
sealed trait GuessResult
object GuessResult {
case object Won extends GuessResult
case object Lost extends GuessResult
case object Correct extends GuessResult
case object Incorrect extends GuessResult
case object Unchanged extends GuessResult
}
In this case an enumeration makes sense since GuessResult can only have one possible value from those mentioned in the list of options. Some important details here:
Now that we have defined the domain model of our application, we can begin with the implementation in the file com/example/Hangman.scala. Inside this file we will create an object that implements the zio.App trait, like this:
import zio._
object Hangman extends App {
def run(args: List[String]): ZIO[ZEnv, Nothing, ExitCode] = ???
}
With just this little piece of code we can learn several things:
Now that we have the basic skeleton of our application, firstly we need to write a functionality to obtain the name of the player by the console, for this we will write an auxiliary function, inside the Hangman object, which allows us to print any message and then requests a text from the player:
import zio.console._
def getUserInput(message: String): ZIO[Console, IOException, String] = {
putStrLn(message)
getStrLn
}
Let’s see this function in more detail:
And that’s it! Well… there is actually a small problem, and that is that if we called this function from the run method, a message would never be displayed in the console and it would go directly to request a text from the user. What is missing then, if we call putStrLn first and then getStrLn? The problem is that both putStrLn and getStrLn return simple descriptions of interactions with the outside world (called functional effects), and if we look closely: are we really doing something with the functional effect returned by putStrLn? The answer is no, we are simply discarding it, and the only effect that is returned by our getUserInput function is the effect returned by getStrLn. It’s more or less as if we had a function like this:
def foo (): Int = {
4
3
}
Are we doing something with the 4 in that function? Nothing! It’s just as if it’s not there, and the same thing happens in our getUserInput function, it’s as if the putStrLn call wasn’t there.
Therefore, we have to modify the getUserInput function so that the effect returned by putStrLn is actually used:
def getUserInput(message: String): ZIO[Console, IOException, String] =
putStrLn(message).flatMap(_ => getStrLn)
What this new version does is to return an effect that sequentially combines the effects returned by putStrLn and getStrLn, using the ZIO#flatMap operator, which receives a function where:
In this way, we are no longer discarding out the effect produced by putStrLn.
Now, because the ZIO data type also offers a ZIO#map method, we can write getUserInput using a for comprehension, which helps us to visualize the code in a way that looks more like a typical imperative code:
def getUserInput(message: String): ZIO[Console, IOException, String] =
for {
_ <- putStrLn(message)
input <- getStrLn
} yield input
This implementation works perfectly, however we can write it differently:
def getUserInput(message: String): ZIO[Console, IOException, String] =
(putStrLn(message) <*> getStrLn).map(_._2)
In this case, we are using another operator to combine two effects sequentially, and it is the <*> operator (which by the way is equivalent to the ZIO#zip method). This operator, like ZIO#flatMap, combines the results of two effects, with the difference that the second effect does not need the result of the first to be executed. The result of <*> is an effect whose return value is a tuple, but in this case we only need the value of getStrLn (the result of putStrLn does not interest us because it is simply a Unit), that is why we execute ZIO#map, which is a method that allows us to transform the result of an effect, in this case we are obtaining the second component of the tuple returned by <*>.
A much more simplified version equivalent to the previous one is the following:
def getUserInput(message: String): ZIO[Console, IOException, String] =
putStrLn(message) *> getStrLn
The *> operator (equivalent to the ZIO#zipRight method) does exactly what we did in the previous version, but in a much more condensed way.
And well, now that we have a function to get a text from the player, we can implement a functional effect to specifically get their name:
lazy val getName: ZIO[Console, IOException, Name] =
for {
input <- getUserInput("What's your name?")
name <- Name.make(input) match {
case Some(name) => ZIO.succeed(name)
case None => putStrLn("Invalid input. Please try again...") *> getName
}
} yield name
As you can see:
And that’s it! However, we can write an equivalent version:
lazy val getName: ZIO[Console, IOException, Name] =
for {
input <- getUserInput("What's your name?")
name <- ZIO.fromOption(Name.make(input)) <> (putStrLn("Invalid input. Please try again...") *> getName)
} yield name
In this version, the result of calling Name.make, which is of type Option, is converted into a ZIO effect, using the ZIO.fromOption method, which returns an effect that ends successfully if the given Option is a Some, and an effect that fails if the given Option is None. Then we are using a new <> operator (which is equivalent to the ZIO#orElse method), which also allows us to combine two effects sequentially, but in a somewhat different way than <*>. The logic of <> is as follows:
As you can see, this new version of getName is somewhat more concise, and so we will stick with it.
Let’s now see how to implement the functionality to randomly choose which word the player has to guess. For this we have a words dictionary, which is simply a list of words located in the file com/example/package.scala.
The implementation is as follows:
import zio.random._
lazy val chooseWord: URIO[Random, Word] =
for {
index <- nextIntBounded(words.length)
word <- ZIO.fromOption(words.lift(index).flatMap(Word.make)).orDieWith(_ => new Error("Boom!"))
} yield word
As you can see, we are using the nextIntBounded function of the zio.random module. This function returns an effect that generates random integers between 0 and a given limit, in this case the limit is the length of the dictionary. Once we have the random integer, we obtain the corresponding word from the dictionary, with the expression words.lift(index).flatMap(Word.make), which returns an Option[Word], which is converted to a ZIO effect using the ZIO.fromOption method, this effect:
If we think about it carefully, can there be any case where getting a word from the dictionary fails? Not really, because the chooseWord effect will never try to get a word whose index is outside the range of the dictionary length, and on the other hand all the words in the dictionary are predefined and not empty. Therefore, we can rule out the wrong case without any problem, using the ZIO#orDieWith method, which returns a new effect that cannot fail and, if it does fail, that would mean that there is some serious defect in our application (for example that some of our predefined words are empty when they should not be) and therefore it should fail immediately with the provided exception.
At the end, chooseWord is an effect with type URIO[Random, Word], which means that:
The next thing we need to do is to implement the functionality to show the game state by console:
def renderState(state: State): URIO[Console, Unit] = {
/*
--------
| |
| 0
| \|/
| |
| / \
-
f n c t o
- - - - - - -
Guesses: a, z, y, x
*/
val hangman = ZIO(hangmanStages(state.failuresCount)).orDieWith(_ => new Error("Boom!"))
val word =
state.word.toList
.map(c => if (state.guesses.map(_.char).contains(c)) s" $c " else " ")
.mkString
val line = List.fill(state.word.length)(" - ").mkString
val guesses = s" Guesses: ${state.guesses.map(_.char).mkString(", ")}"
hangman.flatMap { hangman =>
putStrLn(
s"""
#$hangman
#
#$word
#$line
#
#$guesses
#
#""".stripMargin('#')
)
}
}
We will not go into too much detail about how this function is implemented, rather we will focus on a single line, which is perhaps the most interesting one:
val hangman = ZIO(hangmanStages(state.failuresCount)).orDie
We can see that what this line does, is to first of all, select which figure should be shown to represent the hangman, which is different according to the number of failures the player has had (the file com/example/package.scala contains a hangmanStates variable that consists of a list with the six possible figures). For example:
Now, the expression hangmanStages(state.failuresCount) is not purely functional because it could throw an exception if state.failuresCount is greater than 6. So, since we are working with functional programming, we cannot allow our code to produce side effects, such as throwing exceptions, that is why we have encapsulated the previous expression inside ZIO, which is actually a call to the method ZIO.apply, which allows us to build a functional effect from an expression that produces side effects (we can also use the equivalent method ZIO.effect). By the way, the result of calling ZIO.apply is an effect that can fail with a Throwable, but according to the design of our application we hope that there will never actually be a failure when trying to get the hanged man drawing (since state.failuresCount should never be greater than 6). Therefore we can rule out the wrong case by calling the ZIO#orDie method, which returns an effect that never fails (we already know that if there were any failure, it would actually be a defect and our application should fail immediately). By the way, ZIO#orDie is very similar to ZIO#orDieWith, but it can only be used with effects that fail with a Throwable.
The functionality to obtain a letter from the player is very similar to how we obtain the name, therefore we will not go into too much detail:
lazy val getGuess: ZIO[Console, IOException, Guess] =
for {
input <- getUserInput("What's your next guess?")
guess <- ZIO.fromOption(Guess.make(input)) <> (putStrLn("Invalid input. Please try again...") *> getGuess)
} yield guess
This functionality is very simple, all it does is to analyze the previous state and the state after a player’s attempt, to see if the player wins, loses, has correctly guessed a letter but has not yet won the game, has incorrectly guessed a letter but has not yet lost the game, or if he has retried a letter that was previously tried:
def analyzeNewGuess(oldState: State, newState: State, guess: Guess): GuessResult =
if (oldState.guesses.contains(guess)) GuessResult.Unchanged
else if (newState.playerWon) GuessResult.Won
else if (newState.playerLost) GuessResult.Lost
else if (oldState.word.contains(guess.char)) GuessResult.Correct
else GuessResult.Incorrect
The game loop implementation uses the functionalities that we defined earlier:
def gameLoop(oldState: State): ZIO[Console, IOException, Unit] =
for {
guess <- renderState(oldState) *> getGuess
newState = oldState.addGuess(guess)
guessResult = analyzeNewGuess(oldState, newState, guess)
_ <- guessResult match {
case GuessResult.Won =>
putStrLn(s"Congratulations ${newState.name.name}! You won!") *> renderState(newState)
case GuessResult.Lost =>
putStrLn(s"Sorry ${newState.name.name}! You Lost! Word was: ${newState.word.word}") *>
renderState(newState)
case GuessResult.Correct =>
putStrLn(s"Good guess, ${newState.name.name}!") *> gameLoop(newState)
case GuessResult.Incorrect =>
putStrLn(s"Bad guess, ${newState.name.name}!") *> gameLoop(newState)
case GuessResult.Unchanged =>
putStrLn(s"${newState.name.name}, You've already tried that letter!") *> gameLoop(newState)
}
} yield ()
As you can see, what the gameLoop function does is the following:
At the end, gameLoop returns a ZIO effect that depends on the Console module (this is obvious because it needs to read a text from and write to the console), it can fail with an IOException or end successfully with a Unit value.
Finally, we have all the pieces of our application, and now the only thing that remains for us to dois to put them all together and call them from the run method which, as we have already mentioned , is the entry point of any ZIO-based application:
def run(args: List[String]): ZIO[ZEnv, Nothing, ExitCode] =
(for {
name <- putStrLn("Welcome to ZIO Hangman!") *> getName
word <- chooseWord
_ <- gameLoop(State.initial(name, word))
} yield ()).exitCode
We can see the logic is very simple:
There are some important details to explain here, for example, the expression:
for {
name <- putStrLn("Welcome to ZIO Hangman!") *> getName
word <- chooseWord
_ <- gameLoop(State.initial(name, word))
} yield ()
Returns an effect of type ZIO[Console with Random, IOException, Unit], and it’s interesting to see how ZIO knows exactly, all the time, which modules a functional effect depends on. In this case the effect depends on the Console module and the Random module, which is obvious because our application requires printing messages to and reading from the console and generating random words. However, the run method requires returning an effect of type ZIO[ZEnv, Nothing, ExitCode]. That is why we need to call the ZIO#exitCode method, which returns an effect of the type ZIO[Console With Random, Nothing, ExitCode]as follows:
Now, if we look closely, we are returning an effect of the type ZIO[Console with Random, Nothing, ExitCode], but run requires returning ZIO[ZEnv, Nothing , ExitCode], so why is there no compilation error? For this we need to understand what ZEnv means:
type ZEnv = Clock with Console with System with Random with Blocking
We can see that ZEnv is just an alias that encompasses all of the standard modules provided by ZIO. So, run basically expects an effect to be returned that requires only the modules provided by ZIO, but not necessarily all, and since the effect we are trying to return requires Console with Random, there is no problem. You may be now wondering : is there a case where we have effects that require modules that are not provided by ZIO? And the answer is yes, because we can define our own modules. And now you may be wondering about what to do in these cases, you won’t find the answer in this article, but if you want to know more about this, we have an ebook that explains how to develop modular applications with ZIO, using an implementation of a Tic-Tac-Toe game as an example.
And that’s it! We have finished the implementation of a Hangman game, using a purely functional programming style, with the help of the ZIO library.