Making a small game with Gloss

Making a game, even if you have no intention of making it real and finishing it, is good practice in any programming language. While attempting to learn Haskell, I've made a terminal game - in parallel with 'the same' game made with TypeScript - but I wanted to try making a small retro game with images for graphics.

Perhaps because Haskell is not commonly used for graphics programming there are only a few libraries available. Making this game I settled with Gloss. I've found two other tutorials that may be of interest: Andrew Gibianski's Your First Haskell Application (with Gloss) and Making a Glossy Game! on Monday Morning Haskell.

I use Stack and begun by adding Gloss as a dependency to stack.yaml,

extra-deps:
    - gloss-1.13.1.2

and added 'gloss' (without a version number) to the build-depends of library and executable. Note that because of the limited scope of this project (~200 lines of code), I only used the Main file of the /app folder that is created when templating a new Stack project, stack new [your_project].

I have some assets. All graphics are free and were found at the Open Game Art. The assets are located at such,

assets/
├── food.bmp
├── left1.bmp
├── left2.bmp
├── left3.bmp
├── left4.bmp
├── left5.bmp
├── left6.bmp
├── level
├── right1.bmp
├── right2.bmp
├── right3.bmp
├── right4.bmp
├── right5.bmp
├── right6.bmp
└── tile.bmp

level is a plain text file containing the level represented by . for background, * for wall, and % for food (a carrot). This is the text representation of the level:


********************************
*..............................*
*%%%%%%%.......................*
************..............%....*
*....................***********
*..............................*
*..............................*
*....%.........*****...........*
*..............**.....%........*
*..............................*
*..............................*
*.........................%....*
*....%..**...............*******
*....*****..........************
*..............................*
*..............................*
*.............................%*
***..%.........**.............**
******.........**..........%%%**
******..................********
********......................**
********......................**
***********................%%%**
********************************

This project uses one language extensions, UnicodeSyntax, for pretty code and imports from Gloss,

{-# LANGUAGE UnicodeSyntax #-}

module Main where

import Graphics.Gloss
importGraphics.Gloss.Interface.Pure.Game

It's reasonable to begin by modeling the type-level data structures.

data MoveDirection
 = East
 | West
 | None
 deriving (Eq)

data Heading
 = FacingWest
 | FacingEast
 deriving (Eq)

MoveDirection is concerned with the player moves West or East or doesn't move at all. In this case, I've not included jumping since I was unsure if this could be described as moving in the same sense. Heading is for evaluating if the player is facing West or East. Unless you don't move in the opposite direction you keep facing a direction (also when you jump).

data GameState =
 GameState
 { position :: Point
 , direction :: MoveDirection
 , heading :: Heading
 , currentLevel :: Level
 , spriteCount :: Int
 , speedX :: Float
 , speedY :: Float
 }

GameState is a record holding all game-related data. position holds the player position using Float and Point is imported from the Gloss library. direction is connected to MoveDirection, and heading to Heading.

currentLevel is of type Level and holds the level we viewed before in plain text, although read and normalized to fit the needs. We'll pause in describing the GameState and describe the types for the level and the normalization of the text representation.

type CellType = Char
type Cell = (Point, CellType)
type Level= [Cell]

A singular character, Char, is the type of the cell - the tile, if you will. I call this CellType. A cell as such contains a tuple holding a Point holding the x/y position of the cell, along with the CellType. A Level is a list containing a number of Cell.

When reading the file - level - containing the text representation we will begin the normalization by passing it as a list to prepareData in which each row and the order of succession is passed to makeRow. As the type signature reveals it receives a [String] and returns a Level.

makeRow gets a String (a row from the list of [String] passed to prepareData and an Int, the row number, and returns a Level after transformation. The Level only includes 'actual' tiles, that is walls and carrots (we don't need to include the 'background'). Since Gloss centers things using Cartesian Coordinates and describes a Point using Float we transform the Int arising from the list comprehension and fixates its view position on the window display by multiplying the size of the tile minus half the width (for the x Point) and half the size of the tile. Similarly, we base y of Point on half the height of the window display (and again, half the tile size). We also inject the symbol on the second position of the tuple Cell.

This would transform a character * situated at (0,0) in the text file to ((-528.0, -384.0), '*') and so on.

prepareData :: [String] -> Level
prepareData rawData =
  concat [makeRow (rawData !! y) y | y <- [0..length rawData -1]]

makeRow :: String -> Int -> Level
makeRow row y =
 [ ( ( (fromIntegral x * tileSize) - ((1024 / 2) - (tileSize / 2))
 , (fromIntegral y * tileSize) - ((768 / 2) - (tileSize / 2)))
 , row !! x)
 | x <- [0 .. length row - 1]
 , row !! x == '*' || row !! x == '%'
 ]

tileSize defined:

tileSize :: Float
tileSize =32.0

Returning to the GameState:

 , spriteCount :: Int
 , speedX :: Float
 , speedY :: Float

spriteCount is increased by one whenever the player moves West or East. The sprite I use has 6 images for moving. When reaching 6, we later set it to 0. speedX and speedY is the moving speed of vertical and horizontal movement respectively.

I use the Display mode of play of Gloss. The Gloss documentation describes its parameters as such:

play
:: Display                   Display mode.
-> Color                     Background color.
-> Int                       Number of simulation steps to take for each second of real time.
-> world                     The initial world.
-> (world -> Picture)	     A function to convert the world a picture.
-> (Event -> world -> world) A function to handle input events.
-> (Float -> world -> world) A function to step the world one iteration. It is passed the period of time (in seconds) needing to be advanced.
-> IO ()	 


However, before initiating this game loop we fetch all need resources, sets an initial GameState and normalize the level,

main :: IO ()
main = do
 tileImg <- loadBMP "assets/tile.bmp"
 foodImg <- loadBMP "assets/food.bmp"
 left1 <- loadBMP "assets/left1.bmp"
 left2 <- loadBMP "assets/left2.bmp"
 left3 <- loadBMP "assets/left3.bmp"
 left4 <- loadBMP "assets/left4.bmp"
 left5 <- loadBMP "assets/left5.bmp"
 left6 <- loadBMP "assets/left6.bmp"
 right1 <- loadBMP "assets/right1.bmp"
 right2 <- loadBMP "assets/right2.bmp"
 right3 <- loadBMP "assets/right3.bmp"
 right4 <- loadBMP "assets/right4.bmp"
 right5 <- loadBMP "assets/right5.bmp"
 right6 <- loadBMP "assets/right6.bmp"
 rawData <- readFile "assets/level"
 let level = prepareData $ reverse $ lines rawData
 let state =
 GameState
 { position = (0.0, 0.0)
 , direction = None
 , currentLevel = level
 , spriteCount = 0
 , heading = FacingWest
 , speedX = 0
 , speedY = (-6)
 }

And this is how this transforms to in this project in the Main function,

 play
 window
 background
 fps
 state
 (`render` [ tileImg
 , foodImg
 , left1
 , left2
 , left3
 , left4
 , left5
 , left6
 , right1
 , right2
 , right3
 , right4
 , right5
 , right6
 ])
 handleKeys
 update

In which window, background and fps are defined as,

window :: Display
window = InWindow "Play w. Gloss" (1024, 768) (0, 0)

background :: Color
background = makeColor 0.2 0.1 0.1 1

fps :: Int
fps =60

As seen in Gloss documentation, we treat the first four parameters - window, background, fps, state - as immutable.

The update of the Gloss documentation takes a world and returns a Picture, an isolated side-effect. This project passes a GameState, a set of [Picture] which are rendered to a Picture accordingly.

render :: GameState -> [Picture] -> Picture
render gs imgs =
 pictures
 ([drawTile cell (head imgs) (imgs !! 1) | cell <- currentLevel gs] ++
 [ translate
 (fst (position gs))
 (snd (position gs) + 10)
 (imgs !! (spriteCount gs + 2 + isRight (heading gs)))
 ])

whatImg :: Cell -> Picture -> Picture -> Picture
whatImg (_, cellType) tile food =
 if cellType == '*'
 then tile
 else food

drawTile :: Cell -> Picture -> Picture -> Picture
drawTile cell tileImg foodImg =
 uncurry translate (fst cell) (whatImg cell tileImg foodImg)

isRight :: Heading -> Int
isRight FacingEast = 6
isRight _ =0

The first two elements of the [Picture] list are the images for the wall and the carrot. We iterate the Level of the GameState using a list comprehension passing individual cells together with two images referenced to drawTile. Depending on if the passed cells cellType, determined in whatImg, we render a wall or a carrot at the position of the Point of the cell.

After drawing the 'background', the level as such, the player is rendered at the position of the GameState. The spriteCount + 2 (we jump the wall and carrot in the list of images) + isRight determines what sprite is rendered. If the player is FacingEast we add 6, otherwise 0, since the list of images happens to include sprites for FacingLeft before FacingEast.

Now we continue with the next parameter of play, the parameter for user input.

handleKeys :: Event -> GameState -> GameState
handleKeys (EventKey (SpecialKey KeyLeft) Down _ _) gs =
 gs {direction = West, heading = FacingWest}
handleKeys (EventKey (SpecialKey KeyRight) Down _ _) gs =
 gs {direction = East, heading = FacingEast}
handleKeys (EventKey (SpecialKey KeySpace) Down _ _) gs =
 gs
 { speedY =
 if isCollision gs (fst (position gs), snd (position gs) + speedY gs) '*'
 then 6
 else (-6)
 }
handleKeys _ gs = gs {direction =None}

Using pattern matching we determine four possible states. If the user press the Left or Right arrow the GameState record is updated with a new MoveDirection and Heading. If the user press space, we modify the speedY, given that the player is standing on firm ground. If no key is pressed, the MoveDirection is set to None.

In the last parameter of play, we make updates not relating to side-effects (such as user input in the parameter/function before).

update :: Float -> GameState -> GameState
update _ gs =
 gs
 { speedY = checkSpeedY gs
 , speedX = checkSpeedX gs
 , position = moveY gs $ moveX (direction gs) gs
 , spriteCount = incSprite gs
 , currentLevel = checkFood gs
 }

As previously quoted the update of play is 'A function to step the world one iteration. It is passed the period of time (in seconds) needing to be advanced.'. In this project, we excluded making use of the first parameter for matters of convenience.

Firstly, we check if speedY and speedX and needs updated and if so makes neccessary changes,

checkSpeedY :: GameState -> Float
checkSpeedY gs
 | isCollision gs (fst (position gs), snd (position gs) + speedY gs) '*' = -3
 | speedY gs >= -6 = speedY gs - 0.1
 | otherwise = -6

checkSpeedX :: GameState -> Float
checkSpeedX gs
  | direction gs == West || direction gs == East =
    if speedX gs > 5.0
      then 5.0
      else speedX gs + 0.5
  | otherwise =
    if speedX gs <= 0
      then 0
      else speedX gs - 0.5

isCollision :: GameState -> Point -> CellType -> Bool
isCollision gs pnt checkType =
 any
 (\((x, y), tileType) -> tileType == checkType && isHit pnt (x, y))
 (currentLevel gs)

isHit :: Point -> Point -> Bool
isHit (b1x, b1y) (b2x, b2y) =
 (b1x - 10) < b2x + tileSize &&
 b1x +50-10> b2x && b1y < b2y + tileSize && b1y +54> b2y

If the player is jumping the speedY is no its default value of -6. If the player hits a ceiling, we lower the speedY (otherwise the player would freeze the bottom of a wall tile until the speedY again would be positive). If jumping we decrease the speed by 0.1. If moving horizontally, we check if the MoveDirection is East or West, using guards and increase speedX if it's lower than 5.0. On the other hand, if the player is not moving we decrease speed until it reaches 1 if not already defaulted.

moveX takes a MoveDirection and a GameState and returns a Point. If MoveDirection is either West or East if no collision is at hand we modify the player position. If moving West we multiply the speedX by -1, changing the direction of the moving. When Direction is None we create the illusion of decreasing acceleration by still moving, if speedX is greater than 0.

moveX :: MoveDirection -> GameState -> Point
moveX East gs =
  if not (isCollision gs (fst (position gs) + speedX gs, snd (position gs)) '*')
    then (fst (position gs) + speedX gs, snd (position gs))
    else position gs
moveX West gs =
  if not
       (isCollision
          gs
          (fst (position gs) + speedX gs * (-1), snd (position gs))
          '*')
    then (fst (position gs) + speedX gs * (-1), snd (position gs))
    else position gs
moveX _ gs =
  if speedX gs > 0 &&
     not
       (isCollision
          gs
          ( fst (position gs) +
            speedX gs *
            (if heading gs == FacingWest
               then (-1)
               else 1)
          , snd (position gs))
          '*')
    then ( fst (position gs) +
           speedX gs *
           (if heading gs == FacingWest
              then (-1)
              else 1)
         , snd (position gs))
    else position gs

moveY :: GameState -> Point -> Point
moveY gs pnt =
 if not (isCollision gs (fst pnt, snd pnt + speedY gs) '*')
 then (fst pnt, snd pnt + speedY gs)
else pnt

incSprite increases the spriteCount by one, if not 6 (5 since we begin a 0 and have 6 sprites for each direction).

incSprite :: GameState -> Int
incSprite gs =
 if direction gs /= None
 then if spriteCount gs == 5
 then 0
 else spriteCount gs + 1
else spriteCount gs

If the player is at a Cell holding food we filter out this element, implicitly making the list of Level one element shorter. If continued from 'demo' to 'real' game we would most likely increase the score at this point.

checkFood :: GameState -> Level
checkFood gs =
 filter
 (\cell -> not (isHit (fst cell) (position gs) && snd cell == '%'))
 (currentLevel gs)
Visit the GitHub repo.