Portrait of David Findley

Online Pictionary

Project URL: https://dravid-19.com
Source Code: https://gitlab.com/findley/dravid-19-server, https://gitlab.com/findley/dravid-19-client
Technologies: Haskell, Elm, WebSockets, HTML Canvas, Kubernetes

When the pandemic started in 2020, I worked in the office 5 days a week with my team. We enjoyed working together, so when there was an abrupt transition to WFH we were excited, but we also missed some of the comradery that comes with working together in person. At the start of the pandemic when we were still getting used to the transition, we'd play games like Among Us or skribbl.io a couple of times a week to have an excuse to interact outside of planned zoom calls. We had a good time in particular with skribbl.io, and so my fondness for this online Pictionary game led me to building my own clone of it.


Pure Functional Programming🔗

The most unique feature of this project is that it uses a "pure" functional programming language for both the backend (Haskell) and the frontend (Elm). This made the project significantly more challenging for me than it otherwise would have been. I had been tinkering with Elm for a while and love some aspects of it, but interacting with the Canvas from Elm would be the main difficulty. I had only toyed around with Haskell for some very small projects, so building a websocket server lobby that managed game state was daunting.

Front-End, Elm🔗

I think that Elm is a fantastic introduction to the ML family of languages. It's sort of like Haskell Lite, and it also has better ergonomics for structs/records. Some of what makes Elm so simple might also limit its scalability for a big app or codebase, but it's very nice for small projects. It's not possible to write custom type classes in elm (there are some built-in ones). This makes complex abstraction difficult. It seems to have the effect of keeping Elm code very easy to understand, but the trade-off is that you need more boiler-plate and copy/paste code.

The language aside, the Elm Architecture is great! The language features make handling events and application state delightful. For example, here's a snippet from the main message handler in the lobby. The algebraic data types make pattern matching on the message and other state in a very nice. This is the code handling the mouse move event:

case msg of
    -- ...
    MoveAt point ->
        case pointer of
            Drawing lastPoint lastControl ->
                drawPoint (scalePoint point) lastPoint lastControl model |> noCmd

            OffCanvas ->
                initialPoint (point |> scalePoint |> clampPoint) model |> noCmd

            NotDrawing ->
                model |> noCmd

I found a nice Elm library called elm-canvas for interacting with a canvas which worked perfectly for this app. The drawing aspect of the game works by converting user input into a stream of "Drawables":

type Drawable
    = Spline Point Point Point Color Float
    | Clear

type alias Point =
    ( Float, Float )

A Drawable is either a spline (a curved line segment), or the clear command. Pretty simple! This could be expanded to include things like fill or undo in the future. The reason we use splines instead of points is that it gives a much more natural "smooth" line. If we just draw segments between all of the points, the drawing looks very bad!

Curved line drawn by user looks blocky when constructed with line segments
A drawing constructed with line segments looks blocky.
Curved line drawn by user looks blocky when constructed with line segments
A drawing constructed with splines is smooth like silk.

We calculate a spline control point by using the last point and the new point. It's not an exact science, I just tinkered with it until it produced a line that felt natural for a line drawing by hand. There's a balance here, because if we try to smooth the line too much, it can feel like "fly-by-wire"; there's a disconnect between the line the user wants to draw and what actually appears on the canvas.

The current drawing can be represented as a list of Drawables. This is useful because the server can remember this full list, then if a client has to re-connect the server can send the list and the client can resync the canvas by replaying the Drawables. The Drawable type has some custom string serialization, so it's smaller than a JSON representation, but it could probably be way smaller if I went with a binary protocol. For my little project, this was good enough.

Other than the canvas, the most complicated part of the front-end was just managing event messages and synchronizing game state with the server, but it's not too complicated. Messages that come from the server are typed as:

type ServerMsg
    = Sync GameState
    | Chat ChatMsg
    | Draw String

and messages the client sends are typed as:

type ClientMsg
    = Draw String           -- Send some encoded Drawables
    | PickWord Int          -- Current drawer sends this to pick their word
    | Chat String
    | StartGame             -- Host player sends this message to start the game
    | Settings GameSettings -- Host changes game settings

The client processes events for both server messages and local user input, then produces client messages that it sends to the server.

Back-End, Haskell🔗

Most people think of Haskell as a difficult language to learn and use... and I'd have to agree with that! The last time I really touched Haskell before this was in college. I wrote a (probably incorrect) implementation of an Apriori recommendation generator. Haskell is pretty easy to follow for something like this that's pure math/logic and doesn't need to worry about controlling side-effects. Unfortunately that's not the case with a websocket game server.

The part of this project that I struggled on the most, was the server side state management. It's pretty easy to make a basic web socket server with the websockets package following the examples. The difficulty comes when we need to group the connections into "lobbys" (which are basically message hubs) and manage some shared state that each of the connections can read and modify. I got lost down a rabbit hole of monads. I originally tried to build my own state monad which was wrapping the StateMonad, but it got overly complicated and turned out that it wasn't really the right tool for the job. I eventually found MVar which seemed to be exactly what I needed. The overall server state is represented as:

data ServerState = ServerState
  { lobbyFactory :: LobbyFactory,
    getLobbys :: Map LobbyId (MVar Lobby)

The map of lobbies is the important part. The lobbyFactory is just used to manage getting available words list into the lobby. Our server is just a map of lobbys by their ID, and the lobbys are wrapped in an MVar. Each client that connects to the same lobby will have a reference to the same MVar Lobby state. MVar provides us with a thread-safe way to read and mutate this state.

We can built the necessary "actions" which take a message and the current game state, then produce the next game state and a list of messages that need to be sent. That looks something like this:

pickWord :: Int -> GameState -> (GameState, [SendMessage])

When the user who will be drawing next picks their word, the client sends a message with the int offset for the word they selected. The server processes this with the pickWord action which will produce the next game state, and all of the messages that need to be sent to sync the clients in the lobby.

I handwrote the function to map the client message to the action:

msgToAction :: PlayerId -> ClientMessage -> (GameState -> (GameState, [SendMessage]))
msgToAction playerId msg =
  case msg of
    ClientMessage.Chat contents -> Actions.chat playerId contents
    ClientMessage.Draw drawString -> Actions.draw playerId drawString
    ClientMessage.PickWord wordIndex -> Actions.pickWord wordIndex
    ClientMessage.StartGame -> Actions.startGame playerId
    ClientMessage.Settings settings -> Actions.setSettings playerId settings

The nice part is that nothing so far has to worry about the MVar and state mutation. That can all be handled in one place when we receive a message:

  msg <- WS.receiveData conn
  case fmap (msgToAction playerId) (Aeson.decode msg) of
    Just action -> do
      (lobby, msgs) <- modifyLobby id action mLobby
      fmap (broadcast lobby) msgs |> mconcat
    Nothing -> return ()

Here we're handling client messages. We decode the message then pass it to our msgToAction function to produce the action. Now we call our action to produce the new game state and hand it off to modifyLobby which will use modifyMVar to safely update our game state. Then finally we'll send out the generated messages. It took me a long time to get there, but I'm pretty happy without how this system worked out!

I'm in the camp that doesn't think pure functional programming languages are the future, rather that main stream languages will continue to get more functional features while maintaining the appropriate escape hatches to make them practical for mere-mortals. I much prefer to apply a functional "style" where I can in a language like JavaScript or Rust.