# ==Functional Reactive Programming==, ten years after ==Flappy Bird==
<p class="doc-sub">// status: seedling</p>
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:
```haskell
SF a b
```
Conceptually, 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:
```haskell
data Bird = Bird
{ birdY :: Double
, birdVelocity :: Double
}
data Input = Input
{ flapPressed :: Bool
}
flapVelocity :: Double
flapVelocity = -100
gravity :: Double
gravity = 250
stepBird :: Double -> Input -> Bird -> Bird
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.
```haskell
fallingBird :: Bird -> SF a Bird
fallingBird (Bird y0 v0) = proc _ -> do
v <- imIntegral v0 -< gravity
y <- imIntegral y0 -< v
returnA -< Bird y v
```
The 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:
```haskell
data AppInput = AppInput
{ mouseTap :: Bool
, spacebarTap :: Bool
}
flapTrigger :: SF AppInput (Event ())
flapTrigger = arr $ \input ->
if mouseTap input || spacebarTap input
then Event ()
else NoEvent
```
In 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:
```haskell
flappingBird :: Bird -> SF AppInput Bird
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:
```haskell
switch :: SF a (b, Event c) -> (c -> SF a b) -> SF a b
```
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:
```text
SDL input / clock ──▶ input samples ──▶ SF AppInput World ──▶ draw World / audio IO
```
The 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:
```rust
fn step_world(dt: f32, input: Input, world: World) -> World {
// Pure state transition: easy to replay and test.
world
}
fn frame() {
let input = read_sdl_input();
world = step_world(clock.delta_seconds(), input, world);
draw(world);
}
```
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.
- **`switch` and `dSwitch` differ by one sampling decision.** Decide whether the new signal function sees the current sample or starts on the next one.
- **Events carry values.** `Event bird` can preserve the state at the transition; a bare `Bool` throws 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 `y` direction, 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](https://hackage.haskell.org/package/Yampa-0.10.4/docs/FRP-Yampa.html)
- [Yampa API documentation — switching](https://hackage.haskell.org/package/Yampa-0.10.4/docs/FRP-Yampa.html#v:switch)
- [Courtney, Nilsson, and Peterson — The Yampa Arcade (primary paper)](https://www.antonycourtney.com/work/pubs.html)
- [Haskell 2010 Language Report](https://www.haskell.org/onlinereport/haskell2010/)
- [SDL2 — official library site](https://www.libsdl.org/)
---
Back to [[Notes/Index|Notes]] · see also [[Flappy Bird in Haskell]] · [[Game of Life in Haskell]] · [[OCaml - learning log]] · [[Home]].