diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..3589a1def37a4e813b1a6d7e2b0b6a50e504a87b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,43 @@
+# bloop and metals
+.bloop
+.bsp
+.metals
+project/metals.sbt
+
+# vs code
+.vscode
+
+# scala 3
+.tasty
+
+# sbt
+project/project/
+project/target/
+target/
+
+# eclipse
+build/
+.classpath
+.project
+.settings
+.worksheet
+bin/
+.cache
+
+# intellij idea
+*.log
+*.iml
+*.ipr
+*.iws
+.idea
+
+# mac
+.DS_Store
+
+# other?
+.history
+.scala_dependencies
+.cache-main
+
+# general
+*.class
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..44c759fbb94395af3c6388f7509b7993c24b3c8e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+© 2023, 2024 EPFL. All rights reserved.
+
+“Course” below refers to CS-214 Software Construction at EPFL.
+
+“Course materials” below includes slides, notes, recordings, lab assignments
+(write-ups, code, and accompanying material), exercises, exams, and any other
+materials created or prepared by the course staff, except where attributed to
+someone else.
+
+Permission is granted to make copies and derivatives of these course materials
+*only* in the context of course activities. These materials, or derivatives
+thereof, *may not* be shared with anyone except the course staff, nor distributed
+in any way, publicly or privately, including but not limited to in public git
+repositories or Google Drive folders, except where permission has been explicitly
+granted in writing by a course instructor.
diff --git a/build.sbt b/build.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..deb2988e5e131df7c1ddfa68dc19ace74ea7b05e
--- /dev/null
+++ b/build.sbt
@@ -0,0 +1,62 @@
+import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.*
+import sbt.Keys.libraryDependencies
+
+lazy val shared = crossProject(JSPlatform, JVMPlatform).in(file("./shared"))
+  .jsConfigure(_.enablePlugins(JSDependenciesPlugin))
+  .settings(
+    name := "shared",
+    scalaVersion := "3.5.0",
+    libraryDependencies += dependencies.ujson,
+    scalacOptions ++= Seq("-deprecation", "-feature", "-language:fewerBraces", "-Xfatal-warnings")
+  ).jsSettings(
+    test / aggregate := false,
+    Test / test := {},
+    Test / testOnly := {}
+  ).jvmSettings(
+  )
+
+lazy val client = (project in file("./client"))
+  .dependsOn(shared.js)
+  .enablePlugins(ScalaJSPlugin)
+  .settings(
+    name := "client",
+    version := "0.1.0-SNAPSHOT",
+    scalaVersion := "3.5.0",
+    scalacOptions ++= Seq("-deprecation", "-feature", "-language:fewerBraces", "-Xfatal-warnings"),
+    libraryDependencies ++= Seq(
+      "org.scala-js" %%% "scalajs-dom" % "2.8.0",
+      "com.lihaoyi" %%% "scalatags" % "0.12.0",
+    ),
+    // Add support for the DOM in `run` and `test`
+    jsEnv := new org.scalajs.jsenv.jsdomnodejs.JSDOMNodeJSEnv(),
+    testFrameworks += new TestFramework("utest.runner.Framework"),
+    test / aggregate := false,
+    Test / test := {},
+    Test / testOnly := {}
+  )
+
+lazy val server = (project in file("./server"))
+  .dependsOn(shared.jvm)
+  .settings(
+    name := "server",
+    version := "0.1.0-SNAPSHOT",
+    scalaVersion := "3.5.0",
+    scalacOptions ++= Seq("-deprecation", "-feature", "-language:fewerBraces", "-Xfatal-warnings"),
+    libraryDependencies ++= Seq(
+      dependencies.websocket,
+      dependencies.cask,
+      dependencies.slf4j,
+      dependencies.reflect,
+    ),
+  )
+
+/// Dependencies
+
+lazy val dependencies = new {
+  val caskVersion = "0.9.4" 
+  val websocket = "org.java-websocket" % "Java-WebSocket" % "1.5.4"
+  val cask =  "com.lihaoyi" %% "cask" % caskVersion
+  val ujson = "com.lihaoyi" %% "ujson" % "3.3.1"
+  val slf4j = "org.slf4j" % "slf4j-nop" % "2.0.5"
+  val reflect = "org.reflections" % "reflections" % "0.10.2"
+}
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..8adfb5dc9835905802341b2a17ab1b3d0a5af5e8
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,5 @@
+{
+  "devDependencies": {
+    "jsdom": "^22.1.0"
+  }
+}
diff --git a/client/src/main/scala/cs214/webapp/client/ApplicationJS.scala b/client/src/main/scala/cs214/webapp/client/ApplicationJS.scala
new file mode 100644
index 0000000000000000000000000000000000000000..3bbf15971a2f72ff7bc3d484d4d25abb992eb834
--- /dev/null
+++ b/client/src/main/scala/cs214/webapp/client/ApplicationJS.scala
@@ -0,0 +1,6 @@
+package cs214.webapp.client
+
+class ApplicationJS:
+
+  def start(): Unit =
+    WebClient.start()
diff --git a/client/src/main/scala/cs214/webapp/client/ClientApp.scala b/client/src/main/scala/cs214/webapp/client/ClientApp.scala
new file mode 100644
index 0000000000000000000000000000000000000000..22873d0cbe34e3bb1ca1ac0089005aa651fecf05
--- /dev/null
+++ b/client/src/main/scala/cs214/webapp/client/ClientApp.scala
@@ -0,0 +1,15 @@
+package cs214.webapp
+package client
+
+import org.scalajs.dom
+
+/** The ClientApp interface is used by clients to connect to a server. */
+trait ClientApp:
+  def appId: AppId
+  def init(instanceId: InstanceId, userId: UserId, endpoint: String, target: dom.Element): ClientAppInstance
+
+/** The ClientAppInstance interface is used by the server to store the state of
+  * a client UI.
+  */
+trait ClientAppInstance:
+  def onMessage(msg: util.Try[ujson.Value]): Unit
diff --git a/client/src/main/scala/cs214/webapp/client/Pages.scala b/client/src/main/scala/cs214/webapp/client/Pages.scala
new file mode 100644
index 0000000000000000000000000000000000000000..b8cf36d93f7c1101ec976a883826d6240a87212b
--- /dev/null
+++ b/client/src/main/scala/cs214/webapp/client/Pages.scala
@@ -0,0 +1,244 @@
+package cs214.webapp
+package client
+
+import java.net.{URLDecoder, URLEncoder}
+import scala.concurrent.ExecutionContext.Implicits.global
+import org.scalajs.dom
+import org.scalajs.dom.{Element, KeyCode}
+import org.scalajs.dom.html.{Input, Select, TextArea}
+import scalatags.JsDom.all.*
+
+/** Parent class to web pages */
+abstract class Page:
+  val classList: String
+  def renderInto(target: dom.Element): Unit
+
+  def pageHeader(subtitle: String) =
+    frag(h1(cls := "title", "ScalApp"), h2(subtitle))
+
+  def replaceChildren(target: dom.Element)(frag: Frag): Unit =
+    target.replaceChildren(frag.render)
+    target.setAttribute("class", classList)
+
+  private def path: Seq[String] =
+    this match
+      case HomePage =>
+        Nil
+      case InstanceCreationPage(appId) =>
+        List("app", appId)
+      case JoinPageLoader(appId, instanceId) =>
+        List("app", appId, instanceId)
+      case AppPage(appId, instanceId, userId) =>
+        List("app", appId, instanceId, userId)
+      case _ =>
+        throw IllegalArgumentException(f"No path for $this!")
+
+  def url =
+    f"/${path.map(s => URLEncoder.encode(s, "UTF-8")).mkString("/")}"
+
+object Page:
+  /** Creates a component from a URL path
+    * @param appInfos
+    *   retrieves the app information given its id
+    */
+  def from(url: String): Page =
+    val components = url.stripPrefix("/").stripSuffix("/").split("/")
+    val decoded = components.map(c => URLDecoder.decode(c, "UTF-8")).toList
+    decoded match
+      case List("") =>
+        HomePage
+      case List("app", appId) =>
+        InstanceCreationPage(appId)
+      case List("app", appId, instanceId) =>
+        JoinPageLoader(appId, instanceId)
+      case List("app", appId, instanceId, userId) =>
+        AppPage(appId, instanceId, userId)
+      case _ =>
+        throw IllegalArgumentException(f"Unknown url $url!")
+
+/** The initial home page which fetches the list of apps */
+object HomePage extends Page:
+  val classList = "HomePage"
+
+  def renderInto(target: Element) =
+    Requests.listApps.map: response =>
+      AppSelectionPage(response.apps).renderInto(target)
+
+/** The app selection menu, where the user can create a new app. */
+case class AppSelectionPage(apps: Seq[AppInfo]) extends Page:
+  val classList = "AppSelectionPage"
+
+  def selectApp(appId: AppId): Unit =
+    WebClient.navigateTo(InstanceCreationPage(appId))
+
+  def renderInto(target: Element) = replaceChildren(target):
+    frag(
+      pageHeader("Select an app"),
+      div(
+        id := "app-list",
+        apps.groupBy(_.year).toList.map: (year, appsOfYear) =>
+          frag(
+            h5(year.toString),
+            div(
+              cls := "app-grid",
+              appsOfYear.sortBy(_.name).map: appInfo =>
+                div(
+                  cls := "app-option",
+                  onclick := (() => selectApp(appInfo.id)),
+                  img(src := s"./static/${appInfo.id}.jpg", cls := "app-thumbnail"),
+                  p(cls := "app-option-name", appInfo.name),
+                  p(cls := "app-option-description", appInfo.description)
+                )
+            )
+          )
+      )
+    )
+
+/** The instance creation menu, where you enter the player names */
+case class InstanceCreationPage(appId: AppId) extends Page:
+  val classList = "InstanceCreationPage"
+
+  def submit(e: dom.Event): Unit =
+    e.preventDefault()
+    val userIdStr = getElementById[Input]("user-ids").value
+    val userIds = userIdStr.split("[;,]").map(_.strip).to(Seq).distinct
+    Requests.createInstance(appId, userIds).map: resp =>
+      WebClient.navigateTo(JoinPageLoader(appId, resp.instanceId))
+
+  def renderInto(target: Element) = replaceChildren(target):
+    frag(
+      pageHeader(s"Create a new $appId instance"),
+      form(
+        onsubmit := submit,
+        div(
+          cls := "grid-form",
+          label(`for` := "user-ids", "User IDs: "),
+          input(
+            `type` := "text",
+            id := "user-ids",
+            placeholder := "user1; user2; …",
+            required := true,
+            autofocus := true
+          )
+        ),
+        input(`type` := "submit", value := "Start!")
+      )
+    )
+
+/** The pre-connection menu, which fetches the user list. */
+case class JoinPageLoader(appId: String, instanceId: InstanceId) extends Page:
+  val classList = "JoinPageLoader"
+  def renderInto(target: Element) =
+    Requests.instanceInfo(instanceId).map: resp =>
+      JoinPage(appId, instanceId, resp.userIds).renderInto(target)
+
+/** The connection menu, where a user joins an existing app. */
+case class JoinPage(appId: AppId, instanceId: InstanceId, userIds: Seq[UserId])
+    extends Page:
+  val classList = "JoinPage"
+
+  private def cssId(idx: Int) = f"user-$idx"
+
+  private def handleKeyboardEvent(e: dom.KeyboardEvent): Unit =
+    println(s"Print ${e.keyCode}, ${KeyCode.Enter}")
+    if e.keyCode == KeyCode.Enter then
+      e.preventDefault()
+      dom.window.removeEventListener("keydown", (e: dom.KeyboardEvent) => handleKeyboardEvent(e))
+      joinAppInstance()
+
+  private def handleFormSubmission(e: dom.Event): Unit =
+    e.preventDefault()
+    joinAppInstance()
+
+  private def getSelected =
+    userIds.zipWithIndex.find { (u, i) =>
+      getElementById[Input](cssId(i)).checked
+    }.map(_._1)
+
+  private def joinAppInstance(): Unit =
+    getSelected.map: userId =>
+      WebClient.navigateTo(AppPage(appId, instanceId, userId))
+
+  def renderInto(target: Element): Unit = replaceChildren(target):
+    dom.window.addEventListener("keydown", (e: dom.KeyboardEvent) => handleKeyboardEvent(e))
+    frag(
+      pageHeader("Join app instance"),
+      form(
+        onsubmit := handleFormSubmission,
+        fieldset(
+          legend("Select your username"),
+          for (userId, idx) <- userIds.zipWithIndex
+          yield div(
+            input(
+              `type` := "radio",
+              id := cssId(idx),
+              if idx == 0 then checked := "checked" else frag(),
+              name := "user",
+              value := userId,
+              required := true
+            ),
+            label(`for` := cssId(idx), userId)
+          )
+        ),
+        input(`type` := "submit", value := "Join!")
+      )
+    )
+
+/** The actual app content page */
+case class AppPage(appId: String, instanceId: InstanceId, userId: UserId) extends Page:
+  val classList = f"app $appId"
+
+  private val app: ClientApp =
+    val maybeApp = WebClient.getApp(appId)
+    require(
+      maybeApp.isDefined,
+      s"Could not find an app UI corresponding to app id \"$appId\". There may be a " +
+        s"mismatch between the client UI's appId and server logic's appId, or you may have " +
+        s"forgotten to annotate your UI with @JSExportTopLevel(\"<yourAppId>\")"
+    )
+    maybeApp.get
+
+  def renderInto(target: Element) =
+    replaceChildren(target):
+      frag(header(id := "banner"), tag("section")(id := "app"))
+    Requests.instanceInfo(instanceId).map: appInfo =>
+      val hostName = dom.window.location.hostname
+      val endpoint = appInfo.wsEndpoint
+        .replace("{{hostName}}", hostName)
+        .replace("{{userId}}", URLEncoder.encode(userId, "UTF-8"))
+      IpBanner(appInfo.shareUrl).renderInto(target.querySelector("#banner"))
+      app.init(instanceId, userId, endpoint, target.querySelector("#app"))
+
+/** The top-most share banner */
+case class IpBanner(shareUrl: String) extends Page:
+  val classList = "IpBanner"
+
+  def renderInto(target: Element) =
+    replaceChildren(target):
+      val copyToClipboardButtonText = "📋 Copy to clipboard."
+      frag(
+        span("Share ", a(href := shareUrl, shareUrl), " with your friends to let them join!"),
+        div(
+          id := "banner-actions",
+          button(
+            onclick := (() => WebClient.navigateTo(HomePage)),
+            "🏠 Back to home page"
+          ),
+          button(
+            id := "copy-share-url",
+            onclick := (() =>
+              dom.window.navigator.clipboard.writeText(shareUrl)
+              dom.document.getElementById("copy-share-url").innerText = "Copied!"
+
+              dom.window.setTimeout(
+                () => dom.document.getElementById("copy-share-url").innerText = copyToClipboardButtonText,
+                1000
+              )
+            ),
+            copyToClipboardButtonText
+          )
+        )
+      )
+
+def getElementById[T](id: String) =
+  dom.document.getElementById(id).asInstanceOf[T]
diff --git a/client/src/main/scala/cs214/webapp/client/StateMachineClientApp.scala b/client/src/main/scala/cs214/webapp/client/StateMachineClientApp.scala
new file mode 100644
index 0000000000000000000000000000000000000000..a1271a4e00a4a2561514fafae6881f134660bd6d
--- /dev/null
+++ b/client/src/main/scala/cs214/webapp/client/StateMachineClientApp.scala
@@ -0,0 +1,138 @@
+package cs214.webapp
+package client
+
+import scala.collection.mutable
+import scala.scalajs.js.timers.{SetTimeoutHandle, setTimeout}
+import scala.util.{Failure, Success}
+import org.scalajs.dom
+import scalatags.JsDom.all.Frag
+
+abstract class WSClientApp extends ClientApp:
+  WebClient.register(this)
+
+  protected def init(userId: UserId, sendMessage: ujson.Value => Unit, target: dom.Element): ClientAppInstance
+
+  def init(instanceId: InstanceId, userId: UserId, endpoint: String, target: dom.Element): ClientAppInstance =
+    val socket = new dom.WebSocket(endpoint)
+    socket.onopen = (event: dom.Event) => println("WebSocket connection opened")
+    socket.onclose = (event: dom.CloseEvent) => println(s"WebSocket connection closed: ${event.reason}")
+    socket.onerror = (event: dom.Event) => println(s"WebSocket error: ${event.`type`}")
+
+    val sendMessage = (js: ujson.Value) => socket.send(js.toString)
+    val client = init(userId, sendMessage, target)
+    socket.onmessage = msg =>
+      println(msg)
+      val js = ujson.read(msg.data.toString)
+      client.onMessage(SocketResponseWire.decode(js).flatten)
+
+    client
+
+/** Instance of a client-side state machine application.
+  *
+  * @param userId
+  *   the user for which creating the client application
+  * @param sendMessage
+  *   how to send a json message
+  * @param target
+  *   the element where to draw the application
+  */
+abstract class StateMachineClientAppInstance[Event, View](
+    userId: UserId,
+    sendMessage: ujson.Value => Unit,
+    target: dom.Element
+) extends ClientAppInstance:
+
+  /** Optional CSS style sheet to apply to the application page. When
+    * overriding, do not forget to append to the parent definition.
+    */
+  def css: String = ""
+  if !css.isEmpty then
+    // Add <style> tag in the header of the page
+    val styleTag = dom.document.createElement("style")
+    styleTag.textContent = css
+    dom.document.head.appendChild(styleTag)
+
+  /** Provides serialization methods for events and views */
+  val wire: AppWire[Event, View]
+
+  /** Renders a [[View]] received from the server. The method also takes a
+    * [[UserId]] to get information on the context and an [[onEvent]] callback
+    * which it uses to send server events when specific actions are triggered on
+    * the UI (click on a button for example)
+    * @param userId
+    *   The user id used by the client.
+    * @param view
+    *   The view to render.
+    * @return
+    *   The rendered view.
+    */
+  def render(userId: UserId, view: View): Frag
+
+  /** Renders a non-fatal error as a browser alert */
+  private def renderError(msg: String): Unit =
+    dom.window.alert(msg)
+
+  protected def sendEvent(event: Event) =
+    sendMessage(wire.eventFormat.encode(event))
+
+  /** @inheritdoc */
+  override def onMessage(msg: util.Try[ujson.Value]): Unit =
+    msg.flatMap(EventResponse.Wire.decode) match
+      case Failure(msg)              => WebClient.crash(Exception(msg))
+      case Success(Failure(msg))     => renderError(msg.getMessage)
+      case Success(Success(actions)) => actionsQueue.enqueueAll(actions)
+    tryProcessNextAction()
+
+  /** The views to render */
+  private val actionsQueue: mutable.Queue[Action[ujson.Value]] = mutable.Queue()
+
+  /** Minimal cooldown time between the render of two views */
+  private var actionCooldownDelay = 0
+
+  /** The timestamp for the last render of a view */
+  private var lastActionAppliedMs = System.currentTimeMillis()
+
+  /** A call to the [[tryProcessNextAction()]] function scheduled */
+  private var timeout: Option[SetTimeoutHandle] = None
+
+  /** Sets the [[actionCooldownDelay]] of the client to the specified value */
+  private def setCooldown(ms: Int): Unit = actionCooldownDelay = ms
+
+  /** Apply the next action from [[actionsQueue]] if [[actionCooldownDelay]]
+    * permits it.
+    */
+  private def tryProcessNextAction(): Unit =
+    // If there are still views to render and if no call to this function is scheduled
+    if actionsQueue.nonEmpty && timeout.isEmpty then
+      // Then check if we are out of the cooldown delay
+      if System.currentTimeMillis() - lastActionAppliedMs > actionCooldownDelay then
+        // We can render the view, and dequeue it
+        processAction(actionsQueue.dequeue())
+        // Continue try emptying the queue
+        tryProcessNextAction()
+      else
+        // We still have to wait, put a timeout to call the function later
+        timeout = Some(setTimeout(actionCooldownDelay) {
+          // First remove the timeout so that, if necessary,
+          // the next function call can create a new one
+          timeout = None
+          // Then try to render next views
+          tryProcessNextAction()
+        })
+
+  /** Execute a single action sent by the server. */
+  private def processAction(jsonAction: Action[ujson.Value]): Unit =
+    lastActionAppliedMs = System.currentTimeMillis()
+    setCooldown(0)
+    jsonAction match
+      case Action.Alert(msg) =>
+        dom.window.alert(msg)
+      case Action.Pause(durationMs) =>
+        setCooldown(durationMs)
+      case Action.Render(js) =>
+        // Step1: The client receives the view sent by the server here
+        wire.viewFormat.decode(js) match
+          case Failure(exception) => renderError(exception.getMessage)
+          case Success(jsonView) =>
+            target.replaceChildren:
+              this.render(userId, jsonView).render
diff --git a/client/src/main/scala/cs214/webapp/client/WebClient.scala b/client/src/main/scala/cs214/webapp/client/WebClient.scala
new file mode 100644
index 0000000000000000000000000000000000000000..17fc58c6b0992ef29ed56a51688513debb10fa83
--- /dev/null
+++ b/client/src/main/scala/cs214/webapp/client/WebClient.scala
@@ -0,0 +1,92 @@
+package cs214.webapp
+package client
+
+import scala.collection.mutable
+import scala.util.{Failure, Success}
+import org.scalajs.dom
+
+object WebClient:
+  private val appLibrary: mutable.Map[AppId, ClientApp] = mutable.Map.empty
+
+  val getApp = appLibrary.get
+
+  def register(clientApp: ClientApp): Unit =
+    println(s"Registered ${clientApp.appId}'s UI")
+    appLibrary.put(clientApp.appId, clientApp)
+
+  def start(): Unit =
+    start(dom.document.getElementById("root"))
+
+  def start(root: dom.Element): Unit =
+    println(f"Registered apps: ${appLibrary.keys.toSeq.sorted}.")
+    try
+      Page.from(dom.document.location.pathname)
+        .renderInto(root)
+    catch case t => crash(t)
+
+  def navigateTo(page: Page) =
+    dom.window.location.pathname = page.url
+
+  def crash(t: Throwable) =
+    dom.document.querySelector("body").prepend:
+      import scalatags.JsDom.all.*
+      tag("dialog")(id := "fatal", b("Fatal error: "), t.getMessage).render
+    dom.document.querySelector("#fatal").asInstanceOf[dom.html.Dialog].showModal()
+    throw t
+
+object Requests:
+  import org.scalajs.dom.{Fetch, Headers}
+
+  import concurrent.ExecutionContext.Implicits.global
+  import scala.concurrent.Future
+  import scala.scalajs.js.Promise
+
+  /** Sends a POST request to create a new instance including the given users of
+    * the app with the given id
+    */
+  def createInstance(appId: AppId, userIds: Seq[UserId]): Future[CreateInstanceResponse] =
+    sendPostRequestWith(Endpoints.Api.createInstance)(CreateInstanceRequest.Wire, CreateInstanceResponse.Wire):
+      CreateInstanceRequest(appId, userIds)
+
+  /** Sends a GET request to enumerate available apps */
+  def listApps: Future[ListAppsResponse] =
+    Fetch.fetch(Endpoints.Api.listApps.toString)
+      .decodeResponse(ListAppsResponse.Wire)
+      .logErrors
+
+  /** Sends a GET request to retrieve information on an app. */
+  def instanceInfo(instanceId: InstanceId): Future[InstanceInfoResponse] =
+    Fetch.fetch(f"${Endpoints.Api.instanceInfo.toString}?instanceId=$instanceId")
+      .decodeResponse(InstanceInfoResponse.Wire)
+      .logErrors
+
+  /** Sends a POST request with the given payload to the specified endpoint */
+  private def sendPostRequestWith[R, S](endpoint: Endpoints.Api)(
+      reqWire: WireFormat[R],
+      respWire: WireFormat[S]
+  )(payload: R) =
+    val requestHeaders = dom.Headers()
+    requestHeaders.append("Content-Type", "application/json")
+
+    val requestOptions = new dom.RequestInit:
+      method = dom.HttpMethod.POST
+      headers = requestHeaders
+      body = reqWire.encode(payload).toString
+
+    Fetch.fetch(endpoint.toString, requestOptions)
+      .decodeResponse(respWire)
+      .logErrors
+
+  extension [T](p: Future[T])
+    private def logErrors =
+      p.andThen { case Failure(t) => WebClient.crash(t) }
+
+  case class FailedRequestException(status: Int, msg: String) extends Exception(msg)
+
+  extension (p: Promise[dom.Response])
+    private def decodeResponse[R](wire: WireFormat[R]) =
+      p.toFuture.flatMap: response =>
+        response.text().toFuture.flatMap: txt =>
+          if !response.ok then
+            throw FailedRequestException(response.status, txt)
+          Future.fromTry(wire.decode(ujson.read(txt)))
diff --git a/client/src/main/scala/cs214/webapp/client/graphics/MouseButton.scala b/client/src/main/scala/cs214/webapp/client/graphics/MouseButton.scala
new file mode 100644
index 0000000000000000000000000000000000000000..08cc7f2a7c5e86e91e3053387ac8c57339905c6f
--- /dev/null
+++ b/client/src/main/scala/cs214/webapp/client/graphics/MouseButton.scala
@@ -0,0 +1,14 @@
+package cs214.webapp.client.graphics
+
+enum MouseButton:
+  case Left
+  case Middle
+  case Right
+
+object MouseButton:
+  def from(htmlButtonIdentifier: Int): MouseButton =
+    htmlButtonIdentifier match
+      case 0 => Left
+      case 1 => Middle
+      case 2 => Right
+      case i => throw IllegalStateException(s"Unsupported mouse button: $i")
diff --git a/client/src/main/scala/cs214/webapp/client/graphics/TextClientAppInstance.scala b/client/src/main/scala/cs214/webapp/client/graphics/TextClientAppInstance.scala
new file mode 100644
index 0000000000000000000000000000000000000000..b8ae9384e678189eeeeebefb43988d8876a967ed
--- /dev/null
+++ b/client/src/main/scala/cs214/webapp/client/graphics/TextClientAppInstance.scala
@@ -0,0 +1,178 @@
+package cs214.webapp.client.graphics
+
+import cs214.webapp.UserId
+import cs214.webapp.client.StateMachineClientAppInstance
+import org.scalajs.dom
+import org.scalajs.dom.HTMLInputElement
+import scalatags.JsDom
+import scalatags.JsDom.all.*
+
+enum HTMLAttribute:
+  case cls
+  case style
+
+type CSSProperties = Map[String, String]
+extension (cssProps: CSSProperties)
+  def toCSS(): String = cssProps
+    .map((k, v) => f"${k}: ${v}")
+    .mkString("; ")
+
+/** Text-based UI. Detects mouse events and has an integrated text field */
+abstract class TextClientAppInstance[Event, View](
+    userId: UserId,
+    sendMessage: ujson.Value => Unit,
+    target: dom.Element
+) extends StateMachineClientAppInstance[Event, View](userId, sendMessage, target):
+
+  enum MouseEvent:
+    case Click(button: MouseButton)
+    case MouseDown(button: MouseButton)
+    case MouseUp(button: MouseButton)
+    case HoverEnter
+    case HoverLeave
+
+  /** The console is segmented with [[TextSegment]]s. Each segment represents a
+    * piece of text with its own modifiers and event listeners.
+    *
+    * If multiple parts of your text need to respond independently to mouse
+    * events or need a different style, you should consider separating them in
+    * different [[TextSegment]]s
+    *
+    * @param text
+    *   The text to be displayed
+    * @param onMouseEvent
+    *   How to handle mouse events happening on this text segment
+    * @param cssProperties
+    *   The css properties to inline with this text segment (html style
+    *   attribute)
+    * @param htmlAttributes
+    *   The html attributes to add to this text segment
+    * @param modifiers
+    *   Any additional `JsDom.Modifier`s you may want to add to the element
+    */
+  case class TextSegment(
+      text: String,
+      onMouseEvent: MouseEvent => Unit = _ => (),
+      cssProperties: CSSProperties = Map(),
+      htmlAttributes: Map[HTMLAttribute, String] = Map(),
+      modifiers: scalatags.JsDom.Modifier*
+  )
+
+  /** Transforms a text input from the user into an optional app [[Event]]
+    * @param view
+    *   The current view
+    * @param text
+    *   The input text typed by the user
+    * @return
+    *   An optional application event
+    */
+  def handleTextInput(view: View, text: String): Option[Event]
+
+  /** Transforms a view received by the server into a segmented console display.
+    * Each [[TextSegment]] responds independently to mouse events and defines
+    * its own style.
+    *
+    * @param userId
+    *   The id of the user using this client
+    * @param view
+    *   The view of the application received by the server
+    * @return
+    *   The different text segments of the console
+    */
+  def renderView(userId: UserId, view: View): Vector[TextSegment]
+
+  override def render(userId: UserId, view: View): JsDom.all.Frag =
+    currView = Some(view)
+
+    frag(
+      div(
+        cls := "textapp",
+        div(
+          onclick := (() => focusConsoleInput()),
+          cls := "console",
+          pre(
+            style := "white-space: pre-wrap;",
+            for textSegment <- renderView(userId, view)
+            yield span(
+              textSegment.text,
+              style := textSegment.htmlAttributes.getOrElse(HTMLAttribute.style, textSegment.cssProperties.toCSS()),
+              cls := textSegment.htmlAttributes.getOrElse(HTMLAttribute.cls, ""),
+              textSegment.modifiers,
+              margin := "0",
+              height := "1em",
+              onclick := {
+                (e: dom.MouseEvent) =>
+                  textSegment.onMouseEvent(MouseEvent.Click(MouseButton.from(e.button)))
+              },
+              onmousedown := {
+                (e: dom.MouseEvent) => textSegment.onMouseEvent(MouseEvent.MouseDown(MouseButton.from(e.button)))
+              },
+              onmouseup := { (e: dom.MouseEvent) =>
+                textSegment.onMouseEvent(MouseEvent.MouseUp(MouseButton.from(e.button)))
+              },
+              onmouseover := { (_: dom.MouseEvent) => textSegment.onMouseEvent(MouseEvent.HoverEnter) },
+              onmouseout := { (_: dom.MouseEvent) => textSegment.onMouseEvent(MouseEvent.HoverLeave) }
+            )
+          )
+        ),
+        input(
+          `type` := "text",
+          autofocus := true,
+          id := "console-input",
+          tabindex := 0,
+          onkeydown := handleKeyDown,
+          onblur := {
+            dom.window.setTimeout(
+              () =>
+                focusConsoleInput(),
+              0
+            )
+          }
+        )
+      )
+    )
+
+  private var currView: Option[View] = None
+
+  private def focusConsoleInput(): Unit =
+    dom.document.getElementById("console-input").asInstanceOf[HTMLInputElement].focus()
+
+  private def handleKeyDown(e: dom.KeyboardEvent): Unit = currView
+    .flatMap(view =>
+      if e.keyCode == dom.KeyCode.Enter then
+        e.target match
+          case inputElement: HTMLInputElement =>
+            e.preventDefault()
+            handleTextInput(view, inputElement.value)
+      else
+        None
+    ).foreach(sendEvent)
+
+  override def css: String = super.css +
+    """
+    | .textapp {
+    |   font-size: 1rem;
+    | }
+    |
+    | .textapp input {
+    |   height: 1em;
+    |   width: 100%;
+    |   border: solid 1px black;
+    |   box-sizing: border-box;
+    | }
+    |  
+    |  .textapp .console {
+    |      overflow: hidden;
+    |      padding: 0.5em;
+    |      font-size: 1em;
+    |      display: flex;
+    |      flex-direction: column;
+    |      justify-content: center;
+    |      align-items: start;
+    |      border: solid 1px black;
+    |  }
+    |  
+    |  .textapp .console span {
+    |      box-sizing: border-box;
+    |  }
+    """.stripMargin
diff --git a/client/src/main/scala/cs214/webapp/client/graphics/WebClientAppInstance.scala b/client/src/main/scala/cs214/webapp/client/graphics/WebClientAppInstance.scala
new file mode 100644
index 0000000000000000000000000000000000000000..fe27bd59cf3e067078e727628c96f67a7a05d239
--- /dev/null
+++ b/client/src/main/scala/cs214/webapp/client/graphics/WebClientAppInstance.scala
@@ -0,0 +1,12 @@
+package cs214.webapp.client.graphics
+
+import cs214.webapp.UserId
+import cs214.webapp.client.StateMachineClientAppInstance
+import org.scalajs.dom
+
+/** General web graphics UI, basically a wrapper around raw ScalaJS API */
+abstract class WebClientAppInstance[Event, View](
+    userId: UserId,
+    sendMessage: ujson.Value => Unit,
+    target: dom.Element
+) extends StateMachineClientAppInstance[Event, View](userId, sendMessage, target)
diff --git a/project/build.properties b/project/build.properties
new file mode 100644
index 0000000000000000000000000000000000000000..ee4c672cd0d79d58199c731ea043b4c54eac8744
--- /dev/null
+++ b/project/build.properties
@@ -0,0 +1 @@
+sbt.version=1.10.1
diff --git a/project/plugins.sbt b/project/plugins.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..2357ba0f76c5fb6f42c31f5d853fd4c8b6e33f21
--- /dev/null
+++ b/project/plugins.sbt
@@ -0,0 +1,5 @@
+addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0")
+addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2")
+addSbtPlugin("org.scala-js" % "sbt-jsdependencies" % "1.0.0")
+
+libraryDependencies += "org.scala-js" %% "scalajs-env-jsdom-nodejs" % "1.1.0"
diff --git a/server/src/main/resources/www/static/main.css b/server/src/main/resources/www/static/main.css
new file mode 100644
index 0000000000000000000000000000000000000000..0cfa226f80fff3d6b8c1361cb566bdc131f6fbd7
--- /dev/null
+++ b/server/src/main/resources/www/static/main.css
@@ -0,0 +1,248 @@
+@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,400;0,700;1,400&family=Noto+Color+Emoji&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap');
+
+:root {
+    --rouge: #DA291C;
+    --groseille: #B51F1F;
+    --taupe: #413D3A;
+    --canard: #007480;
+    --leman: #00A79F;
+    --perle: #CAC7C7;
+}
+
+* {
+    box-sizing: border-box;
+}
+
+body,
+html {
+    box-sizing:border-box
+}
+
+html {
+    font-family: 'IBM Plex Sans', sans-serif;
+    font-size: 18px;
+    line-height: 1.3;
+    padding: 0 1rem;
+}
+
+input, select, button {
+    font-size: unset;
+    font-family: unset;
+}
+
+body {
+    max-width:34rem;
+    margin: 1rem auto;
+}
+
+dialog {
+    border: none;
+    outline: thick solid var(--groseille);
+    text-align: center;
+    z-index: 1;
+}
+
+dialog::backdrop {
+    backdrop-filter: blur(0.25rem);
+}
+
+pre, code, kbd, samp {
+    font-family: 'IBM Plex Mono', monospace;
+    line-height: 1.0;
+}
+
+h1, h2, h3, h4, h5, h6 {
+    margin: 1rem 0;
+}
+
+article, aside, details, footer, header, main, nav, section,
+figure, form, ol, p, pre, ul, video, table {
+    margin: 1rem 0;
+}
+
+ol, ul {
+    padding-left: 1em;
+}
+
+header, fieldset, h1.title {
+    border-radius: 0.5rem;
+    padding: 0.5rem;
+}
+
+h1.title {
+    background: var(--rouge);
+    color: white;
+}
+
+h2 {
+    color: var(--canard);
+}
+
+footer h3 {
+    margin-top: 2rem;
+}
+
+p.finished {
+    background: var(--leman);
+    border: thick solid var(--canard);
+    color:white;
+    font-size: 2rem;
+    font-weight: bold;
+    text-align: center;
+}
+
+form {
+    display: flex;
+    flex-direction: column;
+}
+
+fieldset, fieldset:first-child:first-child {
+    margin-top: -0.5rem;
+    align-self: stretch;
+}
+
+fieldset input[type="radio"] {
+    margin: 0.5rem;
+}
+
+fieldset label {
+    padding: 0.25rem;
+}
+
+label {
+    display: inline-block;
+}
+
+select, input, button {
+    padding: 0.5rem;
+}
+
+input[type="submit"], button {
+    padding: 0.5rem 1rem;
+    margin-top: 1rem;
+}
+
+input[type="submit"] {
+    align-self: end;
+}
+
+form > *:first-child {
+    margin-top: 0;
+}
+
+form > *:last-child {
+    margin-bottom: 0;
+}
+
+.grid-form {
+    display: grid;
+    grid-template-columns: max-content auto;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+.grid-form label {
+    text-align: right;
+}
+
+.IpBanner {
+    border: thin solid var(--taupe);
+    word-wrap: break-word;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+}
+
+#banner-actions {
+    width: 100%;
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: center;
+}
+
+#app-list {
+    width: 100%;
+    max-height: 65vh;
+    overflow: auto;
+}
+
+#app-list h5::after {
+    content: "";
+    display: block;
+    width: 100%;
+    height: 1px;
+    background-color: rgba(0, 0, 0, 0.1);
+}
+
+.app-grid {
+    display: grid;
+    grid-gap: 8px;
+
+    padding: 8px;
+}
+
+.app-option {
+    min-height: 0;
+    min-width: 0;
+
+    display: flex;
+    flex-direction: column;
+    justify-content: start;
+    align-items: start;
+
+    overflow: hidden;
+    word-wrap: break-word;
+    text-overflow: ellipsis;
+
+    border-radius: 8px;
+
+    box-shadow: rgba(0, 0, 0, 0.2) 0 2px 1px -1px, rgba(0, 0, 0, 0.14) 0 1px 1px 0, rgba(0, 0, 0, 0.12) 0 1px 3px 0;
+    background-color: rgba(0, 0, 0, 0.05);
+
+    font-size: larger;
+    cursor: pointer;
+}
+
+@media (min-width:1025px) {
+    .app-grid {
+        grid-template-columns: repeat(3, 1fr);
+    }
+    .app-option {
+        aspect-ratio: 11/18;
+    }
+}
+
+
+@media (max-width:1024px)  {
+    .app-grid {
+        grid-template-columns: repeat(2, 1fr);
+    }
+    .app-option {
+        aspect-ratio: 11/22;
+    }
+}
+
+.app-option p {
+    margin: 0;
+    padding: 0 8px;
+    width: 100%;
+}
+
+.app-option-name {
+    font-size: 1rem;
+}
+
+.app-option-description {
+    font-size: 0.7rem;
+    opacity: 0.7;
+}
+
+
+.app-thumbnail {
+    width: 100%;
+    aspect-ratio: 11/11;
+    object-fit: cover;
+}
diff --git a/server/src/main/resources/www/static/webapp.html b/server/src/main/resources/www/static/webapp.html
new file mode 100644
index 0000000000000000000000000000000000000000..3d6d26ed47be82388eec800a78d878db79c97e02
--- /dev/null
+++ b/server/src/main/resources/www/static/webapp.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Scalapp</title>
+    <link rel="stylesheet" href="/static/main.css" />
+  </head>
+  <body>
+    <main id="root"></main>
+    <script type="text/javascript" src="/static/main.js"></script>
+  </body>
+</html>
diff --git a/server/src/main/scala/cs214/webapp/server/ApplicationJVM.scala b/server/src/main/scala/cs214/webapp/server/ApplicationJVM.scala
new file mode 100644
index 0000000000000000000000000000000000000000..d102f6c98ba3bb1811167192c1bea5885b76865e
--- /dev/null
+++ b/server/src/main/scala/cs214/webapp/server/ApplicationJVM.scala
@@ -0,0 +1,35 @@
+package cs214.webapp.server
+
+import web.WebServer
+import cs214.webapp.server.utils.AppsLoader
+import cs214.webapp.Config.HTTP_PORT
+
+/** Main entry point of the server of an application. Starts the server on
+  * construction, call `start()` to initialize the apps.
+  */
+abstract class ApplicationJVM extends cask.Main:
+
+  override def host: String = "0.0.0.0"
+  override def port: Int = HTTP_PORT
+  val allRoutes = Seq(web.WebServerRoutes())
+  println(s"[WebServer] started on ${host}:${port}.")
+
+  def start(): Unit =
+    // Find all apps and register them.
+    val classes = AppsLoader.loadApps()
+
+    classes.foreach: candidateClass =>
+      val appLogicInstance = candidateClass
+        .getDeclaredConstructor()
+        .newInstance()
+        .asInstanceOf[StateMachine[?, ?, ?]]
+
+      if !WebServer.isRegistered(appLogicInstance) then
+        val appPackageName = appLogicInstance.getClass.getPackageName.replaceFirst("apps\\.", "")
+        require(
+          appPackageName.contains(appLogicInstance.appInfo.id),
+          s"Found invalid appId: Package name is \"$appPackageName\"" +
+            s" but id specified in app logic is \"${appLogicInstance.appInfo.id}\"." +
+            s" These values should match!"
+        )
+        WebServer.register(appLogicInstance)
diff --git a/server/src/main/scala/cs214/webapp/server/StateMachine.scala b/server/src/main/scala/cs214/webapp/server/StateMachine.scala
new file mode 100644
index 0000000000000000000000000000000000000000..82f774827de1fe52d944ed50892470daff150833
--- /dev/null
+++ b/server/src/main/scala/cs214/webapp/server/StateMachine.scala
@@ -0,0 +1,96 @@
+package cs214.webapp
+package server
+
+import scala.util.Try
+
+/** A state machine describes the core logic of an application.
+  *
+  * @tparam Event
+  *   The type of the events that this application expects.
+  * @tparam State
+  *   The type of internal states of this application.
+  * @tparam View
+  *   The type of the views sent to the clients.
+  */
+abstract class StateMachine[Event, State, View]:
+  /** An identifier for this app type. */
+  val appInfo: cs214.webapp.AppInfo
+
+  /** Provides serialization methods for events and views. */
+  val wire: AppWire[Event, View]
+
+  /** Initializes a new application. */
+  def init(clients: Seq[UserId]): State
+
+  /** Simulates a transition of the state machine.
+    *
+    * @param state
+    *   The current [[State]] of the application.
+    * @param userId
+    *   The [[UserId]] of the user triggering the event
+    * @param event
+    *   The [[Event]] received
+    * @return
+    *   A sequence of commands to be sent to clients, or [[scala.util.Failure]]
+    *   if the event is illegal given the current state.
+    */
+  def transition(state: State)(userId: UserId, event: Event): Try[Seq[Action[State]]]
+
+  /** Projects an application state to produce a user-specific view.
+    *
+    * This function hides any details of the application's state that the user
+    * should not have access to.
+    *
+    * @param state
+    *   A [[State]] of the application.
+    * @param userId
+    *   The [[UserId]] of a user.
+    * @return
+    *   A view for user [[uid]].
+    */
+  def project(state: State)(userId: UserId): View
+
+/** Extend this class if you want your state machine to receive a [[Tick]] event
+  * in addition to your own custom events. This event will be received every
+  * [[clockPeriodMs]] milliseconds
+  *
+  * @tparam Event
+  *   The type of the events that this application expects.
+  * @tparam State
+  *   The type of internal states of this application.
+  * @tparam View
+  *   The type of the views sent to the clients.
+  */
+abstract class ClockDrivenStateMachine[Event, State, View]
+    extends StateMachine[Tick | Event, State, View]:
+
+  /** The [[Tick]] event will be received by the state machine every
+    * [[clockPeriodMs]] milliseconds.
+    */
+  val clockPeriodMs: Int
+
+  /** Provides serialization methods for events and views of a
+    * [[ClockDrivenStateMachine]].
+    */
+  val clockDrivenWire: AppWire[Event, View]
+
+  override object wire extends AppWire[Tick | Event, View]:
+    override object eventFormat extends WireFormat[Tick | Event]:
+      override def decode(json: ujson.Value): Try[Tick | Event] =
+        Try:
+          val isClockEvent =
+            json.objOpt
+              .flatMap(_.get(ClockDrivenStateMachine.CLOCK_EVENT_HEADER))
+              .exists(_.bool)
+
+          if isClockEvent then TickEventFormat.decode(json.obj.get("tick").get).get
+          else clockDrivenWire.eventFormat.decode(json).get
+
+      override def encode(t: Tick | Event): ujson.Value =
+        throw IllegalStateException("This shouldn't be used. Please use the ScalApp's wire instead.")
+
+    override val viewFormat: WireFormat[View] = clockDrivenWire.viewFormat
+
+object ClockDrivenStateMachine:
+  val CLOCK_EVENT_HEADER = "X-Clock"
+  val CLOCK_EVENT_TICK_HEADER = "tick"
diff --git a/server/src/main/scala/cs214/webapp/server/utils/AppsLoader.scala b/server/src/main/scala/cs214/webapp/server/utils/AppsLoader.scala
new file mode 100644
index 0000000000000000000000000000000000000000..6c47e35adfdb7447a72a361835ce7151f8dde512
--- /dev/null
+++ b/server/src/main/scala/cs214/webapp/server/utils/AppsLoader.scala
@@ -0,0 +1,20 @@
+package cs214.webapp.server
+package utils
+
+import java.lang.reflect.Modifier
+import scala.jdk.CollectionConverters.*
+import org.reflections.Reflections
+
+/** Finds all apps under the `apps` package. A class extending `StateMachine` is
+  * considered to be an app.
+  */
+object AppsLoader:
+  private val APPS_PACKAGE = "apps"
+
+  def loadApps(): List[Class[?]] =
+    val reflections = new Reflections(APPS_PACKAGE)
+    reflections
+      .getSubTypesOf(classOf[StateMachine[?, ?, ?]])
+      .asScala
+      .toList
+      .filterNot(cls => Modifier.isAbstract(cls.getModifiers) || cls.isInterface())
diff --git a/server/src/main/scala/cs214/webapp/server/web/ServerApp.scala b/server/src/main/scala/cs214/webapp/server/web/ServerApp.scala
new file mode 100644
index 0000000000000000000000000000000000000000..5b9b699eb453b7fd8a8a93ad9d1a92693c4b9deb
--- /dev/null
+++ b/server/src/main/scala/cs214/webapp/server/web/ServerApp.scala
@@ -0,0 +1,67 @@
+package cs214.webapp
+package server
+package web
+
+import scala.util.Try
+
+/** Server-side apps definition and abstractions */
+
+/** A server-side app, allows creating new `ServerAppInstance` */
+private[web] sealed trait ServerApp:
+  def appInfo: AppInfo
+  def init(clients: Seq[UserId]): ServerAppInstance
+
+/** A server-side app instance */
+private[web] sealed trait ServerAppInstance:
+  val appInfo: AppInfo
+  val registeredUsers: Seq[String]
+
+  /** Compute setup actions to send to client. */
+  def respondToNewClient(userId: UserId): ujson.Value
+
+  /** Simulate one step of the underlying state machine. */
+  def transition(clients: Seq[UserId])(
+      userId: UserId,
+      jsEvent: ujson.Value
+  ): Try[(Map[UserId, Seq[Action[ujson.Value]]], ServerAppInstance)]
+
+/** Wraps a state machine into a server-side app. */
+private[web] case class StateMachineServerApp[E, S, V](sm: StateMachine[E, S, V]) extends ServerApp:
+  def appInfo: AppInfo = sm.appInfo
+
+  def init(clients: Seq[UserId]): ServerAppInstance =
+    StateMachineServerAppInstance(sm = sm, registeredUsers = clients, state = sm.init(clients))
+
+/** Wraps a state machine and its state into a server-side app instance. */
+private[web] case class StateMachineServerAppInstance[E, S, V](
+    sm: StateMachine[E, S, V],
+    registeredUsers: Seq[UserId],
+    state: S
+) extends ServerAppInstance:
+
+  val appInfo: AppInfo = sm.appInfo
+
+  def respondToNewClient(userId: UserId): ujson.Value =
+    sm.wire.viewFormat.encode(sm.project(state)(userId))
+
+  def transition(clients: Seq[UserId])(
+      uid: UserId,
+      eventJs: ujson.Value
+  ): Try[(Map[UserId, Seq[Action[ujson.Value]]], ServerAppInstance)] = Try:
+    val event = sm.wire.eventFormat.decode(eventJs).get
+    if WebServer.debug then
+      println(f"[debug] got event: $event")
+
+    val actions = sm.transition(state)(uid, event).get
+    if WebServer.debug then
+      println(s"[debug] transition produced actions ${actions.mkString(", ")}")
+
+    val clientActions = clients.map: uid =>
+      uid -> projectFor(uid)(actions).map(_.map(sm.wire.viewFormat.encode))
+    val nextState =
+      actions.collect { case Action.Render(t) => t }.lastOption.getOrElse(state)
+
+    (clientActions.toMap, copy(state = nextState))
+
+  private def projectFor(userId: UserId)(actions: Seq[Action[S]]): Seq[Action[V]] =
+    actions.map(_.map(st => sm.project(st)(userId)))
diff --git a/server/src/main/scala/cs214/webapp/server/web/WebServer.scala b/server/src/main/scala/cs214/webapp/server/web/WebServer.scala
new file mode 100644
index 0000000000000000000000000000000000000000..79c41625a8a5f8622751f1455767c4fcb3317f0a
--- /dev/null
+++ b/server/src/main/scala/cs214/webapp/server/web/WebServer.scala
@@ -0,0 +1,143 @@
+package cs214.webapp
+package server
+package web
+
+import java.util.concurrent.atomic.AtomicBoolean
+import scala.collection.immutable.Map
+import scala.collection.{concurrent, immutable, mutable}
+import scala.util.{Failure, Success, Try}
+
+/** Contains the web server state and functionalities */
+object WebServer:
+  private[web] val debug = false
+
+  private[web] case class RunningClock(
+      clockId: UserId,
+      running: AtomicBoolean
+  )
+
+  private[web] case class RunningServerAppInstance(
+      instance: ServerAppInstance,
+      connectedUsersCount: Int
+  )
+
+  /** Mapping from app ids to their app class */
+  private[web] val appDirectory: mutable.Map[AppId, ServerApp] = mutable.Map()
+
+  /** Mapping from app instance ids to their running instances */
+  private[web] val apps: concurrent.Map[InstanceId, RunningServerAppInstance] = concurrent.TrieMap()
+
+  /** Mapping from app instance ids to the attached running clocks */
+  private[web] val clocks: concurrent.Map[InstanceId, RunningClock] = concurrent.TrieMap()
+
+  private[web] lazy val webSocketServer: WebSocketsCollection = new WebSocketsCollection(Config.WS_PORT)
+
+  /** Registers the given state-machine based app. */
+  def register[E, V, S](sm: StateMachine[E, V, S]): Unit =
+    register(StateMachineServerApp(sm))
+
+  def isRegistered(smInstance: StateMachine[?, ?, ?]): Boolean =
+    appDirectory.contains(smInstance.appInfo.id)
+
+  /** Registers the given app */
+  private def register(app: ServerApp): Unit =
+    // Populate `appDirectory`
+    val appId = app.appInfo.id
+    this.appDirectory.put(appId, app)
+    println(f"[$appId] registered")
+
+  private[web] def createInstance(appId: AppId, clients: Seq[UserId]): InstanceId =
+    // Populate `apps`
+    val instanceId = java.util.UUID.randomUUID.toString
+    apps(instanceId) = RunningServerAppInstance(appDirectory(appId).init(clients), 0)
+
+    apps(instanceId).instance match
+      case StateMachineServerAppInstance(sm: ClockDrivenStateMachine[?, ?, ?], _, _) =>
+        startClockFor(appId, instanceId, sm.clockPeriodMs)
+      case StateMachineServerAppInstance(_, _, _) => ()
+
+    webSocketServer.initializeApp(instanceId)
+    println(f"[$appId] instance created $instanceId")
+    instanceId
+
+  private[web] def shutdownApp(instanceId: InstanceId): Unit =
+    // Remove references of this app
+    val appId = apps.remove(instanceId).map(_.instance.appInfo.id)
+    clocks.get(instanceId).map(_._2).foreach(_.set(false))
+    clocks.remove(instanceId)
+    if appId.isDefined then
+      println(s"[${appId.get}][$instanceId] shut down")
+
+  private def startClockFor(appId: String, instanceId: InstanceId, clockResolutionMs: Int): Unit =
+    object Clock extends Thread:
+      override def run(): Unit =
+        val clockName = s"clock-$instanceId"
+        val running = AtomicBoolean(true)
+        clocks.put(instanceId, RunningClock(clockName, running))
+        setName(clockName)
+        println(f"[$appId][$instanceId] clock started")
+        while running.get() do
+          handleMessage(
+            instanceId,
+            clockName,
+            ujson.Obj(
+              ClockDrivenStateMachine.CLOCK_EVENT_HEADER -> true,
+              ClockDrivenStateMachine.CLOCK_EVENT_TICK_HEADER -> TickEventFormat.encode(Tick(System.currentTimeMillis))
+            )
+          )
+          Thread.sleep(clockResolutionMs)
+
+        println(s"[$appId][$instanceId] clock stopped")
+
+    Clock.start()
+
+  /** Creates a transition from the current state of the application to the next
+    * state of the application.
+    *
+    * Given a [[Seq]] of [[UserId]], corresponding to the clients connected to
+    * the app, accepts and event and the event's author's id. The [[apps]] is
+    * then used to handle the event and gives a sequence of actions for each
+    * user.
+    *
+    * @param clients
+    *   The set of clients currently connected in the application.
+    * @param uid
+    *   The user id of the user who triggered the event.
+    * @param event
+    *   The event triggered by the user.
+    * @return
+    *   A map which links each user to a queue of views they should receive.
+    */
+  private def transition(clients: Seq[UserId], instanceId: InstanceId)(
+      uid: UserId,
+      event: ujson.Value
+  ): Try[immutable.Map[UserId, Seq[Action[ujson.Value]]]] =
+    val instance = apps(instanceId).instance
+    instance.synchronized {
+      instance.transition(clients)(uid, event).map {
+        case (views, newApp) =>
+          apps.update(instanceId, apps(instanceId).copy(instance = newApp))
+          views
+      }
+    }
+
+  /** Dispatches the given `msg`, sent by `userId` for app instance `instanceId`
+    * appropriately to clients
+    */
+  private[web] def handleMessage(instanceId: InstanceId, userId: UserId, msg: ujson.Value): Unit =
+    val transitionResult = transition(webSocketServer.connectedClients(instanceId), instanceId)(userId, msg)
+    val serverResponse = transitionResult match
+      case Failure(exception) =>
+        // If sender is clock, send exception to all clients
+        val senderIsClock = clocks.exists((clockId, _) => clockId == userId) // clocks use their id as userId
+        val recipients =
+          if senderIsClock then webSocketServer.connectedClients(instanceId)
+          else Seq(userId)
+
+        for recipient <- recipients do
+          webSocketServer.send(instanceId, recipient):
+            EventResponse.Wire.encode(Failure(exception))
+      case Success(userActions) =>
+        for (uid, actions) <- userActions do
+          webSocketServer.send(instanceId, uid):
+            EventResponse.Wire.encode(Success(actions))
diff --git a/server/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala b/server/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala
new file mode 100644
index 0000000000000000000000000000000000000000..ae1ada98224502fb4716564065c56ba74be72b62
--- /dev/null
+++ b/server/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala
@@ -0,0 +1,76 @@
+package cs214.webapp
+package server
+package web
+
+import java.net.InetAddress
+import scala.jdk.CollectionConverters.*
+import scala.util.Try
+import cask.endpoints.JsonData
+
+/** HTTP routes of the WebServer */
+private[server] final case class WebServerRoutes()(using cc: castor.Context, log: cask.Logger) extends cask.Routes:
+  /** Paths where the static content served by the server is stored */
+  private val WEB_SRC_PATH = "www/static/"
+
+  /** HTML page to serve when accessing the server `/` and `/app/...` path */
+  private def HTML_STATIC_FILE =
+    cask.model.StaticResource(
+      WEB_SRC_PATH + "webapp.html",
+      classOf[cask.staticResources].getClassLoader,
+      Seq("Content-Type" -> "text/html")
+    )
+
+  lazy val hostAddress: String =
+    val addresses =
+      for
+        intf <- java.net.NetworkInterface.getNetworkInterfaces.asScala
+        if intf.isUp
+        _ = if WebServer.debug then println(f"[debug] found interface $intf")
+        addr <- intf.getInetAddresses.asScala
+        if (addr.isInstanceOf[java.net.Inet4Address]
+          && !addr.isLinkLocalAddress
+          && !addr.isLoopbackAddress)
+        _ = if WebServer.debug then println(f"[debug] found address ${addr.getHostAddress}")
+      yield addr.getHostAddress
+    Try(addresses.toList.head).getOrElse(InetAddress.getLocalHost.getHostAddress)
+
+  @cask.get("/")
+  def getIndexFile() = HTML_STATIC_FILE
+
+  @cask.staticResources("/static")
+  def getStaticResources() = WEB_SRC_PATH
+
+  // For all /app subsegments, provide the HTML page
+  @cask.get("/app")
+  def getApp(segments: cask.RemainingPathSegments) = HTML_STATIC_FILE
+
+  @cask.getJson(f"/${Endpoints.Api.listApps}")
+  def getListApps() =
+    ListAppsResponse.Wire.encode(ListAppsResponse(WebServer.appDirectory.values.map(_.appInfo).toSeq))
+
+  @cask.getJson(f"/${Endpoints.Api.instanceInfo}")
+  def getAppInfo(instanceId: InstanceId) =
+    val response: cask.Response[JsonData] =
+      if WebServer.apps.contains(instanceId) then
+        val app = WebServer.apps(instanceId).instance
+        val shareUrl = f"http://$hostAddress:${Config.HTTP_PORT}/app/${app.appInfo.id}/$instanceId/"
+        val wsEndpoint = f"ws://{{hostName}}:${Config.WS_PORT}/$instanceId/{{userId}}"
+        val response = InstanceInfoResponse(instanceId, app.registeredUsers, wsEndpoint, shareUrl)
+        InstanceInfoResponse.Wire.encode(response)
+      else
+        cask.Response(f"Unknown instance id $instanceId", 400)
+    response
+
+  @cask.post(f"/${Endpoints.Api.createInstance}")
+  def postInitApp(request: cask.Request) =
+    val response: cask.Response[JsonData] =
+      val req = CreateInstanceRequest.Wire.decode(ujson.read(request.text()))
+      if req.isFailure then
+        cask.Response(f"Unable to decode data: ${request.text()}", 400)
+      else
+        val appId = WebServer.createInstance(req.get.appName, req.get.userIds)
+        CreateInstanceResponse.Wire.encode(CreateInstanceResponse(appId))
+    response
+
+  WebServer.webSocketServer.run()
+  initialize()
diff --git a/server/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala b/server/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala
new file mode 100644
index 0000000000000000000000000000000000000000..ca2f9ab3f0b0e473205279677f257d79e4a6508d
--- /dev/null
+++ b/server/src/main/scala/cs214/webapp/server/web/WebSocketsCollection.scala
@@ -0,0 +1,111 @@
+package cs214.webapp
+package server.web
+
+import org.java_websocket.WebSocket
+import org.java_websocket.handshake.ClientHandshake
+import org.java_websocket.server.WebSocketServer
+import java.net.{InetSocketAddress, URLDecoder}
+import scala.collection.mutable
+import scala.util.{Success, Failure, Try}
+
+/** A collection of websockets organized by app Id and client */
+private[web] final class WebSocketsCollection(val port: Int):
+  def onMessageReceive(appId: InstanceId, uid: UserId, msg: ujson.Value): Unit =
+    WebServer.handleMessage(appId, uid, msg)
+
+  // When the user joins the app, the projection of the current state is sent to them
+  def onClientConnect(instanceId: InstanceId, userId: UserId): Unit =
+    val instance = WebServer.apps(instanceId)
+    val newInstance = instance.copy(connectedUsersCount = instance.connectedUsersCount + 1)
+    WebServer.apps(instanceId) = newInstance
+    val js = newInstance.instance.respondToNewClient(userId)
+    send(instanceId, userId):
+      EventResponse.Wire.encode(Success(List(Action.Render(js))))
+    println(f"[${newInstance.instance.appInfo.id}][$instanceId] client \"$userId\" connected")
+
+  def onClientDisconnect(instanceId: InstanceId, userId: UserId): Unit =
+    val instance = WebServer.apps(instanceId)
+    val newInstance = instance.copy(connectedUsersCount = instance.connectedUsersCount - 1)
+    WebServer.apps(instanceId) = newInstance
+
+    val appId = newInstance.instance.appInfo.id
+    println(f"[$appId][$instanceId] client \"$userId\" disconnected")
+    if WebServer.apps(instanceId).connectedUsersCount <= 0 then
+      println(s"[$appId][$instanceId] 0 connected client")
+      WebServer.shutdownApp(instanceId)
+
+  /** All the sessions currently in use, mapping instance ids to an actual
+    * socket object that a client owns
+    */
+  private val sessions: mutable.Map[InstanceId, Seq[(UserId, WebSocket)]] =
+    mutable.Map()
+
+  /** Initialize an empty session list for the given app instance */
+  def initializeApp(instanceId: InstanceId) =
+    require(!sessions.contains(instanceId))
+    sessions(instanceId) = Seq.empty
+
+  /** Runs k with parameters parsed from the websocket connection path. [[k]] is
+    * run while synchronizing on [[sessions]]
+    *
+    * The connection should be on "ws://…/[app_instance_id]/[user_id]" for the
+    * parsing to function properly.
+    */
+  private def withSessionParams[T](socket: WebSocket)(k: (InstanceId, UserId) => T) =
+    sessions.synchronized:
+      try
+        val components = socket.getResourceDescriptor.split("/").takeRight(2)
+        val decoded = components.map(s => URLDecoder.decode(s, "UTF-8"))
+        decoded match
+          case Array(appId, userId) =>
+            if !sessions.contains(appId) then
+              throw IllegalArgumentException("Error: Invalid app ID")
+            k(appId, userId)
+          case _ => throw Exception("Error: Invalid path")
+      catch
+        case t =>
+          socket.send(SocketResponseWire.encode(util.Failure(t)).toString)
+          socket.close()
+
+  /** A single websocket server handling multiple apps and clients. */
+  private val server: WebSocketServer = new WebSocketServer(InetSocketAddress("0.0.0.0", port)):
+    override def onOpen(socket: WebSocket, handshake: ClientHandshake): Unit =
+      withSessionParams(socket): (appId, clientId) =>
+        sessions(appId) = sessions(appId) :+ (clientId, socket)
+        onClientConnect(appId, clientId)
+
+    override def onClose(socket: WebSocket, code: Int, reason: String, remote: Boolean): Unit =
+      withSessionParams(socket): (appId, clientId) =>
+        sessions(appId) = sessions(appId).filter(_._2 != socket) // Unregister the session
+        onClientDisconnect(appId, clientId)
+
+    override def onMessage(socket: WebSocket, message: String): Unit =
+      withSessionParams(socket): (appId, clientId) =>
+        onMessageReceive(appId, clientId, ujson.read(message))
+
+    override def onError(socket: WebSocket, ex: Exception): Unit =
+      // Only report the error, onClosed is called even when an error occurs
+      throw new RuntimeException(ex)
+
+    override def onStart(): Unit =
+      val addr = server.getAddress
+      println(s"[WebSocket] server started on ${addr.getHostName}:${addr.getPort}.")
+
+  /** Starts the server asynchronously. */
+  def run(): Unit =
+    server.setReuseAddr(true) // Ignore leftover connections from pending processes
+    Thread(() => server.run(), "Socket Thread").start()
+
+  /** Enumerates clients connected to [[appId]]. */
+  def connectedClients(appId: InstanceId): Seq[UserId] =
+    sessions.get(appId).map(_.map(_._1).distinct).getOrElse(Seq())
+
+  /** Sends a message to a specific client. */
+  def send(appId: InstanceId, clientId: UserId)(message: ujson.Value) =
+    val wrapped = SocketResponseWire.encode(util.Success(message)).toString
+    sessions.synchronized:
+      for
+        (userId, socket) <- sessions.getOrElse(appId, Seq.empty)
+        if userId == clientId
+      do
+        socket.send(wrapped)
diff --git a/shared/shared/src/main/scala/cs214/webapp/Apps.scala b/shared/shared/src/main/scala/cs214/webapp/Apps.scala
new file mode 100644
index 0000000000000000000000000000000000000000..c5f0f7dd327272608cf2a67bc331c63d3b1f7891
--- /dev/null
+++ b/shared/shared/src/main/scala/cs214/webapp/Apps.scala
@@ -0,0 +1,44 @@
+package cs214.webapp
+
+import ujson.Value
+
+import scala.util.Try
+
+case class AppInfo(
+    /** A lowercase letters ([a-z]) only app identifier (for example
+      * "tictactoe")
+      */
+    id: AppId,
+    /** The display name of the application (for example "Tic Tac Toe"), less
+      * than 32 characters
+      */
+    name: String,
+    /** A description of the application, less than 160 characters */
+    description: String,
+    /** The current year for the course */
+    year: Int
+):
+  private val MAX_APP_DESC_LEN = 160
+  private val MAX_APP_NAME_LEN = 32
+  private val ID_PATTERN = "^[a-z-_]+$"
+
+  require(id.matches(ID_PATTERN), f"App id \"$id\" is invalid. It should match this pattern: $ID_PATTERN")
+  require(name.length <= MAX_APP_NAME_LEN, s"App name \"$name\" is too long (> $MAX_APP_NAME_LEN chars)")
+  require(
+    description.length <= MAX_APP_DESC_LEN,
+    s"App description \"${description}\" is too long (> $MAX_APP_DESC_LEN chars)"
+  )
+
+/** Provides all the necessary encoding/decoding methods for an application. */
+trait AppWire[Event, View]:
+  val eventFormat: WireFormat[Event]
+  val viewFormat: WireFormat[View]
+
+case class Tick(systemMillis: Long)
+
+object TickEventFormat extends WireFormat[Tick]:
+  override def decode(json: Value): Try[Tick] =
+    Try:
+      Tick(json.obj.get("millis").get.num.toLong)
+
+  override def encode(t: Tick): Value = ujson.Obj("type" -> "Tick", "millis" -> ujson.Num(t.systemMillis.toDouble))
diff --git a/shared/shared/src/main/scala/cs214/webapp/Common.scala b/shared/shared/src/main/scala/cs214/webapp/Common.scala
new file mode 100644
index 0000000000000000000000000000000000000000..49d2bc94826ac2c25e5922171dc8782cb6d940fc
--- /dev/null
+++ b/shared/shared/src/main/scala/cs214/webapp/Common.scala
@@ -0,0 +1,47 @@
+package cs214.webapp
+
+object Config:
+  /** Which port the websocket server uses */
+  val WS_PORT = 9090
+
+  /** Which port the HTTP server uses */
+  val HTTP_PORT = 8080
+
+/** Unique identifier for an app */
+type AppId = String
+
+/** Unique identifier for an instance of an app */
+type InstanceId = String
+
+/** Unique identifier for users */
+type UserId = String
+
+/** Encodes an object of type [[T]] to a [[ujson.Value]] */
+trait Encoder[T]:
+  def encode(t: T): ujson.Value
+
+/** Decodes an object of type [[T]] from a [[ujson.Value]] */
+trait Decoder[T]:
+  def decode(json: ujson.Value): util.Try[T]
+
+/** Provides a way to decode and encode an object of type [[T]] to [[Value]] */
+trait WireFormat[T] extends Encoder[T] with Decoder[T]
+
+/** HTTP endpoints */
+object Endpoints:
+  sealed abstract case class Api(path: String):
+    val root = "/api"
+    override def toString = f"$root/$path"
+
+  object Api:
+    object listApps extends Api("list-apps")
+    object createInstance extends Api("create-instance")
+    object instanceInfo extends Api("instance-info")
+
+trait RegistrationProvider:
+  def register(): Unit
+
+/** Using ??? in a val breaks worksheet evaluation, so below we use TODO
+  * instead. This TODO value is a hack!
+  */
+def TODO[A]: A = null.asInstanceOf[A]
diff --git a/shared/shared/src/main/scala/cs214/webapp/Exceptions.scala b/shared/shared/src/main/scala/cs214/webapp/Exceptions.scala
new file mode 100644
index 0000000000000000000000000000000000000000..b7552ed292ccab746608c7e89d4a9c94a1f960dc
--- /dev/null
+++ b/shared/shared/src/main/scala/cs214/webapp/Exceptions.scala
@@ -0,0 +1,19 @@
+package cs214.webapp
+
+/** An exception generated by an application */
+class AppException(message: String) extends Exception(message)
+
+/** The app was waiting for a different user. */
+case class NotYourTurnException()
+    extends AppException("It is not your turn!")
+
+/** User attempted illegal move.
+  * @param reason
+  *   Why the move is illegal
+  */
+case class IllegalMoveException(reason: String)
+    extends AppException(s"Invalid move: $reason!")
+
+/** There was an error during the parsing of a JSON message. */
+case class DecodingException(msg: String)
+    extends AppException(f"Error while trying to decode JSON message: $msg.")
diff --git a/shared/shared/src/main/scala/cs214/webapp/Messages.scala b/shared/shared/src/main/scala/cs214/webapp/Messages.scala
new file mode 100644
index 0000000000000000000000000000000000000000..7457ee2e395c31f8d79041bcab7394747501a7b2
--- /dev/null
+++ b/shared/shared/src/main/scala/cs214/webapp/Messages.scala
@@ -0,0 +1,113 @@
+package cs214.webapp
+
+import ujson.*
+
+import java.util.UUID
+import scala.util.{Failure, Success, Try}
+
+/** An action that client should perform */
+enum Action[+StateOrView]:
+  /** Show a message box. */
+  case Alert(msg: String)
+
+  /** Wait before processing the next action. */
+  case Pause(durationMs: Int)
+
+  /** Redraw the UI with new data. */
+  case Render(st: StateOrView)
+
+  def map[T](f: StateOrView => T): Action[T] =
+    this match
+      case Alert(msg)        => Alert(msg)
+      case Pause(durationMs) => Pause(durationMs)
+      case Render(st)        => Render(f(st))
+
+object Action:
+  object Wire extends WireFormat[Action[Value]]:
+    def encode(t: Action[Value]): Value =
+      t match
+        case Alert(msg)        => Obj("type" -> "Alert", "msg" -> msg)
+        case Pause(durationMs) => Obj("type" -> "Pause", "durationMs" -> durationMs)
+        case Render(js)        => Obj("type" -> "Render", "js" -> js)
+
+    def decode(json: Value): Try[Action[Value]] = Try:
+      json("type").str match
+        case "Alert"  => Alert(json("msg").str)
+        case "Pause"  => Pause(json("durationMs").num.toInt)
+        case "Render" => Render(json("js"))
+        case _        => throw DecodingException(f"Unexpected action: $json")
+
+/** A response to an event */
+type EventResponse = Try[Seq[Action[ujson.Value]]]
+object EventResponse:
+  val Wire: WireFormat[EventResponse] = TryWire(SeqWire(Action.Wire))
+
+/** A response to a socket message */
+type SocketResponse = Try[ujson.Value]
+val SocketResponseWire = TryWire(IdentityWire)
+
+/** A response to the list-apps query */
+case class ListAppsResponse(apps: Seq[AppInfo])
+
+object ListAppsResponse:
+  object Wire extends WireFormat[ListAppsResponse]:
+    def encode(t: ListAppsResponse): ujson.Value =
+      ujson.Obj("apps" -> Arr(t.apps.map {
+        case AppInfo(id, name, description, year) =>
+          Obj("id" -> id, "name" -> name, "desc" -> description, "year" -> year)
+      }*))
+    def decode(js: ujson.Value): Try[ListAppsResponse] = Try:
+      ListAppsResponse(
+        js("apps").arr
+          .map(_.obj)
+          .map(js =>
+            AppInfo(
+              id = js("id").str,
+              name = js("name").str,
+              description = js("desc").str,
+              year = js("year").num.toInt
+            )
+          )
+          .to(Seq)
+      )
+
+/** An HTTP request sent to create a new application instance for the server */
+case class CreateInstanceRequest(appName: String, userIds: Seq[UserId])
+
+object CreateInstanceRequest:
+  object Wire extends WireFormat[CreateInstanceRequest]:
+    val strsWire = SeqWire(StringWire)
+    def encode(t: CreateInstanceRequest): ujson.Value =
+      ujson.Obj("appName" -> Str(t.appName), "userIds" -> strsWire.encode(t.userIds))
+    def decode(js: ujson.Value): Try[CreateInstanceRequest] = Try:
+      CreateInstanceRequest(js("appName").str, strsWire.decode(js("userIds")).get)
+
+case class CreateInstanceResponse(instanceId: InstanceId)
+
+object CreateInstanceResponse:
+  object Wire extends WireFormat[CreateInstanceResponse]:
+    def encode(t: CreateInstanceResponse): ujson.Value =
+      ujson.Obj("instanceId" -> Str(t.instanceId))
+    def decode(js: ujson.Value): Try[CreateInstanceResponse] = Try:
+      CreateInstanceResponse(js("instanceId").str)
+
+/** The response to an AppInfo query */
+case class InstanceInfoResponse(instanceId: InstanceId, userIds: Seq[UserId], wsEndpoint: String, shareUrl: String)
+
+object InstanceInfoResponse:
+  val strsWire = SeqWire(StringWire)
+  object Wire extends WireFormat[InstanceInfoResponse]:
+    def encode(t: InstanceInfoResponse): ujson.Value =
+      ujson.Obj(
+        "instanceId" -> t.instanceId,
+        "userIds" -> strsWire.encode(t.userIds),
+        "wsEndpoint" -> t.wsEndpoint,
+        "shareUrl" -> t.shareUrl
+      )
+    def decode(js: ujson.Value): Try[InstanceInfoResponse] = Try:
+      InstanceInfoResponse(
+        js("instanceId").str,
+        strsWire.decode(js("userIds")).get,
+        js("wsEndpoint").str,
+        js("shareUrl").str
+      )
diff --git a/shared/shared/src/main/scala/cs214/webapp/Wires.scala b/shared/shared/src/main/scala/cs214/webapp/Wires.scala
new file mode 100644
index 0000000000000000000000000000000000000000..2c7fe460056165a13995de050f68ca9826fe0ce4
--- /dev/null
+++ b/shared/shared/src/main/scala/cs214/webapp/Wires.scala
@@ -0,0 +1,84 @@
+package cs214.webapp
+
+import ujson.*
+
+import scala.util.{Failure, Success, Try}
+
+object IdentityWire extends WireFormat[ujson.Value]:
+  def encode(t: ujson.Value): Value = t
+  def decode(js: Value): Try[ujson.Value] = Success(js)
+
+object BooleanWire extends WireFormat[Boolean]:
+  def encode(t: Boolean): Value = Bool(t)
+  def decode(js: Value): Try[Boolean] = Try(js.bool)
+
+object StringWire extends WireFormat[String]:
+  def encode(t: String): Value = Str(t)
+  def decode(js: Value): Try[String] = Try(js.str)
+
+object IntWire extends WireFormat[Int]:
+  def encode(t: Int): Value = Num(t)
+  def decode(js: Value): Try[Int] = Try(js.num.toInt)
+
+case class OptionWire[T](wt: WireFormat[T]) extends WireFormat[Option[T]]:
+  def encode(o: Option[T]): ujson.Value =
+    o match
+      case None    => Obj()
+      case Some(t) => Obj("get" -> wt.encode(t))
+  def decode(js: ujson.Value): Try[Option[T]] = Try:
+    js.obj.get("get").map(wt.decode(_).get)
+
+case class TryWire[T](wt: WireFormat[T]) extends WireFormat[Try[T]]:
+  def encode(t: Try[T]): Value =
+    t match
+      case Failure(exn) =>
+        Obj(
+          "type" -> "failure",
+          "msg" -> Str(exn.getMessage),
+          "stacktrace" -> exn.getStackTrace.mkString("StackTrace(", ", ", ")")
+        )
+      case Success(t) =>
+        Obj("type" -> "success", "get" -> wt.encode(t))
+
+  def decode(json: Value): Try[Try[T]] = Try:
+    json("type").str match
+      case "failure" =>
+        Failure(Exception(json("msg").strOpt.getOrElse("Unkown error")))
+      case "success" =>
+        Success(wt.decode(json("get")).get)
+      case _ =>
+        throw DecodingException(f"Unexpected try: $json")
+
+case class SeqWire[T](wt: WireFormat[T]) extends WireFormat[Seq[T]]:
+  def encode(s: Seq[T]): ujson.Value =
+    Arr(s.map(wt.encode)*)
+  def decode(js: ujson.Value): Try[Seq[T]] = Try:
+    js.arr.map(wt.decode(_).get).toSeq
+
+case class CastWire[T1, T2](w2: WireFormat[T2], enc: T1 => T2, dec: T2 => Try[T1])
+    extends WireFormat[T1]:
+  def encode(t1: T1): ujson.Value =
+    w2.encode(enc(t1))
+  def decode(js: ujson.Value): Try[T1] =
+    w2.decode(js).flatMap(dec)
+
+class SetWire[T](wt: WireFormat[T])
+    extends CastWire[Set[T], Seq[T]](
+      SeqWire(wt),
+      (s: Set[T]) => s.toSeq,
+      (s: Seq[T]) => Success(s.toSet)
+    )
+
+case class PairWire[T1, T2](w1: WireFormat[T1], w2: WireFormat[T2])
+    extends WireFormat[(T1, T2)]:
+  def encode(p: (T1, T2)): ujson.Value =
+    Arr(w1.encode(p._1), w2.encode(p._2))
+  def decode(js: ujson.Value): Try[(T1, T2)] = Try:
+    (w1.decode(js.arr(0)).get, w2.decode(js.arr(1)).get)
+
+class MapWire[K, V](wk: WireFormat[K], wv: WireFormat[V])
+    extends CastWire[Map[K, V], Seq[(K, V)]](
+      SeqWire(PairWire(wk, wv)),
+      (m: Map[K, V]) => m.toSeq,
+      (s: Seq[(K, V)]) => Success(s.toMap)
+    )