Shmup.md

November 7, 2020 · View on GitHub

This document breaks down a small game written using apecs. We’re going to be making a little shoot ‘em up-style game, mirroring the Entitas example. Consider this a vertical slice; we’ll cover most of apecs’ features, but not in great detail. If you are not familiar with the basics of ECS it might be worth reading the introductory sections of the paper.

If you want to run the game, clone this repository and run stack exec shmup. Since this document is a literate Haskell file (or a rendered markdown file, in which case the .lhs file is in the same folder), you can also compile it directly with GHC and run the game. The arrow keys move you, space shoots, escape quits. If you have any questions or suggestions while working through this tutorial, don’t hesitate to create an issue or send a message.

Let’s start at the top. Apecs’ type-level machinery tends to effect a large number of pragma’s. Don’t worry, GHC will happily let you know if you missed any.

{-# LANGUAGE DataKinds                  #-}
{-# LANGUAGE FlexibleContexts           #-}
{-# LANGUAGE FlexibleInstances          #-}
{-# LANGUAGE MultiParamTypeClasses      #-}
{-# LANGUAGE ScopedTypeVariables        #-}
{-# LANGUAGE TemplateHaskell            #-}
{-# LANGUAGE TypeApplications           #-}
{-# LANGUAGE TypeFamilies               #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

The Apecs module forms the apecs prelude, it re-exports everything you typically need.

import Apecs

For graphics and input we use apecs-gloss, which is a (thin) layer around the gloss graphics library. Gloss is very easy to use, and ideal for simple games such as this one.

import Apecs.Gloss

The linear library is the de facto library for small-dimensional vector types.

import Linear

Finally, we use random for our RNG, and import some base stuff.

import System.Random
import System.Exit
import Control.Monad
import Data.Monoid
import Data.Semigroup (Semigroup)

We need Monoid for mempty, but in recent GHC’s that requires also defining Semigroup instances. So, depending on your GHC version, you might not actually need the Semigroup import/instances.

With the imports taken care of, we can start defining Components. We do so by first defining a data type, and then give it an instance of Component.

Each Component is stored in a separate data structure, called its storage or store. The Component instance declaration specifies which store a Component uses. We’ll mostly be using the most basic store here, called Map.

Position and Velocity are straightforward Components; they define an Entity’s position and velocity as two-dimensional vectors of Floats. The reason we use Float over Double is that most OpenGL-based libraries, including gloss, use Floats. You can use Double, but if you don’t need the extra accuracy, using Float will save you a bunch of conversions.

newtype Position = Position (V2 Float) deriving Show
instance Component Position where type Storage Position = Map Position

newtype Velocity = Velocity (V2 Float) deriving Show
instance Component Velocity where type Storage Velocity = Map Velocity

The following two Components are unit types, i.e. they only have a single inhabitant. Unit types are common in apecs, as they can be used to tag an Entity.

data Target = Target deriving Show
instance Component Target where type Storage Target = Map Target

data Bullet = Bullet deriving Show
instance Component Bullet where type Storage Bullet = Map Bullet

Particle is also used to tag an Entity, but unlike Target and Bullet, also has a remaining life span (in seconds) field.

data Particle = Particle Float deriving Show
instance Component Particle where type Storage Particle = Map Particle

Player is a unit type, but instead of storing it in a Map, we use a Unique. A Unique is a Map that will only hold a single Component; if we assign Player to Entity 3 and then to Entity 4, only Entity 4 will have a Player. This enforces that there is only ever one player at the store level, and it will be the first example of how a store can change the behavior of a Component.

data Player = Player deriving Show
instance Component Player where type Storage Player = Unique Player

The third store we will use is Global, used to model global variables. Global stores also hold a single Component, but unlike Unique, that Component does not belong to any particular Entity. Instead, a Global store will always yield its one Component, regardless of the Entity it is queried for. So more accurately, a global Component belongs to /every/ Entity.

The initial value of a Global will be drawn from that Component’s Monoid instance.

Score keeps the score, and Time the total elapsed time.

newtype Score = Score Int deriving (Show, Num)
instance Semigroup Score where (<>) = (+)
instance Monoid Score where mempty = 0
instance Component Score where type Storage Score = Global Score

newtype Time = Time Float deriving (Show, Num)
instance Semigroup Time where (<>) = (+)
instance Monoid Time where mempty = 0
instance Component Time where type Storage Time = Global Time

You might already have noticed that there is more than one way to divide the game state into Components. For example, since Player, Target, Bullet, and Particle are mutually exclusive, we could have enforced that by defining a single Component like this:

data EtyType = Player | Target | Bullet | Particle Float

Defining separate Components makes it easier to efficiently iterate over one type of Entity, so that’s the approach we will use in this tutorial, but both ways are equally valid and can be equally fast.

Now that we have defined our Components, we need to create a game world. This is generally done through Template Haskell, as follows:

makeWorld "World" [''Position, ''Velocity, ''Player, ''Target, ''Bullet, ''Score, ''Time, ''Particle, ''Camera]

makeWorld defines a World data type, and the necessary instances. More information on what exactly it generates can be found in the apecs paper.

At this point I also like to define some type synonyms and constants:

type System' a = System World a
type Kinetic = (Position, Velocity)

playerSpeed, bulletSpeed, enemySpeed, xmin, xmax :: Float
playerSpeed = 170
bulletSpeed = 500
enemySpeed  = 80
xmin = -100
xmax = 100

hitBonus, missPenalty :: Int
hitBonus = 100
missPenalty = 40

playerPos, scorePos :: V2 Float
playerPos = V2 0 (-120)
scorePos  = V2 xmin (-170)

With that, we are ready to start writing our first Systems.

initialize :: System' ()
initialize = do
  playerEty <- newEntity (Player, Position playerPos, Velocity 0)
  return ()

initialize initializes our game state. In this case we only create a player, at the initial player position and with a velocity of 0. playerEty is a value of type Entity, which is actually just an integer value. In this case it will be 0, since it is the first Entity, and counting starts at 0. In practice, we almost never use Entity values directly, and we won’t actually use the playerEty value.

stepPosition :: Float -> System' ()
stepPosition dT = cmap $ \(Position p, Velocity v) -> Position (p + dT *^ v)

stepPosition is the canonical example of a System; it adds every Entity’s velocity to its position. cmap is ubiquitous in apecs, and you’ll use it to define most of your game logic. cmap’s behaviour is heavily dependent on the type of the function we map, so I will briefly discuss that type for every use of cmap, and how to interpret it. In this case, that’s (Position, Velocity) -> Position.

cmap will iterate over every Entity that has the Component on the left-hand side, and write the Component on the right-hand side. In this case, the left-hand Component is (Position, Velocity). As you can see, a tuple of Components is considered a Component as well, and it is the first example of how Components can be Composed into bigger Components. When we iterate over a tuple, what happens internally is that we iterate over all Entities that have the first Component (Position), and then test whether the Entity also has the remaining Components, in this case Velocity.

clampPlayer :: System' ()
clampPlayer = cmap $ \(Player, Position (V2 x y))
                   -> Position (V2 (min xmax . max xmin $ x) y)

clampPlayer constrains the player’s x position between xmin and xmax. We can express it using a simple cmap as well. The function has type (Player, Position) -> Position, which iterates over Player, reads Player and Position, and writes Position.

Here we see the usefulness of unit types. We never have to worry about the actual Entity value of the player, but instead we just refer to it using the Player Component. Since Player is a Unique value, we can be sure that this only ever affects at most one Entity.

incrTime :: Float -> System' ()
incrTime dT = modify global $ \(Time t) -> Time (t+dT)

incrTime increments the total elapsed time by dT. In this case, we cannot use cmap, as we cannot iterate over a Global. If you try to do so, you will get a type error about how Global Time does not have an instance of ExplMembers; which is to say you cannot retrieve a list of members from a Global. Instead we have to use modify, which is like cmap for a single Entity. As mentioned before, the exact Entity argument does not matter for a global Component. global is just an alias for -1.

Side note: In earlier versions of apecs, the members of a global were defined to be [ -1 ], so that you could cmap over a global. This was removed, since it violated a number of common-sense properties such as cmap over (a, b) being semantically equivalent to (b, a). So, now it is simply a type error.

Let’s make things more interesting. clearTargets needs to destroy the targets that move out of bounds. We are going to try expressing this using cmap.

An important thing to be aware of is that in apecs, there is no such thing as destroying/deleting/removing an /Entity/. Instead, you can only destroy /Components/, and if you want to get rid of an Entity entirely, you often need to destroy each of its Components individually.

Our mapped function will have type (Target, Position, Velocity) -> Maybe (Target, Position, Velocity). Maybe represent optionality, on the left-hand side it represents a read that might fail, on the right-hand side it is a write that can also delete a Component. If we return a Just c, c gets written as normal, but when we return Nothing, those same Components will be deleted instead.

clearTargets :: System' ()
clearTargets = cmap $ \all@(Target, Position (V2 x _), Velocity _) ->
  if x < xmin || x > xmax
     then Nothing
     else Just all

This works fine, but it’s not ideal. We don’t really need to read Velocity, we are only ever interested in removing it. Furthermore, we don’t want to have to write all three Components, we just want to be able to delete them.

The next System illustrates how we can make our cmaps more specific. stepParticles needs to decrement the life time of all Particles, and remove them if their timer reaches 0. So we need to iterate over and read Particle, and either delete Particle, Position, and Velocity, or write Particle. This is done with a function of type Particle -> Either Particle (Not (Particle, Kinetic)).

As in most Haskell libraries, where tuples represent conjunction, Either represents a disjunction. In the case of apecs, the Component (a,b) represents the presence of both a and b, whereas Either a b represents the presence of at least one of a or b (with b having precedence when reading).

Not :: Not c can be used to delete something, just like Nothing :: Maybe c. It can also occur on the left-hand side, where an Entity has a Component (a, Not b) if it has an a, but no b. We will see this behaviour later.

Combined, Either a (Not b) will either write a, or delete b.

stepParticles :: Float -> System' ()
stepParticles dT = cmap $ \(Particle t) ->
  if t < 0
     then Right $ Not @(Particle, Kinetic)
     else Left  $ Particle (t-dT)

If you’ve never seen it, the Not @c syntax is from the TypeApplications pragma, and is equivalent to Not :: Not c.

We can take cmap even further. For bullets, we want to clear them when they leave the screen, and if so, decrement the score.

We will use a function of type (Bullet, Position, Score) -> Either () (Not (Bullet, Kinetic), Score). Let’s break this down.

  • (Bullet, Position, Score) means we iterate over Entities that have all three of those Components. Score is a global Component, and as explained previously, can be said to belong to every Entity. Remember though, we cannot iterate over globals, so you get a type error if Score is in the first position on the left-hand side.

  • Writing a Left () value does nothing.

  • Writing Right (Not, Score s) will both delete (Bullet, Kinetic), and write Score. What happens when you change the type to Either () (Not (Bullet, Kinetic, Score))? Try it out, the answer might surprise you.

Putting it together:

clearBullets :: System' ()
clearBullets = cmap $ \(Bullet, Position (V2 _ y), Score s) ->
  if y > 170
     then Right $ (Not @(Bullet, Kinetic), Score (s-missPenalty))
     else Left ()

In some cases, just because something can be expressed in cmap, does not mean it necessarily should. In the example above, you could argue that we perform an unnecessary read on the Score, since we read it for every Bullet. Furthermore, we jump through a lot of hoops just to be able to use Left () to essentially do nothing. In this particular case, we could have used

cmapIf (\(Position y) -> y > 170) (\(_ :: Bullet) -> Not @ (Bullet, Position))

Sometimes, however, it might be easier to just add some side-effects. It might not surprise you that there also is a cmapM and a cmapM_. These pretty much work as you would expect, they take a function of type cx -> SystemT w m cy, meaning they iterate over and read their input argument, and run the System in the output.

We’ll use cmapM_ to do some collision handling. We first iterate over all (Target, Position, Entity)s, and for each, iterate over all (Bullet, Position, Entity) Entities. Then, when the distance between two positions is below the threshold, we destroy both Entities, create a bunch of particles, and update the score.

The Entity Component is the same Entity returned by e.g. newEntity, it is just an integer value in a newtype. When read as a Component, it will return whatever Entity it is queried at, i.e. for Entity ety' <- get ety, ety' == ety will be true by definition.

Destroying Components for a specific Entity (rather than through cmap) is done with destroy.

Side note: There are 5 primitive operations on stores/Components: exists, get, set, destroy, and members. All other Systems are implemented using these 5 operations. For example, we could write cmap as follows:

myCmap :: (..) => (cx -> cy) -> System' ()
myCmap f = do
  etys <- members (Proxy @cx)
  forM_ etys $ \ety -> do
    cx <- get ety
    set ety (f cx)

I won’t go into further detail here, but the take-away here is that cmap is pretty ordinary; the actual magic happens by choosing interesting implementations of the above 5 functions.

Anyway, collision handling:

handleCollisions =
  cmapM_ $ \(Target, Position posT, etyT) ->
    cmapM_ $ \(Bullet, Position posB, etyB) ->
      when (norm (posT - posB) < 10) $ do
        destroy etyT (Proxy @(Target, Kinetic))
        destroy etyB (Proxy @(Bullet, Kinetic))
        spawnParticles 15 (Position posB) (-500,500) (200,-50)
        modify global $ \(Score x) -> Score (x + hitBonus)

Again, every time we delete e.g. a Bullet, we have to remember to also delete its Kinetic (position and velocity). If you forget to do so, you will have a Component with just a Position and a Velocity floating around. It won’t really interact with anything, but it will still take up memory and have its position updated in every stepPosition.

People have asked why there is no way to simply delete an Entity and all of its Components. The reason apecs can’t do it for you is kind of technical, but comes down to that there is no obvious way of centrally tracking what Entities have what Components. Furthermore, adding such a System would be limiting in other ways, and additional complexity would make it harder to integrate apecs with other Systems. Instead, in this tutorial we use type synonyms like Kinetic to define hierarchies of Components and easily delete many at once. If this is an issue for you, you could write an All type synonym, a tuple containing all Components. If you then destroy ety (Proxy @All), you would be sure that all Components get deleted.

triggerEvery runs a System periodically. Nothing about this is apecs-specific, except for the get global, which we’ve seen before. If you hadn’t noticed by the way, get doesn’t need a Proxy because unlike e.g. destroy it can infer what Component to act on from its return value.

triggerEvery :: Float -> Float -> Float -> System' a -> System' ()
triggerEvery dT period phase sys = do
  Time t <- get global
  let t' = t + phase
      trigger = floor (t'/period) /= floor ((t'+dT)/period)
  when trigger $ void sys

spawnParticles does what it says on the tin. The random values are generated in the IO monad, so we use liftIO.

spawnParticles :: Int -> Position -> (Float,Float) -> (Float,Float) -> System' ()
spawnParticles n pos dvx dvy = replicateM_ n $ do
  vx <- liftIO $ randomRIO dvx
  vy <- liftIO $ randomRIO dvy
  t  <- liftIO $ randomRIO (0.02,0.3)
  newEntity (Particle t, pos, Velocity (V2 vx vy))

Finally, we assemble all our pieces into a single System.

step :: Float -> System' ()
step dT = do
  incrTime dT
  stepPosition dT
  clampPlayer
  clearTargets
  clearBullets
  stepParticles dT
  handleCollisions
  triggerEvery dT 0.6 0   $ newEntity (Target, Position (V2 xmin 80), Velocity (V2 enemySpeed 0))
  triggerEvery dT 0.6 0.3 $ newEntity (Target, Position (V2 xmax 120), Velocity (V2 (negate enemySpeed) 0))

apecs-gloss provides a layer of convenience around the gloss Graphics.Gloss.Interface.IO.Game module. We’ll use it to make a window, render the game, and handle player input.

Let’s start by looking at input handling. We define a function that maps each possible input to a System:

handleEvent :: Event -> System' ()
handleEvent (EventKey (SpecialKey KeyLeft) Down _ _) =
  cmap $ \(Player, Velocity (V2 x _)) -> Velocity (V2 (x-playerSpeed) 0)

handleEvent (EventKey (SpecialKey KeyLeft)  Up   _ _) =
  cmap $ \(Player, Velocity (V2 x _)) -> Velocity (V2 (x+playerSpeed) 0)

handleEvent (EventKey (SpecialKey KeyRight) Down _ _) =
  cmap $ \(Player, Velocity (V2 x _)) -> Velocity (V2 (x+playerSpeed) 0)

handleEvent (EventKey (SpecialKey KeyRight) Up   _ _) =
  cmap $ \(Player, Velocity (V2 x _)) -> Velocity (V2 (x-playerSpeed) 0)

handleEvent (EventKey (SpecialKey KeySpace) Down _ _) =
  cmapM_ $ \(Player, pos) -> do
    newEntity (Bullet, pos, Velocity (V2 0 bulletSpeed))
    spawnParticles 7 pos (-80,80) (10,100)

handleEvent (EventKey (SpecialKey KeyEsc) Down   _ _) = liftIO exitSuccess

handleEvent _ = return ()

Next, we’ll look at drawing. This is done by constructing gloss Picture values. I recommend looking at the gloss documentation to see what sort of things you can do with it.

Our drawing function will produce such a Picture. The easiest way to draw multiple Entities is to use the foldDraw function from apecs-gloss. It performs a cfold of some drawing function, and combines all results into a larger Picture.

translate' :: Position -> Picture -> Picture
translate' (Position (V2 x y)) = translate x y

triangle, diamond :: Picture
triangle = Line [(0,0),(-0.5,-1),(0.5,-1),(0,0)]
diamond  = Line [(-1,0),(0,-1),(1,0),(0,1),(-1,0)]

draw :: System' Picture
draw = do
  player  <- foldDraw $ \(Player, pos) -> translate' pos . color white  . scale 10 20 $ triangle
  targets <- foldDraw $ \(Target, pos) -> translate' pos . color red    . scale 10 10 $ diamond
  bullets <- foldDraw $ \(Bullet, pos) -> translate' pos . color yellow . scale 4  4  $ diamond

  particles <- foldDraw $
    \(Particle _, Velocity (V2 vx vy), pos) ->
        translate' pos . color orange $ Line [(0,0),(vx/10, vy/10)]

  Score s <- get global
  let score = color white . translate' (Position scorePos) . scale 0.1 0.1 . Text $ "Score: " ++ show s

  return $ player <> targets <> bullets <> score <> particles

And with that, we can run our little game!

main :: IO ()
main = do
  w <- initWorld
  runWith w $ do
    initialize
    play (InWindow "Shmup" (220, 360) (10, 10)) black 60 draw handleEvent step

That concludes our tour. Again, please let me know if you have any questions or comments, through GitHub issues/twitter/reddit.