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:
- As a player making a guess, I can use the mouse to flip cards one by one.
- In order to know whether my guess was correct, as a player having selected two cards, I want cards I flipped to remain visible face-up for a few seconds after flipping them.
- As a player, I want to see my score as the game progresses, so I can track who is ahead.
- As a player reaching the end of the game, I want to know who won by seeing a summary of each player’s guesses.
- …
We then write more detailed requirements (the following are just brief sketches):
- Cards should be displayed as an array of squares.
- Clicking a card should flip it.
- When two matching cards are flipped, these cards should remain face up for the rest of the game.
- When two mismatched cards are flipped, they should be remain visible (face up) for 3 seconds, then flipped back.
- Once all cards are permanently face up, the game should stop.
- Throughout the game, a counter should display each player’s score.
- …
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?
- All cards present on the board.
- Which cards are temporarily flipped.
- Which players are connected to the game.
- Which cards each player has permanently flipped.
- Which phase the game is currently in. In memory this is simply
PickingCards
,ViewingCards
(while looking at flipped cards), orDone
).
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:
Playing(…)
, representing an ongoing game;Finished(…)
, representing a finished game.
We send only the current player in the Playing
state (rather than the list of all players), and we send a board containing CardView
s, 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:
-
We start by ruling out invalid inputs: all user inputs in the
Done
phase (since the game is over), as well as all out-of-turn events (events coming from a player other than the current player). -
We then implement the core game logic. Notice how we transition through multiple states without user interactions in the last line of the transition function (we first render the cards face up, then pause for a fixed amount of time, then display the new state).
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:
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:
We identify two different parts:
-
“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.
-
“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:
- How to lay out the cards;
- How to display individual cards;
- What happens when a player clicks on a card;
- When a player is allowed to click on a card.
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!