Building a Game with Hickory

 

For the past few months I've been working on a game library for Haskell. I call it Hickory.

It provides...

  • OpenGL rendering capabilities for both desktop (through GLFW) and iOS
  • A primary abstraction (the 'Scene') that connects resource loading, game logic, rendering, and input/event processing.
  • Texture loading
  • Font loading and text rendering
  • Abstractions for things like menus, GUI elements, multi-touch panning/zooming, entity/component systems, and more.

Goals for Hickory

Straightforward

Hickory should provide functions for all common game functionality.

Industrial Strength

Performance isn't the primary focus, but Hickory should be a viable way to make professional quality games.

Flexible

I hesitate to call Hickory an "engine", because generally engines are inflexible beasts. Hickory, on the other hand, is a toolbox. You can use one tool without getting tied to the rest. Eventually, many parts of Hickory will become separate libraries.

Let's make a game with Hickory

This tutorial will provide an overview of many aspects of Hickory. We'll make a simple top-down style shooter.

Installing Hickory

$ git clone https://github.com/asivitz/Hickory.git
$ cd Hickory/
$ cabal install
$ cd GLFW
$ cabal install
$ cd ..

Up and Running

We'll start by laying down some stub functions and datatypes for our Scene. A Scene ties together our game state, a pure stepping function to transform the game state each frame, a rendering function, and an input queue. The input queue provides a way to process key presses, mouse clicks, or even custom events from other scenes.

First, some imports.

import Engine.Scene.Scene
import Engine.Scene.Input
import Types.Types
import Math.Matrix
import Graphics.Drawing
import Platforms.GLFW

Next, we'll declare a few simple datatypes.

data Model = EmptyModel -- Represents our game state

type Event = RawInput -- Represents our game events

The Model type represents our entire game state. Right now it is empty, but eventually it will contain the player and missile positions.

The Event type determines both what sort of events our Scene will process, as well as what sort of events it can produce. In this case, we use RawInput, which Hickory provides as an interface for keyboard and mouse/touch input, which is all we'll need for this game. However, it is possible to use a more complex event type that can support menu selections, for example.

Before we can render our scene, we may need to load some resources (shaders, textures, etc.) We'll keep it simple for now.

data Resources = EmptyResources

loadResources :: IO (Resources)
loadResources = return EmptyResources

That loaded Resources structure will get passed into our render function, which will do nothing for now.

render :: Resources -> RenderInfo -> Model -> IO ()
render resources renderinfo model = return ()

We also need to generate a view matrix. This is used by OpenGL to render our scene. We'll start by just returning the identity matrix.

calcCameraMatrix :: Size Int -> Model -> Mat44
calcCameraMatrix (Size w h) model = mat44Identity

Our step function will contain our actual game logic. It takes the current game model and transforms it based on the event stream and the elapsed time since the previous frame. The RenderInfo from the previous frame is also included so that you can do things like unproject a mouse click into world coordinates.

stepModel :: RenderInfo -> [Event] -> Double -> Model -> (Model, [Event])
stepModel renderinfo inputEvents delta model =
      (model, [])
      

We return a tuple (model, []) because we can also pass events on to another scene, or to the same scene on the next frame. We don't have any such events right now so we just have an empty list. And, our game doesn't do anything yet so we return the model unchanged.

We then collect these functions in a Scene, and connect them by creating a SceneOperator. The SceneOperator manages the model state and the input queue, which we need to persist through each game loop.

makeScene :: IO (SceneOperator Event)
makeScene = makeSceneOperator EmptyModel -- The starting state of our game model
                              stepModel -- Our game logic step function
                              loadResources -- Our resource loading function
                              calcCameraMatrix -- A function that calculates 
                                               -- our scene's view matrix
                              render -- Our Rendering function
                              worldLayer -- A layer to draw in
                              

Currently there are only a few choices of layer available (worldLayer for your game, and uiLayer for your UI overlay), but that will change.

Finally, we run our scene in the GLFW game loop.

main :: IO ()
main = do
  operator <- makeScene
  
  glfwMain "Demo" -- Window title
      (Size 480 640) -- Window size
      [operator] -- List of scene operators
      (_addEvent operator) -- The function used to process GLFW's input events
      

This code is typed up in Example/Shooter/shooter1.hs. Let's run it!

$ cd Example/Shooter/
$ ghc shooter1.hs
$ ./shooter1

You should see a wonderfully blank window.

Adding our Player

We'll need a few more imports.

import Math.Vector
import Graphics.DrawUtils
import Graphics.Shader
import Camera.Camera
import Types.Color
import qualified Data.HashMap.Strict as HashMap

We'll add a player that we can move around with the arrow keys. The player will be drawn as a white square.

First we need to add the player data to our model. We'll represent our player as a single 2D position.

data Model = Model { 
    playerPos :: V2
    }
    

A new game will start with the player at position (0,0)

newGame :: Model
newGame = Model vZero -- vZero is equivalent to (v2 0 0)

Now we need to process our input events. The only one we care about right now is InputKeysHeld (so we can move our player). We will take that event, run through all the held keys and look for arrow keys, and use them to construct a movement vector.

data GameInput = GameInput (Maybe V2)
 
buildVecWithKeys :: V2 -> (Key, Double) -> V2
buildVecWithKeys vec (key, heldTime) = 
    vec + (case key of
                Key'Up -> v2 0 1
                Key'Left -> v2 (-1) 0
                Key'Down -> v2 0 (-1)
                Key'Right -> v2 1 0
                _ -> vZero)
 
-- This function turns a raw event list into a movement vector our game logic can use
collectInput :: [Event] -> GameInput
collectInput events = foldl process (GameInput Nothing) events
    where process gameInput (InputKeysHeld hash) = 
            let movementVec = foldl buildVecWithKeys vZero (HashMap.toList hash)
                in GameInput (Just movementVec)
          process gameInput _ = gameInput

Now we can use that movement vector to move our player. Our net movement for this frame will be the requested movement vector scaled by the playerMovementSpeed multiplied by the time elapsed since the last frame (delta).

playerMovementSpeed = 100

stepModel :: RenderInfo -> [Event] -> Double -> Model -> (Model, [Event])
stepModel renderinfo events delta model@Model { playerPos = p} =
        let (GameInput movementVec) = collectInput events 
            newPlayerPos = case movementVec of
                Nothing -> p
                -- The |* operator multiplies a vector by a scalar
                Just v -> p + (v |* (delta * playerMovementSpeed))
            in (model { playerPos = newPlayerPos }, [])
            

To draw our player we need to load a simple shader program. A shader program tells OpenGL how to draw something, and is written in a special language called GLSL. It is outside the scope of this tutorial to explain how to write them, but the Hickory project includes a few simple ones that cover many use cases, including drawing solid or textured rectangles (including sprites). For our player, we just need a solid color, so we'll build a shader program out of a simple vertex shader (Shader.vsh), and a simple fragment shader (SolidColor.fsh). You probably only need to write your own shaders if you want specific visual effects.

The Resources type now contains our solid shader. We'll load the shader files from the resources path that we pass in.

data Resources = Resources {
    solidShader :: Shader
    }
    
loadResources :: String -> IO Resources
loadResources path = do
    solid <- loadShader path "Shader.vsh" "SolidColor.fsh"
    case solid of
        Just sh -> return $ Resources sh
        Nothing -> error "Couldn't load resources."
        

Note that the loadShader function returns a (Maybe Shader), so we need to check if the load actually succeeded before storing it in Resources.

Now we need a view matrix that can actually show something. In this case we'll use an orthogonal projection whose width is equal to the window's width, and is centered around (0,0). The depth will run from 1 to 100.

calcCameraMatrix :: Size Int -> Model -> Mat44
calcCameraMatrix (Size w h) model = 
        let proj = Ortho (realToFrac w) 1 100 True
            camera = Camera proj vZero
            in cameraMatrix camera (aspectRatio (Size w h))
            

And now we can actually render the player. Again, the Resources type now contains a shader we can use.

render :: Resources -> RenderInfo -> Model -> IO ()
render Resources { solidShader = solid } (RenderInfo _ _ layer)  Model { playerPos = p } = do
        drawSpec (v2tov3 p (-5)) layer (SolidSquare (Size 10 10) white solid)
        

The v2tov3 function turns our 2D position vector into a 3D vector suitable for rendering (by placing it at the Z-coordinate -5).

It's easier to understand the drawSpec function by looking at its type.

drawSpec :: Vector3 -> Layer -> DrawSpec -> IO ()

And the DrawSpec type is declared like this:

data DrawSpec = Square FSize Color TexID Shader
              | SolidSquare FSize Color Shader
              

And finally, we'll need to pass in the proper resources path so we know where to load the shaders.

makeScene :: String -> IO (SceneOperator Event)
makeScene resPath = makeSceneOperator newGame
                                      stepModel
                                      (loadResources resPath)
                                      calcCameraMatrix
                                      render
                                      worldLayer
                                      
main :: IO ()
main = do
    operator <- makeScene "resources"
    
    glfwMain "Demo"
             (Size 480 640)
             [operator]
             (_addEvent operator)
             

And make sure the shaders are actually there! (We only pass in the root "resources" path. The shader loading system will automatically look in the Shaders sub-directory.)

$ ls resources/Shaders
Shader.fsh         Shader.vsh         SolidColor.fsh

This code is typed up in Example/Shooter/shooter2.hs. You can move the player around with the arrow keys.

$ ghc shooter2.hs
$ ./shooter2

Fire ze missiles!

Now we'll give our player some fire power. We'll adapt our game model to include a firing direction and a list of missile position and direction pairs. Our newGame model now includes a default firing direction of (1,0), which is to the right.

data Model = Model { 
           playerPos :: V2,
           firingDirection :: V2,
           missiles :: [(V2, V2)]
           }
           
newGame :: Model
newGame = Model vZero (v2 1 0) []

We now need to collect space bar presses along with the arrow keys. We'll add a didFire bool to our GameInput type to indicate whether we requested a shot.

data GameInput = GameInput {
   movementVec :: (Maybe V2),
   didFire :: Bool
   }
 
collectInput :: [Event] -> GameInput
collectInput events = foldl process (GameInput Nothing False) events
    where process gameInput (InputKeysHeld hash) = 
            let moveVec = foldl buildVecWithKeys vZero (HashMap.toList hash)
                in gameInput { movementVec = (Just moveVec) }
          process gameInput (InputKeyDown Key'Space) = 
                gameInput { didFire = True }
          process gameInput _ = gameInput

We need a speed for the missile flight, as well as a function to determine whether the missile should expire. Afterall, we don't want missiles to fly on forever.

missileMovementSpeed = 200

missileInBounds :: (V2, V2) -> Bool
missileInBounds (pos, _) = vmag pos < 500

Our stepping function is now a little more interesting. We need to move our player as before, but also store that movement direction as the firing direction. And then if a new missile was requested, we'll fire that in the firing direction. And finally we'll move all the missiles.

stepModel :: RenderInfo - [Event] -> Double -> Model -> (Model, [Event])
stepModel renderinfo events delta Model { playerPos, firingDirection, missiles } =
        let GameInput { movementVec, didFire } = collectInput events 
            (fireDir', playerPos') = case movementVec of
                        -- If we didn't move, keep the old firing direction
                        Nothing -> (firingDirection, playerPos)
                        
                        -- If all the movement keys cancel out, keep
                        -- the old firing direction
                        Just v | vnull v -> (firingDirection, playerPos)
                        
                        -- If we move, then the firingDirection should
                        -- change as well
                        Just v -> (v, playerPos + (v |* (delta * playerMovementSpeed)))
                        
            -- Fire ze missiles!
            missiles' = if didFire
                            then ((playerPos', fireDir') : missiles)
                            else missiles 
                            
            -- and finally we move all of the missiles, 
            -- and remove the ones that went out of bounds
            movedMissiles = filter missileInBounds $ 
                map (\(pos, dir) -> (pos + (dir |* (delta * missileMovementSpeed)), dir))
                    missiles'
                    
            in (Model { playerPos = playerPos', 
                        firingDirection = fireDir', 
                        missiles = movedMissiles }, [])
                        

We'll draw our missiles using a simple circle texture, so we'll need a shader that can handle textures, as well as the texture itself.

First we'll add the new shader and texture to the Resources datatype.

data Resources = Resources {
        solidShader :: Shader,
        texturedShader :: Shader,
        missileTex :: TexID
}

And we'll load them in our loadResources function. Just like loadShader, loadTexture may fail and return Nothing, so we need to check that before storing the texture in Resources. loadTexture will look in the "images" sub-directory inside the passed in resources directory.

loadResources :: String -> IO Resources
loadResources path = do
        solid <- loadShader path "Shader.vsh" "SolidColor.fsh"
        
        -- To draw the missiles, we also need a shader that can draw
        -- textures, and the actual missile texture
        textured <- loadShader path "Shader.vsh" "Shader.fsh"
        missiletex <- loadTexture path "circle.png"
        
        case (solid, textured, missiletex) of
            (Just solSh, Just texSh, Just missTex) -> 
                return $ Resources solSh texSh missTex
            _ -> error "Couldn't load resources."
            

Now all we need to do is draw our missiles. This is our new render function:

render :: Resources -> RenderInfo -> Model -> IO ()
render Resources { solidShader, texturedShader, missileTex } (RenderInfo _ _ layer)  Model { playerPos, missiles } = do
    drawSpec (v2tov3 playerPos (-5)) 
             layer 
             (SolidSquare (Size 10 10) white solidShader)
             
    -- Draw the missiles
    forM_ missiles $ \(pos, _) ->
        drawSpec (v2tov3 pos (-5)) 
                 layer 
                 (Square (Size 5 5) (rgb 1 0 0) missileTex texturedShader)
                 

And that's a wrap! This version is typed up in shooter3.hs. Let's make sure we have the right shader and texture files, then we'll compile and run it!

$ ls resources/Shaders
Shader.fsh         Shader.vsh         SolidColor.fsh
$ ls resources/images
circle.png
$ ghc shooter3.hs
$ ./shooter3

If you would like to see more tutorials, shoot me an e-mail or message me on twitter @asivitz.