Making a small game with Gloss

— A tutorial

2020-11-01

Making a game, even if you have no intention of finishing it, is good practice in any programming language. While learning Haskell, before I’ve made a terminal game 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 graphics libraries available. Making this game I settled with Gloss.1 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.hs of the /app folder that is created when templating a new Stack project, stack new [your_project].

I used 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 extension, 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 wither the player moves West or East, or doesn’t move at all. I’ve not included a state for jumping to MoveDirection 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 — even 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 a Float number. Point is imported from the Gloss library. direction is linked 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.

I’ll pause in describing the GameState and describe the types used for a level, as well as the normalization of the text representation of a level.

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 containing 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, I begin the normalization by passing it as a list to prepareData. In prepareData each row and the order of succession is passed to makeRow. As the type signature reveals, it takes a [String] and returns a Level.

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

makeRow takes a String, a row from the list of [String] passed to prepareData, and an Int — the row number — and returns a Level after the normalization.

The Level only includes ‘actual’ tiles, that is walls and carrots (we don’t need to include the ‘background’). Since Gloss centers objects 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 displayDisplayby multiplying the size of the tile minus half the width (for the xPoint`) and half the size of the tile.

Similarly, I base y of Point on half the height of the window Display and again, half the tile size. I also inject the symbol to the second position of the tuple Cell.

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 == '%'
 ]

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

32.0 is assigned to tileSize,

tileSize :: Float
tileSize = 32.0

Returning to the GameState, I set the types for a sprite-count, spriteCount, and vertical and horizontal speed, speedX and speedY.

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

spriteCount is increased by 1 whenever the player move West or East. The sprite I use has 6 images for moving, so when spriteCount reaches 6, I set it to 0. speedX and speedY is the moving speed of vertical and horizontal movement respectively, measured in ‘pixels’ (a value rounded when used).

I use the Display mode of play. 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 value for 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 () 

Before initiating the game loop, I fetch all 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)
 }

After this I call play,

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 the Gloss documentation, the first four parameters - window, background, fps, state - are immutable.

The update function takes a world and returns a Picture, an isolated side-effect. I pass the GameState, a set of [Picture] which are rendered to a Picture.

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 is the images for the wall and the carrot. I 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 value, determined in whatImg, I 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 (I jump the wall and carrot in the list of images) added with the value of isRight determines what sprite is rendered. If the player is FacingEast I add 6, otherwise 0, since the list of images happens to include sprites for FacingLeft before FacingEast.

Now I 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 I determine between 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, I modify speedY, if the player is standing on firm ground. If no key is pressed, the MoveDirection is assigned 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, I’ve excluded making use of the first parameter for matters of convenience.

Firstly, I check if speedY and speedX are to be updated and if so make necessary 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 assigned the default value of -6. If the player hits a ceiling, I lower the speedY value. Otherwise the player would freeze at the bottom of a wall tile until the speedY again would be positive. If jumping, I decrease the speed by 0.1. If moving horizontally, I 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, I 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, and if no collision is at hand, I modify the player position. If moving West, I multiply the speedX by -1, changing the direction of the movement. When Direction is None, I create the illusion of a decreasing acceleration by still moving the player, 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 I 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)

Now we have the beginning of a game.

About | Archive