Banner B. Schafer.

Yampa Fundamentals

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.

What is Yampa?

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.

The Core Abstraction

A Signal Function (SF) transforms time-varying input into time-varying output. Think: SF i o ~ (Time -> i) -> (Time -> o)

Signal Functions (SF)

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:

SF is an Arrow

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

Signals and Time

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:

Signal

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.

time

Outputs elapsed time

SF a Time
localTime

Time since SF started

SF a Time
constant

Ignore input, output constant

b -> SF a b

Basic Signal Functions

Lifting Pure Functions

arr - Lift a Function

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

Stateful Primitives

integral

integral :: VectorSpace a s => SF a a

Integrates the input signal over time. Essential for physics simulations (velocity → position).

derivative

derivative :: Fractional s => SF a a

Computes the derivative of the input signal (position → velocity). Use with caution—it’s crude!

delay

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

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.

Event Primitives

edge

edge :: SF Bool (Event ())

Produces an event when the input boolean transitions from False to True.

after

after :: Time -> b -> SF a (Event b)

Produces an event with the given value after the specified time has elapsed.

hold

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

Event Combinators

fmap

Transform event value

Event (a -> b)
tag

Replace event value

Event a -> b -> Event b
gate

Filter events by condition

Event a -> Bool -> Event a

Arrow Notation (proc)

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.

Enabling Arrow Notation

Add {-# LANGUAGE Arrows #-} at the top of your file to enable proc notation.

Basic Syntax

-- 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

The Parts of proc Notation

proc pattern -> do

Introduces an arrow computation, binding the arrow’s input to a pattern (like lambda for arrows).

output <- arrow -< input

The arrow statement. Runs arrow with input, binding the result to output.

returnA -< value

Returns value as the output of the arrow. Always the last line (like return in monadic do).

let bindings

Pure Haskell expressions. Use let for normal computations (no -< needed).

Complete Example

{-# 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

⚠️ Arrow Limitations

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!).


Accumulation and State

Yampa provides several primitives for accumulating state over time, essential for counters, scores, and stateful game logic.

accumBy

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

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

Running Signal Functions

To actually execute a signal function, you need to connect it to the real world. Yampa provides two main approaches:

reactimate - Production Use

reactimate

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:

embed - Testing Use

embed

embed :: 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]

Key Concepts Summary

Continuous Time

  • Signals that change smoothly
  • Mouse position, velocity, temperature
  • Use integral, derivative

Discrete Time

  • Events that occur at moments
  • Button presses, collisions, timers
  • Use Event, edge, after

The Yampa Philosophy

Don’t think about game loops and state updates. Think about signals flowing through transformations. The framework handles time, you describe relationships.


Common Patterns

Movement with Keyboard Input

movement :: SF Controller (V2 Float)
movement = proc ctrl -> do
  dir <- directionFromKeys -< ctrl
  pos <- integral -< dir
  returnA -< pos

Cooldown Timer

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 with Damage Events

health :: Float -> SF (Event Float) Float
health initial = accumHoldBy (\hp damage -> max 0 (hp - damage)) initial
“The trick to working in FRP is to think of continuous streams of values over time. Stop thinking about state updates at discrete ticks, and start thinking about signal transformations.”

Created with the Twilight Scholar design system · For more depth, see the study guide on Yampa Switches · Official Yampa Documentation