What better waste of time than writing a mind numbing game in a language you don’t know? Let’s dive into Elm. Elm is a strongly typed functional language that compiles to JavaScript. Its design is centred around the Model/View/Controller concept.

This document may teach you:

Our game is simple. The original slasher game is one I remember from way back when I was playing with GW Basic. Your hero is zipping around the screen at break neck speeds and the only way to control him is by placing slash and backslash characters from which it bounces off. The goal is to catch the golden snitch which only stays in one place for so long.

Figure 1: Screenshot of “Slasher”

You can play this game here.

Main

Elm has a set of different application formats, depending on the level of interactivity that is needed:

The Elm guide has a section describing the difference between sandbox and element. The event loop of element looks like this:

Figure 1: Browser.element event loop

We will be creating an element program, compile it to a JavaScript file, and create a HTML and CSS around it by hand. The lay-out of such a program in Elm looks as follows:

«slasher/src/Main.elm»=

module Main exposing (..)

<<imports>>
<<main>>

<<model>>
<<update>>
<<subscriptions>>
<<view>>

The main function tells Elm that we create an element

«main»=

main =
    Browser.element
        { init = init
        , update = update
        , subscriptions = subscriptions
        , view = view }

The rest of the program has:

Imports

«imports»=

import Browser
import Array exposing (Array, repeat, indexedMap, toList, set, get)
import List exposing (concat)
import Browser.Events exposing (onAnimationFrameDelta, onKeyDown)
import Html exposing (Html, button, div, text, p, input, main_, a)
import Html.Attributes exposing (href)
import Html.Events exposing (onClick, preventDefaultOn)
import Svg exposing (svg, circle, line, rect, g, polygon, text_)
import Svg.Attributes exposing (..)
import Random

import Json.Decode as Decode

The Model

The model will be a grid of cells and two actors: our hero and the snitch (if you like to play around with the code, try to add a list of enemy actors that can hurt the hero).

«model»=

type alias Model =
    { actors : { player : Actor, snitch : Actor }
    , grid : Grid
    , snitchTime : Float
    , state : GameState
    }

In addition to the actors and the grid, we have to keep time, and a game state flag. The game state tells us if the game is running etc.

«model»+

type GameState = Start | Running | Pause | Won

We define the grid as nested arrays of cells, where each cell can have the value Empty, Slash or BackSlash:

«model»+

type alias Grid = Array (Array Cell)

type Cell
    = Empty
    | Slash
    | BackSlash

gridRef : (Int, Int) -> Grid -> Cell
gridRef (i, j) grid = 
    Maybe.withDefault Empty 
    <| Maybe.andThen (\ row -> get i row) (get j grid)

gridSet : (Int, Int) -> Cell -> Grid -> Grid
gridSet (i, j) cell grid =
    let row_ = get j grid
    in case row_ of
        Nothing  -> grid
        Just row -> set j (set i cell row) grid

Lastly, an Actor is something that has a location and a velocity, each a 2-tuple of floats.

«model»+

type alias Actor =
    { location : (Float, Float)
    , velocity : (Float, Float)
    }

Config

«model»+

type alias Config =
    { gridSize : (Int, Int)
    , playerSpeed : Float
    , scale : Int
    , snitchTime : Float
    }

config : Config
config =
    { gridSize = (80, 50)
    , playerSpeed = 0.03
    , scale = 15
    , snitchTime = 10000
    }

inRange : (Int, Int) -> Bool
inRange (i, j) = 
    let (w, h) = config.gridSize
    in i >= 0 && i < w && j >= 0 && j < h

Init

The model is initialised by the init function. This function has the funny signature () -> (Model, Cmd Msg). We’ll return to the type definition of Msg later, but what this means is that the init function generates an initial state and a command (or list of commands) that tells the Elm run-time to perform an action upon initialisation. Such an action can be anything that requires some form of external state: HTTP requests, location/time information, and in our case: generating random numbers.

«model»+

init : () -> (Model, Cmd Msg)
init _ = 
    let (width, height) = config.gridSize
        playerLoc       = ((toFloat width)/2 + 0.5, 2.5)
    in (
    { actors =
        { player = { location = playerLoc
                   , velocity = (0.0, config.playerSpeed) }
        , snitch = { location = (0.0, 0.0)
                   , velocity = (0.0, 0.0) }
        }
    , grid = makeGrid config.gridSize
    , snitchTime = 0.0
    , state = Start
    }, Random.generate
            PlaceSnitch
            (Random.pair (Random.int 0 (width - 1))
                         (Random.int 0 (height - 1))))

makeGrid : (Int, Int) -> Array (Array Cell)
makeGrid (width, height) =
    Array.repeat height
                 (Array.repeat width Empty)

Subscriptions

«subscriptions»=

keyDecoder : Decode.Decoder Msg
keyDecoder =
  Decode.map KeyPress (Decode.field "key" Decode.string)

subscriptions : Model -> Sub Msg
subscriptions model = Sub.batch
    [ onAnimationFrameDelta TimeStep
    , onKeyDown keyDecoder
    ]

Update

The update function has the following signature:

«update-function»=

update : Msg -> Model -> (Model, Cmd Msg)

This means that, given a message and a model state, we can generate a new model state and a list of requests to the Elm run-time. This list of requests has the type Cmd Msg, telling Elm that, whatever the request, it has to respond with a Msg.

«msg-type»=

type Msg
    = KeyPress String
    | TimeStep Float
    | PlaceSnitch (Int, Int)

In our case, a message is either a KeyPress indicating a key being pressed, a TimeStep when a new animation frame is triggered or a PlaceSnitch when the random number generator returns a fresh pair of coordinates. We’ll defer the handling of these messages to helper functions.

«update-function»+

update msg model =
    case msg of
        TimeStep dt   -> let next = timeStep dt model
                         in (next, checkSnitchTime next)
        KeyPress k    -> (keyMap k model, Cmd.none)
        PlaceSnitch l -> (placeSnitch l model, Cmd.none)

«update»=

<<msg-type>>
<<update-function>>

Time step

Each time step, if the game in in Running state, we need to check if the game is won, and otherwise move all actors that need moving.

«update»+

timeStep : Float -> Model -> Model
timeStep dt ({actors, grid, snitchTime, state} as model) =
    case state of
        Running -> if didWeWin model
                   then { model
                        | state = Won }
                   else { model
                        | snitchTime = snitchTime - dt
                        , actors =
                            { actors
                            | player = updateActor grid dt actors.player } }
        _       -> model

Did we win?

«update»+

didWeWin : Model -> Bool
didWeWin ({actors} as model) =
    activeCell actors.snitch == activeCell actors.player

Update actors

Updating an actor is a bit of boring bookkeeping. We check whether the actor’s active grid cell changes. This happens when the actor moves across the center of a cell. If the active cell changed, potentially the actor bounced off a (back)slash or a wall.

«update»+

updateActor : Grid -> Float -> Actor -> Actor
updateActor grid dt actor =
    let a = activeCell actor
        b = activeCell <| moveActor dt actor 
    in if a /= b then case (gridRef a grid) of
        Slash     -> bounceActor dt Slash actor
        BackSlash -> bounceActor dt BackSlash actor
        Empty     -> if inRange b 
            then moveActor dt actor
            else bounceOffWall dt actor
    else
        moveActor dt actor

Active cell

To get a smooth flying experience the actors have floating point coordinates. From the coordinates we need to compute the integer indices into the grid.

«update»+

activeCell : Actor -> (Int, Int)
activeCell actor =
    let (x, y) = actor.location
    in case (actorDirection actor) of
        East  -> (round x, floor y)
        West  -> (round (x - 1.0), floor y)
        North -> (floor x, round (y - 1.0))
        South -> (floor x, round y)            

Direction

From the velocity we often need to know in which of the four cardinal directions an actor is moving.

«update»+

type Direction = North | East | South | West

actorDirection : Actor -> Direction
actorDirection actor =
    let (vx, vy) = actor.velocity
    in if (abs vx) > (abs vy) then
        if vx > 0 then East else West
    else
        if vy > 0 then South else North

Actor dynamics

When an actor moves, it may do so in one of three ways:

For each of these cases we have a separate function.

«update»+

moveActor : Float -> Actor -> Actor
moveActor dt actor =
    let (x, y)   = actor.location
        (vx, vy) = actor.velocity
    in { actor | location = (x + dt * vx, y + dt * vy) }

bounceActor : Float -> Cell -> Actor -> Actor
bounceActor dt cell actor =
    let (i, j)   = activeCell actor
        (vx, vy) = actor.velocity
        newloc   = ((toFloat i) + 0.5, (toFloat j) + 0.5)
    in case cell of
        Slash     -> moveActor dt { location = newloc, velocity = (-vy, -vx) }
        BackSlash -> moveActor dt { location = newloc, velocity = (vy, vx) }
        Empty     -> actor

bounceOffWall : Float -> Actor -> Actor
bounceOffWall dt actor =
    let (x, y)   = actor.location
        (vx, vy) = actor.velocity
        newloc   = case actorDirection actor of
            North -> (x, 0.0)
            South -> (x, 50.0)
            East  -> (80.0, y)
            West  -> (0.0, y)
    in moveActor dt { location = newloc, velocity = (-vx, -vy) }

Snitch time

Each time step we decreased the snitchTime field. If it drops below 0, we ask the Elm runtime for a new random snitch location.

«update»+

checkSnitchTime : Model -> Cmd Msg
checkSnitchTime {snitchTime} =
    let (w, h) = config.gridSize
    in if snitchTime < 0.0 
    then Random.generate
            PlaceSnitch
            (Random.pair (Random.int 0 (w - 1))
                         (Random.int 0 (h - 1)))
    else Cmd.none

Moving the snitch

When the snitch is placed we reset the snitchTime to the configured value

«update»+

placeSnitch : (Int, Int) -> Model -> Model
placeSnitch (x, y) ({actors} as model) = 
    { model
    | snitchTime = config.snitchTime
    , actors = 
        { actors 
        | snitch =
            { location = ((toFloat x) + 0.5, (toFloat y) + 0.5)
            , velocity = (0.0, 0.0) } } }

Keymap

We listen to three keys: space, left arrow and right arrow. We could have listened for \ and / keys, but in Firefox the / key also activates quick search. We could work around this, but it is a bit of a hassle.

«update»+

keyMap : String -> Model -> Model
keyMap k ({state} as model) =
    let slash cell = if state == Running
                     then place cell model
                     else model
    in case k of
        " " -> let newState = case state of
                    Running -> Pause
                    Start   -> Running
                    Pause   -> Running
                    Won     -> Won
               in { model | state = newState }
        "ArrowLeft"  -> slash BackSlash
        "ArrowRight" -> slash Slash
        _            -> model

Placing a (back)slash

«update»+

place : Cell -> Model -> Model
place cell ({actors, grid} as model) =
    let loc = activeCell actors.player
    in { model | grid = gridSet loc cell grid }

View

scale : Int
scale = config.scale

fScale : Float
fScale = toFloat scale

viewCell : (Int, Int) -> Cell -> List (Html Msg)
viewCell (i, j) c =
    case c of
        Slash ->     [ line [ x1 (String.fromInt (scale * i + scale))
                            , y1 (String.fromInt (scale * j))
                            , x2 (String.fromInt (scale * i))
                            , y2 (String.fromInt (scale * j + scale))
                            , class "slash" ] [] ]
        BackSlash -> [ line [ x1 (String.fromInt (scale * i))
                            , y1 (String.fromInt (scale * j))
                            , x2 (String.fromInt (scale * i + scale))
                            , y2 (String.fromInt (scale * j + scale))
                            , class "slash" ] [] ]
        Empty ->     []

formatPath : List (Float, Float) -> String
formatPath pts = case pts of
    []           -> ""
    (x, y)::rest -> (String.fromFloat <| x * fScale) ++ "," ++
                    (String.fromFloat <| y * fScale) ++ " " ++ formatPath rest

viewHero : Actor -> Html Msg
viewHero actor =
    let (x, y) = actor.location
        path   = case actorDirection actor of
            South -> [ (x, y)
                     , (x + 0.3, y - 1)
                     , (x - 0.3, y - 1) ]
            North -> [ (x, y)
                     , (x + 0.3, y + 1)
                     , (x - 0.3, y + 1) ]
            West  -> [ (x, y)
                     , (x + 1, y + 0.3)
                     , (x + 1, y - 0.3) ]
            East  -> [ (x, y)
                     , (x - 1, y + 0.3)
                     , (x - 1, y - 0.3) ]
    in polygon [ points <| formatPath path, class "hero" ] []

viewSnitch : Actor -> Html Msg
viewSnitch actor =
    let (x, y) = actor.location
    in circle [ cx (String.fromFloat <| x * fScale)
              , cy (String.fromFloat <| y * fScale)
              , r (String.fromInt <| scale // 2)
              , class "snitch" ] []

viewOverlay : GameState -> Html Msg
viewOverlay state =
    let (w, h)  = config.gridSize
        sWidth  = (String.fromInt <| config.scale * w)
        sHeight = (String.fromInt <| config.scale * h)
        middleX = (String.fromInt <| config.scale * w // 2)
        middleY = (String.fromInt <| config.scale * h // 2)
        rectA   = rect [ x "0", y "0", width sWidth
                       , height sHeight
                       , rx (String.fromInt <| scale)
                       , ry (String.fromInt <| scale)
                       , style "fill: black" ] []
        textA s = text_ [ x middleX, y middleY, textAnchor "middle" ]
                        [ text s ]
        overlay s = g [ id "overlay" ] [ rectA, textA s ]
    in case state of
        Running -> g [] []
        Won     -> overlay "YOU WIN!"
        Start   -> overlay "press space to start"
        Pause   -> overlay "PAUSE"

viewSnitchBar : Float -> Html Msg
viewSnitchBar t =
    let u       = t / config.snitchTime
        (w, h)  = config.gridSize
        sWidth  = String.fromFloat <| (toFloat w) * u * (toFloat config.scale)
    in rect [ x "0", y (String.fromInt <| h * config.scale + 10)
            , width sWidth, height (String.fromInt <| config.scale // 2)
            , id "snitch-bar" ] []

viewArena : Model -> Html Msg
viewArena ({actors, grid, state, snitchTime} as model) =
    let blurAtPause = class 
                    <| if state == Running
                       then "non-blurred"
                       else "blurred"
    in svg [ width "100%"
           , viewBox ("-3 -3 " ++ (String.fromInt <| scale * 80 + 4) ++ " " ++    (String.fromInt <| scale * 50 + 24))]
           [ g [] [rect [ x "0", y "0"
                        , width (String.fromInt (scale * 80))
                        , height (String.fromInt (scale * 50))
                        , rx (String.fromInt <| scale)
                        , ry (String.fromInt <| scale)
                        , id "box" ] []]
           , g [blurAtPause]
               (concat (toList
               (indexedMap 
                   (\ y rows -> (concat (toList (indexedMap 
                               (\ x cell -> viewCell (x, y) cell)
                               rows))))
                   grid)))
           , g [blurAtPause] [ viewHero actors.player
                  , viewSnitch actors.snitch ]
           , (viewOverlay state)
           , (viewSnitchBar snitchTime)
           ]

view : Model -> Html Msg
view model =
    main_ []
        [ div [ id "header" ] [ text "\\ \\ S L A S H E R / /" ]
        , div [ id "arena" ] [ viewArena model ]
        , div [ id "help" ] [ text "keys: Left \\ | Right / | Space pause" ]
        , div [ id "footer" ]
        [ text "Use the source, at "
        , a [ href "https://entangled.github.io/" ]
            [ text "Entangled!" ] ]
        ]

Completing the game

From the code we can either build a HTML or a JavaScript file for embedding. We will manually create the HTML so that we can also create a stylesheet. Here’s the Makefile for creating an optimized and uglified version of slasher (you can install uglifyjs with npm install -g uglify-js):

«slasher/Makefile»=

.PHONY: build

build: slasher.min.js

slasher.js: src/Main.elm
    elm make src/Main.elm --output=slasher.js --optimize

slasher.min.js: slasher.js
    uglifyjs slasher.js --compress 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe' | uglifyjs --mangle --output=slasher.min.js

The HTML can be very short now:

«docs/slasher-game.html»=

<!DOCTYPE html>
<html>
  <head>
    <title>Slasher</title>
    <meta charset="UTF-8"> 
    <<google-font>> 
    <link rel="stylesheet" href="css/slasher.css">
  </head>

  <body>
    <div id="slasher"></div>

    <script src="js/slasher.min.js"></script>
    <script>
      Elm.Main.init({ node: document.getElementById("slasher") });
    </script>
  </body>
</html>

Browse Google fonts for a nice slashery font:

«google-font»=

<link href="https://fonts.googleapis.com/css?family=Love+Ya+Like+A+Sister" rel="stylesheet">

And add some style

body {
    font-family: "Love Ya Like A Sister", sans serif;
    background-image: radial-gradient(circle, #112233, #000033);
    color: white;
}

#arena svg {
    max-height: 80vh;
}

#header {
    font-size: 20pt;
    text-align: center;
    margin-bottom: 5pt;
}

#help {
    text-align: center;
}

#footer {
    font-family: sans serif;
    font-size: 8pt;
    background: #ffffff44;
    color: black;
    text-align: center;
    padding: 4pt 0;
    margin: 10pt 0 0 0;
    border-radius: 5pt;
}

#footer a {
    color: #202;
}

.blurred {
    filter: blur(1pt);
}

/* svg #overlay {
    filter: blur(1pt);
} */

svg #overlay rect {
    opacity: 0.3;
    fill: black;
}

svg #overlay text {
    font-size: 70pt;
    fill: white;
}

svg #box {
    opacity: 0.5;
    stroke: goldenrod;
    stroke-width: 2pt;
    fill: none;
}

svg line.slash {
    stroke: #8888ff;
    stroke-width: 2pt;
}

svg .hero {
    fill: red;
    stroke: black;
}

svg .snitch {
    fill: gold;
    stroke: black;
}

svg #snitch-bar {
    opacity: 0.8;
    fill: gold;
    stroke: black;
}