A Complete Guide to Functional Reactive Programming with Arrows
Yampa lets you describe continuous-time and discrete-time behaviors as composable Signal Functions, transforming how we think about time in programs from explicit state management to declarative signal transformations.
Yampa is a Functional Reactive Programming (FRP) library for Haskell that provides a domain-specific language for programming deterministic hybrid systems—those with both continuous-time and discrete-time behaviors.
Instead of thinking about your program as a loop that updates state at each tick, Yampa lets you think about signals that vary over time and signal functions that transform these signals.
A Signal Function (SF) transforms time-varying input into time-varying output. Think: SF i o ~ (Time -> i) -> (Time -> o)
The fundamental building block in Yampa is the Signal Function:
data SF i o
-- Read as: a transformation from signals of type i to signals of type o
-- Where Signal a ≈ Time -> a
Signal functions are:
Signal functions form an Arrow, which means they can be composed in flexible ways beyond simple function composition. This gives you powerful combinators for building complex signal networks.
instance Category SF where
id :: SF a a -- Identity signal function
(.) :: SF b c -> SF a b -> SF a c -- Composition
instance Arrow SF where
arr :: (a -> b) -> SF a b -- Lift pure function
first :: SF a b -> SF (a, c) (b, c) -- Apply to first component
(&&&) :: SF a b -> SF a c -> SF a (b, c) -- Fan-out
In Yampa, you don’t work with signals directly. Instead, you work with Signal Functions that transform signals. However, it helps to understand the concept:
A signal is a value that varies over time: Signal a ≈ Time -> a
Examples: mouse position, temperature reading, character velocity
Yampa handles time implicitly. You never pass time around manually—the framework manages it for you through the SF abstraction.
Outputs elapsed time
SF a Time
Time since SF started
SF a Time
Ignore input, output constant
b -> SF a b
arr :: (a -> b) -> SF a b
Takes a pure function and lifts it into a signal function. The function is applied to each input value at each time step.
-- Add 10 to every input
add10 :: SF Int Int
add10 = arr (+10)
-- Check if greater than 20
gt20 :: SF Int Bool
gt20 = arr (> 20)
-- Compose them
composite :: SF Int Bool
composite = add10 >>> gt20
integral :: VectorSpace a s => SF a a
Integrates the input signal over time. Essential for physics simulations (velocity → position).
derivative :: Fractional s => SF a a
Computes the derivative of the input signal (position → velocity). Use with caution—it’s crude!
delay :: Time -> a -> SF a a
Delays the signal by the given time, using the second argument as the initial value.
-- Snake moving at constant velocity
snakeVelocity :: SF () (V2 Float)
snakeVelocity = constant (V2 1 0)
-- Integrate to get position
snakePosition :: SF () (V2 Float)
snakePosition = snakeVelocity >>> integral
Events represent discrete-time signals—values that may or may not occur at any given moment.
data Event a = NoEvent | Event a
Events are similar to Maybe, but semantically different: an Event-carrying signal should change rarely, representing discrete occurrences like button presses, collisions, or timers firing.
edge :: SF Bool (Event ())
Produces an event when the input boolean transitions from False to True.
after :: Time -> b -> SF a (Event b)
Produces an event with the given value after the specified time has elapsed.
hold :: a -> SF (Event a) a
Holds onto the most recent event value, providing continuous output.
-- Timer that fires after 5 seconds
timer :: SF a (Event ())
timer = after 5 ()
-- Button press detection
buttonPress :: SF Bool (Event ())
buttonPress = edge
-- Hold the last direction
lastDirection :: SF (Event Direction) Direction
lastDirection = hold North
Transform event value
Event (a -> b)
Replace event value
Event a -> b -> Event b
Filter events by condition
Event a -> Bool -> Event a
Just as do notation makes monadic code readable, proc notation makes arrow code readable. It’s syntactic sugar that the compiler desugars into arrow combinators.
Add {-# LANGUAGE Arrows #-} at the top of your file to enable proc notation.
-- Without proc notation (arrow combinators)
snakePosition = constant (V2 1 0) >>> integral
-- With proc notation (more readable!)
snakePosition = proc _ -> do
velocity <- constant (V2 1 0) -< ()
position <- integral -< velocity
returnA -< position
Introduces an arrow computation, binding the arrow’s input to a pattern (like lambda for arrows).
The arrow statement. Runs arrow with input, binding the result to output.
Returns value as the output of the arrow. Always the last line (like return in monadic do).
Pure Haskell expressions. Use let for normal computations (no -< needed).
{-# LANGUAGE Arrows #-}
snakeGame :: SF () (V2 Float)
snakeGame = proc input -> do
upPress <- onPress keyUp (V2 0 (-1)) -< input
downPress <- onPress keyDown (V2 0 1) -< input
leftPress <- onPress keyLeft (V2 (-1) 0) -< input
rightPress <- onPress keyRight (V2 1 0) -< input
-- Merge all directional events
let allDirs = asum [upPress, downPress, leftPress, rightPress]
-- Hold the most recent direction
direction <- hold (V2 0 1) -< allDirs
-- Integrate to get position
position <- integral -< direction
returnA -< position
Unlike monads, arrows don’t let values influence control flow. You can’t pattern match on arrow outputs to decide which arrow to run next (that’s what switches are for!).
Yampa provides several primitives for accumulating state over time, essential for counters, scores, and stateful game logic.
accumBy :: (b -> a -> b) -> b -> SF (Event a) (Event b)
Accumulates events using a folding function. Outputs an event with the new accumulated value each time an input event occurs.
accumHoldBy :: (b -> a -> b) -> b -> SF (Event a) b
Like accumBy, but continuously outputs the current accumulated value (zero-order hold).
-- Count button presses
clickCounter :: SF (Event ()) Int
clickCounter = accumHoldBy (\count _ -> count + 1) 0
-- Score accumulator
scoreKeeper :: SF (Event Int) Int
scoreKeeper = accumHoldBy (+) 0
To actually execute a signal function, you need to connect it to the real world. Yampa provides two main approaches:
reactimate :: IO a -> (Bool -> IO (DTime, Maybe a)) -> (Bool -> b -> IO Bool) -> SF a b -> IO ()
Runs a signal function indefinitely in a loop, handling input sensing and output actuation.
Parameters:
IO a - Initialization action (runs once at start)Bool -> IO (DTime, Maybe a) - Input sensing (called repeatedly, returns time delta and input)Bool -> b -> IO Bool - Output actuation (called with each output, returns whether to continue)SF a b - The signal function to runembed :: SF a b -> (a, [(DTime, Maybe a)]) -> [b]
Runs a signal function on a pre-recorded input stream, useful for testing in GHCi.
-- Test a signal function in GHCi
>>> embed (arr (+10)) (0, [(1, Just 5), (1, Just 10), (1, Nothing)])
[10, 15, 20, 20]
-- Test accumulation
>>> embed (accumHoldBy (+) 0) (deltaEncode 1 [Event 1, NoEvent, Event 5])
[1, 1, 6]
integral, derivativeEvent, edge, afterDon’t think about game loops and state updates. Think about signals flowing through transformations. The framework handles time, you describe relationships.
movement :: SF Controller (V2 Float)
movement = proc ctrl -> do
dir <- directionFromKeys -< ctrl
pos <- integral -< dir
returnA -< pos
cooldown :: Time -> SF (Event ()) (Event ())
cooldown duration = proc trigger -> do
canFire <- dHold True -< trigger `tag` False
reset <- after duration () <<< arr (const ()) -< trigger
returnA -< gate trigger canFire
health :: Float -> SF (Event Float) Float
health initial = accumHoldBy (\hp damage -> max 0 (hp - damage)) initial
Created with the Twilight Scholar design system · For more depth, see the study guide on Yampa Switches · Official Yampa Documentation