A Game in Haskell - Dino Rush

Feburary 28th, 2018

Motivation

Writing a game in Haskell appears to be an elusive, white-whale task. And with an existing handful of small and citable games, I wish more existed with documentation to ease the barrier of entry. It's disappointing because writing a game can be a challenging and fun way to explore a language. And Haskell could be a great language for creating small-medium sized games. Anything larger sounds like a bad idea. Yet I haven't written a game with graphics in recent years. Instead, I dug out increasingly larger chunks of programming knowledge with Haskell. And that has given back more than originally expected. It's been enough now, whereas I can take decent shot at a game and not be completely appalled by the code later.

Before embarking on a short and starry-eyed journey, it seemed easy to imagine such a codebase -- flaws included. "Flaws." I decidedly choose not to use FRP for example. Nothing against FRP. It's an interesting and high level way to operate with UIs, and perhaps I'll come back to it someday. I don't know. But for this project, it felt unwise to get caught up or side-tracked with explaining another concept. This plate already holds a complete meal.

When imagining how to organize the big to small things, the flaws mentally accrue more than I can manage. They can be only understood by doing it. It definitely wasn't going to be perfect, and facing the imperfections would be a process.

At the very least, I wanted to create something exhaustive that I would have appreciated a couple years ago. And the game must work at all costs. The direction meant to satisfy these mindsets. It's not the only way to write a game in Haskell. There will always be more to learn and apply on this cliff that never seems to stop growing. Without glancing at the latest extensions and most advanced libraries, Haskell still has plenty to offer. Yeah. Let's stick with that.

The Game (video)

The game is called Dino Rush because the objective is for a dinosaur -- stay with me on this -- to rush past obstacles. Sure, it's not very interesting from a design perspective. And without a doubt, it's a Skinner box game. It's shamelessly stolen from inspired by a certain, offline game. But it's fairly simple from a player perspective. That's important for keeping scope.

Of course, it wasn't as straight forward from a programmer perspective. That makes smartest decision overall was to pick a silly game. Again, scope. Plenty of time was spent getting the "foundation" to be functional before writing any gameplay.

Not surprisingly, the polishing and forming a coherent architecture took the most time. Ha! But they were both worthy causes. The level of polish need to exist somewhere and somehow. And the architecture must allow such things to be clear and easily expandable. That's the point of a good architecture.

Architecture

The architecture was extracted throughout the course of the development. At a super high-level, the boxes below represent all the modules and types of modules that fit together. It's a reusable model for many more games. But it's just that -- a model. Even Dino Rush doesn't adhere 100%, and that's okay. It's awfully similar enough to demonstrate utility.

As you can see, all the arrows flow upwards. A higher-level box gets pointed by lower-level boxes it depends on. (There was likely an unconscious inspiration from a Hexagon architecture and previous games. It's hard to know at this point.)

Main - Do all the things!

- Do all the things! Runner - Main loop and scene state machine.

- Main loop and scene state machine. Scenes - Represent pieces of a coherent story. Lots of glue code.

- Represent pieces of a coherent story. Lots of glue code. Effects - Eventually IO actions. Impure.

- Eventually IO actions. Impure. Managers - For state machines. Leans toward deterministic and likely pure.

- For state machines. Leans toward deterministic and likely pure. Config - Loaded read-only or referenced values. Mixed purity.

- Loaded read-only or referenced values. Mixed purity. State - Global stitching of all types in state. All pure.

- Global stitching of all types in state. All pure. Resources - Types and functions for loading and freeing. (eg. textures, music)

- Types and functions for loading and freeing. (eg. textures, music) Engine - Most code. Game logic, physics, entities, commands. All pure!

- Most code. Game logic, physics, entities, commands. All pure! Wrappers - A place to wrap IO code which could use a cleaner API

- A place to wrap IO code which could use a cleaner API Third Party Libraries - All the dependencies (eg. sdl2 , linear , animate )

Main (code)

Let's take it from the top. At the main function. In general, it's important to keep the main function concise as possible. When jumping into any codebase, not having clear indication where to dig into next feels pretty obnoxious. Event if it's not intentional, and it's probably not. It happens when intertwining setup, configuration, and miscellaneous IO actions. Be sparse for kindness.

main :: IO () main = do SDL.initialize [SDL.InitVideo, SDL.InitAudio] Font.initialize Mixer.openAudio Mixer.defaultAudio 256 window <- SDL.createWindow "Dino Rush" SDL.defaultWindow { SDL.windowInitialSize = V2 1280 720 } renderer <- SDL.createRenderer window (-1) SDL.defaultRenderer resources <- loadResources renderer mkObstacles <- streamOfObstacles <$> getStdGen let cfg = Config { cWindow = window , cRenderer = renderer , cResources = resources } runDinoRush cfg (initVars mkObstacles) mainLoop SDL.destroyWindow window freeResources resources Mixer.closeAudio Mixer.quit Font.quit SDL.quit

Good.

The function has 5 essential parts:

Initialize the media libraries

Load resources (Fonts, images, and sounds)

Run the main loop

Free the resources

Uninitialize the media libraries and clean up shop

Because this isn't a large and memory intensive game, frequently loading and unloading resources at runtime isn't needed. If it was, there's a place for that somewhere in the architecture -- either Effects or Managers.

MTL Style and Lenses

Dino Rush's usage of mtl style, a pattern named after the mtl library, is the most technically complex part of the code. If you're unfamiliar with this pattern, I highly suggest you stop here and go learn about it. It's a game changer to be aware of and much bigger topic than this post. And commonly aligned with mtl style, Dino Rush follows a (basic) use of lenses.

These links are more than exhaustive (eg. Free Monad isn't relevant):

Hey! Did you just skip all of those links? Alright. Fine. Here's the skinny.

In order to have side-effects and still carry data around, Monad Transformers are the way to go. They compose useful monads like StateT and ReaderT , which carry the data around.

By wrapping the monad transformers in a newtype, the advantages of the transformers are retained by with the help of a language extension, GeneralizedNewtypeDeriving . In reality, the typeclass instances are kept separated from ReaderT , StateT , and IO .

newtype DinoRush a = DinoRush (ReaderT Config (StateT Vars IO) a) deriving (Functor, Applicative, Monad, MonadReader Config, MonadState Vars, MonadIO, MonadThrow, MonadCatch)

Plus, it's a cleaner looking signature.

exampleSignature :: Int -> DinoRush ()

That's a lot easier to read and write than the underlying transformer stack.

The DinoRush monad can do IO , modify Vars , and access the Config . Now, combine that with a function to run the transformer.

runDinoRush :: Config -> Vars -> DinoRush a -> IO a runDinoRush config v (DinoRush m) = evalStateT (runReaderT m config) v

Bam!

The DinoRush monad can run in IO as if it wasn't wrapped.

But there's a problem here. This is especially true if you care about isolating side-effects or testing. Because DinoRush nests an IO underneath, there's no guarantee that any function has known side-effects. And there's another problem. Accessing the Vars state in StateT underneath DinoRush is nearly synonymous with managing global state. As a program becomes larger, that can be an overwhelming rat nest. It's everyone's favorite software problem.

Imagine if you can restrict which data accessed by the use of a type signature. Because that's possible. Like this:

updateDino :: (MonadState s m, HasPlayVars s, Renderer m, AudioSfx m) => Step DinoAction -> m ()

updateDino doesn't need to know or care that s is an amalgamation of other state. It just knows what it cares about; it can access PlayVars . This what the wonderful mtl and lens libraries allow.

The last thing to do is instance HasPlayVars on Vars and instance Renderer and AudioSfx on DinoRush . The code achieves high cohesion with loose coupling. Great!

Runner (code)

The Runner is the module holds the main loop. It provides 3 distinct purposes. First, it steps the current Scene . Second, it manages and effects the transitions between Scene s. Last and just as important, it does the common functions that happen every frame -- polling input, drawing to the screen, and delaying the frame. All of this runs inside the main loop.

I'll freely admit that Runner isn't the best name. It's an overloaded term. But I couldn't think of a better one!

mainLoop = do updateInput -- 1 input <- getInput clearScreen -- 2 clearSfx -- 3 scene <- gets vScene updateQuake -- 4 step scene -- 5 playSfx -- 6 drawScreen -- 6 delayMilliseconds frameDeltaMilliseconds nextScene <- gets vNextScene stepScene scene nextScene -- 7 let quit = nextScene == Scene'Quit || iQuit input || ksStatus (iEscape input) == KeyStatus'Pressed unless quit mainLoop

Poll and store the input Clear out the screen for drawing Clear out the list of sound effects to play. There's a type Sfx that represents each sound effect. Updates the state for an earthquake every 30 seconds. It's a convenient exception to the architecture model. This kind of detail wouldn't be here if different kind of scenes were added. Behind the step function steps the current Scene . Each Scene has its own logic, sound, drawing, and dreams. Those are intentionally opaque. where step scene = do case scene of Scene'Title -> titleStep Scene'Play -> playStep Scene'Pause -> pauseStep Scene'Death -> deathStep Scene'GameOver -> gameOverStep Scene'Quit -> return () Once the scene step finishes, play sound effects on the list and redraw the screen. This is an interesting bit. Because a scene needs to declare the next scene, there's a temptation to let the scenes form direct dependencies on each other. Scenes can be maintain flatness with indirection. They really shouldn't know much about each other in any case. So, there needs to be an intermediate step for transitions and one that ensures low complexity. That's done by forcing the transitions up into the Runner . The Runner knows all of the scenes, and let it handle the transitions. stepScene scene nextScene = do when (nextScene /= scene) $ do case nextScene of Scene'Title -> titleTransition Scene'Play -> case scene of Scene'Title -> playTransition Scene'Pause -> pauseToPlay _ -> return () Scene'Death -> case scene of Scene'Play -> deathTransition _ -> return () Scene'Pause -> case scene of Scene'Play -> playToPause _ -> return () Scene'GameOver -> return () Scene'Quit -> return () modify (\v -> v { vScene = nextScene })

And visually represented.

Scenes (code)

Remember that the next Scene is decided in the current Scene 's step. It doesn't happen at the level of the Runner , which deals with the transitions as a result.

Title - Waiting area before starting a game in Play

- Waiting area before starting a game in Play - Scene for game play -- it encompasses the most code and the complexity to match

- Scene for game play -- it encompasses the most code and the complexity to match Pause - Lower music and pause all gameplay and sprite animations

- Lower music and pause all gameplay and sprite animations Death - Wait until the dinosaur drops off the screen

- Wait until the dinosaur drops off the screen GameOver - Fade out to black and wait for a user input to return to the Title scene

- Fade out to black and wait for a user input to return to the scene Quit - Exit

Pause , Death , and GameOver all access the same Play state, PlayVars . Although they treat it as read-only access for drawing, that breaks the mold of the proposed architecture. It was an intentionally cut corner. It was to avoid excessively data mapping as described in the State section below.

If there was another state-machine, representing sub-scenes within the Play scene, it would justify it. The sub-scenes could share read-only access of each other. It's hard to say, but I think that's where it was growing.

Managers (code)

In the realm of mtl style, a Manager is a monadic type class representing an interface to hidden state. Whatever touches that data is not responsible for how it manages it self. Just stick with the interface. They're abstract data types as derived by the CLU language.

Dino Rush has two -- SceneManager , the interface for the scene state machine, and HasInput , the interface for updating and accessing game input. Each interface is capable of being mocked with pure types.

Each Scene imports the SceneManager in order to set the next scene.

class Monad m => SceneManager m where toScene :: Scene -> m ()

HasInput is a bit of state machine that accesses IO. It's not really deterministic. However, it's not outwardly effectful. It's just reading of input and storage of input. The interface is dumb.

class Monad m => HasInput m where updateInput :: m () setInput :: Input -> m () getInput :: m Input

Like sprite animation, detecting changes between key presses has been a pet peeve in other games. I made the key-state library to generalize the same set of types and functions.

In the case of updateInput , SDL events are polled then adapted into the Dino Rush's idea of an input. It's unique to the Dino Rush domain as it's used through the Engine , Scene , and Runner .

updateInput' :: (HasInput m, SDLInput m) => m () updateInput' = do input <- getInput events <- pollEventPayloads setInput (stepControl events input)

The adaption happens inside stepControl . By the key code, next helper function steps through the events deciding the next key state.

stepControl :: [SDL.EventPayload] -> Input -> Input stepControl events Input{iSpace,iUp,iDown,iEscape} = Input { iSpace = next 1 [SDL.KeycodeSpace] iSpace , iUp = next 1 [SDL.KeycodeUp, SDL.KeycodeW] iUp , iDown = next 1 [SDL.KeycodeDown, SDL.KeycodeS] iDown , iEscape = next 1 [SDL.KeycodeEscape] iEscape , iQuit = elem SDL.QuitEvent events } where next count keycodes keystate | or $ map pressed keycodes = pressedKeyState | or $ map released keycodes = releasedKeyState | otherwise = maintainKeyState count keystate released keycode = or $ map (keycodeReleased keycode) events pressed keycode = or $ map (keycodePressed keycode) events

Effects (code)

An Effect is also a monadic type class. Unlike a Manager , whether they act as a state machine is irrelevant. The purpose is to represent side-effects.

The Renderer type has side-effects. It clears the screen, draws the screen, draws sprites, and draws numbers.

class Monad m => Renderer m where clearScreen :: m () drawScreen :: m () getDinoAnimations :: m (Animations DinoKey) ... drawDino :: DrawSprite DinoKey m ... drawNumber :: Number -> (Int, Int) -> m ()

It's a low-level Effect where some funcitons should be used to form clearer intentions. Another Effect , HUD , does that by depending on Renderer in its implementation.

class Monad m => HUD m where drawHiscore :: m () drawScore :: m () drawControls :: m () drawHiscore' :: (Renderer m, MonadState s m, HasCommonVars s) => m () drawHiscore' = do cv <- gets (view commonVars) drawHiscoreText (1150, 16) drawNumbers (fromIntegral $ cvHiscore cv) (1234, 50) drawNumbers :: Renderer m => Integer -> (Int, Int) -> m () drawNumbers int (x,y) = mapM_ (\(i, n) -> drawNumber n (x - i * 16, y)) (zip [0..] (toNumberReverse int))

Writing the code that controls the camera was actually pretty fun.

Zoom, zoom.

class Monad m => CameraControl m where adjustCamera :: Camera -> m () disableZoom :: m () enableZoom :: m ()

An easy way to zoom with SDL is to modify the viewport. With the Camera type and functions from the Engine , the moveCamera helper function was a quick stitch.

moveCamera :: MonadIO m => SDL.Renderer -> Camera -> m () moveCamera renderer Camera{camZoom, camOrigin} = do SDL.rendererScale renderer $= (fmap realToFrac camZoom) let dim = fmap truncate $ screenV2 SDL.rendererViewport renderer $= (Just $ SDL.Rectangle (SDL.P $ (fmap truncate $ moveOrigin camOrigin)) dim) SDL.rendererClipRect renderer $= (Just $ SDL.Rectangle (SDL.P $ V2 0 0) dim)

Although, moving the camera isn't enough. The HUD can't have zoomed viewport. The images will scale off and around the screen. Using a stored Camera value, zooming could be toggled.

disableZoom' :: (MonadIO m, MonadReader Config m) => m () disableZoom' = do renderer <- asks cRenderer moveCamera renderer initCamera enableZoom' :: (MonadIO m, MonadReader Config m, MonadState Vars m) => m () enableZoom' = do renderer <- asks cRenderer cam <- gets vCamera moveCamera renderer cam

When re-enabling a zoom, the camera resumes from the stored Camera .

adjustCamera' :: (MonadIO m, MonadReader Config m, MonadState Vars m) => Camera -> m () adjustCamera' cam = do modify $ \v -> v { vCamera = cam } renderer <- asks cRenderer moveCamera renderer cam

Managers versus Effects

Or Managers and Effects. I debated whether or not to split these into separate categories. A case could be made to keep them merge into a single directory called Class or something similar. But if game dev is art, and art is about intention, then these lines should be drawn like God intended.

Config (code)

Config is the environment data inside the ReaderT transformer. It's a basic use for a transformer, including with mtl style. The data is always read-only while running a transformer stack. However, there are exceptions with IORef , MVar , and TVar which reference values. They're need needed for performance and multi-threaded programs.

data Config = Config { cWindow :: SDL.Window , cRenderer :: SDL.Renderer , cResources :: Resources }

There's not much going on here, and that's good. Just the SDL window, renderer, and various loaded resources. It would also hold command-line related arguments if they were needed. But they're not.

State (code)

The global stitched state is the Vars type. Similar to appearance of scene transitions, they look opaque from this perspective.

data Vars = Vars { vCommon :: CommonVars , vScene :: Scene , vNextScene :: Scene , vTitle :: TitleVars , vPlay :: PlayVars , vGameOver :: GameOverVars , vInput :: Input , vCamera :: Camera } deriving (Show, Eq)

To Duplicate or Not to Duplicate

Deciding whether or not to duplicate reused data was a careful balance.

It's clear that different scenes creates intentional boundaries between the updating of state. But an issue arises is when a scene does the same stateful updates or reads the same state. Should the scene only have access to its own state or a shared state?

An individualistic person would believe the "right" way feels that duplication is right. So mind your business.

An group-oriented person would disagree. Sharing is caring.

I kid with the stretched analogy, but the debate still remains the same. Each scene represents a kind of "domain", and data originating outside that domain should be projected into it during transition. The benefit is that each scene can evolve (or die) independently of another. It's a solid lego block, but with an obvious cost. The scalability requires significantly more data mapping between scenes. It's trivial, boring, and time-consuming.

The counter argument is that the lego block goes against the DRY principle. (I'm fairly hesitant to argue for that point anyways.) I may be convinced that "a single source of truth" is valid. This game isn't running over a network. So why immediately complicate that? And if problems arise, it could be refactored if need be -- YAGNI. You'll need to be on the look out.

There's really two ways to do this. The first, place the state behind a Manager . It's likely overkill as mtl style with MonadState is already the chosen route. "Like I don't need any more type classes in my life." It's an option though.

The second, which sounds more reasonable for simple getting and setting, is to be more fined grain with state. This approach takes advantage of mtl style. There is a single source of truth. And it's a half-way compromise to the scene-based approach.

I'm not convinced there's a "right" way. Using best judgement feels like the way to go right now. And I know that I'd be satisfy with a plan or an idea how it could be refactored between each approach. That's really the best principle.

Common

Late in the development, I caved into creating explicit set of common state. When every scene accessed these types, it could be an exception.

data CommonVars = CommonVars { cvHiscore :: Score , cvQuake :: Quake , cvSfx :: [Sfx] } deriving (Show, Eq)

cvQuake and cvSfx is modified in the mainLoop, and cvHiscore is drawn on every frame. It still felt like betrayal in a functional language. Common state is the junk drawer.

In retrospect, cvSfx as common was valid but approached poorly. It has a resemblance of vInput , and it should have used a similar Manager type class .

Resources (code)

The Resources type holds all the necessary images, sound effects, and music. It's also in the same module for loading those images, sounds, and fonts. Draw and configure textures.

Resources was appropriately extracted from the Config type and its module. The resources required unique set of detailed instructions for loading and freeing. The details became quite messy for Config in the same vein that Main should be concise. Config is concerned about all read-only at a higher level.

Sprites & Animations

With 2D games, a sprite system can save a lot of repetitive work.

To load sprite sheets and manage animations, I used my animate library. The majority of sprites aren't super complicated and can be described along a pattern. Using with a helpful JSON loader, the process of writing sprite loaders and editing can be done trivially.

Animate.readSpriteSheetJSON loadTexture "resource/dino.json" :: IO (Animate.SpriteSheet DinoKey SDL.Texture Seconds)

Because animate doesn't know about SDL, loadTexture is passed in as callback function which needs to load a SDL.Texture . Most of instructions for forming the animated sprite are in the JSON file.

{ "image": "resource/dino.png", "alpha": [255,0,255], "clips": [ [ 0, 0, 48, 48], [ 48, 0, 48, 48], [ 96, 0, 48, 48], [ 144, 0, 48, 48], [ 192, 0, 48, 48], [ 240, 0, 48, 48], [ 288, 0, 48, 48], [ 336, 0, 48, 48], [ 384, 0, 48, 48], [ 432, 0, 48, 48], [ 480, 0, 48, 48], [ 528, 0, 48, 48], [ 576, 0, 48, 48], [ 624, 0, 48, 48], [ 672, 0, 48, 48], [ 720, 0, 48, 48], [ 768, 0, 48, 48], [ 816, 0, 48, 48], [ 864, 0, 48, 48], [ 912, 0, 48, 48], [ 960, 0, 48, 48], [ 1008, 0, 48, 48], [ 1056, 0, 48, 48], [ 1104, 0, 48, 48] ], "animations": { "Idle": [ [0,0.2], [1,0.2], [2,0.2], [3,0.2] ], "Move": [ [4,0.01], [5,0.01], [6,0.01], [7,0.01], [8,0.01], [9,0.01] ], "Kick": [ [10,0.1], [11,0.1], [12,0.1] ], "Hurt": [ [13,0.05], [14,0.05], [15,0.05], [16,0.05] ], "Sneak": [ [17,0.01], [18,0.01], [19,0.01], [20,0.01], [21,0.01], [22,0.01], [23,0.01] ] } }

image is the file path of the sheet

is the file path of the sheet alpha is an optional tuple for color key representing transparency

is an optional tuple for color key representing transparency clip is an array of tuples [x,y,w,h] representing a clipped rectangle on the sheet

is an array of tuples [x,y,w,h] representing a clipped rectangle on the sheet animations , which is keyed by the animation key, holds lists of frames of the clip index and value for delaying (seconds in this case).

The animation key is one cool Haskell-ly thing about the animate library. The key is left opened as type parameter for user defined algebraic data types. DinoKey is one used here.

data DinoKey = DinoKey'Idle | DinoKey'Move | DinoKey'Kick | DinoKey'Hurt | DinoKey'Sneak deriving (Show, Eq, Ord, Bounded, Enum)

Each of the keys needs to map over to text in order to satisfy KeyName type class.

instance Animate.KeyName DinoKey where keyName = dinoKey'keyName dinoKey'keyName :: DinoKey -> Text dinoKey'keyName = \case DinoKey'Idle -> "Idle" DinoKey'Move -> "Move" DinoKey'Kick -> "Kick" DinoKey'Hurt -> "Hurt" DinoKey'Sneak -> "Sneak"

Music & SFX

The Mixer.load function has a neat interface from the Mixer.Loadable type class.

class Loadable a where decode :: MonadIO m => ByteString -> m a load :: MonadIO m => FilePath -> m a free :: MonadIO m => a -> m ()

Loadable is used for both the Music type and the Chunk type, the sound effect type. So while the statements look the same, the types are inferred differently.

gameMusic <- Mixer.load "resource/v42.mod" jumpSfx <- Mixer.load "resource/jump.wav"

Surface & Texture

Moving from SDL to SDL 2, the concept of Texture was introduced. Previously, only a Surface type was used for rendering onto a buffer. Texture is the result of hardware acceleration from GPU based libraries (OpenGL) are widely standardized and supported. When SDL was first released, that wasn't true.

With SDL 2, render with Texture . However, you're still stuck with loading image file as a Surface ,

loadSurface :: FilePath -> Maybe Animate.Color -> IO SDL.Surface loadSurface path alpha = do surface <- Image.load path case alpha of Just (r,g,b) -> SDL.surfaceColorKey surface $= (Just $ V4 r g b 0x00) Nothing -> return () return surface

With a quick wrapper function, they'll convert into Texture s.

loadTexture path c = SDL.createTextureFromSurface renderer =<< loadSurface path c

Fonts

When drawing text onto a screen, it's a 2-step preparation process.

Load the font with the SDL ttf library and define the font size by pixel height. Draw a string of text creating a Surface and convert the Surface into a Texture .

Drawing the Texture is no different than drawing any other Texture s at runtime. Just render it.

To be honest, it felt awkward and wasteful to create an entirely new texture for a string of text. The given interface encourages that.

Surely the reasons err on memory constraints. I'm not a fan, but I haven't dug much into an alternative. Boo me. From previous knowledge, I'm aware of font maps, but there was a readily available and elegant way to draw text dynamically at runtime. One which doesn't care or know the font size at startup and resizes as needed. I'd assume that clever caching is involved.

Engine (code)

If there's something where Haskell really shines, it's doing data transformations. And Engine is the place all about data transformations. Fortunately, it's where most of the intricate code exists. And every part of the game depends on it. It's the center of what is. Things in here are pure. It's really something that was done correctly.

Physics & Collision Detection

The collision detection is pretty simple. It's just checking for an intersection between AABB types. Things don't bounce off each other and fly away. Anything more advanced than a quick test is completely unnecessary. Plus, I attempted two separate mediocre physics engines (1 & 2) years ago, but this time around there wasn't obvious value to it. So I kept it easy.

Entities

Most games have a concept of an Entity. Perhaps they have an entire framework or library for dealing with entities. Entity Component System is the term. Or ECS for short.

I intentionally did not go that route.

For one reason, they're known to be difficult in Haskell. Haskell doesn't inherit the same way as the other languages where is a ECS normal. Advanced type wrangling needs to be done to be almost comparable.

For another reason, I'm not quite sold on the idea. I understand it saves time by allowing things to be super flexible and dynamic. But I like the predictability and clarity of sum types or explicitly different types.

And worst case GADTs can come to the rescue when sum types need more flexibility.

Adapters & Commands

As mentioned before, the Input type is a type only pertains to Dino Rush. It's a projection from the SDL event types. This isn't a unique situation where that pattern occurs.

The Sfx type follows the same idea but outwards. Rather than passing around a Mixer.Chunk , a Sfx is passed around. Eventually, the appropriate mapping from the Sfx type to playing the chunk occurs in an incredibly dull fashion.

playSfx' :: (Audio m, HasCommonVars s, MonadState s m) => m () playSfx' = do CommonVars{cvSfx} <- gets (view commonVars) forM_ cvSfx $ \sfx -> case sfx of Sfx'Jump -> playJumpSfx Sfx'Duck -> playDuckSfx Sfx'Point -> playPointSfx Sfx'Bird -> playBirdSfx Sfx'Hurt -> playHurtSfx Sfx'Lava -> playLavaSfx Sfx'Quake -> playQuakeSfx Sfx'Rock -> playRockSfx Sfx'Recover -> playRecoverSfx Sfx'Stock -> playStockSfx

Laziness

Sometimes, laziness can be awesome. The obstacles and their placements are randomly generated into a list, which is a stream because it's freaking lazy!

streamOfObstacles :: RandomGen g => g -> [(Int, ObstacleTag)] streamOfObstacles g = zip (map (\dist -> dist `mod` 18 + 3) $ randoms g) (randoms g)

Watch your step. I may just generate a Fibonacci sequence to celebrate.

1, 1, 2, 3, 5, 8, ...

Wrappers (code)

Occasionally, I'll come across a piece of irreducibly complex IO code. They can't be mocked or tested in another way easily. Usually, there's an under-exported newtype involved with smart constructors.

It's often not worth exposing that kind of trouble through out the rest of the codebase. So try to keep those troublemakers isolated in Wrappers , Resources , or Main .

Other times, the provided API could use a little adjustment for my usage.

class Monad m => SDLInput m where pollEventPayloads :: m [SDL.EventPayload] pollEventPayloads' :: MonadIO m => m [SDL.EventPayload] pollEventPayloads' = liftIO $ map SDL.eventPayload <$> SDL.pollEvents

I'm not picking on sdl2 package. There's just nor much else in the Wrapper s. The bindings' API are obviously well thought out as they integrate nicely with existing libraries like linear , vector , and lens . Big Kudos!

Conclusion

This was a small learning experience which I felt pushed. Yet there's a couple things, which I could have done differently, during development without needing hindsight. Both are related to discipline.

Once the Scene idea became real, I should have been more strict with keeping the Scene modules focused on its purpose. The purpose is to compose functions from the other modules -- Engine , Manager , and Effect -- which forms a concise and coherent interaction of widely originating components. Frequently, I'd extract something to be pure. And more functions were created related to that function. Next thing you know it, there's several potentially pure functions which should be in Engine .

Another thing is that I should have been more eager to create an Effect . It can feel expensive to create another type class or module. But for the sake of clarity, it's worth it.

Overall, I'm actually happy with the result as it achieves its main goal. It's a Haskell game that plays on my small laptop. The game generates enough sense of urgency to be "fun." I watched a few people played it, and they seem to enjoy it. That was very cool.

Thanks for reading!

P.S. My high score is 227.

/r/haskell

/r/gamedev

Acknowledgements