As a long time Java coder I’m finding that it’s a great time to start working with Scala. Scala 3 libraries are fun to work with and contextual abstractions feel natural once you’ve used them a few times. Unfortunately most Scala job postings available in Canada require a few years of professional Scala experience.

That puts Scala in the hobby language bucket for me but it’s so nice to work with that I want to try using it with some more serious side projects. AWS Lambda makes it cheaper to start and run a small side project and the recent JVM SnapStart feature reduces cold start latency.

I spun up an experimental project with a Scala.js frontend, a Scala AWS Lambda backend and the results were promising. Using Monocle for lenses, scala-js-snabbdom and ScalaTags I was able to make an interesting frontend UI that could also be rendered on the backend.

I haven’t done extensive performance testing. The backend cold start times with SnapStart have been around 1 second measured with curl which is good enough for my uses. Response times after cold start with curl have usually been between 100-200ms, but I will probably have to keep track of it as the logic and data access gets more involved.

For the frontend I stitched together snabbdom and ScalaTags with some of my own glue code. The Tyrian UI library or Slinky would probably be a better choice for a more serious project.

The RockTheJVM Fullstack Typelevel Stack course uses Tyrian to build a complete application. It’s on my list to work on in the future.

The code for this project is available on GitHub.

Building the Javascript bundle

I wanted this project to have shared logic and UI component code between the frontend and backend. I like server side rendering and that was also something that I wanted support for.

To setup the sbt project I ended up with 3 subprojects:

  • commonui for the shared frontend and backend code
  • backend for the backend code and AWS lambda package
  • frontend for the frontend code and browser JavaScript bundle

The sbt-crossproject created the commonui directory structure which saved me some time figuring out how to set it up. It took me a while to figure out that a cross-project contains sbt projects but the cross-project root is not an sbt project itself but it also makes a lot of sense once you use it.

- commonui/
  - shared/
    - src/main/scala/
  - js/
  - jvm/
  - build.sbt
- backend/
  - src/main/scala/
  - build.sbt
- frontend/
  - src/main/scala/
  - build.sbt
  - package.json
- build.sbt

As an sbt novice I probably made a few mistakes but the main points should be the same even if they’re hidden by a plugin:

  • Compile the Scala code to Javascript with Scala.js
  • Import the Scala.js Javascript to the frontend project
  • Use a web bundler Parcel.js to create browser JavaScript bundle and include any npm libraries required

Compiling Scala to Javascript

Compiling Scala.js projects is straightforward once you have the directory structure setup up properly. The commonui project is setup as a cross project so it can output JVM and JS code.

First the plugins have to be added to project/plugins.sbt.

addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0")
addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0")

It’s tempting to put the code that uses ScalaJS dependencies in the commonui/js subproject but for now I think it’s cleaner to restrict it to just the shared code.

lazy val root = project.in(file("."))
lazy val commonui = (crossProject(JSPlatform, JVMPlatform) in file("commonui"))
  .settings(libraryDependencies ++= Seq(
    "dev.optics" %%% "monocle-core" ,
    "dev.optics" %%% "monocle-macro"
  ).map(_ % monocleVersion)   )

The frontend project is pure ScalaJS which allows it to import ScalaJS-only dependencies. Using dependsOn adds the commonui library to the frontend project

lazy val commonuiJS = commonui.js
lazy val commonuiJVM = commonui.jvm
lazy val circeDependencies = Seq(libraryDependencies ++= Seq(
  "io.circe" %%% "circe-core",
  "io.circe" %%% "circe-generic",
  "io.circe" %%% "circe-parser"
).map(_ % circeVersion))
lazy val frontend = (project in file("frontend"))
  .enablePlugins(ScalaJSPlugin)
  .dependsOn(commonuiJS)
  .settings(
      Compile / mainClass   := Some("Main"),
    scalaJSUseMainModuleInitializer := true,
    libraryDependencies ++= Seq(
      "io.github.buntec" %%% "scala-js-snabbdom" % "0.1.0",
      "org.scala-js" %%% "scalajs-dom" % "2.4.0"
    ),
    circeDependencies)

With this setup the JS can be compiled with sbt frontend/fastLinkJS for the development version or sbt frontend/fullLinkJS for a minimized production version. The Scala.js build documentation has more detailed information about these targets.

This produces JS code but it has to be bundled for the browser.

I heard you liked build systems, so I connected a build system to your build system

The general problem of connecting JS bundlers to non-JS build systems is a leaky abstraction jungle for me. Even when the bundler is just webpack it can be tough to map a pure webpack configuration to the equivalent abstraction in the build system. At the same time, it’s boring glue code that shouldn’t be repeated everywhere.

I wanted an easy off-ramp if I wanted to change bundlers so I connected the bundler manually. The Scala.js Vite sbt plugin might be a better option in general though.

Running sbt frontend/fastLinkJS produces frontend/target/scala-3.2.1/frontend-fastopt and sbt frontend/fastLinkJS produces frontend/target/scala-3.2.1/frontend-opt.

- frontend/
  - target/
    - scala-3.2.1/
      - frontend-fastopt/
        - main.js
        - main.js.map
      - frontend-opt/
        - main.js
        - main.js.map

Looking at the Parcel.js build docs the HTML entry point seemed to be the easiest way to plug in the source. With the HTML entry point a content hash is generated and added to the bundle filename which makes deployment easier and is useful for having multiple versions live at the same time.

I added a hardcoded frontend/src/main/html/index.html to do this but it should probably be dynamically generated to support both fastLinkJS and fullLinkJS. As a bonus this means I can test the frontend code from a browser with using the parcel dev server.

<html>
<script type="module" src="../../../target/scala-3.2.1/frontend-opt/main.js"></script>
<link rel="stylesheet" href="https://unpkg.com/chota@latest">
<body>
Parcel bootstrap
<div id="snabbdom-container">

</div>
</body>
</html>

Parcel.js is installed by running npm install in the frontend directory. It mostly works without configuration but adding the source configuration to package.json tells it where to find the HTML entry point without a command line argument.

{
  "name": "cardzfrontend",
  "version": "1.0.0",
  "source": "src/main/html/index.html",
  "devDependencies": {
    "parcel": "^2.8.3",
    "parcel-reporter-clean-dist": "^1.0.4"
  }
}

To run Parcel from sbt I added a Global target to the root project.

Global / parcelJavascriptFiles := {
  (frontend / Compile / fullLinkJS).value
  Process("npx" :: "parcel" :: "build" :: Nil, file("frontend")) ! streams.value.log
}

Parcel.js will produce a bundle in frontend/dist.

- frontend/
  - dist/
    - index.e1b1309e.js
    - index.html

Adding a .parcelrc and parcel-reporter-clean-dist will clean out old versions.

{
   "extends": ["@parcel/config-default"],
   "reporters": ["...", "parcel-reporter-clean-dist"]
}

I copied over the bundle filename with the hash over to the backend so that it can be included in the backend html. The bundle itself will be served from S3.

Global / frontendJavascriptFiles := {
  parcelJavascriptFiles.value
  val javascriptFiles = FileTreeView.default.list(Glob(Paths.get("frontend").toAbsolutePath) / "dist" / "index*.js")
  val filenames = javascriptFiles.collectFirst {
    case (path, attributes) => path.toAbsolutePath.getFileName.toString
  }
  filenames
}

Adding this as a Global target makes it easier to use the target in the backend project but maybe there’s a better way?

lazy val backend = (project in file("backend"))
  .dependsOn(commonuiJVM)
  .enablePlugins(JavaAppPackaging)
  .settings(
    topLevelDirectory := None,
    libraryDependencies ++= Seq(
      "com.lihaoyi" %%% "scalatags" % "0.12.0",
      "com.amazonaws" % "aws-lambda-java-core" % "1.2.2",
      "com.amazonaws" % "aws-lambda-java-events" % "3.11.1",
      "io.github.crac" %  "org-crac" %  "0.1.3"
    ),
    circeDependencies,
    Compile / resourceGenerators += Def.task {
      val file = (Compile / resourceManaged).value / "frontend" / "frontend.properties"
      val filename = (Global / frontendJavascriptFiles).value.head
      val contents = s"frontend.javascript.entrypoint=$filename"
      IO.write(file, contents)
      Seq(file)
    }.taskValue
  )

This produces a frontend.properties file that we will read in the backend project.

frontend.javascript.entrypoint=index.e1b1309e.js

AWS Lambda backend

We can now serve a basic index page and reference the generated bundle (deployment will come later). For some basic CSS styling, I included the chota CSS framework.

The lambda handler class just has to implement the RequestHandler interface. The class name will added to the deployment configuration later on.

import com.amazonaws.services.lambda.runtime.Context
import com.amazonaws.services.lambda.runtime.RequestHandler
import com.amazonaws.services.lambda.runtime.LambdaLogger
import com.amazonaws.services.lambda.runtime.events
import com.amazonaws.services.lambda.runtime.events.{APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent}
import com.slopezerosolutions.scalatags.ScalaTagsBuilder
import scalatags.Text.all.*

import scala.jdk.CollectionConverters.MapHasAsJava
import scala.io.Source
import org.crac.Resource
import org.crac.Core
import scalatags.Text

class LambdaHandler extends RequestHandler[APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent], Resource {

  private val frontendProperties = new FrontendProperties()

  override def handleRequest(input: APIGatewayProxyRequestEvent, context: Context): APIGatewayProxyResponseEvent = {
    gamePage
  }

  private def gamePage = {
    val htmlOutput = html(
      head(
        script(raw(s"""var apiBaseUrl="${frontendProperties.apiBaseUrl}";""")),
        script(attr("type") := "module", src := entryPointUrl),
        link(rel := "stylesheet", href := "https://unpkg.com/chota@latest")
      ),
      body()
    ).toString
    val event = new APIGatewayProxyResponseEvent()
      .withStatusCode(200)
      .withHeaders(Map("content-type" -> "text/html").asJava)
      .withBody(htmlOutput)
    event
  }
}

For this basic page only a few dependencies are required.

    libraryDependencies ++= Seq(
      "com.lihaoyi" %%% "scalatags" % "0.12.0",
      "com.amazonaws" % "aws-lambda-java-core" % "1.2.2",
      "com.amazonaws" % "aws-lambda-java-events" % "3.11.1",
      "io.github.crac" %  "org-crac" %  "0.1.3"
    )

To package up everything, I’m using the sbt-native-packager plugin and its universal target. This will produce a zipped directory of jar files. Some articles online recommend a deployment that merges all the jar files fatJar style but this approach worked well and it’s nice to see all the dependencies packaged separately in the directory.

The resulting project/plugins.sbt looks like this.

addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0")
addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0")
addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.4")

Because I now need to plug the sbt build system results into the Serverless framework build/deploy system, I added another target to output the artifact path to a json file.

lazy val lambdaPackage = taskKey[Unit]("Lambda package")
lambdaPackage := {
  val packageZip = (backend / Universal / packageBin).value
  val file = backend.base / "target" / "universal" / "lambda.json"
  val basePath = root.base.getAbsoluteFile
  val zipFile = basePath.relativize(packageZip).get.toString
  val contents = s"""{"artifact":"${zipFile}"}"""
  IO.write(file, contents)
  ()
}

This writes to backend/target/universal/lambda.json

{"artifact":"backend/target/universal/backend-0.1.0-SNAPSHOT.zip"}

If you want to see how this gets plugged in to the deploy process, skip ahead. For a change of pace, I’ll move on to client and server side rendering.

Client and server side rendering

ScalaTags is an awesome server side rendering library, and scala-js-snabbdom is an awesome client side virtual dom library. Even for a side project, server side rendering has enough benefits that I would use another language to get it.

Fortunately projects like Tyrian have server side rendering support. But when I took a look at the APIs for ScalaTags and scala-js-snabbdom it looked like an interesting exercise to link them together by hand. It looks like a similar approach could work with Slinky, but it might require a separate class for backend components.

The APIs are fairly similar, the main difference is the way dom attributes and event handlers are handled. In scala-js-snabbdom, event handlers receive org.scalajs.dom.Events but the backend doesn’t need to know about these events. ScalaTags can render inline event handlers but I just need the plain DOM in the initial render, scala-js-snabbdom can handle the events when it loads.

So I just need a common interface that outputs DOM elements, and a different implementation for ScalaTags and scala-js-snabbdom. This basically boils down to something similar to a Visitor. I also found out that the Tagless final pattern is similar with better typechecking properties.

This case is just DOM elements all the way down so only one return type is needed but Scala has some nice syntax for this type of use case.

For the interface I just added a method for each tag that I needed, with some overloads for convenience.

package com.slopezerosolutions.dombuilder

trait DomBuilder[T] {

  def div(domAttributes: DomAttributes, contents: String | List[T]): T

  def div(contents: String | List[T] ): T = {
    div(DomAttributes.empty, contents)
  }

  def button(domAttributes: DomAttributes, contents: String | List[T]): T

  def input(domAttributes: DomAttributes): T
}

scala-js-snabbdom has a finer grained separation between different types of DOM attributes, so it was easier to use a similar level of separation in the interface layer.

object DomAttributes {
  val empty = DomAttributes()
}
case class DomAttributes(props: Map[String,String] = Map(),
                         attributes: Map[String,String] = Map(),
                         handlers: Map[String, Any => Unit] = Map())

Implementing the interface is straightforward so I’m only including the ScalaTags implementation. For the ScalaTags implementation the event handlers are not rendered.

class ScalaTagsBuilder extends DomBuilder[ConcreteHtmlTag[String]] {

  def div(domAttributes: DomAttributes, contents: String | List[ConcreteHtmlTag[String]]): ConcreteHtmlTag[String] = {
    contents match {
      case text: String => scalatags.Text.all.div(modifiers(domAttributes): _*)(text)
      case list: List[ConcreteHtmlTag[String]] => scalatags.Text.all.div(modifiers(domAttributes): _*)(list)
    }
  }

  def button(domAttributes: DomAttributes, contents: String | List[ConcreteHtmlTag[String]]): ConcreteHtmlTag[String] = {
    contents match {
      case text: String => scalatags.Text.all.button(modifiers(domAttributes): _*)(text)
      case list: List[ConcreteHtmlTag[String]] => scalatags.Text.all.button(modifiers(domAttributes): _*)(list)
    }
  }

  override def input(domAttributes: DomAttributes): ConcreteHtmlTag[String] = {
    scalatags.Text.all.input(modifiers(domAttributes): _*)
  }

  private def modifiers(domAttributes: DomAttributes): Array[Modifier] = {
    val modifiers = ArrayBuffer[Modifier]()
    for((key,value) <- domAttributes.props) {
      modifiers += (attr(key) := value)
    }
    for ((key, value) <- domAttributes.attributes) {
      modifiers += (attr(key) := value)
    }
    modifiers.toArray
  }
}

In Java the implementation would be similar but using this interface would be verbose. But using Scala 3’s given and using clauses and the import trick from Tagless final the abstraction is mostly invisible.

given and using pass the implementation through the call stack and import removes the references to the builder implementation when calling the interface methods.

object EnemyComponent {
  def mainView[T](enemy: Enemy, children: T*)(using domBuilder: DomBuilder[T]): T = {
    import domBuilder.*

    div(DomAttributes(attributes = Map("class" -> "card")),
      List(
        div(s"Enemy ${enemy.id}"),
        div(s"Health: ${enemy.health}"),
        div(children.toList)
      ))
  }
}

case class Enemy(id: String, health: Int)

When EnemyComponent.mainView is called and a given DomBuilder is in scope, it will automatically be passed in.
In the backend we use given domBuilder: DomBuilder[Text.TypedTag[String]] = new ScalaTagsBuilder().
In the frontend we can use given domBuilder: DomBuilder[VNode] = new SnabDomBuilder().

Now we have a way to render the same component code on the server side and the client side, we just need a way to handle frontend state and events.

Virtual DOM and contexts

Virtual DOM libraries are my preferred way to build frontends but a lot of frameworks optimize for the global context. This makes sense for a lot of applications but I’ve also run into cases where local contexts are useful. In React this ends up in useState calls but with Scala it looked like there should be a more functional way to do it.

I started with a basic Redux like store, it stores a mutable reference to one object that should be immutable. After the mutable reference is updated it is published to all subscribers.

package com.slopezerosolutions.dombuilder

import monocle.macros.GenLens
import monocle.Iso

class RootViewContext[S](var rootContext: S) {
  private var subscribers: Vector[(RootViewContext[S])=>Unit] = Vector()
  def updateContext(update: S => S): Unit = {
    rootContext = update(rootContext)
    publish()
  }

  def publish(): Unit = {
    for (subscriber <- subscribers) {
      subscriber.apply(this)
    }
  }

  def viewContext: ViewContext[S, S] = {
    ViewContext(global = rootContext,
      local = rootContext,
      updateGlobal = updateContext,
      updateLocal = updateContext)
  }

  def subscribe(subscriber: RootViewContext[S] => Unit): Unit = {
   subscribers = subscribers :+ subscriber
  }
}

After that I added a way to “zoom” into a section of the store using a Lens or a Prism. The Prism zooms in to a field that could store different subclass instances (or any type where the instance has multiple cases). This is useful for supporting a section of the page that could change to multiple different views.

I have a player name entry section that changes into a game view section after the player’s name is entered. If the ui switches from the player name section to the game view section and then sends an update to the player name section right after the Prism will safely ignore that update. Since the Prism handles the case where the subclass instance isn’t there anymore, the instance is passed in when zooming in to avoid introducing Option.

case class ViewContext[R, C](global: R,
                             local: C,
                             updateGlobal: (R => R) => Unit,
                             updateLocal: (C => C) => Unit) {
  def zoomInto[Z](zoom: Lens[C, Z]): ViewContext[R, Z] = {
    val zoomedContext = zoom.get(local)
    def childUpdater(updateChild: (Z => Z)): Unit = {
      updateLocal(zoom.modify(updateChild))
    }
    copy[R,Z](
      local =  zoomedContext,
      updateLocal = childUpdater
    )
  }

  def zoomOptional[Z](zoomOptional: Optional[C,Z], zoomedLocal: Z):  ViewContext[R, Z] = {
    def optionalChildUpdater(updateChild: (Z => Z)): Unit = {
      updateLocal(zoomOptional.modify(updateChild))
    }
    copy[R, Z](
      local = zoomedLocal,
      updateLocal = optionalChildUpdater
    )
  }

  def update(updater: C => C): Unit = {
    updateLocal(updater)
  }
}

It turns out this was enough to implement the event handling that I needed.

What is your name?

At the start of the game the player enters their name.

Player name page

To implement this I created a global context that has a slot for the currently active page “section”.

object AppContext {
  val viewContext: Lens[AppContext, AppSection.Context] = GenLens[AppContext](_.viewContext)
  val playerName: Lens[AppContext, Option[String]] = GenLens[AppContext](_.playerName)
}
case class AppContext(eventAdapter: EventAdapter,
                      viewContext: AppSection.Context,
                      uuidGenerator: () => String,
                      playerName: Option[String] = None,
                      gameServiceOption: Option[GameService] = None)


The AppSection is an empty base class that handles dispatching rendering to subclasses. It’s a primitive version of a frontend router. When it renders a section, it also “zooms in” to that section’s context.

object AppSection {
  abstract class Context
  val defaultContext = new Context{}

  def mainView[T](context: ViewContext[AppContext, AppContext])(using domBuilder: DomBuilder[T]): T = {
    import domBuilder._
    val local = context.local
    local.viewContext match {
      case sectionContext: GameView.Context => {
        GameView.mainView(
          context.zoomOptional(AppContext.viewContext.andThen(GameView.gameContext),
            sectionContext)
        )
      }
      case sectionContext: NameEntry.Context => {
        NameEntry.mainView(context.zoomOptional(AppContext.viewContext.andThen(NameEntry.context),
          sectionContext))
      }
      case _ => div(DomAttributes(props = Map("data-error" -> "Unhandled ui context")), List())
    }
  }
}

The name entry section stores the state of the text input in the context. When the Enter name button is clicked, it takes the player’s name and stores it in the global context.

context.updateLocal(playerName.modify(_ => input)) updates the player’s name in the local context . context.updateGlobal(AppContext.playerName.modify(_ =>; Some(local.playerName))) updates the player’s name in the global context so it can be used by other parts of the app.

Both methods end up updating the same mutable reference in the RootViewContext, but hiding the Lens calls behind updateLocal is useful.

Because playerName is a Monocle Lens, .modify builds a new function that performs the immutable update. Introduction to Optics is a good introduction to the Monocle Lens library.

Each update call will update the root context and publish the updated state to any subscribers. After the player enters their name and that change is published, context.updateGlobal(AppContext.viewContext.modify(_ => GameView.createNewGame(global))) switches the section to the GameView section.

object NameEntry {
  case class Context(playerName: String = "") extends AppSection.Context

  val context = Prism.partial[AppSection.Context, Context] { case c: Context => c }(identity)
  val playerName: Lens[Context, String] = GenLens[Context](_.playerName)

  def mainView[T](context: ViewContext[AppContext, Context])(using domBuilder: DomBuilder[T]): T = {
    import domBuilder._
    val local = context.local
    val global = context.global
    div(List(
      div(List(input(DomAttributes(props = Map("name" -> "playerName", "value" -> local.playerName),
        handlers = Map("change" -> global.eventAdapter.textInputAdapter((input) => {
          context.updateLocal(playerName.modify(_ => input))
        }))
      )))),
      div(List(
        button(DomAttributes(handlers = Map("click" -> global.eventAdapter.clickAdapter(() => {
          context.updateGlobal(AppContext.playerName.modify(_ => Some(local.playerName)))
          context.updateGlobal(AppContext.viewContext.modify(_ => GameView.createNewGame(global)))
        }))),
          "Enter name")))
    ))
  }
}

There’s one other detail here for server-side-rendering. I want to hide the org.scalajs.dom.Event type from the backend code. To do that I made the DOM event handlers Any => Unit so they can accept any type.

The problem is that I now need a place to handle org.scalajs.dom.Event on the frontend. To do this I added an EventAdapter interface that has a do-nothing implementation in the backend and a useful implementation in the frontend.

trait EventAdapter {
  def textInputAdapter(handler: (String) => Unit): (Any => Unit) = {
    (any: Any) => ()
  }

  def clickAdapter(handler: () => Unit): (Any => Unit) = {
    (any: Any) => ()
  }
}
class SnabDomEventAdapter extends EventAdapter {
  override def textInputAdapter(handler: (String) => Unit): (Any => Unit) = {
    (event) =>
      event match {
        case inputEvent: dom.Event => {
          inputEvent.target match {
            case e: HTMLInputElement => {
              handler(e.value)
            }
          }
          ()
        }
      }
  }

  override def clickAdapter(handler: () => Unit): (Any => Unit) = {
    (event) =>
      event match {
        case inputEvent: dom.MouseEvent => {
          handler()
        }
      }
  }
}

After adding this to the global AppContext it can be accessed by the views.

handlers = Map("change" -> global.eventAdapter.textInputAdapter((input) => {
          context.updateLocal(playerName.modify(_ => input))
        }))

The question now is how this setup is initialized and how updates are rendered. Most of the initialization is constructing the RootViewContext and then using scala-js-snabbdom to patch in DOM updates.

For proper server-side-rendering, you would want to render the same context in the frontend and the backend. This would require passing some JSON representing the context from the backend to the frontend then using that to initialize the context. I skipped this part since the page is small and I wouldn’t notice the difference at this point.

The DOM update is provided by a RootViewContext subscriber. It renders the updates and passes them to scala-js-snabbdom. scala-js-snabbdom requires you to pass in the results of the previous patch call to work properly so there’s some extra code to handle that case.

object Main {
  def main(args: Array[String]): Unit = {
    val container = dom.document.getElementById("snabbdom-container")
    val patch = init(Seq(Attributes.module,
      Classes.module,
      Props.module,
      Styles.module,
      EventListeners.module,
      Dataset.module))


    given domBuilder: DomBuilder[VNode] = new SnabDomBuilder()

    var containerVnode: Option[VNode] = None
    val rootViewContext = RootViewContext(AppContext(new SnabDomEventAdapter(),
      NameEntry.Context(),
      () => js.Dynamic.global.crypto.randomUUID().asInstanceOf[String],
      gameServiceOption = Some(new FrontendGameService(new FrontendConfiguration()))
      ))
    rootViewContext.subscribe((root) => {
      val context = root.viewContext
      val vnodes = h("div",
        VNodeData(props = Map("id" -> "snabbdom-container")),
        Array[VNode](AppSection.mainView(context)))
      containerVnode = containerVnode match {
        case Some(vNode) => {
           Some(patch(vNode, vnodes))
        }
        case None => {
          Some(patch(container, vnodes))
        }
      }

    })
    rootViewContext.publish()
  }
}

Now that we know what our name is, it’s time to play the game.

Gameplay

When the game starts the player sees their current hand of cards, a view of the current enemies and their health. They have attack cards that can attack enemies and heal cards that can heal their health points.

Initial game

The hand of cards is rendered as some divs. When the player selects a card the card goes into the selected card box to give you more options for how to play it.

def selectCard(card: Card): Context => Context = {
  selectedCard.replace(Some(card))
}

Instead of removing the selected card from the hand, it was more convenient to filter out the selected card from the cards in hand. If another piece of code wants to deactivate the selected card, it can just set it to None instead of replacing the card into the hand of cards.

div(DomAttributes(attributes = Map("class" -> "row")),
  List(
    div(DomAttributes(attributes = Map("class" -> "col bd-dark")),
      local.cards.toList.filterNot(local.selectedCard.isDefined && _.id == local.selectedCard.get.id).map((card) =>
        GameCard.mainView(card, _ => {
          context.update(selectCard(card))
        }))),
    div(DomAttributes(attributes = Map("class" -> "col")),
      List(local.selectedCard match {
        case Some(card) => GameCard.mainView(card, _ => {
          context.update(selectedCard.replace(None))
        })
        case None => div("Select a card")
      }))
  ))

The card view renders attack cards and heal cards using different pattern match cases.

object GameCard {

  def mainView[T](card: Card,
                  cardClickHandler: Any => Unit = _ => ())(using domBuilder: DomBuilder[T]): T = {
    import domBuilder._

    card match {
      case attackCard: AttackCard => div(
        DomAttributes(attributes = Map("class" -> "card bd-dark"),
          handlers = Map("click" -> cardClickHandler)),
        List(
          div(s"Attack card: ${attackCard.id}"),
          div(s"Attack points: ${attackCard.attackPoints}")
        )
      )
      case healCard: HealCard => div(
        DomAttributes(attributes = Map("class" -> "card bd-dark"),
          handlers = Map("click" -> cardClickHandler)),
        List(div(s"Heal card: ${healCard.id}"),
        div(s"Adds ${healCard.healPoints} points of health"))
      )
    }
  }
}

After picking an attack card the player can pick the enemy to attack.

Select attack target

When an attack card is selected the enemies will render with an Attack button so that they can be targeted. The attack button should be extracted to another method, but it was nice to prototype this behavior inline.

EnemyComponent.mainView(enemy,
  local.selectedCard match {
    case Some(card@AttackCard(id, attackPoints)) => button(
      DomAttributes(attributes = Map("class" -> "button error"),
        handlers = Map("click" -> (_ -> {
          context.update(
            playCard(card)
              .andThen(attackEnemy(enemy, card))
              .andThen(resolveKilledMonsters)
          ) 
        }))
      ),
      "Attack")
    case _ => div("")
  }
)

After attacking an enemy their health is reduced, if the enemy health goes below zero the enemy is removed.

def playCard(card: Card): Context => Context = {
  selectedCard.replace(None).andThen(cards.modify(_.filterNot(_.id == card.id)))
}

def attackEnemy(enemy: Enemy, card: AttackCard): Context => Context = {
  // eachEnemy is a Monocle Traversal that updates every enemy
  eachEnemy.modify((otherEnemy) => if otherEnemy.id == enemy.id
  then Enemy.health.modify(_ - card.attackPoints)(otherEnemy)
  else otherEnemy)
}

def resolveKilledMonsters(context: Context): Context = {
  val killedEnemies = context.enemies.filter(_.health <= 0)
  context.copy(
    enemies = context.enemies.filterNot(_.health <= 0),
    enemiesKilled = context.enemiesKilled + killedEnemies.length
  )
}

The card is then removed from the player’s hand and the player can then select another card.

After attack

After picking a heal card they can heal themselves. This works basically the same way the attack code does but adds health instead of subtracting it.

Select heal target

After heal

Drawing a new hand of cards

The player has played all the cards in their hand, an HTTP call is made to draw a new hand of cards. For some games every player action should be sent to the server and the server should manage the game logic. To stub out that case I added an HTTP API to the lambda to draw more cards.

  override def handleRequest(input: APIGatewayProxyRequestEvent, context: Context): APIGatewayProxyResponseEvent = {
    if(input.getPath == "/game" && input.getHttpMethod == "GET"){
      return gamePage
    } else if (input.getPath == "/cards/draw" && input.getHttpMethod == "POST"){
      return drawCards()
    }
    notFoundPage(input.getPath)
  }
  private def drawCards() = {
    val random = new Random()
    val cards = (1 to 5).map(
      _ => {
        random.nextInt(2) match {
          case 0 => AttackCard(java.util.UUID.randomUUID().toString, random.nextInt(5) + 1)
          case 1 => HealCard(java.util.UUID.randomUUID().toString,  random.nextInt(3) + 1)
        }
      }
    )
    val event = new APIGatewayProxyResponseEvent()
      .withStatusCode(200)
      .withHeaders(Map("content-type" -> "application/json").asJava)
      .withBody(cards.asJson.toString)
    event
  }

Again we have to stub out the backend because the frontend is using scala-js-dom Fetch to make the call. I made an interface in commonui and Option it in the global AppContext. Since I’m expecting service calls to be rare compared to event handlers, Option makes it clear that the implementation might not be available.

import scala.concurrent.Future

trait GameService {
  def drawCards(): Future[Option[Vector[Card]]]
}
case class AppContext(eventAdapter: EventAdapter,
                      viewContext: AppSection.Context,
                      uuidGenerator: () => String,
                      playerName: Option[String] = None,
                      gameServiceOption: Option[GameService] = None)


Then the implementation is added to the frontend project.

import org.scalajs.dom
import org.scalajs.dom.{Request, RequestInit}
import org.scalajs.dom.experimental.HttpMethod

import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future
import scalajs.js.Thenable.Implicits.*
import io.circe.*
import io.circe.generic.auto.*
import io.circe.parser.*

class FrontendGameService(frontendConfiguration: FrontendConfiguration) extends GameService {
  override def drawCards(): Future[Option[Vector[Card]]] = {
    val request = new Request(frontendConfiguration.apiUrl("cards/draw"),
      new RequestInit {
      method = dom.HttpMethod.POST
    })
    val response: Future[Either[Error, Vector[Card]]] = for {
      response <- dom.fetch(request)
      bodyText <- response.text()
    } yield {
      decode[Vector[Card]](bodyText)
    }
    response.map(_.toOption)
  }
}

The view code calls this API if a GameService is present in the global context.

Because update only accepts a function that performs an update, we don’t have the actual result of that update. That might be useful to add, but in that case it can only return an Option because when the update is applied, the section might have switched.

To work around this, I checked the current local state to see if the player’s hand would be empty after playing a card. Since the GameService returns a Future, the update with the new cards happens after the HTTP call completes.

Prism will prevent this update in case it happens after the section is changed. One issue is if the player can switch between multiple game instances, this call could complete later and add the cards to a different instance. In that case it might be useful to put a guard in the Prism that also checks the game instance id.

handlers = Map("click" -> (_ -> {
  context.update(
    playCard(card)
      .andThen(attackEnemy(enemy, card))
      .andThen(resolveKilledMonsters)
  )
  if (isEmptyAfterPlaying(card, local)) {
    global.gameServiceOption.map( gameService => {
      gameService.drawCards().map {
        case Some(drawnCards) => context.update(cards.modify(_ ++ drawnCards))
        case None => ()
      }
    })
  }
}))

That completes the game with some non-trivial use cases stubbed out. Time to ship it.

Deploying with Serverless Framework

To deploy this package I used the Serverless Framework.

To install serverless framework I added a package.json file. node.js was already installed using nvm.

{
  "name": "test-scala-serverless",
  "version": "1.0.0",
  "devDependencies": {
    "serverless": "^3.30.1",
    "serverless-s3-sync": "^3.1.0"
  }
}

Running npm install installs the framework in the project.

To configure it, I added a serverless.yml file.

service: aws-test-scala-serverless
frameworkVersion: '3'

provider:
  name: aws
  runtime: java11
  region: us-west-2



package:
  artifact: ${file(./backend/target/universal/lambda.json):artifact}

functions:
  api:
    handler: LambdaHandler
    events:
      - http: ANY /{paths+}
    snapStart: true
    environment:
      ASSETS_BASE_URL: ${file(./deploy-config.${opt:stage, 'dev'}.json):assetsUrlBase}
      API_BASE_URL: ${file(./deploy-config.${opt:stage, 'dev'}.json):apiBaseUrl}

custom:
  s3Sync:
    - bucketName: ${file(./deploy-config.${opt:stage, 'dev'}.json):assetsBucketName}
      bucketPrefix: ${file(./deploy-config.${opt:stage, 'dev'}.json):assetsBucketPrefix}
      localDir: frontend/dist

plugins:
  - serverless-s3-sync

Back to backend/target/universal/lambda.json. It had a reference to the universal package that sbt built with .jar files of all the backend code and dependencies.

{"artifact":"backend/target/universal/backend-0.1.0-SNAPSHOT.zip"}

This snippet points serverless to the zip file, and skips the default Serverless Framework packaging step.

package:
  artifact: ${file(./backend/target/universal/lambda.json):artifact}

The frontend code is deployed to an S3 hosted static website. Serverless Framework needs to know what S3 bucket to upload to and the backend needs to know where the JS bundles are hosted. This config is set in the deploy-config.dev.json file.

Update: The S3 site also needs a CORS configuration. I couldn’t find the original source I used for the configuration but this post seems to have the right steps. The CloudFront configuration is a pain, but it provides caching and better configuration options vs S3 direct hosting.

{
  "assetsBucketName":  "YOURBUCKET",
  "assetsBucketPrefix": "testscalaserverless/dev/assets",
  "assetsUrlBase": "https://www.example.com/testscalaserverless/dev/assets",
  "apiBaseUrl": "https://www.example.com/testscalaserverless/dev"
}

The serverless-s3-sync plugin is used to copy the JS bundle to S3 during the deploy phase. To be safe it might be better to run the S3 upload before deploying to AWS Lambda.

If you’re following along, you would have to set up AWS credentials with a named profile.

To deploy, run sbt lambdaPackage to build the package, and npx serverless to deploy to AWS Lambda and S3.

sbt lambdaPackage
npx serverless deploy --aws-profile MYAWSPROFILENAME

Serverless will create a new REST API Gateway and print out the url on the command line. Creating a custom domain for the API is a whole other process.

AWS Lambda SnapStart

Without SnapSnart the cold start response times are around 2 seconds measured by curl. This project doesn’t have a lot of dependencies, so most of it is probably extracting .jar files and loading classes. With SnapStart the cold start response times came down to around 1 second.

To implement SnapStart, add the CraC dependency, register the handler with Core.getGlobalContext.register, and implement the afterRestore and beforeCheckpoint methods.

libraryDependencies ++= Seq(
  "io.github.crac" %  "org-crac" %  "0.1.3"
)
class LambdaHandler extends RequestHandler[APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent], Resource {

  Core.getGlobalContext.register(this)

  override def handleRequest(input: APIGatewayProxyRequestEvent, context: Context): APIGatewayProxyResponseEvent = {
    if (input.getPath == "/game" && input.getHttpMethod == "GET") {
      return gamePage
    } else if (input.getPath == "/cards/draw" && input.getHttpMethod == "POST") {
      return drawCards()
    }
    notFoundPage(input.getPath)
  }

  def afterRestore(context: org.crac.Context[? <: org.crac.Resource]): Unit = {

  }

  def beforeCheckpoint(context: org.crac.Context[? <: org.crac.Resource]): Unit = {
    gamePage
  }

}

beforeCheckpoint will run when you deploy, saving a memory snapshot that will be used during cold starts. I wanted to preload most of the libraries that would be used, so I just ran the gamePage method. Tuning AWS Lambda JVM SnapStart goes in detail into how to do this with different Java frameworks and proper response time measurements.

Takeaways

I’m pretty happy with the results so far, the main thing I will probably try next is using Tyrian and the Indigo HTML game engine. Lessons learned:

  • A lot can be done on the browser with Scala.js and pure Scala.js libraries
  • I wish I had found out about ScalaTags and scala-js-snabbdom sooner
  • given and using clauses can clean up abstractions that would normally be verbose and cumbersome
  • Monocle lenses enable interesting possibilities for immutable data and user interfaces
  • AWS Lambda SnapStart makes JVM Scala lambda cold starts quick enough for a lot of use cases
  • Being able to pass build targets between sbt sub-projects made it easier to pass JS bundle information around
  • Serverless framework is a nice way to deploy Scala lambdas with a bit of configuration