Hello! I hope you are well. It has been a little while between posts as I keep starting things and then not finishing them.
My most recent distraction has been remaking a game that I wrote a year or two back called It Is The Egg, which is an HTML canvas game where eggs roll around and generally have a nice time. I wanted to add more levels and features but I have no strong desire to write Typescript in my free time, so I have made the somewhat foolish and time-sucking decision to port it over to Purescript.
So far I’ve learned a few things, listed thus:
- Rewriting an entire game takes a while actually.
- Using Purescript means about 30% of the LOC it took in Typescript
- A lot of the problems have already been solved by other people
- Abstractions are helpful
Anyhow, I figured concrete useful examples are the most difficult thing to find when explaining functional programming, and since I stumbled across a few in this process I figured I’d share some. Today I’m going to example how to describe positions and movement using a semigroup
.
Meaningless? Possibly! Perhaps some examples…
So we have a Player
type - it describes one of the eggs onscreen. The eggs have a position
onscreen, and a direction
that they are currently moving in.
type Player
= { position :: Coord
direction :: Coord
, }
This is the Coord
type. The eagle-eyed might notice it’s a newtype
rather than a type
- the reason for this will be explained shortly.
newtype Coord = Coord
x :: Int
{ y :: Int
, offsetX :: Int
, offsetY :: Int
, }
The game board is a grid, so position
is a Coord
where x
and y
describe the current square the Player
is in, and any movement away from the center is described by offsetX
and offsetY
. Once a players position
offset goes over a certain boundary, we increase or decrease x
or y
and set offsetX
or offsetY
back to zero.
The position
for the pink egg in the above screenshot would look something like this:
position :: Coord
position= Coord { x: 3
: 2
, y: 0
, offsetX: 32
, offsetY }
The direction
is also a Coord
, where we use x
and y
to express which direction the player is headed. Our pink egg above is falling downwards, which would look like this:
falling :: Coord
falling= Coord { x: 0
: 1
, y: 0
, offsetX: 0
, offsetY }
The offsetX
and offsetY
don’t really have much use in this context, but that’s fine.
So what’s the use in expressing two slightly different things in the same data type and also what about that newtype
and why have we not mentioned semigroup
yet?
An abridged version of each game turn goes as follows.
- Look around to see if we are still able to move in direction we want to
- If not, change direction
- Move the player in whichever direction we’re now decided on
Looking around the board
OK. So firstly we need to check around the board to see if we are allowed to carry on moving where we are moving. Without going into the whole mechanics of the game board, let’s say we have a function that tells us whether a certain Coord
on the board is a place we are allowed to go.
canMove :: Coord -> Boolean
What this function does is none of our business at this point - all we need to know is whether the square we intend to move into is happy with our decision to do so.
My first implementation followed the original logic and looked something like this:
canIMoveNext :: Player -> Boolean
@{ position: Coord pos, direction: Coord dir }
canIMoveNext player| dir.x < 0 = canMove ( Coord $ pos { x = pos.x - 1 } )
| dir.x > 0 = canMove ( Coord $ pos { x = pos.x + 1 } )
| dir.y < 0 = canMove ( Coord $ pos { y = pos.y - 1 } )
| dir.y > 0 = canMove ( Coord $ pos { y = pos.y + 1 } )
| otherwise = true
“If we are moving left then look to the current square but with x reduced once but if we’re looking right then look to the current square but with x increased but if we’re looking up….”.
Quite a laborious thing to read really, and it says nothing of it’s intentions.
(If you are not familiar with guard
syntax, think of this as very similar to select case
statement in Javascript.)
Stand back, I am going to use Maths.
OK, so we made that Coord
a newtype
for a reason right? Let’s add some typeclass instances to it and get it working for us for a change.
First we must please the gods of boilerplate by deriving some standard instances of Eq
, Ord
and Show
. Purescript needs this to be a little more explicit that Haskell does, sadly.
newtype instance eqCoord :: Eq Coord
derive newtype instance ordCoord :: Ord Coord
derive newtype instance showCoord :: Show Coord derive
derive newtype instance
means “You know how you’re a newtype
wrapped around something, can you just copy whatever the thing inside does? Ace, thanks.”
This means we can now compare and order things, which is helpful although not the main point here.
Let’s define a semigroup
!
instance semigroupCoord :: Semigroup Coord where
Coord fst) (Coord snd)
append (= Coord { x: fst.x + snd.x
: fst.y + snd.y
, y: fst.offsetX + snd.offsetX
, offsetX: fst.offsetY + snd.offsetY
, offsetY }
(Notice that unlike Haskell, Purescript likes us to give our instances names, hence we have chosen the olympically dull semigroupCoord
. It could be pintsOfCream
or indepedenceDayIsPrettyUnderatedAsFarAsBlockbustersGo
, go absolutely wild if you like.)
So what does this append
function we have defined do then?
It takes two Coord
values (fst
and snd
in this case) - adds up x
, y
, offsetX
and offsetY
- then makes and returns a new Coord
. Therefore we can do stuff like add a position
and a direction
together.
In the above example of our pink egg, and our position
and falling
values defined above, we can check whether we’re OK to keep falling downwards as such.
canIMoveDown :: Boolean
= canMove (position <> falling)
canIMoveDown -- canIMoveDown == true (according to the picture)
(Note we’re using <>
which is another name for the append
function, we could just as easily have written canMove (append position falling)
but I think the little <>
looks nicer.)
Looking around the board with Maths
Back to our implementation of canIMoveNext
, here is the new version that combines our player’s direction
and position
in a nice tidy way.
canIMoveTwo :: Player -> Boolean
canIMoveTwo player= canMove (player.position <> player.direction)
Nice.
Changing direction
What if our egg hits a wall or something? What should we do then? It should be as easy as reversing the direction
, right?
invert :: Coord -> Coord
Coord coord)
invert (= Coord { x: (-1) * coord.x
: (-1) * coord.y
, y: (-1) * coord.offsetX
, offsetX: (-1) * coord.offsetY
, offsetY }
Therefore we can use it on our player like thus:
turnAround :: Player -> Player
turnAround player= player { direction = invert player.direction }
And wrap up the entire logic for the turn like this:
updateDirection :: Player -> Player
updateDirection player= if canMove player.position
then player
else turnAround player
(Readers with a keen eye will notice we did not use anything particularly clever or typeclass based here, I just included it for completeness.)
Actually moving that egg around
Let’s get back to moving the egg around. Once we’ve worked out that our egg is moving somewhere it is allowed to, we need to actually update the offsetX
and offsetY
in it’s position
so that’s in the new location and ready to go through all this hell again. As web browsers are flaky at best, instead of moving by a set amount the actually amount to increment the change depends on how much time has passed since the last frame. Therefore we need a function that takes the amount to move and a Player
, and returns a new Player
that has moved in some way.
Here, in a similar manner to our earlier function, is something like my original painful version.
incrementPlayerPosition :: Int -> Player -> Player
@{ position: Coord pos, direction: Coord dir }
incrementPlayerPosition moveAmount player= player { position = newPosition }
where
newPosition| dir.x < 0 = Coord $ pos { offsetX = pos.offsetX - moveAmount }
| dir.x > 0 = Coord $ pos { offsetX = pos.offsetX + moveAmount }
| dir.y < 0 = Coord $ pos { offsetY = pos.offsetY - moveAmount }
| dir.y > 0 = Coord $ pos { offsetY = pos.offsetY + moveAmount }
| otherwise = Coord pos
It should work but it’s a bit terrifying. Let’s break it up and get some of that sweet semigroup
magic working for us.
Firstly, a helper function - this takes an Int
and a Coord
and creates a new Coord
that describes the movement we want the egg to do.
createMoveCoord :: Int -> Coord -> Coord
Coord coord)
createMoveCoord amount (= Coord { x: 0
: 0
, y: amount * coord.x
, offsetX: amount * coord.y
, offsetY }
Therefore if we take the falling
value from earlier, and a moving amount of 20
, it would create something like this:
downMove :: Coord
downMove= Coord { x: 0
: 0
, y: 0
, offsetX: 20
, offsetY }
With our helpful helper function in hand, and a dash of <>
, we can now rewrite our move function like thus:
incrementPlayerDirection :: Int -> Player -> Player
incrementPlayerDirection amount player= player { position = newPosition }
where
newPosition :: Coord
newPosition= player.position <> moveCoord
moveCoord :: Coord
moveCoord= createMoveCoord amount player.direction
So here we’re creating a Coord
for the movement (called moveCoord
) - and then using the <>
function to combine it with the current position
to make a new Coord
called newPosition
, and then making a new Player
with that position
.
Putting it all together
Now we’ve created all our functions, we can plop them together like this to do the whole move:
doMove :: Int -> Player -> Player
doMove amount= updateDirection
>>> incrementPlayerDirection amount
Easy as pie.
“But I thought we’d actually draw one of those attractive looking eggs and instead we’ve just done some shitty maths”, I hear you say. OK, OK, sure, I get it, you want adventure, you want action. We’ll come to it soon, I promise.