Last updated on

Building a game

This is a transcript of “practical engineering” lecture on building webapps. In this lecture, we implement a game of “memory” (also called “Concentration”).

Specs

We start with user stories and a UI mock-up. In memory there is just one kind of users and very few actions, so the stories are simple:

We then write more detailed requirements (the following are just brief sketches):

Notice how these requirements describe the behavior of the app, but not its implementation: requirements should not constrain internal implementation details.

Initialization

We create a new project with sbt new:

$ sbt new https://gitlab.epfl.ch/cs214/ul2024/webapp-template.g8.git
Template applied in /home/cpc/lectures/webapp-memory/.

App model

Our app needs types for states, views, and type, a view type, and events. These types will be the foundation for everything else we do, so it’s worth taking the time to get them right — especially the view and event types, since they are shared by the clients and the server.

Let’s start with events.

Events

Events are actions that a client can take to change the state of the game. What events can be triggered during a game of memory?

In our version of memory, there is just one: clicking on a card! We create the corresponding types in apps/shared/src/main/scala/apps/memory/types.scala:

enum Event:
  /** User clicked on a card. */
  case CardClicked(cardId: Int)

apps/shared/src/main/scala/apps/memory/types.scala

State

What do we need to describe the state that our game is in?

We can model this state as a case class containing one field per piece of state, in the same types.scala file:

type Card = String
type CardIndex = Int
enum Phase:
  case FlippingCards, ViewingCards, Done
case class State(
    cards: Vector[Card],
    flipped: Set[CardIndex],
    players: Vector[UserId],
    matched: Map[UserId, Vector[CardIndex]],
    phase: Phase
):
  lazy val removed = matched.values.flatten.toSet
  lazy val isFinished = matched.values.map(_.length).sum == cards.length
  lazy val hasCorrectSelection = flipped.size > 1 && flipped.map(cards.apply).size == 1

apps/shared/src/main/scala/apps/memory/types.scala

Views

Next, we defined views, which capture the information that users have access to.

In some cases, we want users to have access to all of that information. In this case, the view’s is equivalent to the application’s state, i.e., any user can see the entirety of the state. This could happen in a to-do list app where all users can see all to-dos on a common dashboard.

In most cases, however, we want to redact some of the application’s state before sending to the user. For example, if we deploy a banking application where any user can see the amount held in all bank accounts, including the ones they do not own, we might end up receiving a fair amount of complaints from unsatisfied customers. Instead, what we most likely want is to show a user only the part of the application state that relates to the bank account they own. Additionally, we may want to further process the data to make it easier to render on screen.

In memory, we want to hide the value of face-down cards. We could remove just that information, but it’s more convenient to further customize the view type to make it easy to render:

/** A view of the game's state.
  *
  * @param stateView
  *   A projection of the current phase of the game.
  * @param alreadyMatched
  *   The cards that each player has already successfully matched.
  */
case class View(
    state: StateView,
    matched: Map[UserId, Vector[Card]]
)

enum StateView:
  /** The game is ongoing. */
  case Playing(phase: PhaseView, currentPlayer: UserId, board: Vector[CardView])

  /** The game is over (there may be multiple winners with the same score). */
  case Finished(winnerIds: Set[UserId])

enum PhaseView:
  /** It's our turn to pick two cards. */
  case FlippingCards

  /** It's another player's turn: we're waiting for them to flip cards. */
  case Waiting

  /** We're looking at a correct match =). */
  case GoodMatch

  /** We're looking at an incorrect match =(. */
  case BadMatch

enum CardView:
  case FaceDown
  case FaceUp(card: Card)
  case AlreadyMatched(card: Card)

apps/shared/src/main/scala/apps/memory/types.scala

In addition to a history of which matching cards each player has already flipped (matched), our view type includes a view of the rest of the app state, represented as two different case classes:

We send only the current player in the Playing state (rather than the list of all players), and we send a board containing CardViews, which indicate the status of each card.

Wire

We need a way to serialize and deserialize events and views. We omit the details here: refer to the webapp exercises for details!

override object eventFormat extends WireFormat[Event]:
  override def encode(event: Event): Value =
    event match
      case CardClicked(cardId) =>
        Obj("type" -> "CardClicked", "cardId" -> cardId)

  override def decode(js: Value): Try[Event] = Try:
    js("type").str match
      case "CardClicked" =>
        CardClicked(js("cardId").num.toInt)
      case _ =>
        throw DecodingException(f"Invalid memory event $js")

apps/shared/src/main/scala/apps/memory/Wire.scala

override object viewFormat extends WireFormat[View]:
  val matchedWire = MapWire(StringWire, VectorWire(StringWire))

  override def encode(v: View): Value =
    Obj("state" -> StateViewFormat.encode(v.state), "matched" -> matchedWire.encode(v.matched))

  override def decode(js: Value): Try[View] = Try:
    View(StateViewFormat.decode(js("state")).get, matchedWire.decode(js("matched")).get)

apps/shared/src/main/scala/apps/memory/Wire.scala

object StateViewFormat extends WireFormat[StateView]:
  import StateView.*
  val boardWire = VectorWire(CardViewFormat)
  val winnerIdsWire = SetWire(StringWire)

  override def encode(view: StateView): Value =
    view match
      case Playing(phase, currentPlayer, board) =>
        Obj(
          "type" -> "Playing",
          "phase" -> PhaseViewFormat.encode(phase),
          "currentPlayer" -> currentPlayer,
          "board" -> boardWire.encode(board)
        )
      case Finished(winnerIds) =>
        Obj("type" -> "Finished", "winnerIds" -> winnerIdsWire.encode(winnerIds))

  override def decode(js: Value): Try[StateView] = Try:
    js("type").str match
      case "Playing" => Playing(
          phase = PhaseViewFormat.decode(js("phase")).get,
          currentPlayer = js("currentPlayer").str,
          board = boardWire.decode(js("board")).get.to(Vector)
        )
      case "Finished" => Finished(winnerIdsWire.decode(js("winnerIds")).get)
      case _          => throw DecodingException(f"Unexpected board view: $js")

apps/shared/src/main/scala/apps/memory/Wire.scala

object PhaseViewFormat extends WireFormat[PhaseView]:
  import PhaseView.*

  override def encode(view: PhaseView): Value =
    Str(view.toString)

  override def decode(js: Value): Try[PhaseView] = Try:
    try PhaseView.valueOf(js.str)
    catch
      case e: IllegalArgumentException =>
        throw DecodingException(f"Unexpected phase view: $js")

apps/shared/src/main/scala/apps/memory/Wire.scala

object CardViewFormat extends WireFormat[CardView]:
  import CardView.*

  override def encode(view: CardView): Value = view match
    case FaceDown =>
      Obj("type" -> "FaceDown")
    case FaceUp(card: Card) =>
      Obj("type" -> "FaceUp", "card" -> card)
    case AlreadyMatched(card: Card) =>
      Obj("type" -> "AlreadyMatched", "card" -> card)

  override def decode(js: Value): Try[CardView] = Try:
    js("type").str match
      case "FaceDown"       => FaceDown
      case "FaceUp"         => FaceUp(js("card").str)
      case "AlreadyMatched" => AlreadyMatched(js("card").str)
      case _                => throw DecodingException(f"Unexpected card view: $js")

apps/shared/src/main/scala/apps/memory/Wire.scala

App logic

Once our model is defined, we have every tool we need to implement the logic of our application. We first create the corresponding file apps/jvm/src/main/scala/apps/memory/Logic.scala and add a class definition for a standard state machine:

class Logic extends StateMachine[Event, State, View]:

apps/jvm/src/main/scala/apps/memory/Logic.scala

We add to it an AppInfo instance:

override val appInfo: AppInfo = AppInfo(
  id = "memory",
  name = "Memory",
  description = "Test your visual memory in this classic card-matching game!",
  year = 2024
)

apps/jvm/src/main/scala/apps/memory/Logic.scala

… a wire:

override val wire = memory.Wire

apps/jvm/src/main/scala/apps/memory/Logic.scala

… and initialization function, which shuffles a deck of cards:

override def init(clients: Seq[UserId]): State =
  State(
    cards = Random.shuffle(Logic.CARDS ++ Logic.CARDS),
    flipped = Set(),
    players = clients.to(Vector),
    matched = clients.map(_ -> Vector.empty[CardIndex]).toMap,
    phase = Phase.FlippingCards
  )

apps/jvm/src/main/scala/apps/memory/Logic.scala

We can then move on to the implementation of the core function of the StateMachine class, the transition function:

override def transition(state: State)(userId: UserId, event: Event): Try[Seq[Action[State]]] = Try:
  import Action.*

  state.phase match
    case Phase.Done =>
      throw IllegalMoveException("Game is already over!")
    case Phase.ViewingCards =>
      throw AssertionError("Impossible!")
    case Phase.FlippingCards =>
      val State(cards, flipped, players, matched, phase) = state
      val CardClicked(cardId) = event.asInstanceOf[CardClicked]
      assert(flipped.size < 2)

      if state.players.head != userId then
        throw NotYourTurnException()

      if cardId < 0 || cardId > cards.length then
        throw IllegalMoveException("Invalid card id!")

      val withCardFlipped = state.copy(flipped = flipped + cardId)

      if withCardFlipped.flipped.size < 2 then
        Seq(Render(withCardFlipped))
      else // Two cards flipped: did we win?
        val withUpdatedTallies =
          if withCardFlipped.hasCorrectSelection then
            withCardFlipped.copy(
              matched =
                val updatedTally = matched(userId) ++ withCardFlipped.flipped
                matched + (userId -> updatedTally)
            )
          else
            withCardFlipped.copy( // Change player only if we lost
              players = players.tail :+ players.head
            )

        val finalState =
          withUpdatedTallies.copy(
            flipped = Set(),
            phase =
              if withUpdatedTallies.isFinished
              then Phase.Done
              else Phase.FlippingCards
          )

        val SHOW_CARDS_PAUSE_MS = 2500
        Seq(
          Render(withCardFlipped.copy(phase = Phase.ViewingCards)),
          Pause(SHOW_CARDS_PAUSE_MS),
          Render(finalState)
        )

apps/jvm/src/main/scala/apps/memory/Logic.scala

Finally, we implement the projection function, adapting our game’s state to a player’s view:

override def project(state: State)(userId: UserId): View =
  val State(cards, flipped, players, matched, phase) = state

  val stateView =
    if phase == Phase.Done then
      val bestScore = matched.values.map(_.length).max
      val bestPlayers = matched.collect:
        case (uid, indices) if indices.length == bestScore => uid
      Finished(bestPlayers.toSet)
    else
      val board = cards.zipWithIndex.map: (card, idx) =>
        if state.removed.contains(idx) then
          AlreadyMatched(card)
        else if flipped.contains(idx) then
          FaceUp(card)
        else
          FaceDown
      val phaseView = phase match
        case Phase.FlippingCards =>
          if userId == players.head then FlippingCards else Waiting
        case Phase.ViewingCards =>
          if state.hasCorrectSelection then GoodMatch else BadMatch
        case Phase.Done =>
          throw AssertionError("Unreachable.")
      Playing(phaseView, players.head, board)

  View(
    matched = matched.view.mapValues(indices => indices.map(cards.apply)).toMap,
    state = stateView
  )

apps/jvm/src/main/scala/apps/memory/Logic.scala

Our app logic is done!

App UI

Now on to the last part: implementing the user interface for our game. We create a new WSClientApp and the corresponding TextClientAppInstance (since we’re building a text UI) in apps/js/src/main/scala/apps/memory/TextUI.scala.

@JSExportTopLevel("memory_text")
object TextUI extends WSClientApp:
  def appId: String = "memory"
  def uiId: String = "text"

  def init(userId: UserId, sendMessage: ujson.Value => Unit, target: dom.Element): ClientAppInstance =
    TextUIInstance(userId, sendMessage, target)

apps/js/src/main/scala/apps/memory/TextUI.scala

class TextUIInstance(userId: UserId, sendMessage: ujson.Value => Unit, target: dom.Element)
    extends TextClientAppInstance[Event, View](userId, sendMessage, target):

apps/js/src/main/scala/apps/memory/TextUI.scala

override val wire = memory.Wire

apps/js/src/main/scala/apps/memory/TextUI.scala

We use click events to flip cards (displayed as emojis), so we don’t need to handle text input events. Thus, we return None in the corresponding handler:

override def handleTextInput(view: View, text: String): Option[Event] =
  None

apps/js/src/main/scala/apps/memory/TextUI.scala

renderView

The last function left to implement is renderView. We start by with a static header, followed by the current view of the game:

import cs214.webapp.client.graphics.{TextSegment as TS}
override def renderView(userId: UserId, view: View): Vector[TS] =
  renderHeader() ++ renderInternal(view)

apps/js/src/main/scala/apps/memory/TextUI.scala

private def renderHeader(): Vector[TS] =
  Vector(
    TS(text = "Memory: ", cssProperties = Map("font-weight" -> "bold", "font-size" -> "120%")),
    TS("Pick pairs of matching cards!", cssProperties = Map("font-style" -> "italic", "font-size" -> "120%")),
    TS("\n\n")
  )

apps/js/src/main/scala/apps/memory/TextUI.scala

This will create the header below. The line break at the end will make it so that the result of renderInternal(view) is displayed on its own line, below the header:

Memory: Pick pairs of matching cards! … … …
renderInternal

renderInternal has two cases: Finished and Playing:

private def renderInternal(view: View): Vector[TS] = view.state match
  case StateView.Finished(winnerIds) =>
    TS(f"Congrats to ${winnerIds.toSeq.sorted.mkString(", ")}!\n\n")
      +: renderMatched(view.matched)
  case StateView.Playing(phase, currentPlayer, board) =>
    renderSubHeader(phase, currentPlayer)
      :++ renderBoard(board, phase != PhaseView.Waiting, phase == FlippingCards)
      :++ renderMatched(view.matched)

apps/js/src/main/scala/apps/memory/TextUI.scala

In the Finished case, we collect the winners and print a message.

In the Playing case, follow the sketch below and split it into distinct areas of interest:

The different parts of the UI

We identify two different parts:

  1. “State info sub-header”, which informs the player on the current state of the game. This allows the player to know which action they are allowed to perform at a given time.

  2. “Memory board”, which displays the cards of the current game.

Sub-header

We implement the first section (“State info sub-header”) as a single TextSegment that converts the current phase of the game into text for the player. This translates into the following function:

private def renderSubHeader(phase: PhaseView, currentPlayer: UserId) =
  Vector(
    TS(phase match
      case FlippingCards => "It's your turn! Pick two matching cards.\n\n"
      case Waiting       => f"It's $currentPlayer's turn.\n\n"
      case GoodMatch     => f"Two points for $currentPlayer!\n\n"
      case BadMatch      => f"Better luck next time, $currentPlayer!\n\n"
    )
  )

apps/js/src/main/scala/apps/memory/TextUI.scala

Board

To render the board, we consider four important concerns:

First, rendering the board:

private def renderBoard(cards: Seq[CardView], enabled: Boolean, allowClick: Boolean): Vector[TS] =
  val oC = onClick(allowClick)
  cards.zipWithIndex.map: (c, idx) =>
    renderCard(c, enabled, Map("font-size" -> "250%"), oC(idx))
  .toVector
    :+ TS("\n\n")

private def onClick(allowClick: Boolean)(idx: CardIndex) =
  if allowClick then Some(() => sendEvent(Event.CardClicked(idx)))
  else None

apps/js/src/main/scala/apps/memory/TextUI.scala

Individual cards are rendered with renderCard:

private def renderCard(
    card: CardView,
    enabled: Boolean,
    style: Map[String, String],
    onClick: Option[() => Unit]
): TS =
  val cardIcon = card match
    case CardView.FaceDown             => "❓"
    case CardView.FaceUp(card)         => card
    case CardView.AlreadyMatched(card) => card
  val alreadyMatched =
    card.isInstanceOf[CardView.AlreadyMatched]
  val onMouseEvent = onClick
    .filter(_ => !alreadyMatched)
    .map(handler =>
      (evt: MouseEvent) =>
        evt match
          case MouseEvent.Click(_) => handler()
          case _                   => ()
    )
  val cardStyle =
    Map("font-family" -> "var(--emoji)", "line-height" -> "1.3")
      ++ (if onMouseEvent.nonEmpty then Map("cursor" -> "pointer") else Map())
      ++ (if alreadyMatched || !enabled then Map("opacity" -> "0.7") else Map())
      ++ style
  TS(cardIcon, cssProperties = cardStyle, onMouseEvent = onMouseEvent)

apps/js/src/main/scala/apps/memory/TextUI.scala

Finally we render one line per player to indicate cards already matched by individual players:

private def renderMatched(matched: Map[UserId, Vector[Card]]): Vector[TS] =
  TS("Scores:\n")
    +: matched.iterator.flatMap: (player, cards) =>
      TS(text = s"$player: ")
        +: cards.map(c => renderCard(CardView.FaceUp(c), true, Map(), None))
        :+ TS("\n")
    .to(Vector)

apps/js/src/main/scala/apps/memory/TextUI.scala

We conclude by setting a light gray background color on the UI, and configuring word-breaking to allow lines to wrap.

override def css: String = super.css +
  """
  html {
    background: #FAFAFA;
  }
  pre {
    word-break: break-all;
  }
  """

apps/js/src/main/scala/apps/memory/TextUI.scala

And we’re all done!