Long ago, when I was first learning C++, I wrote an asteroids game. It was a mess and the code has been lost, which is probably for the best, but I did learn quite a bit in going through that exercise. It also contributed to me getting an internship at a game company one summer, probably because I only sent them the executable and not the source code (whoever tried it out must not have got very far either since I remember it having a memory leak that would usually crash the game if you got too far past level 10). Lately I’ve been looking for a meatier project to apply Haskell to, but not too meaty1. Maybe it’s just nostalgia, but an asteroids game seems like it might fit the bill. So as a pedagogical exercise I’m writing an asteroids game in Haskell (instead of doing something useful). See here if you want to follow along, for this blog post you want the blog-post-1 tag.
Why asteroids? It’s simple and self-contained. The graphics are relatively easy for a programmer to come up with and the game rules are straight-forward. But there is enough complication there to make it a more than a few hours sort of exercise. And that’s what I’m looking for, something that will be more than a quick exercise and require more than one or two source files to implement, while not being something that’s going to take me a year to finish2.
For this exercise the game will be relatively stripped down, I’m not going to bother with sound for example. The graphics will be done using OpenGL (overkill in this case, but I’ve got a passing familiarity with it) while window set-up and input wrangling will be handled by SDL. There are a couple of SDL libraries for Haskell, I’ll be using sdl2. This is mostly because it doesn’t require odd build gymnastics to get it working on OSX. The documentation is far from complete, but the library is a straight-forward FFI mapping of the C library. So it’s not too hard too consult the C library documentation and do some C to Haskell translation in order to fill in the gaps.
To begin with I generated a cabal file using
cabal init. I won’t go into details here as there’s plenty of tutorials around for that. The same goes for setting up a cabal sandbox for the project (highly recommended), using git, etc. The next goal is to get a window on the screen, a space-ship in the middle of the window, and be able to rotate the space-ship around using the keyboard. Along the way we’ll end up with a tutorial on how to get SDL, OpenGL, and Haskell to play nicely with each other. If you checked out the
blog-post-1 tag then the code that does all this is in
First lets take a look at
main and the imports that make everything go:
import qualified Data.Time.Clock as DTC import Data.Word import qualified Foreign.C.String as FCS import qualified Foreign.Marshal.Alloc as FMA import qualified Foreign.Storable as FS import qualified Graphics.Rendering.OpenGL as GL import Graphics.Rendering.OpenGL (($=)) import qualified Graphics.UI.SDL as SDL main :: IO () main = do initRes <- SDL.init SDL.SDL_INIT_EVERYTHING putStrLn $ "Init res = " ++ (show initRes) title <- FCS.newCString "Hasteroids" let (width, height) = (640, 640) win <- SDL.createWindow title 0 0 width height SDL.SDL_WINDOW_OPENGL SDL.glCreateContext win GL.clearColor $= GL.Color4 0 0 0 1 GL.lineWidth $= 1 GL.lineSmooth $= GL.Enabled GL.viewport $= (GL.Position 0 0, GL.Size (fromIntegral width) (fromIntegral height)) GL.matrixMode $= GL.Projection GL.loadIdentity GL.ortho2D (-100.0) 100.0 (-100.0) 100.0 GL.matrixMode $= GL.Modelview 0 start <- DTC.getCurrentTime mainLoop win $ RS RotateNone 0.0 start SDL.destroyWindow win SDL.quit
Note that I’ve qualified most of the imports in this file so that it is more apparent which libraries the various functions and types in play come from. First we initialize SDL, create a window, attach OpenGL to the window, and set up the OpenGL renderer. There are just a few tricky things to pay attention to here. First,
createWindow can’t deal with Haskell strings so we have to allocate a C string for the window title, that’s how low-level the sdl2 library is. For now I’m just hard-coding the window size to be 640 by 640, later this can be moved to a configuration file so that a recompilation isn’t necessary to change it. The
$= is OpenGL’s operator for setting parameters (I imported it without qualification since
GL.$= is just clumsy looking). Much of the rest of
main is dedicated to setting up the basic OpenGL state: the color used to clear the screen (black), the line width (1 seems to work well), and turning on anti-aliasing for starters. Then we need to set up our view-port and projection matrix. We want to use the whole window so the view-port is set to start at (0,0) and has the same width and height as the window. The projection is a bit trickier, but not too tricky since we’re just working in 2D. Note that the width and the height of the window are equal giving an aspect-ratio of one. This means that the ratio of width to height in our projection matrix also needs to be one. Here I set things up so that the origin of our coordinate system is at the center of the screen and extends 100 units in both directions along the x and y axes. This gives us a width to height ratio of one to match the window dimensions. If the window is set up with a different ratio then we need to make sure that our coordinate system is set up with the same ratio else we’ll get weird warping as things rotate. At this point we’ve finished mucking with the projection matrix so we switch to model-view matrix mode where we’ll stay for the rest of the game. Next we enter the main loop passing it our window and the initial game state (more on that in a minute). When
mainLoop finishes the game is done and we destroy the window and shutdown SDL.
Currently our game state is just
data RotateDir = RotateNone | RotateCW | RotateCCW deriving Eq data RotateState = RS RotateDir GL.GLfloat DTC.UTCTime
Since all we’ve got is a rotating spaceship at this point the game state is relatively simple. We have the direction the ship is rotating in (not rotating, clock-wise, or counter clock-wise), the rotation angle, and the time that the state was created at. In
main we create an initial state with zero rotation angle,
RotateNone for the direction, and the time that the main loop is entered.
Now for the main game loop:
mainLoop :: SDL.Window -> RotateState -> IO () mainLoop win state@(RS d a t) = do event <- checkForEvent case event of Just (SDL.QuitEvent _ _) -> return () Just (SDL.KeyboardEvent t _ _ _ _ k) -> handleKey t k state >>= mainLoop win Nothing -> do ct <- DTC.getCurrentTime let state' = RS d (updateAngle d a t ct) ct renderWindow state' win mainLoop win state' _ -> mainLoop win state
It’s straight-forward: first check for an event (there’s some trickiness hiding here that we’ll get to in a minute). If we get a
QuitEvent (generated by the user closing the window) then
mainLoop returns triggering the end of the program. If we get a
KeyboardEvent then the user has either pressed or released a key on the keyboard. We handle the key, possibly updating the game state in the process, and then call
mainLoop again with the updated state. If there’s no events waiting for us then we update the state based on how much time has passed, redraw the window, and then call
mainLoop again with the updated state. If we get any other kind of event then we just call
checkForEvent function has to deal with some FFI stuff so it bears some scrutiny:
checkForEvent :: IO (Maybe SDL.Event) checkForEvent = FMA.alloca $ \event -> do res <- SDL.pollEvent event case res of 1 -> FS.peek event >>= return.Just _ -> return Nothing
pollEvent is an FFI wrapper around a C-library function that expects to be passed a pointer to an already allocated event structure.
alloca handles allocating and deallocating this structure for us. Between allocation and deallocation it passes the event object pointer to the lambda that we provide.
pollEvent fills in the structure if there is an event waiting and then returns 1 while a return value of 0 means there wasn’t an event waiting. So we just match on the return code and pull the event out of the FFI
Ptr Event using
peek if there was one.
The only events we currently care about are quit events and keyboard events. The former just causes the program to exit and has been covered above. The latter changes the state of the spaceship and is handled like so:
handleKey :: Word32 -> SDL.Keysym -> RotateState -> IO RotateState handleKey t key state | t == SDL.SDL_KEYDOWN = handleKeyDown key state | t == SDL.SDL_KEYUP = handleKeyUp key state | otherwise = return state
From the keyboard event we get whether the key was pressed or released and which key it was. The first layer of keyboard event handling just determines if the key was pressed or released.
handleKeyDown :: SDL.Keysym -> RotateState -> IO RotateState handleKeyDown key state@(RS d a t) | SDL.keysymKeycode key == SDL.SDLK_LEFT = updateState RotateCCW | SDL.keysymKeycode key == SDL.SDLK_RIGHT = updateState RotateCW | otherwise = return state where updateState dir = do ct <- DTC.getCurrentTime return $ RS dir (updateAngle d a t ct) ct
Currently the only keys we care about are the left and right arrows, if we get any others we just return the state that was passed in unchanged. When one of the arrows is pressed we need to update the state so that we’re rotating in the direction of that key. But we also need to update the the spaceship’s angle based on how long the previous rotation had been going since the last state update. That’s handled by the call to
updateAngle in the state update.
handleKeyUp :: SDL.Keysym -> RotateState -> IO RotateState handleKeyUp key state@(RS d a t) | SDL.keysymKeycode key == SDL.SDLK_LEFT = updateState RotateCCW | SDL.keysymKeycode key == SDL.SDLK_RIGHT = updateState RotateCW | otherwise = return state where updateState dir | dir == d = do ct <- DTC.getCurrentTime return $ RS RotateNone (updateAngle d a t ct) ct | otherwise = return state
When a key is released we need to stop rotating if the released key matches the current rotation direction. What can happen is that the user presses one arrow (starting rotation in that direction) and then presses the other arrow without releasing the first arrow. This causes the spaceship to start rotating in the second arrow’s direction. Now the user releases the first arrow key. Since we’re no longer rotating in that key’s direction we ignore this key release. Finally, the second arrow is released and since we’re rotating in that direction we now stop rotating. Note that there is a problem here: if the user presses one key, then presses the other without releasing the first, and then releases the second without releasing the first we will stop rotating even though there is a key down. I’ll probably fix that at some point, but for now I’m fine with the way things are. Also note that, as with the key down handler, we update the spaceship angle based on the time since the last state update while we are updating the state with the new rotation direction.
angVel :: GL.GLfloat angVel = 100.0 updateAngle :: RotateDir -> GL.GLfloat -> DTC.UTCTime -> DTC.UTCTime -> GL.GLfloat updateAngle d a t1 t2 = case d of RotateCCW -> a + diff*angVel RotateCW -> a - diff*angVel RotateNone -> a where diff = realToFrac $ DTC.diffUTCTime t2 t1
Currently the angular velocity of the spaceship is hard-coded, this is another parameter that will go in a configuration file at a later date. Updating the angle is just a simple velocity x time calculation with the sign determined by the rotation direction. We have to apply
realToFrac to the time difference to convert it to a
GLFloat so that it matches the other types in the calculation.
shipScaleFactor :: GL.GLfloat shipScaleFactor = 0.35 renderWindow :: RotateState -> SDL.Window -> IO () renderWindow (RS _ a _) win = do GL.clear [GL.ColorBuffer] GL.loadIdentity GL.rotate a $ GL.Vector3 0.0 0.0 1.0 GL.color $ GL.Color3 0.0 1.0 (0.0 :: GL.GLfloat) GL.scale shipScaleFactor shipScaleFactor 1.0 GL.renderPrimitive GL.LineLoop $ mapM_ vertex2f [(-5.0, -5.0), (0.0, 10.0), (5.0, -5.0), (0.0, 0.0) ] SDL.glSwapWindow win vertex2f :: (GL.GLfloat, GL.GLfloat) -> IO () vertex2f (x, y) = GL.vertex $ GL.Vertex2 x y
Finally we have the drawing function. First we have a scale factor, yet another parameter for the configuration file. That’s just there because getting the ship to be a good size on the screen required trial and error and I didn’t want to have to apply the scale factor to the vertex values by hand.
renderWindow just clears the window, clears the model-view matrix, sets the spaceship rotation angle and scales it, sets the line color, then finally renders the lines that make up the spaceship. The final call to SDL swaps the window buffers so that the user can see the updated spaceship.
vertex2f is just a utility function for converting pairs of
Double to OpenGL 2-dimensional vertex structures.
So with all that we can just do a quick
cabal run and end up with
So it works, but it seems a bit imperative with almost all the code is in the IO monad. There’s some recursion in
mainLoop, hopefully that counts for something. Maybe there’s a way to use functional reactive programming to do all this, but it seems like overkill at this point. So I’m just going to proceed as is and get the spaceship moving next time.
It would probably make sense to look for an open source program to contribute to, but I don’t feel like doing that quite yet. I’d prefer to stumble about the wilderness of the Haskell landscape on my own a bit more to see what I can find before I start heading for civilization. Hopefully I won’t pick up too many bad habits along the way.↩
No guarantees on it not taking a year (or more) since I’m not overflowing with free time or energy to pour into it.↩