Functional Reactive Programming, ten years after Flappy Bird
// status: seedling
In 2015 I built Flappy Bird in Haskell with Yampa and SDL2. The project was deliberately a small game, but it was also an attempt to answer a larger question: can a real-time program be described as a set of relationships over time instead of a mutable loop that updates fields in place?
The answer I came away with was “yes, within a clear boundary.” Functional Reactive Programming (FRP) made the bird's motion, input events, and mode changes easier to compose. It did not make time disappear, remove the renderer, or make every reactive library interchangeable. A decade later, the durable lesson is less “use Yampa for games” and more “separate the time model from the effectful edge, then make the transitions explicit.”
What Yampa actually models
Yampa is an arrowized FRP DSL embedded in Haskell for hybrid systems: systems with continuous or sampled values plus discrete events. Its central type is a signal function:
SF a bConceptually, an SF a b transforms a time-varying signal of a into a time-varying signal of b. In the implementation, time is sampled, and the runtime advances the network when the host supplies an input sample. An Event a is a value that may occur at a particular sample, carrying an a when it does.
The two types are deliberately different. A key held for half a second is a signal of booleans; a key-down transition is an event. Treating both as Bool tends to produce repeated flaps or missed edges.
The outside world enters and leaves through a small boundary. Yampa's reactimate takes an initialisation action, an input-sensing action, and an actuation action. The signal network can remain mostly pure while SDL input and drawing stay in IO at the edge.
Start with the equations
Before choosing combinators, write down the state transition. A deliberately boring bird model is enough:
data Bird = Bird
{ birdY :: Double
, birdVelocity :: Double
}
data Input = Input
{ flapPressed :: Bool
}
flapVelocity = -100
gravity = 250
stepBird dt input (Bird y v) =
let v' = if flapPressed input
then flapVelocity
else v + gravity * dt
y' = y + v' * dt
in Bird y' v'This stepBird function is not Yampa, and that is useful. It gives a reference model for tests and a fallback implementation if the signal network becomes difficult to inspect. FRP should make the time relationship easier to compose, not make the domain equation less understandable.
The Yampa version expresses the same relationship using integration. The original Flappy Bird experiment used imIntegral to integrate acceleration into velocity and velocity into position:
The following is intentionally close to the 2015 project. Yampa's API and package version may differ today; the type-level shape and the integration relationship are the part worth carrying forward.
fallingBird (Bird y0 v0) = proc _ -> do
v <- imIntegral v0 -< gravity
y <- imIntegral y0 -< v
returnA -< Bird y vThe exact initial conditions and signs depend on the coordinate system. In screen coordinates, increasing y points down; in a mathematical coordinate system, it often points up. Keep the equation and the coordinate convention together rather than “fixing” signs in the renderer.
Events are values carried by time
Yampa's event type lets the input layer turn multiple raw sources into one semantic event:
data AppInput = AppInput
{ mouseTap :: Bool
, spacebarTap :: Bool
}
flapTrigger = arr $ \input ->
if mouseTap input || spacebarTap input
then Event ()
else NoEventIn a real game, the booleans above would be edge-detected rather than “currently down” states. The point is the boundary: SDL reports device facts, and the signal network consumes the semantic event “flap now.” That makes it possible to test the network with a small sequence of AppInput values instead of a live window.
The original project combined the falling signal with a flap event and switched to a continuation that resets velocity:
flappingBird bird0 = switch sf continueAfterFlap
where
sf = proc input -> do
bird <- fallingBird bird0 -< ()
flap <- flapTrigger -< input
returnA -< (bird, flap `tag` bird)
continueAfterFlap (Bird y _v) =
flappingBird (Bird y flapVelocity)This is close to the code in the old experiment, with the state kept small enough to see in one screen. The continuation receives the current bird when the event occurs, preserves its position, and replaces its velocity. The event is not a boolean branch that is checked forever; it is a discrete transition in the signal network.
Switching is where the semantics become real
Yampa's switch has the type:
The first signal function runs until the event occurs. The event payload builds a new signal function, which is then used at the switching time and in the future. dSwitch is the delayed-observation variant. That one-sample distinction matters for games: should the flap affect the current sample, or the next one?
This also explains why a game needs an explicit mode model. A bird can be in “flying,” “dead,” or “ready” state, and each mode can be a signal function. A collision event can switch from flying to dead; a restart event can switch from dead to a fresh initial network. If those transitions are hidden in mutable flags scattered across the render loop, it becomes hard to tell which state owns the current velocity, score, and timers.
The switch continuation is evaluated strictly at the time of switching. Building a large new network in a hot path can therefore be visible in a frame. Conversely, retaining old closures forever can keep state alive. These are ordinary resource and allocation concerns; FRP does not exempt the design from them.
Rendering is still an effect
An FRP network can produce a World value; SDL still needs an IO action to draw it. A clean architecture has three layers:
SDL input / clock ──▶ input samples ──▶ SF AppInput World ──▶ draw World / audio IOThe middle layer is where the game rules live. It can be driven by recorded samples, a deterministic clock, or a live reactimate loop. The left and right sides own window handles, audio devices, texture uploads, and shutdown.
This separation is the part I would carry to Rust or OCaml. The syntax changes, but the architecture remains useful:
The Rust loop is not FRP by itself. It is an explicit discrete-time model with effects at the edge. It can still inherit the useful FRP discipline: compose transformations over time, represent events distinctly, and keep IO orchestration out of the domain equations.
What survived the ten-year gap
Time should have one owner
If the bird integrates with one clock, collision uses another, and animation uses a third, bugs appear at boundaries. Choose whether the network consumes elapsed time or fixed steps, and pass that choice through the model. Fixed-step simulation is especially useful for replay and tests; variable-step integration may feel smoother but needs limits for large pauses.
Discrete and continuous values deserve different names
Event () says “one occurrence”; Bool says “a value at every sample.” Maybe a can represent an event in a pure trace, but it does not automatically carry the timing and switching semantics of an FRP library. Naming the distinction is more important than choosing a particular constructor.
Pure simulation is a debugging superpower
The Flappy Bird clone had a natural test trace: initial bird, a sequence of elapsed times, and flap events. With a pure model, I can replay a collision, compare two integration rules, or check that score increments exactly once when a pipe passes. A live SDL loop is a poor place to discover arithmetic bugs.
FRP families are not interchangeable
“FRP” now covers several non-equivalent designs: classic continuous-time semantics, arrowized systems such as Yampa, push-based event streams, and UI signal graphs. A stream library may be excellent at asynchronous messages and still have no notion of a continuous derivative or an instantaneous switch. Conversely, a continuous signal model may be awkward for a huge dynamic collection of UI subscriptions. Compare semantics, scheduling, and lifetime rules—not just API names.
A small retrospective on architecture
The older Haskell project deliberately tried to avoid putting game state in “dirty IO Monads.” That instinct was useful, but I would now phrase the goal more precisely: keep the domain state transition pure and make the effect boundary explicit. IO is not dirty; it is the honest place for SDL and audio. The danger is letting device polling, mutable handles, collision rules, and rendering order become one function that cannot be replayed.
This is also why Game of Life in Haskell remains a useful companion experiment. Its cell transition is pure and discrete; only the SDL rendering loop needs IO. Flappy Bird adds continuous-ish integration and event-triggered switching, so it exposes timing and ownership questions that a cellular automaton can hide.
The design I would use today is therefore hybrid in the literal sense: pure step functions for local rules, a typed event/state model for transitions, and an explicit loop or FRP network for composition. Use Yampa when its arrowized time model helps the problem. Do not introduce a signal graph merely to avoid writing a small, inspectable state transition.
Things that tripped me up
- A key state is not a key event. Polling “space is down” every frame creates repeated flaps; detect the transition or model an event explicitly.
- Initial values matter. Integrators need a starting position and velocity. A wrong time-zero convention can look like a physics bug several frames later.
switchanddSwitchdiffer by one sampling decision. Decide whether the new signal function sees the current sample or starts on the next one.- Events carry values.
Event birdcan preserve the state at the transition; a bareBoolthrows away that context. - Continuous time is approximated. Yampa's conceptual signals are dense in time, but a real SDL application samples them. Frame rate, pauses, and integration error still exist.
- Coordinates leak across boundaries. Screen
ydirection, gravity signs, and sprite anchors should be part of the model contract, not patched in drawing code. - A pure core does not remove IO. SDL input, window creation, audio, and drawing still belong somewhere; make that edge small and named.
- Long-lived signal networks own state. Switching, spawning, and retaining networks can allocate and retain more than expected. Lifetime is part of the design.
- “FRP” is not one library contract. Check event semantics, sampling, switching, and cleanup before porting an idea from one FRP system to another.
References
- Flappy Bird in Haskell
- Game of Life in Haskell
- Yampa API documentation — signal functions and events
- Yampa API documentation — switching
- Courtney, Nilsson, and Peterson — The Yampa Arcade (primary paper)
- Haskell 2010 Language Report
- SDL2 — official library site
Back to Notes · see also Flappy Bird in Haskell · Game of Life in Haskell · OCaml - learning log · Home.