October 17, 2019

Functional Five in a Row using Scala

I am starting to learn and improve my functional programming skills. And I do not see the better way rather than practicing while coding small projects and reflecting on them. I implemented a small CLI version of “Five in a Row” game.

Rules

It is very simple game with the simple rules:

  1. There is a board of 19x19 cells.
  2. Players perform move after each other. A player puts stone to an empty cell.
  3. The goal is to collect 5 or more stones of the same color in a row - diagonal, horizontal or vertical.
  4. The first player who has collected five in a row is a winner.

Game loop and game state

I wrote all the basic logic of the game with pure functions without any side effects and then incapsulated all state changes in the one place - the game loop. And even there to avoid using mutable variables I applied a recursive approach to the game loop.

Basically, the game loop is very simple:

  1. Print the board.
  2. Check if the game is finished.
  3. Print an error from previous move.
  4. Read a command.
  5. Execute the command - perform a move or quit.
  6. Start again.

Here is the code:

@tailrec
def loop(game: Game, error: Option[ScalamokuError]): Unit = {
  printBoard(game.board)

  if (game.isFinished) {
    val r = game.winner.map(w => s"and the winner is $w.").getOrElse("and this is the draw!")
    println(s"The game is finished ${r}")
    return
  }

  if (error.isDefined) {
    Console.err.println("Error: " + error.get.message)
  }

  val command = StdIn.readLine(s"Expects the move of ${game.expects} (type 'quit' to exit):").trim
  if (command == "quit") {
    return
  }

  val parts = command.split(",")
  if (parts.length == 2) {
    val position = Position(parts(0).trim.toInt, parts(1).trim.toInt)
    game.move(position) match {
      case Right(g) => loop(g, None)
      case Left(e) => loop(game, Some(e))
    }
  } else {
    loop(game, error)
  }
}

To run start the “loop”, we need to call it with an initial game and without an error:

object Scalamoku {
  def main(args: Array[String]): Unit = {
    loop(Game.of(rows = 19, columns = 19, requiredInRow = 5), None)
  }
}

The game is generalized over number of rows, columns and required stones in a row.

Game logic

As I wrote earlier, the game logic is purely functional without any mutations and side effects. The game has only function in its API to perform the move:

case class Game(rows: Integer, columns: Integer, requiredInRow: Integer, board: Board, expects: Stone, isFinished: Boolean, winner: Option[Stone]) {
  def move(position: Position): Either[ScalamokuError, Game] =
    if (isFinished) {
      Left(GameFinishedError())
    } else {
      board.set(position, expects).map { updatedBoard =>
        val won = Position.directions.exists(hasInRow(position, expects, updatedBoard, _))
        val winner = if (won) Some(expects) else None
        val finished = updatedBoard.isFull || won

        Game(rows, columns, requiredInRow, updatedBoard, expects.opposite, finished, winner)
      }
    }

  @tailrec
  private def hasInRow(p: Position, s: Stone, b: Board, d: (Position) => Position, c: Integer = 1): Boolean = {
    val next = d(p)
    c == requiredInRow || (board.at(next).contains(s) && hasInRow(next, s, b, d, c + 1))
  }
}

To protect game invariants, first I check that the game is not finished and if not I update the board and then check if there is winner or the board is full.

I use Either monad to express result of successful or failed result of the state update. For me, the most complex part was to check efficiently that there are required number of the stones in a row. But using the right abstractions I have simplified and figured out simple solution:

  1. I wrote a function which returns all directions of the given position as functions.
  2. Then for the given position I traversed all directions and checked in if there exists at least one direction with required number of stones in a row.

Complete solution

And that’s all. It was fun to write this game using Scala and functional approach.

There is the complete solution:

import scala.annotation.tailrec
import scala.io.StdIn

object Scalamoku {
  def main(args: Array[String]): Unit = {
    loop(Game.of(rows = 5, columns = 5, requiredInRow = 3), None)
  }

  @tailrec
  def loop(game: Game, error: Option[ScalamokuError]): Unit = {
    printBoard(game.board)

    if (game.isFinished) {
      val r = game.winner.map(w => s"and the winner is $w.").getOrElse("and this is the draw!")
      println(s"The game is finished ${r}")
      return
    }

    if (error.isDefined) {
      Console.err.println("Error: " + error.get.message)
    }

    val command = StdIn.readLine(s"Expects the move of ${game.expects} (type 'quit' to exit):").trim
    if (command == "quit") {
      return
    }

    val parts = command.split(",")
    if (parts.length == 2) {
      val position = Position(parts(0).trim.toInt, parts(1).trim.toInt)
      game.move(position) match {
        case Right(g) => loop(g, None)
        case Left(e) => loop(game, Some(e))
      }
    } else {
      loop(game, error)
    }
  }

  def printBoard(board: Board): Unit = {
    for (row <- 0 until board.rows) {
      for (column <- 0 until board.columns) {
        board.at(Position(row, column)) match {
          case Some(White()) => print(" W ")
          case Some(Black()) => print(" B ")
          case None => print(" . ")
        }
      }
      println()
    }
  }
}

object Game {
  def of(rows: Integer, columns: Integer, requiredInRow: Integer): Game = Game(rows, columns, requiredInRow, Board(Map(), rows, columns), White(), isFinished = false, None)
}

case class Game(rows: Integer, columns: Integer, requiredInRow: Integer, board: Board, expects: Stone, isFinished: Boolean, winner: Option[Stone]) {
  def move(position: Position): Either[ScalamokuError, Game] =
    if (isFinished) {
      Left(GameFinishedError())
    } else {
      board.set(position, expects).map { updatedBoard =>
        val won = Position.directions.exists(hasInRow(position, expects, updatedBoard, _))
        val winner = if (won) Some(expects) else None
        val finished = updatedBoard.isFull || won

        Game(rows, columns, requiredInRow, updatedBoard, expects.opposite, finished, winner)
      }
    }

  @tailrec
  private def hasInRow(p: Position, s: Stone, b: Board, d: (Position) => Position, c: Integer = 1): Boolean = {
    val next = d(p)
    c == requiredInRow || (board.at(next).contains(s) && hasInRow(next, s, b, d, c + 1))
  }
}

case class Board(stones: Map[Position, Stone], rows: Integer, columns: Integer) {
  def at(position: Position): Option[Stone] =
    if (position.in(rows, columns)) {
      stones.get(position)
    } else {
      None
    }

  def isFull: Boolean = stones.size >= (rows * columns)

  def set(position: Position, stone: Stone): Either[ScalamokuError, Board] =
    if (isFull) {
      Left(FullBoardError())
    } else if (!position.in(rows, columns)) {
      Left(PositionOutOfRangeError(position, rows, columns))
    } else if (stones.contains(position)) {
      Left(PositionOccupiedError(position))
    } else {
      Right(Board(stones + (position -> stone), rows, columns))
    }
}

object Position {
  def directions: List[(Position) => Position] = List(
    _.up,
    _.upRight,
    _.right,
    _.rightDown,
    _.down,
    _.downLeft,
    _.left,
    _.upLeft
  )
}

case class Position(row: Integer, column: Integer) {
  def up: Position = Position(row - 1, column)

  def upRight: Position = Position(row - 1, column + 1)

  def right: Position = Position(row, column + 1)

  def rightDown: Position = Position(row - 1, column + 1)

  def down: Position = Position(row - 1, column)

  def downLeft: Position = Position(row - 1, column - 1)

  def left: Position = Position(row, column - 1)

  def upLeft: Position = Position(row + 1, column - 1)

  def in(rows: Integer, columns: Integer): Boolean = row >= 0 && row < rows && column >= 0 && column < columns
}

trait ScalamokuError {
  def message: String = ???
}

case class GameFinishedError() extends ScalamokuError {
  override def message: String = "the game is already finished"
}

case class FullBoardError() extends ScalamokuError {
  override def message: String = "the board is full"
}

case class PositionOccupiedError(position: Position) extends ScalamokuError {
  override def message: String = s"the position (${position.row}, ${position.column}) is already occupied"
}

case class PositionOutOfRangeError(position: Position, rows: Integer, columns: Integer) extends ScalamokuError {
  override def message: String = s"the position (${position.row}, ${position.column}) is out of range (rows = ${rows}, columns = ${columns})"
}

trait Stone {
  def opposite: Stone = ???
}

case class White() extends Stone {
  override def opposite: Stone = Black()

  override def toString: String = "White"
}

case class Black() extends Stone {
  override def opposite: Stone = White()

  override def toString: String = "Black"
}