· 3 MIN READ

Shipping a Game in a Language With No Exceptions

Postmortem for GhostBust Hotline. What it was like to lead a team of three building a real-time shooter in Elm, a pure functional language, and why the compiler ended up helping us so much.

  • #games
  • #elm
  • #functional-programming
  • #postmortem

This summer, three of us released GhostBust Hotline, a 2D top-down shooter where the ghosts stay invisible until you reveal them. Every line of it is written in Elm, a pure functional language with no runtime exceptions and no mutable state. That's very different from how game code is normally written, and it took some getting used to.

You can't just write enemy.hp -= damage

In most engines, gameplay code is a bunch of objects changing each other's state. Elm doesn't work like that. The whole game is one immutable value, and the only way anything changes is through a pure function: update : Msg -> Model -> Model.

When a ghost takes damage, there's no method call. A message gets routed to the ghost, which produces a new ghost and then a new world state. For the first couple of weeks this felt really clumsy. Around week three it started to click, because every change had a cause I could trace. Bugs were always reproducible, since the same messages in the same order always give you the same state. Our longest debugging session was about twenty minutes. I've had bugs in Unity take days.

Particles without a particle engine

There aren't any usable particle libraries for Elm, so we wrote the muzzle flashes, ghost reveals and death effects ourselves. Each particle system is a list of records that gets updated every frame.

updateParticle : Float -> Particle -> Maybe Particle
updateParticle dt p =
    if p.life <= 0 then
        Nothing
    else
        Just
            { p
                | pos = add p.pos (scale dt p.vel)
                , vel = scale (1 - p.drag * dt) p.vel
                , life = p.life - dt
            }

Tuning them frame by frame took a long time, but I think it was worth it. Players don't really notice good particle effects, but a game feels a lot flatter without them.

Designing AI for invisible enemies

Players normally plan by watching enemy animations, which doesn't work when the enemy is invisible. Our solution was to have each ghost type give itself away through the environment instead, with its own movement noise and particle disturbances. That makes revealing ghosts a skill, and each type needs a different strategy once you can see it. Writing per-ghost AI in a pure language was actually pretty nice, because each behaviour is just a function from world state to an action, and it's easy to test on its own.

Leading a team of three

Two process decisions helped us a lot:

  1. CI from the first day. GitHub Actions checked that the Elm code compiled on every push. In Elm, code that compiles almost always runs, so a green build meant the game was playable. We never had to stress before a demo.
  2. Splitting work by feature, not by layer. Nobody owned "the rendering code". Each of us owned whole features from start to finish. With everything going through one update function, that was the split that made sense.

I also edited the trailer in DaVinci Resolve, and it showed me that marketing a game is a whole skill of its own.

Would I do it again?

For a game jam or a course project, yes. The compiler catches a lot of bugs before you even run the game, and the architecture works well for a small team. You can play GhostBust Hotline here.