Writing a simple CLI game in Scala 3
After many years of waiting, Scala 3 was finally released in May 2021. Moreover, 2021 was also the year of release of the 3rd version of cats-effect, the very popular scala’s functional programming framework.
I do Scala programming for a living, but most of the codebase I work on daily is still using Scala 2.13 (or sometimes even 2.12). Most of the code I write is using a functional stack based on cats-effect, but again, I haven’t yet had a chance to migrate all the services to cats-effect 3.
Last weekend, I had a free evening, so I decided to write a simple console application for playing tic-tac-toe in Scala 3 and CE3.
My goals were:
- Creating a CLI program that would allow playing tic-tac-toe for 2 players with a console.
- The application should display the status of the game as an ASCII representation of the board.
- Players should be able to pass coordinates of the fields as a combination of a single letter and a number, like
A1
,B2
etc.
This is supposed to be an introductory tutorial for people new to Scala 3 and cats-effect, so I will try to explain everything I’m doing. On the other hand, this is my first “serious” Scala 3 application, so there might be some areas for improvement in my code. If you have any clues or tips for me, please feel free to leave a comment.
As an exercise, I tried to use as many Scala 3 enhancements and new features as I was able to figure out. I also try to follow functional-programming paradigms. I use helper methods and data types from the newest versions of cats
(2.7.0 at the time this article was written) and cats-effect
(version 3.3.1).
Starting up
I will use sbt
as my build tool. After setting up a new project with sbt new scala/scala3.g8
, I'd just need to add a few dependencies. To keep my game as simple as possible, I will restrict them to the absolute minimum and add just cats
and cats-effect
. My testing framework of choice is munit.
libraryDependencies += "org.typelevel" %% "cats-effect" % "3.3.1"
libraryDependencies += "org.typelevel" %% "cats-core" % "2.7.0"
libraryDependencies += "org.typelevel" %% "munit-cats-effect-3" % "1.0.7" % Test
I decided to try out the new braceless syntax of Scala 3, which I'm not yet very familiar with.
So as the next step, I will add support for scalafmt
to help me with formatting. In order to do that, I just need to add a plugin in project/plugins.sbt
:
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3")
Then I will need to create a simple config file .scalafmt.conf
:
version = 3.3.0
runner.dialect = scala3
All the code snippets I show in the post are also available on GitHub, where you can find the whole code of the game.
Modelling basic domain
After the initial setup, I can start by modelling some domain objects. I’d need some way to represent players of our game. There will always be two of them, one playing with X
and another with O
symbols.
In Scala 2, I’d choose to model such enumeration with ADT.
I would create a sealed trait
and then create two instances of case object
.
sealed trait Player
case object X extends Player
case object O extends Player
Additionally, I would probably use an auxiliary library like enumeratum, that can automatically create a values
list of all cases. This is the most common way of simulating missing enum features in Scala 2.
Scala 3 filled the gap and introduced a native way for encoding enumerations with the keyword enum
:
enum Player:
case X
case O
Enums in Scala can have methods. I will add one for switching between players. It will just check if the player is X
and then return O
or otherwise:
def next: Player = this match
case X => O
case O => X
It’s also possible to create companion objects for enums just like for regular classes. Since I’m using cats
, it would be handy to provide instances of Eq and Show typeclasses for Player
.
In Scala 2, instances of type classes could be automatically derived using libraries like derevo, kittens or magnolia. Unfortunately, at the time I'm writing this article, these were not updated for Scala 3. Scala 3 has built-in support for creating derivation mechanisms for typeclasses. I could utilize it to create generic derivations of Eq
and Show
for all my classes and enums.
Both approaches seem to be overkill for such a simple application. Since I want to keep my game as simple as possible, I will just create instances for Eq
and Show
by hand using helper methods provided by cats
.
object Player:
given Show[Player] = Show.fromToString
given Eq[Player] = Eq.fromUniversalEquals
As you might have noticed, implicit
from Scala 2 is replaced with the new keyword given
. Since we initialize given instances in a companion object of Player, they will always be in scope for the Player class (it worked similarly for implicits in Scala 2).
Great! Let’s create another enum
that will represent the status of a particular field on the game board.
enum FieldStatus:
case Empty
case Taken(player: Player)
As you can see, unlike Java’s enums, Scala’s can have parameterized constructors. In this regard, it can be as flexible as hierarchical ADT in Scala 2.
Adding global variable
For the sake of simplicity, I want to hardcode the size of the board of my game to 3x3 fields. I will store the size in a constant called BoardSize
. In Scala 2, I would either need to put that constant in a regular object (maybe called Globals
) or I could use a package object. In Scala 3, it’s no longer necessary, I can make BoardSize
a top-level definition in any file. All objects in the same package will have visibility of BoardSize
without the need to import it.
val BoardSize = 3
Parsing user input
As a next step, I will create a case class called Coordinate
. It will describe pair numbers representing the position of the field using the x and y-axis.
final case class Coordinate(x: Int, y: Int)
The users will be passing data as text like A1
or C3
, so I would need some way to parse it.
Scala has a lot of amazing libraries for parsing text like fastparse or cats-parse, but I want to have as few dependencies as possible, so I will write my own parsing method.
A great place to add a utility function for parsing such strings would be the companion object of Coordinate
.
object Coordinate:
private val Letters = LazyList.from('A').take(BoardSize).toVector
def parse(s: String): Option[Coordinate] =
def fromLetter(c: Char): Option[Int] = Letters.indexOf(c.toInt) match
case -1 => None
case x => Some(x)
def fromDigit(s: Char): Option[Int] = Option
.when(s.isDigit)(s.asDigit - 1)
.filter(d => d >= 0 && d < BoardSize)
s.toCharArray match
case Array(letter, digit) => (fromLetter(letter), fromDigit(digit)).mapN(Coordinate.apply)
case _ => None
The method parse
converts the input string into an array of chars, then matches only arrays with two characters. The first character is parsed using the function fromLetter
and the second one is parsed by fromDigit
. Both of these functions return Option
to indicate if they were able to successfully convert the raw input character.
Only if both of them succeed, can I create an instance of Coordinate case class. I use mapN from cats
to convert tuple (Option[Int], Option[Int])
into Option[Coordinate]
:
(fromLetter(letter), fromDigit(digit)).mapN(Coordinate.apply)
For formatting Coordinate
into a string, I will create an instance of the Show
typeclass and put it into the companion of Coordinate
:
given Show[Coordinate] = Show.show(c =>
Letters.get(c.x) match {
case Some(letter) if c.y >= 0 && c.y < BoardSize =>
s"${letter.toChar}${c.y + 1}"
case _ => "--OUT OF BOUNDS--"
}
)
To find out if my implementation is working, I will create simple tests. There are many capable testing frameworks I could use, but I chose munit since it has great cats-effect integration. I also considered weaver, or cats-effect-testing with scalatest or specs2.
The implementation of the test for checking out parsing of the input string will go over a few cases:
test("should parse correct coordinates") {
//when
val cases = List(
("A1", Some(Coordinate(0, 0))),
("A3", Some(Coordinate(0, 2))),
("B1", Some(Coordinate(1, 0))),
("B2", Some(Coordinate(1, 1))),
("B3", Some(Coordinate(1, 2))),
("C1", Some(Coordinate(2, 0))),
("C3", Some(Coordinate(2, 2)))
)
//then
cases.foreach {
case (rawString, expected) =>
assert(clue(Coordinate.parse(rawString)) == clue(expected))
}
}
I also created some other tests for invalid input and for formatting Coordinate
into a string. You can check them on GitHub.
Building the board
Another important building block of the game will be the Board
. It will represent a 2D 3x3 board. The most obvious way to implement such a structure is a nested array containing instances of FieldStatus
.
Instead of using a plain mutable Array
, I will use ArraySeq. It’s just an immutable wrapper over an array introduced in Scala 2.13. It offers a functional API for operations like modifying or accessing its elements.
Additionally, since ArraySeq
uses a plain array under-the-hood, it allows for efficient (with complexity O(1)) access to any random element.
To improve type safety and the ergonomy of my board interface, I could introduce an additional class Board
, that would be just a wrapper over ArraySeq[ArraySeq[FieldStatus]]
.
Instead, I will use another feature of Scala 3: opaque types. I can declare Board
as follows:
opaque type Board = ArraySeq[ArraySeq[FieldStatus]]
This way, the runtime type of the Board ArraySeq
will still be nested, but during compilation, it will be treated as a completely new type Board
. This allows for enhanced type safety with 0 runtime overhead!
Still, since outside the companion object of Board
I wouldn’t be able to use methods of ArraySeq
, my new opaque type will be pretty useless. I can enhance it with a full-fledged API by adding a new factory method for creating an empty board and adding extension methods that would allow accessing elements using Coordinate
instances and for doing updates.
object Board:
def create: Board = ArraySeq.fill(BoardSize)(ArraySeq.fill(BoardSize)(FieldStatus.Empty))
extension (f: Board)
def apply(c: Coordinate): Option[FieldStatus] = for
line <- f.get(c.y)
cell <- line.get(c.x)
yield cell
def update(c: Coordinate, fieldStatus: FieldStatus): Option[Board] =
import c.{x, y}
for
line <- f.get(y)
yield f.updated(y, line.updated(x, fieldStatus))
Extensions is another mechanism introduced in Scala 3. It's a replacement for Scala 2 implicit classes. It is used for enhancing types we don’t own (for example that were defined in the standard library). Since Board
is basically just ArraySeq
, the only way to provide our own methods for it is by extensions.
Similarly, as with other classes, I will provide an instance of Show
for Board
:
given Show[Board] with
def show(f: Board) =
val numbers = LazyList.from(1).take(f.size).map(String.format("%1$2d", _))
val letters = LazyList.from('A').take(f.size).map(_.toChar.toString)
(" " + letters.mkString(" ") + "\n") + f.zip(numbers).map {
case (line, number) =>
(number + " ") + line.map {
case FieldStatus.Empty => " "
case FieldStatus.Taken(Player.X) => "X"
case FieldStatus.Taken(Player.O) => "O"
}.mkString("|")
}.mkString("\n " + "- " * f.size + "\n")
The result of printing the board with that Show
instance should look like:
A B C
1 X| |
- - -
2 | |
- - -
3 |O|
Adding errors
In order to implement the engine of our game, we’d need to define some errors first.
We can create an enum that will extend the Throwable
thus creating a very clean exception hierarchy:
import cats.syntax.all._
enum GameError(msg: String) extends Throwable(msg):
case FieldAlreadyTaken(c: Coordinate, p: Player) extends GameError(show"The field $c is already taken by $p.")
case WrongPlayer(c: Coordinate, p: Player) extends GameError(show"Wrong player: $p.")
case CoordinateOutOfBound(c: Coordinate) extends GameError(show"Coordinates are out of bounds: $c (${c.x}, ${c.y}).")
case GameIsOver(winner: Option[Player]) extends GameError(winner.fold("Game was drawn")(p => show"Game was already won by $p."))
For creating error messages, I used the show
interpolator from cats
. Its main advantage is that it uses the Show
instance to create a string representation of the object instead of calling toString
.
Assembling the game board
Now let’s create a model for a game. It will need to hold a state of the game: which player’s turn it is. It will also use Board
to store information on which fields are empty and which are already taken by one of the players.
Let’s start with creating an enum that would define the state of the game:
enum GameStatus:
case Ongoing(nextPlayer: Player)
case Won(winner: Player)
case Drawn
Now it’s time for a case class:
final case class Game(fields: Board, status: GameStatus, moves: Int)
I added an additional field moves
to easily track how many moves were already done by players.
First, let’s create a factory method for creating a new empty board:
def create: Game = Game(Board.create, GameStatus.Ongoing(Player.X), 0)
The method move
will describe the state transition of the Game
object after receiving two pieces of information: which player is making a move and the coordinate of the target field.
Depending on its internal state and arguments, it can either succeed and return an updated instance of Game or fail.
There are many possible failure scenarios: attempt to make a move by a wrong player, attempt to take a non-empty field or just doing a move on a game that is already finished.
I will use the Either datatype to represent the partial nature of the function. For failing cases, I will return the Left
subtype with an instance of specific GameError
. Otherwise, the function will return Right
with the updated Game
.
def move(c: Coordinate, p: Player): Either[GameError, Game] =
status match
case GameStatus.Won(winner) => GameError.GameIsOver(winner.some).asLeft
case GameStatus.Drawn => GameError.GameIsOver(none).asLeft
case GameStatus.Ongoing(nextPlayer) =>
fields(c) match
case Some(FieldStatus.Empty) =>
if nextPlayer === p then
fields
.update(c, FieldStatus.Taken(p))
.map(updated =>
process(copy(fields = updated, moves = moves + 1), p, c)
)
.toRight(GameError.CoordinateOutOfBound(c))
else GameError.WrongPlayer(c, p).asLeft
case Some(FieldStatus.Taken(_)) =>
GameError.FieldAlreadyTaken(c, p).asLeft
case None => GameError.CoordinateOutOfBound(c).asLeft
What is left to implement here is the process
function (not the best name, but I couldn’t figure out better for now). Its purpose would be to get the Game
instances updated with a new move and figure out a new state of the game. It would need to check if the game is drawn, won by any players or if it can just continue.
To check if a game was won by any of the players, I would need to test if any horizontal, vertical or diagonal row contains three marks of any particular player.
I can use the following algorithm: I will start with the coordinate of the last applied move and then go horizontally (go left and right), vertically (up and down) and on diagonals (from top left to the bottom right and from top right to bottom left) and count fields until I spot an empty field or a field taken by another player. It sounds easy so far.
My first step will be creating a type representing a translation of coordinate to its neighbouring coordinate. I decided to use a tuple of 2 integers (the first one represents a move on the x-axis and the other one on the y-axis). The range of Int
in JVM is -2^31 i 2^31 -1 and I just need -1 (to go left or up), 0 (to leave coordinate is the same place) or +1 (to go down or right).
Thankfully Scala 3 again offers a solution. I can represent Direction
as a tuple of two union types containing 3 singleton types (for -1, 0 and 1):
type Direction = (-1 | 0 | 1, -1 | 0 | 1)
Then I will add constants for all directions including diagonals. For example, T
will represent getting one square up the board.
object Direction:
// left
val L: Direction = (-1, 0)
// right
val R: Direction = (1, 0)
// top
val T: Direction = (0, -1)
// bottom
val B: Direction = (0, 1)
// left top
val LT: Direction = (-1, -1)
// right top
val RT: Direction = (1, -1)
// left bottom
val LB: Direction = (-1, 1)
// right bottom
val RB: Direction = (1, 1)
The method translate
in Coordinate
will take Direction
and apply translation returning a new instance of Coordinate. It returns Option
since it can fail in case a new coordinate will be outside of the board. For example, if I try to apply RB
on a coordinate that is on the right edge of the board, it should return None
.
def translate(d: Direction): Option[Coordinate] = d.bimap(_ + x, _ + y) match
case (nx, ny) =>
Option.when(nx >= 0 && nx < BoardSize && ny >= 0 && ny < BoardSize)(
Coordinate(nx, ny)
)
Now I can implement process
. It has a recursive helper function count
that counts fields in any direction until it encounters a field that is doesn’t belong to a player. The function is annotated with @tailrec
to make sure it’s tail-recursive and stack-safe. It doesn’t really matter in our case, because we won’t blow up the stack by checking boards of size 3x3, so I do it just as a good practice.
Next, I create a list of checks for all the required directions to get the number of consecutive fields taken by a player that just made a move. If any of the elements of the list is equal to the BoardSize, it means the player was able to mark the whole line of 3 and the game is over.
If this is not the case, but there are already 9 moves (BoardSize
squared) the game is drawn.
In other cases, the game can continue.
private def process(
board: Game,
player: Player,
coordinate: Coordinate
): Game =
// Goes in the single direction as long as it finds fields taken by the player.
// If bounds of the board or empty or other player's field is found, it returns the score.
@tailrec
def count(direction: Direction, c: Coordinate, score: Int): Int =
c.translate(direction) match
case Some(next) =>
board.fields(next) match
case Some(FieldStatus.Taken(`player`)) =>
count(direction, next, score + 1)
case _ => score
case _ => score
val victory = List(
// horizontal
count(Direction.L, coordinate, 0) + count(Direction.R, coordinate, 0),
// from top left to bottom right
count(Direction.LT, coordinate, 0) + count(Direction.RB, coordinate, 0),
// from bottom left to top right
count(Direction.LB, coordinate, 0) + count(Direction.RT, coordinate, 0),
// vertical
count(Direction.B, coordinate, 0) + count(Direction.T, coordinate, 0)
).exists(_ + 1 >= BoardSize)
if victory then board.copy(status = GameStatus.Won(player))
else if board.moves === (BoardSize * BoardSize) then board.copy(status = GameStatus.Drawn)
else board.copy(status = GameStatus.Ongoing(player.next))
The function process
could be optimized in many ways (for example by doing checks in parallel), but I guess it’s good enough for my POC.
Now I will write a test to check my implementation. I will add an extension helper function moveMultiple
that will be visible only for tests and will allow me to easily apply sequence moves to a Game
instance.
extension (b: Game)
def moveMultiple(moves: (String, Player)*): Either[GameError, Game] =
moves.foldLeft(b.asRight) {
case (board, (move, player)) =>
val coordinate = Coordinate.parse(move).getOrElse(fail(s"Wrong coordinate: $move !"))
board.flatMap(_.move(coordinate, player))
}
It just takes a vararg of tuples containing the move and the player and then applies them sequentially on the game instance.
Example test for checking if the game is correctly drawn could look like that:
test("should allow playing whole game and drawing") {
//given
val board = Game.create
//when
val Right(result) = board.moveMultiple(
("A1", Player.X),
("A2", Player.O),
("A3", Player.X),
("B1", Player.O),
("B3", Player.X),
("B2", Player.O),
("C2", Player.X),
("C3", Player.O),
("C1", Player.X)
)
//then
assert(clue(result.status) == GameStatus.Drawn)
}
You can check other tests for Game
on GitHub.
At this point, I’ve got a working “engine” of my application and I can programmatically play the whole game of tic-tac-toe.
Creating a runtime
Until this point, my app didn’t interact with the outside world. At this moment, I would like to add some side effects: for reading the user's input from the console and for printing out messages and state of the board.
First, I will start with creating a trait that will define actions for both reading and writing:
import cats.effect.IO
trait Console:
def readLine: IO[String]
def printLine(s: String): IO[Unit]
Both actions are performing side-effects by interacting with the system console.
The functional approach for handling side-effecting code is using an effect-system like IO monad from cats-effect. The IO monad suspends execution of side-effecting action. Such suspended actions are pure values that can be composed together creating a bigger and bigger chained IO. The composed program then can run and execute all suspended side-effects.
The class GameRuntime
will be responsible for handling all game flow. It will be using actions from the console to compose a bigger functional program. Thus all methods in GameRuntime
will return IO
as well. The instance of Console
will be passed in the constructor to GameRuntime
.
final class GameRuntime(console: Console):
The first method that I will define is readLoop
. Its purpose is to ask the player to enter the coordinates of their move and read their input from the console. The entered string is then parsed by the method Coordinates.parse
, which returns Some
if it was able to decode coordinates and None
otherwise. If parsing fails, the function asks the user to enter correct coordinates and then calls itself recursively. This way, it effectively loops until the user provides valid input. If coordinates are parsed successfully, the function just returns them.
To compose IO
, we can use nested flatMap
calls or use for-comprehension, which is just syntax sugar for chained flatMaps.
private def readLoop(nextPlayer: Player): IO[Coordinate] = for
_ <- console.printLine(show"Player $nextPlayer please make a move: ")
line <- console.readLine
result <- Coordinate.parse(line) match
case Some(result) => result.pure[IO] // pure just wraps pure value into context of IO
case None =>
console.printLine("Please enter correct coordinate:") >> readLoop(nextPlayer)
yield result
You might notice that the recursive call of readLoop
is placed after the >>
operator. In essence, it is just a shortcut for flatMap
. Another very similar operator is *>
. You can read more about the differences between these two here.
Another interesting detail is that readLoop
is not tail-recursive. Wouldn’t it cause a stack overflow in case a user attempts to enter illegal input too many times? The answer is no because the IO monad is tramopolined and can safely be used in recursive functions without even being tail-recursive.
Let's now write the function that will bind everything together. I will call it loop
. It will be responsible for printing the state of the board, getting inputs from players, and displaying the winner if the game is over (or informing about the draw).
private def loop(game: Game): IO[Unit] = for
_ <- console.printLine(show"\n${game.fields}\n")
_ <- game.status match
case GameStatus.Drawn => console.printLine("\nDraw!\n")
case GameStatus.Won(player) =>
console.printLine(show"\nPlayer ${player} won the game!\n")
case GameStatus.Ongoing(nextPlayer) =>
for
_ <- console.printLine("")
move <- readLoop(nextPlayer)
_ <- game.move(move, nextPlayer) match
case Right(updated) => loop(updated)
case Left(error) =>
console.printLine(error.getMessage) >> loop(game)
yield ()
yield ()
Method loops
calls itself recursively until the game is over. Since loop
is composing Console
and other methods returning IO
, its result type will be also IO.
Now let’s write the last utility methods that will be the public entry point of GameRuntime
.
val run: IO[Unit] =
console.printLine("\n-- Starting a new game --\n") >> loop(Game.create)
It displays information about a new game starting and then creates a Game
instance and passes it to loop
.
Now I need to create tests for GameRuntime
. In order to be able to pass coordinates to the game and do any assertions on the printed output, I’d need to capture text lines written to or read by the console. Alternatively, since all IO
actions are encapsulated in trait Console
, I would just need to provide a mocked implementation of this trait.
The mock will have two internal lists, the first one for lines that are read for the console and the other one for those that are written. The application, by reading lines from the console, will be removing values from the first list. If it attempts to read a line when the list is empty, it will fail the test. On the other hand, it will just append a new line to the second list every time an application writes something to the console. It will make it possible to do assertions to check the output of the application.
import cats.effect.kernel.Ref
import cats.effect.IO
import munit.Assertions._
class TestConsole private (
inputs: Ref[IO, List[String]],
outputs: Ref[IO, List[String]]
) extends Console:
def printedLines: IO[List[String]] = outputs.get.map(_.reverse)
override def readLine: IO[String] =
inputs.modify {
case x :: xs => (xs, x)
case Nil => fail("No more input strings for test console!")
}
override def printLine(s: String): IO[Unit] = outputs.update(xs => s :: xs)
object TestConsole:
def create(inputs: String*): IO[TestConsole] = for
inputs <- Ref.of[IO, List[String]](inputs.toList)
outputs <- Ref.of[IO, List[String]](Nil)
yield TestConsole(inputs, outputs)
My mock console is using Ref to allow for effectful mutations of lists. If I used tagless final in my game’s code, I could also use the State monad to handle state changes (modifications of both lists).
Now I can create the first test. It will check the scenario when player X
wins:
test("should allow playing the whole game by player X") {
val VictoryX = List(
"A1",
"B1",
"A2",
"B2",
"A3"
)
for
console <- TestConsole.create(VictoryX*)
_ <- GameRuntime(console).run
lines <- console.printedLines
yield assert(clue(lines.lastOption).contains("\nPlayer X won the game!\n"))
}
Great! There are more tests for various scenarios. You can check them on GitHub.
As you can see, with the simple OOP trick of inversions of control, I was able to easily test the application performing external side effects. I just delegated all side-effecting actions to the interface (trait) and then provided a mocked implementation for tests.
Final steps
The last thing I need to do is create a runnable application. First, I will need to provide a real implementation of the Console
trait. It will simply delegate the read action to IO.readLine
and write action to IO.println
.
object LiveConsole extends Console:
override def readLine: IO[String] = IO.readLine
override def printLine(s: String): IO[Unit] = IO.println(s)
Then I will create a class Main
that will extend IOApp.Simple
from cats-effect
. I need to override the method run
that returns IO[Unit]
. It is an IO
that describes my whole application.
To start a new game, I must call a run
from GameRuntime
. I then will use the foreverM
combinator to loop my application forever. Whenever the game ends, it will start another one right away.
object Main extends IOApp.Simple:
override val run = GameRuntime(LiveConsole).run.foreverM
Now, let’s try it. I just need to type sbt run
and I should be able to play the game:
Everything seems to be working fine!
Wrapping up
I hope you will find this short tutorial helpful, especially if you’re considering adopting Scala 3 and CE3 in your codebase.
It was definitely a lot of fun for me to use all these new features. I look forward to migrating the services I maintain to the newest version of Scala.
Curious to know how developers feel about Scala 3? Download the full version of the Scala Tech Report here.