Documentation of the haskell code for Defect Process, a 2d hack n' slash game on Steam. The full source code is available on GitHub.

[Brief Overview] | [Cross-platform Notes] | [Additional Links]

General Code Structure

The main game loop is a tail recursive call, which simplified looks like:
gameMain :: World -> IO World
gameMain world = do
    world' <- updateWorld world
    gameMain world'
Since this is a purely functional language the way this works in general is that we use modified copies of data, rather than updating the data in-place.[1]
The top level design is very similar to what's described in Three Layer Haskell Cake. See src/AppEnv/Types.hs type for the full definition.

Message Passing Rules Everything Around Me

Message passing is core to how the code works. Consider updating some game entity:
updatePlayer :: Player -> IO Player
updatePlayer player = do
    player' <- ... useful things ...
    return player'
This is fine until updatePlayer needs to change anything else in addition to Player, e.g. spawn a particle effect or pull a switch in the level. Adding individual things to the return type gets tedious quickly, e.g. updatePlayer :: Player -> IO (Player, [Particle], Level).
Perhaps a better approach is:
updatePlayer :: World -> IO World
updatePlayer world =
    let
        player    = _player world
        particles = _particles world
        level     = _level world
    in do
        player'    <- ... useful things ...
        particles' <- ... useful things ...
        level'     <- ... useful things ...
        return $ world
            { _player    = player'
            , _particles = particles'
            , _level     = level'
            }
This also works but now looking at the updatePlayer type it's no longer clear what it changes. Any field of World could be modified so it's much harder to reason about what's happening.

The approach taken by this game is to split update functions into two parts, think/update:
thinkPlayer :: Player -> [Message]
thinkPlayer player =
    [ ... message to move the player ...
    , ... message to make a particle effect ...
    , ... message to flip a level switch ...
    ]

updatePlayer :: [Message] -> Player -> IO Player
updatePlayer messages player = do
    player' <- ... update player according to messages ...
    return player'
which is used as follows:
gameMain :: World -> IO World
gameMain world =
    let
        player    = _player world
        particles = _particles world
        level     = _level world

        playerMessages    = thinkPlayer player
        particlesMessages = thinkParticles particles
        levelMessages     = thinkLevel level
        allMessages       = playerMessages ++ particleMessages ++ levelMessages
    in do
        player'    <- updatePlayer allMessages player
        particles' <- updateParticles allMessages particles
        level'     <- updateLevel allMessages level
        return $ world
            { _player    = player'
            , _particles = particles'
            , _level     = level'
            }
This allows different game entities to communicate with each other while keeping their own update functions clean.[2] The full implementation is more complicated but the core idea is as described above, see src/Msg/ and src/World/Main.hs for the relevant code.

Game Engine

The simulation runs at a fixed 120hz but renders at any refresh rate, implemented as described in Fix Your Timestep!.

Windowing, rendering, and input are implemented with:
SDL2 (bindings)
SDL2_Image (bindings)
SDL2_ttf (bindings)
Audio is implemented with C bindings to FMOD[3].

The game engine code is mostly contained in src/Window/. It's kind of a basic 2d engine, not that much interesting to talk about.

Garbage Collection

This game uses the recently implemented --nonmoving-gc garbage collector, which avoids stop-the-world behavior that could cause stutters. That flag isn't critical however, the default --copying-gc behavior is also fine in testing (< 1 ms pauses).
This post indicates that long pause times don't start to happen until the GHC runtime is managing many gigabytes of memory. For some types of games that could potentially be the case,[4] but not this one.

Optimization

Minimum spec for this game is an x86-64 machine that can handle rendering from multiple 4k textures,[5] which effectively limits how slow the CPU is going to be. In testing the game has always been GPU-bound on weaker systems, so there hasn't been much profiling work done on the haskell code yet. There's likely a decent amount of low hanging fruit to optimize. Performance hasn't been an issue during development, but it'd also be fair to point out that the game isn't really computing a ton of stuff.

Dev Console

The in-game dev console commands ignore the message passing technique described above, in favor of a more direct approach. These are essentially debug commands, so the code here is intentionally separated out from the usual flow. It'd be possible to write the whole game with this "direct approach", but messier. This is an illustration of how that could look, for comparison.

Miscellaneous Thoughts