Snake in Purescript

How to program a simple game in Purescript

Posted: 2016-11-06 , Modified: 2016-11-06

Tags: purescript, programming, functional programming, game

Introduction

Purescript is a functional programming language that compiles down to Javascript. In this post, I describe the process of building a simple game, Snake, in PureScript. Along the way, we’ll see how input/signals work, how to render to the screen, and how to work with arrays. I assume familiarity with functional programming. I’ll make some comparisons with Haskell and Elm.

I like Purescript because it has the functional power of Haskell, while fitting very well into the web ecosystem (e.g. interops with Javascript), like Elm. Think of it as Elm with all the features that come with Haskell, such as monads.

Some resources:

Getting started

First follow the instructions here to install Purescript.

Start a new project by

mkdir basic-games
cd basic-games
pulp init

Create a file Snake.purs in src/.

Imports

First, the imports.

module Snake where

import Prelude -- must be explicitly imported
import Control.Monad.Eff (Eff)
import Control.Monad.Eff.Console (CONSOLE, log)
import Control.Monad.Eff.Random (RANDOM, randomInt)
import Data.Array (length, uncons, slice, (:), last)
import Data.Array.Partial (head)
import Data.Functor
import Data.Generic
import Data.Int
import Data.Maybe
import Data.Traversable
import Data.Tuple
import Graphics.Canvas (CANVAS, closePath, lineTo, moveTo, fillPath,
                        setFillStyle, arc, rect, getContext2D,
                        getCanvasElementById, Context2D, Rectangle, clearRect)
import Partial.Unsafe (unsafePartial)
import Signal (Signal, runSignal, foldp, sampleOn, map4)
import Signal.DOM (keyPressed)
import Signal.Time (Time, second, every)
import Test.QuickCheck.Gen -- for randomness

You will need to install all these packages. You can install them one at a time via

bower install <package-name> --save

or copy the bower.json file here and do bower install to install all of them at once.

The Model-Update-View-Signal Architecture

We break our program into the following parts. (For more information, see the Elm architecture tutorial.)

To put these components together we use:

The skeleton of the program looks like this.

-- Model
type Model = ???

start :: Model 
start = ???

-- Update
type Input = ???

step :: Input -> Model -> Model
step inp m = ???

-- View
render :: Model -> Eff _ Unit 
render m = ???

-- Signal 
input :: Eff _ (Signal Input)
input = ???

-- Putting it all together
main :: Eff _ Unit
main = 
    void do
      signal <- input
	  -- game :: Signal Model
      game <- foldp step start signal
	  -- map :: forall a b. (a -> b) -> f a -> f b
	  --        (Model -> Eff _ Unit) -> Signal Model -> Signal (Eff _ Unit)
      runSignal (map render game)

The Eff monad corresponds to Haskell’s IO monad, but uses extensible effects: it lists out explicitly all the effects it has (access to randomness, DOM, drawing canvas, console, etc.). For example, Eff (random :: RANDOM, canvas :: CANVAS | eff) Unit means having effects that include RANDOM and CANVAS). PureScript will infer all effects if you put _. See Chapter 8.10 of the PureScript book.

For a warm-up, here is a basic example following this paradigm. Here, the model is an integer (the location on a single axis), the update is moving -1, 0, or 1 units, the view is simply writing the location to the console, and the signal comes from the arrow keys once every second.

Model

For Snake, the model is a record containing

We also include some the dimensions xd, yd of the game board, the size of each square (alternatively, these can be hard-coded in), and the prev square (this is optional—it gives us an easy way to erase the tail of the snake when it moves). Valid positions are in [1, xd]×[1, yd].

We want the mouse location to be generated at random. How to do this? We’ll fill in that part later. (For now, you can put in an arbitrary point.)

Note also that the y-coordinate is 0 at the top of the screen and increases going down. The snake starts at the upper-left corner moving right.

type Point = Tuple Int Int

--MODEL
type Snake = Array Point

type Model = {xd :: Int, yd :: Int, size :: Int, mouse:: Point, snake :: Snake, dir :: Point, alive :: Boolean, prev :: Maybe Point}

start :: Model
start = 
  {xd : 25, yd : 25, size : 10, mouse : ??, snake : [Tuple 1 1], dir: Tuple 1 0, alive : true, prev : Nothing}

Note unlike Haskell,

Update

First we need two helper function: check to see if a point is in bounds, and check to see if the snake is OK (given the point where the head of the snake moves, check to see that it is in bounds and not part of the snake’s body).

inBounds :: Point -> Model -> Boolean
inBounds (Tuple x y) m = 
  (x > 0) && (y > 0) && (x <= m.xd) && (y <= m.yd)

checkOK :: Point -> Model -> Boolean
checkOK pt m = 
  let
    s = m.snake
  in
    m.alive && (inBounds pt m) && not (pt `elem` (body s))
	
body :: forall a. Array a -> Array a
body li = slice 0 ((length li) - 1) li

For the step function, there are 3 cases:

step :: Partial => Point -> Model -> Model
step dir m = 
  let
	-- override the direction with the input, unless there is no input (corresponding to (0,0))
    d = if dir /= Tuple 0 0
        then dir
        else m.dir
    s = m.snake
    let hd = (head s + d)
    if checkOK hd m
      then 
        if (hd == m.mouse) 
        then m { snake = hd : s
               , mouse = ???
               , dir = d
               , prev = Nothing -- snake grows; nothing is deleted
               }
        else m { snake = hd : (body s)
               , dir = d
               , prev = last s -- snake moves; the last pixel is deleted
               }
      else m { alive = false, prev = Nothing}

Some differences from Haskell:

Note that we can add points! (Note the line head s + d.) This is because Data.Tuple has the instance (Ring a, Ring b) => Ring (Tuple a b). If both components of a tuple can be added, then addition is automatically defined for the tuple.

View

Given the model, we have 4 things to render: the walls (a 27 × 27 rectangle here), the background (a 25 × 25 rectangle), the snake and the mouse. For instructions on using purescript-canvas see Chapter 9 of the PureScript book. Change the colors as you wish.

render :: forall eff. Partial => Model -> (Eff _ Unit)
render m = 
  void do
        let s = m.snake
        let size = m.size
        Just canvas <- getCanvasElementById "canvas"
        ctx <- getContext2D canvas
        --walls
        setFillStyle wallColor ctx
        fillPath ctx $ rect ctx
                     { x: 0.0
                     , y: 0.0
                     , w: toNumber $ size*(m.xd + 2)
                     , h: toNumber $ size*(m.yd + 2)
                     }
        --interior
        setFillStyle bgColor ctx
        fillPath ctx $ rect ctx
                     { x: toNumber $ size
                     , y: toNumber $ size
                     , w: toNumber $ size*(m.xd)
                     , h: toNumber $ size*(m.yd)
                     }
        --snake 
        for s (\x -> colorSquare m.size x snakeColor ctx)
        --mouse
        colorSquare m.size (m.mouse) mouseColor ctx
		
colorSquare :: forall eff. Int -> Point -> String -> Context2D -> Eff (canvas :: CANVAS | eff) Context2D
colorSquare size (Tuple x y) color ctx = do
  setFillStyle color ctx
  fillPath ctx $ rect ctx $ square size x y

square :: Int -> Int -> Int -> Rectangle
square size x y = { x: toNumber $ size*x
                  , y: toNumber $ size*y
                  , w: toNumber $ size
                  , h: toNumber $ size
                  }

white = "#FFFFFF"
black = "#000000"
red = "#FF0000"
yellow = "#FFFF00"
green = "#008000"
blue = "#0000FF"
purple = "800080"

snakeColor = white
bgColor = black
mouseColor = red
wallColor = green

The render function is a bit inefficient since it is redrawing the entire canvas every step (it doesn’t make much difference for such a simple game though). We can replace later calls to render with renderStep which only changes the squares that need to be changed at each time step. (There is much more freedom than Elm to draw specify what you want to draw and redraw.)

renderStep :: forall eff. Partial => Model -> Eff (canvas :: CANVAS | eff) Unit
renderStep m = 
  void do
        let s=m.snake
        Just canvas <- getCanvasElementById "canvas"
        ctx <- getContext2D canvas
        colorSquare m.size (head s) snakeColor ctx
        case m.prev of
          Nothing -> colorSquare m.size (m.mouse) mouseColor ctx
          Just pt -> colorSquare m.size pt bgColor ctx
		  --make use of the fact: either we draw the mouse or erase the tail, not both, at any one step

Signal

The step function takes an update of type Point, so we need to produce a Signal Point.

purescript-signal contains signals from various sources, e.g. which keys are pressed and the time. We use the following functions from there:

Below, input gives a direction corresponding to the arrow key pressed every 1/20 second.

--SIGNALS
inputDir :: Eff _ (Signal Point)
inputDir = 
    let 
        f = \l u d r -> ifs [Tuple l $ Tuple (-1) 0, Tuple u $ Tuple 0 (-1), Tuple d $ Tuple 0 1, Tuple r $ Tuple 1 0] $ Tuple 0 0
--note y goes DOWN
    in
      map4 f <$> (keyPressed 37) <*> (keyPressed 38) <*> (keyPressed 40) <*> (keyPressed 39)

input :: Eff _ (Signal Point)
input = sampleOn (fps 20.0) <$> inputDir

fps :: Time -> Signal Time
fps x = every (second/x)

ifs:: forall a. Array (Tuple Boolean a) -> a -> a
ifs li z = case uncons li of
             Just {head : Tuple b y, tail : tl} -> if b then y else ifs tl z
             Nothing         -> z 

At this point, we can slap in a main and then compile.

main :: Eff _ Unit
main =
    void $ unsafePartial do
      -- create the signal
      dirSignal <- input
      game <- foldp step start dirSignal
      runSignal (map render game)

But we don’t have randomness yet.

Adding randomness

How do we model randomness in a purely functional program?

We need to add randomness as an effect.

Something that doesn’t work

The initialization and step functions need randomness, so we can try to rewrite the functions so they have the following type signatures:

init :: Eff (random:: RANDOM) Model 

step :: forall e. Point -> Eff (random::RANDOM | e) Model -> Eff (random::RANDOM | e) Model

render :: Eff (random:: RANDOM) Model -> Eff _ Unit 

input :: Eff _ (Signal Update)

-- Putting it all together
main :: Eff _ Unit
main = 
    void do
      startGame <- init
      signal <- input
	  -- game :: Signal Model
      game <- foldp step startGame signal
	  -- map :: (a -> b) -> f a -> f b
	  --        (Model -> Eff _ Unit) -> Signal Model -> Signal (Eff _ Unit)
      runSignal (map render game)

If you try this you will get very weird behavior: the mouse will jump all over the place! My best explanation for this is that the effects are compiled in un-executed form in the signal, and at each step, the effects are executed starting from the beginning. Not only does this mean that the random numbers generated are different, it means that the the program will run slower and slower. See a discussion here.

Using a generator

The standard way to do this is to include a Seed in the state, and whenever we need randomness, use the seed to generate a random number and a new seed, and update the seed. We can add a field for seed in Model

But this is clunky, and just the kind of thing that monads make easier to express! This sounds like a State Seed and in fact, the Gen monad is (basically) just this:

type GenState = { newSeed :: Seed, size :: Size }

newtype Gen a = Gen (StateT GenState Identity a)

Our step function will no longer be a -> b -> b, but will draw on randomness, so will take the form a -> b -> Gen b. We now replace

foldp :: forall a b. (a -> b -> b) -> b -> (Signal a) -> (Signal b)

with the function

foldpR :: forall a b e. (a -> b -> Gen b) -> b -> (Signal a) -> Eff (random :: RANDOM | e) (Signal b)

This is very reusable, and will make anything else we write with randomness painless.

For this, we need a bit of footwork. Behind the scenes, we do as we said before: a function f :: a -> b -> Gen b is the same as a function f' :: a -> (b, GenState) -> (b, GenState). We unravel f, and foldp using this f' as the step function and an initial seed. For sake of reusability, I’ve put foldpR in a separate module, in more generality than necessary, here; add import SignalM to Snake.

The function step :: Partial => Point -> Model -> Model is now step :: Partial => Point -> Model -> Gen Model. In the branch of the if statement where the snake eats the mouse, we keep generating points within the dimensions until the point is not part of the snake. Note that PureScript uses pure instead of return. We add pure to the other branches (not shown).

  do 
    newMouse <- untilM (\pt -> not (pt `elem` s || pt == hd)) (randomPoint m.xd m.yd)
    pure $ m { snake = hd : s
             , mouse = newMouse
             , dir = d
             , prev = Nothing -- snake grows; nothing is deleted
             }

The auxiliary functions here are (untilM being the monadic analogue of “repeat until”)

untilM :: forall m a. (Monad m) => (a -> Boolean) -> m a -> m a
untilM cond ma = 
    do 
      x <- ma
      if cond x then pure x else untilM cond ma

randomPoint :: Int -> Int -> Gen Point
randomPoint xmax ymax = 
    do
      x <- chooseInt 1 xmax
      y <- chooseInt 1 ymax
      pure $ Tuple x y

We also similarly incorporate randomness in the starting model by replacing start :: Model with init :: forall e. Eff (random::RANDOM | e) Model. The final main looks like

main :: Eff _ Unit
main = 
    void $ unsafePartial do
      --draw the board
      gameStart <- init
      render gameStart
      -- create the signals
      dirSignal <- input
      -- need to be in effect monad in order to get a keyboard signal
      game <- foldpR step gameStart dirSignal
      runSignal (map renderStep game)

See all the code here.

The version I linked to has a few extra lines of code so that pressing SPACE restarts the game; the additions are in SnakeS.purs, at the bottom.

Building

To build the project, run

mkdir dist
pulp build
pulp browserify -m Snake/index.js >> dist/Snake.js

Browserify creates a javascript file with all the javascript libraries included. We need a html file to host the javascript. Create html/index.html with a canvas of the appropriate dimensions:

<!DOCTYPE html>
<html>
  <head>
    <title>Snake</title>
  </head>
  <body>
    <canvas id="canvas" width="270" height="270" style="border: 1px solid black; display: block; margin: auto;"></canvas>
    <script type="text/javascript" src="../dist/Snake.js"></script>
  </body>
</html>

Now open the file and play!


  1. The PureScript signal library is very similar to the former Elm signal library. Note that Elm no longer uses signals.