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 ifScoreis 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 writeScore. What happens when you change the type toEither () (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.