2019-08-14 21:08

You want to give Haskell a try for game development? I think it's a good idea. I wrote a 2D arcade game called Lambda-Heights in Haskell and want to share my experiences with you.

Preliminaries

A basic knowledge of Haskell and functional programming in general should be sufficient. If you never saw any Haskell code before, I recommend you reading Learn You a Haskell for Great Good!.

Game logic

You should start with the game logic, because that's the real functionality of the game. Discover the data types you need to build up a set of pure functions that operate on these types.

Try to decompose the game logic into a bunch of small pure functions. Pure functions are composable and independent of each other. You should prevent big data types. The bigger your types, the more functions operate on those types. Cut types down into smaller types.

Here is a simple example:

data Layer = Layer { layerSize :: V2 Float, layerPosition :: V2 Float } data Player = Player { playerScore :: Int, playerPosition :: V2 Float, playerVelocity :: V2 Float, playerAcceleration :: V2 Float } ... falling :: Player -> Bool falling player = let V2 _ y = playerVelocity player in y < 0 onTop :: Player -> Layer -> Bool player `onTop` layer = let V2 w _ = layerSize layer in playerPosition player `inside` (layerPosition layer, V2 w 20)

Game loop

After you coded the core functionality of your game, you need to update it step by step over time - at least around 60 times per second. To accomplish this you need a type of loop in your game. You could either choose a traditional game loop or you try a functional reactive programming library. I think reactive-banana would be a good choice if you want to go with the frp approach. I decided to implement a traditional game loop, because I knew how it works and I wanted to get things done.

The game loop executes a few phases each frame. You can model these phases as function type synonyms with type variables to stay abstract:

type Input m e = m e type Update s r e = e -> s -> Either r s type Output m s r e = e -> Either r s -> m () type Render m s = s -> m ()

The type variable m stands for monad and is IO in basic cases.

During the input phase, you collect all occured events (e.g. key and mouse events). That needs to run inside the IO monad.

monad. The update phase applies all events to the state and returns either the new state or the result if the end is reached. This is pure code. There is no IO code permitted as the declaration tells us.

code permitted as the declaration tells us. The output phase can serialize state to disk, or send updates over the network and so on. This is IO code, as the name implies.

code, as the name implies. The render phase takes the new state and presents it on the screen. Whichever technology you choose - that needs to be IO code.

Finally, you have three of four phases running inside the IO monad. You may ask yourself if this is a proper functional way. I would say yes, because the whole game logic, every update to your game objects, physics calculation and other things happen during the update phase. This code is easy to test and free from side effects.

You can write a recursive loop function that takes an initial state and executes these four phases until we reach the end.

startLoop :: (MonadIO m) => Input m e -> Update s r e -> Output m s r e -> Render m s -> s -> m r

It's quite easy as far as you know how to handle timing to stay framerate independent. The linked article about game loops explains everything in detail.

Input

The input phase basically reads general events like mouse and key events and transforms them into game events.

Here is a simple example that polls pending events using SDL2 and transforms them into player events:

keyInput :: IO [PlayerEvent] keyInput = do events <- SDL.pollEvents return $ mapMaybe toPlayerEvent events toPlayerEvent :: SDL.Event -> Maybe PlayerEvent toPlayerEvent event = case SDL.eventPayload event of SDL.KeyboardEvent keyEvent -> keyToPlayerEvent $ keyEventProperties keyEvent _ -> Nothing keyEventProperties :: SDL.KeyboardEventData -> (SDL.Keycode, SDL.InputMotion) keyEventProperties keyEvent = let code = SDL.keysymKeycode $ SDL.keyboardEventKeysym keyEvent motion = SDL.keyboardEventKeyMotion keyEvent in (code, motion) keyToPlayerEvent :: (SDL.Keycode, SDL.InputMotion) -> Maybe PlayerEvent keyToPlayerEvent (SDL.KeycodeA, SDL.Pressed) = Just $ PlayerMoved MoveLeft True keyToPlayerEvent (SDL.KeycodeA, SDL.Released) = Just $ PlayerMoved MoveLeft False keyToPlayerEvent _ = Nothing

The keycodes are hard coded, but you are free to load them from a config file.

Update

The update phase depends highly on your type of game. You take the events from the input phase and apply them to the update function. The update function uses your pure functions from the game logic section above to calculate the next game state.

Here you can take a look at the update logic of Lambda-Heights.

Output

During the output phase you can do whatever you want. I used this phase to write all events into a transaction channel. A separate thread reads from the channel and serializes all events to the local disk. This gave me the opportunity to implement a replay / demo feature.

Render

I chose the SDL2 binding for this example, because it serves an easy API for basic graphics operations and window context creation. At first you need to create a window and a renderer:

type RenderContext = (SDL.Window, SDL.Renderer) createContext :: Text -> IO RenderContext createContext windowTitle = do let windowSettings = SDL.defaultWindow { SDL.windowMode = SDL.FullscreenDesktop } window <- SDL.createWindow windowTitle windowSettings renderer <- SDL.createRenderer window (-1) SDL.defaultRenderer return (window, renderer)

Now you can write a Render function as described in the game loop section. Our render function needs access to a RenderContext to draw things to the screen. Easy - just add a RenderContext parameter to the function:

renderGame :: RenderContext -> GameState -> IO ()

which is basically the same as

renderGame :: RenderContext -> Render IO GameState

The first parameter RenderContext is very special to the implementation and does not show up in the Render type as declared above. Render takes a state s and does an IO action. You can use currying to inject the RenderContext into the renderGame so that it matches the Render type. GameState is the state you carry through the game loop. Using currying for dependency injection is a very common technique in functional programming. You can initialize dependencies like contexts, resources, configurations or factories and things like that at the beginning of your application, curry it inside functions that need these dependencies and throw the resulting partially applied functions into your application.

State machine

Now you have a basic structure to run a game, but you need a few things beside the actual game, like menus for the main screen, settings screen, pause overlay and so on. These are different states which can be modeled with a simple data type:

data State = Exit | Menu | Play

A simple function executes these different states:

startState :: (MonadIO m) => State -> m State startState Exit = return Exit startState Menu = startMenu >>= startState startState Play = startGame >>= startState

Each function that executes a state creates an initial state for the loop, starts the loop, evaluates the result and returns the next state for your state machine, which is actually a state transition:

newtype MenuResult = Selected State menuInput :: IO [UpDownEvent] menuUpdate :: [UpDownEvent] -> MenuState -> Either MenuResult MenuState menuOutput :: [UpDownEvent] -> Either MenuResult MenuState -> IO () menuRender :: MenuState -> IO () startMenu :: (MonadIO m) => m State startMenu = do Selected state <- startLoop menuInput menuUpdate menuOutput menuRender newMenuState return state

Conclusion

That's it! This is the basic architecture of Lambda-Heights. It worked quite well for me for several reasons: