Friday, May 27, 2011

Beyond Chat : Lift, Comet and Akka

Earlier this week Diego Medina put together a really nice blog post about Comet in Lift, and that reminded me that I've been meaning to write this one.

A few weeks ago, as part of the Learn Scala in 2 Days workshop, I wrote a little demo of Lift as a frontend to the adventure game we were developing for the class project. At that point in the workshop we had already gone from using a simple text-input client to using Akka to decouple I/O from game logic, so adding in a CometActor was a no-brainer. In this post I'm going to go over that Akka interaction as well as detailing how I wrote the CometActor.

Game Overview



Before we get into the Lift side of things, I want to provide an overview of the game components that we're working with. Full source for the demo is available on GitHub, but I'll pull relevant snippets out.

First, let's start with the interaction model. We have a single GameActor engine (Akka) that is responsible for receiving messages from the frontend and dispatching them. The very first message the frontend must send to the GameActor is a Join message:

case class Join(name : String, input : ActorRef, display : ActorRef)


The join message contains three values: the desired name of the player joining, as well as references to the frontend actors responsible for displaying and processing choices (input) and for displaying status messages. Once the GameActor has these references, everything can be done asynchronously because at that point it's all just a system of actors. We do make one exception and use a synchronous request/reply when sending the Join message so that the GameActor can indicate to the frontend that the Join succeeded.

In any case, after the initial Join the GameActor can send one of two messages to the frontend. First, the "input" actor can be sent a Prompt message:

case class Prompt(status : String, message: Option[String], choices : List[Choice])
case class Choice(description : String, agent : ActorRef, action : Action)
sealed trait Action


The Prompt message basically contains the current status of the player, an optional message (e.g. "You have moved to X"), and a List of current Choices for the player. A Choice simply binds a description to some Action as well as the actor that will perform the Action. Remember, everything here is an actor (including other players), so this allows us to do direct chats between users among other things. Action is just a marker trait so that we can constrain things. On the frontend side, when a choice is made the frontend simply sends the "action" instance to the "agent" instance as specified in the Choice.

We also want the GameActor to be able to asynchronously update the player's display, so we'll add a Display message:

case class Display(message : String) extends Action


Note that we also make this an Action so that it can pull double-duty as the message that players send to one another for chat.

On to Lift



Now that we have an outline of interaction between the frontend and the game engine, let's look at how we can realize the frontend in Lift using a CometActor. This source is in GameDisplay.scala.

The first thing we want to do is define a case class to hold our current state. In Lift, Comet components live for the duration of the page view, so re-rendering the page will create a new component. In our case we want the state to stay in a user's session so that a refresh doesn't end their game.

case class ClientState(name : String,
status : String,
messages : List[String],
choices : List[Choice])


In our GameDisplay, we set up a SessionVar to hold this state in a Box so that we can represent an unjoined state as Empty.

class GameDisplay extends CometActor {
...
object currentState extends
SessionVar[Box[ClientState]](Empty)
...


Bridging in Akka



Before we get into the render and message-processing logic of our GameDisplay, we have one more piece of plumbing to create: a bridge between Akka and Lift Actors. Lift has its own Actor library, and it's based on the same core concepts, but their not directly interchangable. For the purposes of this demo I wrote a small proxy Actor with Akka to forward messages from the Akka side of things back to the CometActor (remember, we pass Akka ActorRefs to the GameEngine):

class BridgeActor extends Actor {
private var target : Option[CometActor] = None
def receive = {
case comet : CometActor => target = Some(comet)
case msg => target.foreach(_ ! msg)
}
}


Basically you can send a reference to a CometActor to the BridgeActor and it registers it as the forwarding target. Any other message is sent to the forwarding target, if defined. There's actually a cleaner way to do this by using structural types and redefining the Join case class (and GameActor's internal code) as:

object ActorType {
type Basic = { def ! (msg : Any) : Unit }
}
case class Join(name : String, input : ActorType.Basic, display : ActorType.Basic)


Just remember that structural typing uses reflection, so you need to be aware of performance and functionality constraints if you want to use it.

In any case, our code uses the BridgeActor, so we need to set that up in our GameDisplay constructor by instantiating it Akka-style (Actors.actorOf(...).start()) and then sending it a "this" reference to register our GameDisplay as the forwarding target:

class GameDisplay extends CometActor {
// A bridge between the Lift and Akka actor libraries
private val bridge = Actors.actorOf(classOf[BridgeActor]).start()
bridge ! this

// Make sure to stop our BridgeActor when we clean up Comet
override protected def localShutdown() {
  bridge.stop()
}
...

Note that we also override our localShutdown method to make sure we clean up the BridgeActor when our own Comet component is cleaned up (iron9light, thanks for pointing this out).

Comet Rendering



At this point we've defined how we'll store our Comet state and how it will receive messages from the game engine, so now it's time to actually look at the rendering and message processing. In Lift, the CometActor's main render is handled by the "render" method (surprise!). In our case, we want to render differently based on whether the player has joined the game. First, let's look at what happens when the player hasn't joined the game.

def render = currentState.is match {
case Empty => {
/* We need to prompt the player for their name in order to join
* the game. The ajaxForm method wraps a regular HTML form
* (specified here directly with Scala's XML literals) and processes
* the form as an AJAX submission. */
SHtml.ajaxForm(
What's your name? ++
/* SHtml.text generates a text input that invokes a Scala
* callback (in this case, the login method) with the text
* it contains when the form is submitted. */
SHtml.text("", login) ++
<input type="submit" value="Log in" />
)
}
...
}


Hopefully the comments are self-explanatory, but I do want to point out how nice Lift's AJAX support is. Simply wrapping a normal form in SHtml.ajaxForm(...) gets me a form that will submit via an AJAX call. AJAX and Comet work really well together, and Lift has first-class support both.

Sending Messages Synchronously



At this point we've rendered a simple form out to the user, and when they put in their name and hit the "Log in" button we'll get a callback (from the SHtml.text input) with the submitted name in the login method:

def login (name : String) {
Controller.game !! Join(name, bridge, bridge) match {
case Some(Prompt(status,message,choices)) =>
currentState.set(Full(ClientState(name,status,message.map(List(_)) getOrElse Nil,choices)))
case other => error("Error: " + other)
}
reRender()
}


The "!!" is actor-ese (supported by both Lift and Akka actors) to send a message to an actor and wait for a reply. In this example we're sending the Join message to our game engine and wait for the game engine's response to pattern match. Note that the return type fo "!!" is an Option (or Box in LiftActor) because it's possible that an error may occur during processing. We only care about the game engine returning our first Prompt, so I've skimped a bit on error handling (errors are simply displayed as a Lift error message), but in a real app you might want something more involved.

If we do get back a prompt, we want to initialize our state by setting a new Full box containing said state. We simply copy in the name and current choices, and we map the optional message to a List. In either the success or error case we re-render the whole page since everything is going to change.

Rendering with CSS bindings



Now that we have some state, we use the second match in our render method to produce some markup:

def render = currentState.is match {
...
case Full(state @ ClientState(name, status, messages, choices)) => {
/* When we have state to render, utilize Lift's
* CSS binding Domain-Specific Language (DSL) to
* process the template we were given. More on CSS Bindings
* can be found here:
*
* http://www.assembla.com/wiki/show/liftweb/Binding_via_CSS_Selectors
*/
"#status *" #> status &
"#messages *" #> messages.reverse.map{Text(_) ++ <br/>} &
".choice" #> generateChoices(state)
}
}  


This is simply a set of CSS selector transforms that fill in our template (src/main/webapp/index.html):

<div class="lift:comet?type=GameDisplay">
<h1>Status: <span id="status">Nothing</span></h1>
<h1>Messages:</h1>
<div id="messages"></div>
<h1>You may:</h1>
<div id="choices">
<ul>
<li class="choice">Something</li>
</ul>
</div>
</div>


We delegate to the "generateChoices" method to create the actual choices since we'll want to use this in other places:

/**
* Because we need to generate choices in both our main render and our
* updateChoices methods, we refactor out the common generation here.
*/
def generateChoices(state : ClientState) : NodeSeq = state.choices.flatMap {
/* Special handling for chat messages. Here we create a new form
* that allows us to customize our message */
case Choice(description, agent, Display(otherPlayer)) => {
SHtml.ajaxForm(
<li>Say "{ SHtml.text("hi", message => agent ! Display(state.name + " says: " + message)) }" to {otherPlayer}
<input type="submit" value="Send!" /></li>
)
}
case choice =>
SHtml.a(() => perform(choice), <li>{choice.description}</li>)
}


Here's where we get a little fancy. We're going to flatMap over our list of choices to produce a NodeSeq of the markup. If the choice happens to be a "Display" choice (remember, this is for chat or one-way notification), we generate a text input inside an AJAX form so that we can send user-specified text to the other player's display actor. If it's a normal choice, we just set up an AJAX link to perform the choice when clicked. Performing the choice simply sends the Choice's action to the Choice's actor and waits for a Prompt response. If it receives a proper Prompt response it sends it to itself (this allows for uniform handling of Prompt changes):

choice.agent !! ClientChoice(state.name, choice.action) match {
case Some(p : Prompt) => this ! p
case other => error(other.toString)
}


Reacting to External Events



Now, up to this point everything is user-driven. However, the fact that Lift's Comet support is actor-driven means we can respond to events triggered in the game engine, too. We achieve this by hooking into the actor processing on the mediumPriority method. This is a PartialFunction[Any,Unit] that can process selected messages. Remember, to work with Akka we've provided a bridge, but otherwise there's no difference if you decide to stick entirely with Lift actors (or some other impl).

Our mediumPriority handler wants to cover two main cases. First, we need to handle display messages sent to us, either from other players or from the game engine:

override def mediumPriority = {
case Display(message) => currentState.foreach {
state => {
currentState.set(Full(state.copy(messages = message :: state.messages)))
partialUpdate(updateMessages())
}
}
...
}


If we receive a new Display message, we update our state (using the ClientState case class's copy method) and then call partialUpdate. The partialUpdate method allows us to send arbitrary JavaScript commands to the client asynchronously to update the page. In our case, the updateMessages method uses JsCmds.SetHtml to replace the "messages" div with our new list of messages:

def updateMessages() : JsCmd = {
currentState.is.map {
state => JsCmds.SetHtml("messages", state.messages.reverse.flatMap{Text(_) ++ <br/>})
} openOr JsCmds.Noop
}


Similarly, if we receive a new Prompt message, we update our state and use partialUpdate to not only render the new messages, but a new list of Choices, too:

override def mediumPriority = {
...
case Prompt(newStatus, message, newChoices) => currentState.foreach {
state => {
// Optionally prepend the provided message
val newMessages = message.map(_ :: state.messages) getOrElse state.messages

currentState.set(Full(state.copy(status = newStatus,
messages = newMessages,
choices = newChoices)))
partialUpdate(updateMessages() & updateChoices())
}
}
}


The updateChoices method is similar to updateMessages, except we can use the CSS transforms again on the "defaultHtml" member. This member is initialized to whatever the content of our Comet template tag is, so we can reuse it later. Remember that CSS transforms are really just NodeSeq => NodeSeq functions, which is why we can use "apply" to perform the transform in place:

def updateChoices() : JsCmd = {
currentState.is.map {
state => JsCmds.SetHtml("choices",
("#choices ^^" #> "ignore" &
".choice" #> generateChoices(state)).apply(defaultHtml)
) &
JsCmds.SetHtml("status", Text(state.status))
} openOr JsCmds.Noop
}


Wrapping Up



As you can see, we can add a lot of rich functionality with very little code. Excluding the game engine itself, the Comet actor is 170 lines of code including comments, and the template is about 30. We're able to perform any action that the game engine sends us, including customized chat with other players. Lift's use of the Actor model makes event-driven rendering simple and allows for some fantastic client-side functionality.

I hope that people find this post informative, and I welcome feedback. Happy Lifting!

2 comments:

iron9light said...

When are you destroy the BridgeActor? It seems it will still be alive after CometActor is dead.

Derek said...

You're right, I missed that. I'll have the example code updated shortly. Thanks!