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.