Files
functional-programming-snake/course/08-event-streams.md
T

31 KiB
Raw Blame History

Module 8 — Event streams

Time: ~4 h · You'll edit: JS js/src/stream.js · Scala scala/src/Stream.scala · F# fsharp/src/Stream.fs Tests: JS cd js && node --test "test/m08-*.test.js" · Scala cd scala && ./test.sh M08 · F# cd fsharp && ./test.sh M08 Prerequisites: Modules 0–7 (especially Module 5, "a game is a fold", and Module 7, "effects are values")

Why this module

In Module 5 you showed that a whole game is a fold: replay(newGame(seed), actions). That works beautifully when you have the list of actions in advance — in a test, or for a replay. But a real player doesn't hand you a list. Actions arrive one at a time, over time: the clock ticks, a key goes down, another key, more ticks. Where do they come from, and how do they become Actions?

The usual answer is callbacks and a mutable variable:

// The usual, stateful way (do NOT write this in the course)
let state = newGame(seed)
setInterval(() => { state = update(state, Action.Tick); draw(ctx, state) }, 150)
window.addEventListener('keydown', (e) => {
  const action = keyToAction(e.key)
  if (action) { state = update(state, action); draw(ctx, state) }
})

This works, and it is where most games start. But look at what we've lost. The logic "every 150 ms is a tick; arrow keys are turns; fold them into states; draw each state" is scattered across two callbacks that share a hidden let. You can't test the wiring without a browser and a real clock. You can't ask "what would happen if these five events arrived?" without actually making them happen.

This module does for events over time what Module 7 did for effects: it turns them into values. A stream will be a pure description of how to react to events. You'll build a small library of streams and combinators (map, filter, merge, scan, …) from scratch, and you'll test any wiring by feeding it a list of events — no browser, no clock, no waiting.

Learning objectives

By the end of this module you can:

  • explain a stream as a pure automaton (a Mealy machine): step : Event → (outputs, nextStream);
  • implement sources (keyDowns, keyUps, interval) and combinators (map, filter, filterMap/choose, merge, scan, startWith) and a pure run;
  • explain where a stream "remembers" things when there are no variables, and trace interval's last through a sequence of events;
  • predict the output of a small stream pipeline for a given list of events;
  • compare this design with RxJS observables (push-based, stateful subscriptions) and with classic functional reactive programming.

Concepts

1. What a program receives from the outside world

Our game only reacts to three kinds of event:

  • time passing: "it is now t milliseconds since the program started";
  • a key going down: which key ('ArrowUp', 'w', ' ' …);
  • a key going up: which key.

As in every module so far, the first step is to make them data:

JavaScript

// An input event is a plain object:
//   { tag: 'Time', t }        t: milliseconds since start
//   { tag: 'KeyDown', key }   key: the browser's KeyboardEvent.key, e.g. 'ArrowUp'
//   { tag: 'KeyUp', key }

Scala

// Already in scala/src/Stream.scala
enum Event:
  case Time(t: Long)
  case KeyDown(key: String)
  case KeyUp(key: String)

F#

// Already in fsharp/src/Stream.fs
type Event =
    | Time of int64
    | KeyDown of string
    | KeyUp of string

Naming note. The JS doc comments call this type Input; Scala and F# call it Event, because their m09 module is already named Input and the two names would clash. Same thing.

Notice that time arrives as an event too. The program never asks for the time (Date.now() is banned by the purity audit); the outside world tells it what time it is, the same way it tells it about keys. Hidden time becomes an explicit input. Keep that in mind; it's the most important design decision in this module.

2. A stream is a machine you feed one event at a time

What is the smallest useful description of "something that reacts to events"? When an event arrives, it should be able to:

  1. produce zero or more outputs (a key press produces one turn; a clock event may produce no tick at all, or several);
  2. change how it will react to the next event (an interval needs to remember when it last fired).

Without variables, "change" must mean "give back a new machine". That gives the whole definition:

Stream<A> = { step : Event → [ A[], Stream<A> ] }

Feed one event to step; get back the outputs it caused and the stream to use for the next event.

JavaScript

// Stream<A> = { step: (event) => [outputs: A[], next: Stream<A>] }

Scala

final case class Stream[A](step: Event => (List[A], Stream[A]))

F#

type Stream<'a> = Stream of (Event -> 'a list * Stream<'a>)

This is a well-known object in computer science: a Mealy machine, a state machine whose output depends on its current state and its input (Mealy machine). The usual textbook definition has a set of states and a transition table; ours is the same idea where "the current state" is hidden inside the closure of step, and "the next state" is the next stream itself. In Haskell this is also called an automaton arrow or a "Mealy" type (Justin Le, "Intro to Machines & Arrows" is a gentle introduction).

From everyday programming: compare an event listener. In the observer pattern — addEventListener('keydown', handler) is an instance of it — a handler is called once per event and typically reacts by changing some variable that lives outside it. A stream's step is also called once per event, but instead of changing anything it returns what happened (the outputs) and the handler to use for the next event. You may also have written a state machine as a switch over a currentState variable that each event reassigns. A stream is that state machine with the variable removed: each state is a stream, and the transition is "return the next one". Unlike an iterator, which gives you the next element when you ask, a stream takes an event when you push one, and gives you some outputs plus the rest of itself.

Let's write one by hand to get a feel for it — not one of the exercises, but a cousin: a stream that counts key presses and emits the running count.

// counter(n): a stream that has seen n key presses so far.
const counter = (n) => ({
  step: (ev) =>
    ev.tag === 'KeyDown'
      ? [[n + 1], counter(n + 1)]   // output the new count; the next machine remembers n + 1
      : [[], counter(n)],           // no output; nothing to remember changes
})
const presses = counter(0)

Step it by hand:

presses           = counter(0)
presses.step(KeyDown 'a')   → [ [1], counter(1) ]
counter(1).step(Time 40)    → [ [],  counter(1) ]
counter(1).step(KeyDown 'b')→ [ [2], counter(2) ]

Where is the count stored? Not in a variable. It's the argument n of the function that built the current stream, captured in the closure of step. Each step returns a new stream that captures the new count. The state lives in the next stream, not in a cell that gets overwritten.

3. No hidden state: run the same stream twice

To test a stream, we need a function that feeds it a list of events and collects everything it emits. That's run:

run(s, events) → all outputs, in order

It is a fold over the events — exactly like replay in Module 5, except that each step also produces outputs, which you append. The accumulator is the pair (outputs so far, current stream).

Because step never mutates anything, a stream value is not "used up" by running it. The test suite checks precisely this:

test('m08 running the same stream value twice gives the same outputs', () => {
  const s = scan(merge(interval(10), map(keyDowns, () => 100)), (a, x) => a + x, 0)
  const xs = inputs(time(25), down('a'), time(40))
  assert.deepStrictEqual(run(s, xs), run(s, xs))
  assert.deepStrictEqual(run(s, xs), [10, 30, 130, 160, 200])
})

Take a minute and work out by hand why the second list is [10, 30, 130, 160, 200] (you'll be able to once you've read the sections on interval, merge and scan). Then think about why the first assertion is remarkable. In most event libraries, a stream object holds internal state that changes as events flow; running it a second time would continue where it left off, or not work at all. Here, s is a value like the number 3: you can use it as often as you like.

4. Sources: where outputs come from

keyDowns emits the key of every KeyDown event and ignores everything else. keyUps does the same for KeyUp. These two remember nothing, so the "next stream" can be the very same stream. (Ask yourself: is that safe? Why can a stateless stream return itself?)

interval(ms) is the interesting one. It turns the clock into regular beats: on Time t, it emits every multiple of ms that has been crossed since the last time event — every k*ms with last < k*ms ≤ t — in ascending order. It starts with last = 0 and afterwards last becomes t. The values it emits are those multiples themselves.

Why "every multiple crossed" rather than "one beat per time event"? Because the outside world does not call us on a perfect schedule. The browser reports time about 60 times per second, but if the tab was in the background for two seconds, the next time event might jump from t = 1000 to t = 3000. The game should then catch up on all the ticks it missed, not silently skip them. (This idea, separating game time from the rate at which the outside world calls you, is the classic "fixed timestep" pattern; see Game Programming Patterns: Game Loop.)

Here is how last threads through a sequence of events for interval(10):

event        last before   multiples k*10 in (last, t]    outputs     last after
──────────   ───────────   ─────────────────────────────  ─────────   ──────────
Time 5            0         (0, 5]   → none                 []             5
Time 10           5         (5, 10]  → 10                   [10]          10
KeyDown 'a'      10         (ignored)                       []            10
Time 35          10         (10, 35] → 20, 30               [20, 30]      35
Time 38          35         (35, 38] → none                 []            38
Time 41          38         (38, 41] → 40                   [40]          41

And as streams: each row hands its "last after" to the stream for the next row.

interval(10) = from(10, last=0) ──Time 5──▶ from(10, 5) ──Time 10──▶ from(10, 10) ──KeyDown──▶ from(10, 10) ──Time 35──▶ from(10, 35) ...
                                   []                     [10]                       []                         [20, 30]

The stub docs tell you how: "write a private helper that carries last". interval(ms) itself is just that helper started at last = 0. Before you code it, answer: what are the outputs of run(interval(10), [Time 35])? (Answer: [10, 20, 30]; one big jump catches up on everything.)

From everyday programming: in a loop you would keep last in a variable declared outside the loop body, let last = 0; for (const ev of events) { …; last = ev.t }, so that each iteration sees what the previous one left behind. The helper does the same job without the variable: it takes last as a parameter, and each step builds the next stream by calling the helper again with the new value. The accumulator is carried from step to step between events, inside the next stream.

5. Combinators: building streams from streams

Each combinator takes a stream (or two) and gives a new stream. They all follow the same pattern, and once you see it, you'll be able to write any of them:

On each event: step the inner stream(s); transform the outputs; and return, as the next stream, the same combinator applied to the inner stream(s)' next stream(s).

That last part is what people get wrong at first. If map(s, f) returned s.next as its next stream, the mapping would be forgotten after one event. The next stream must again be "mapped", so it's map(next, f).

Here is what each one does. Pay attention to the order of outputs; the tests do.

  • map(s, f): apply f to every output.
  • filter(s, p): keep the outputs for which p is true.
  • filterMap(s, f): apply f, which returns either a value or "nothing", and keep only the values. In JS "nothing" is null; in Scala f returns an Option (Some(b) / None); in F# it's an option, and the function is called choose like List.choose. It's the combination of map and filter you'll need for keyToAction in Module 9 (most keys mean nothing).
  • merge(a, b): both streams step on every event; the outputs of a come before the outputs of b. Both next streams are kept.
  • scan(s, f, z): fold the outputs into an accumulator starting at z, and emit each new accumulator (not z itself). If one event causes three outputs, scan emits three accumulators. The latest accumulator must be remembered for the next event — where? (Same answer as for counter and interval.)
  • startWith(s, v): emit v once, before the outputs of the first step; afterwards behave exactly like the inner stream.

A small worked example to check your understanding of merge and ordering:

merge(interval(10), map(keyDowns, () => -1))     events: Time 10, KeyDown 'a', Time 20
  Time 10      → interval: [10]   keys: []    → [10]
  KeyDown 'a'  → interval: []     keys: [-1]  → [-1]
  Time 20      → interval: [20]   keys: []    → [20]
run → [10, -1, 20]

And scan, accumulating +:

scan(keyDowns, (acc, k) => acc + k, '')     events: KeyDown 'a', KeyDown 'b'
  KeyDown 'a'  → outputs ['a'] → acc '' + 'a' = 'a'    → emits ['a'];   next remembers 'a'
  KeyDown 'b'  → outputs ['b'] → acc 'a' + 'b' = 'ab'  → emits ['ab'];  next remembers 'ab'
run → ['a', 'ab']

From everyday programming: map and filter are the array methods of the same names — or, in SQL terms, the expression list of a SELECT and the WHERE clause — applied to a list that hasn't happened yet: the outputs are transformed or dropped as each event arrives, instead of all at once. scan is a reduce that reports every intermediate result instead of only the last one, like the running-balance column of a bank statement, or a spreadsheet column where each cell is "the cell above plus the amount on this row". [3, 4, 5].reduce((a, x) => a + x, 0) gives 12; scanning the same list gives [3, 7, 12].

Language note. In the Scala track these are methods: s.map(f), s.merge(other), and s.scan(z)(f) with the initial value in its own parameter list, like foldLeft. In F# they are data-last module functions: Stream.map f s, Stream.scan f z s, Stream.startWith v s, Stream.run events s.

6. startWith and the "nothing happens until an event arrives" rule

startWith has one subtle property worth understanding now, because Module 9 depends on it. A stream can only emit when an event arrives; there is no "at time zero, before anything" moment. So startWith(s, v) emits v at the first event, whatever it is, together with (before) that event's own outputs:

run(startWith(keyDowns, 'init'), [Time 1, KeyDown 'a'])   → ['init', 'a']   (emitted at Time 1)
run(startWith(keyDowns, 'init'), [])                       → []             (no event, no output)

In the game, startWith will put the initial game state at the front of the stream of states, so the very first clock event draws the board before anything has moved.

7. Two ways to think about streams: pull and push

There are two classic implementation strategies for event streams, and it's worth knowing which one we chose and why.

Push (observables, RxJS). A source pushes values to subscribers by calling their callbacks. fromEvent(window, 'keydown').pipe(map(...), filter(...)).subscribe(cb) builds a chain of objects, each of which holds a list of subscribers and its own internal state (a scan operator holds its accumulator in a field). Subscribing starts the machinery; you get a Subscription to stop it. It's powerful and practical — this is how RxJS works — but the stream objects are stateful: you can't run "the same" stream twice and get a fresh run without care ("hot" vs "cold" observables), and testing needs special schedulers that fake time.

Pure automata (our design). A stream is a value with a step function. Nobody subscribes; the caller pushes one event in and gets outputs back, plus the next stream. All the state is in the returned values. The price is that someone must hold "the current stream" between events and call step — that's the one mutable variable you'll write in the runtime in Module 9. The reward is that everything else is pure, and testing is run(s, listOfEvents).

RxJS observable Our Stream
What is a stream? an object with subscribers and internal state a pure function Event → (outputs, next)
Where is a scan's accumulator? a field inside the operator, mutated captured in the next stream value
Run it twice depends (hot/cold); usually not "fresh" always the same result
Test it marble tests with a virtual scheduler run(s, events) and deepEqual
Who drives it? the source, via callbacks the runtime, one step at a time

8. Where this comes from: functional reactive programming

The idea of describing interactive programs as pure values that change over time is called functional reactive programming (FRP). It was introduced by Conal Elliott and Paul Hudak in "Functional Reactive Animation" (ICFP 1997), which modelled animations as behaviours (values that vary continuously with time, Time → a) and events (streams of timestamped occurrences). Our streams are a discrete, simplified version of FRP events: we only see time at the moments the outside world reports it.

Later work in Haskell, such as Yampa (used for games, including a Haskell Quake clone), represents reactive programs as signal functions, which are exactly automata stepped by the runtime — very close to what you're building here. Elm started as an FRP language and later moved to the simpler model of subscriptions that deliver messages to update (Elm guide: Time) — which, as you'll see in Module 9, is what our stream of actions folded with scan amounts to.

Exercises

About test names. The test titles quoted under "Proved by" are the JavaScript ones. The Scala and F# suites test the same things one-to-one, but their titles use each track's vocabulary: "events" instead of "inputs", None instead of null, () instead of undefined, bind/choose instead of flatMap/filterMap, IO.map throwingF io instead of map(io, throwingF), and so on. Failing tests are reported by name when you run the module's tests; to read all titles, open scala/test/M08Stream.test.scala or fsharp/tests/M08Stream.fs. Fixture-based tests have the same case names in every track.

Do them in this order; later ones use earlier ones (filterMap can be written using map and filter; the tests for everything use run, so write run early).

Exercise 8.1 — run

JavaScript

export const run = (s, inputs) => /* A[]: every output, in order */

Scala

def run(events: List[Event]): List[A] = ???   // a method on Stream[A]: s.run(events)

F#

let step (e: Event) (s: Stream<'a>) : 'a list * Stream<'a> = failwith "TODO m08"   // F# only, see below
let run (events: Event list) (s: Stream<'a>) : 'a list = failwith "TODO m08"

F# only: in JS and Scala you step a stream with s.step(e). In F# the function is wrapped in the single-case union Stream, so the F# file has a small helper Stream.step e s that unwraps it and calls the function (one line with a pattern match, let (Stream f) = s). Write it first and use it everywhere else. It has its own test: step feeds one event and returns its outputs and the next stream.

A pure fold over the events (no let reassignments, no push). Proved by: run of no inputs is empty (Scala/F#: run of no events is empty), and every other test.

Exercise 8.2 — sources: keyDowns, keyUps, interval

JavaScript

export const keyDowns = /* Stream<string> */
export const keyUps = /* Stream<string> */
export const interval = (ms) => /* Stream<number> */

Scala (in object Stream)

lazy val keyDowns: Stream[String] = ???
lazy val keyUps: Stream[String] = ???
def interval(ms: Long): Stream[Long] = ???

F#

let keyDowns : Stream<string> = (* ... *)
let keyUps : Stream<string> = (* ... *)
let interval (ms: int64) : Stream<int64> = failwith "TODO m08"

Proved by: keyDowns emits keys of KeyDown inputs only, keyUps emits keys of KeyUp inputs only, interval emits each multiple once as time passes, interval emits several multiples after one big time jump, interval ignores key inputs.

Watch out for: a stream that refers to itself (keyDowns's next stream is keyDowns). In JS a const object can mention itself inside a function body, because the body only runs later. In Scala that's why these are lazy vals. In F# a value can't refer to itself directly; write a small recursive function (let rec) that builds the stream and apply it. For interval, the private helper takes ms and last. Counting the multiples: the first multiple strictly after last is (floor(last / ms) + 1) * ms, and the last one not after t is floor(t / ms) * ms.

Exercise 8.3 — map, filter, filterMap

JavaScript

export const map = (s, f) => /* Stream<B> */
export const filter = (s, p) => /* Stream<A> */
export const filterMap = (s, f) => /* Stream<B>; f returns a value or null */

Scala (methods)

def map[B](f: A => B): Stream[B] = ???
def filter(p: A => Boolean): Stream[A] = ???
def filterMap[B](f: A => Option[B]): Stream[B] = ???

F# (choose is F#'s name for filterMap)

let rec map (f: 'a -> 'b) (s: Stream<'a>) : Stream<'b> = failwith "TODO m08"
let rec filter (p: 'a -> bool) (s: Stream<'a>) : Stream<'a> = failwith "TODO m08"
let choose (f: 'a -> 'b option) (s: Stream<'a>) : Stream<'b> = failwith "TODO m08"

Proved by: map transforms every output, filter keeps outputs satisfying the predicate, filterMap transforms outputs and drops nulls (Scala/F#: ... drops Nones).

Exercise 8.4 — merge, scan, startWith

JavaScript

export const merge = (a, b) => /* Stream<A> */
export const scan = (s, f, z) => /* Stream<B>; f(acc, output) */
export const startWith = (s, v) => /* Stream<A> */

Scala (methods)

def merge(other: Stream[A]): Stream[A] = ???
def scan[B](z: B)(f: (B, A) => B): Stream[B] = ???
def startWith(v: A): Stream[A] = ???

F#

let rec merge (a: Stream<'a>) (b: Stream<'a>) : Stream<'a> = failwith "TODO m08"
let rec scan (f: 'b -> 'a -> 'b) (z: 'b) (s: Stream<'a>) : Stream<'b> = failwith "TODO m08"
let startWith (v: 'a) (s: Stream<'a>) : Stream<'a> = failwith "TODO m08"

Proved by: merge puts outputs of the first stream before the second at the same input, merge interleaves sources by input order, scan emits each new accumulator, not the initial value, scan passes the output to the folding function, startWith emits the value once, before the outputs of the first step, startWith emits at the first input even if that step has no outputs, startWith emits nothing when no input ever arrives, and running the same stream value twice gives the same outputs.

Watch out for: in scan, one event can produce several outputs, so you fold those outputs too, emitting every intermediate accumulator, and the next stream remembers the last accumulator (or the old one if there were no outputs). In startWith, only the first step is special — what should the next stream be so that v is never emitted again?

Run (all of Module 8):

  • JS: cd js && node --test "test/m08-*.test.js"
  • Scala: cd scala && ./test.sh M08
  • F#: cd fsharp && ./test.sh M08

Hints

Exercise 8.1 — nudge

run is replay from Module 5 with outputs: fold over the events with an accumulator that holds two things, the outputs so far and the current stream. At the end, return only the outputs.

Exercise 8.1 — near-solution (try the others first)
export const run = (s, inputs) =>
  inputs.reduce(
    ([outs, stream], i) => {
      const [out, next] = /* ? */
      return [/* ? */, next]
    },
    [[], s],
  )[0]

Scala: events.foldLeft((List.empty[A], this)) { case ((outs, s), e) => ... }._1. F#: List.fold with a tuple accumulator, then fst.

Exercise 8.2 — nudge

keyDowns looks at the event's tag: one output for KeyDown, none otherwise; next stream: itself. For interval, write from(ms, last) returning a stream; on Time t it emits the multiples in (last, t] and returns from(ms, t); on keys it emits nothing and returns a stream that still remembers the same last.

Exercise 8.2 — stronger hint

To list the multiples without a loop: compute first = floor(last / ms) + 1 and count = max(0, floor(t / ms) - first + 1); the outputs are (first + n) * ms for n from 0 to count - 1. In JS Array.from({ length: count }, (_, n) => ...); in Scala (first to floor(t/ms)).map(_ * ms).toList (Long arithmetic); in F# a list expression [ first .. t / ms ] |> List.map (fun k -> k * ms) (integer division on int64 already floors for non-negative numbers).

Exercise 8.3 / 8.4 — nudge

All combinators have the same shape: step the inner stream(s), compute the outputs, and return the same combinator around the inner next stream(s). Write map first and copy its shape. filterMap can be built from map and filter without writing a step at all.

Exercise 8.4 — stronger hint (scan and startWith)

For scan: from the inner outputs [o1, o2, o3] and accumulator z, you need [f(z,o1), f(f(z,o1),o2), …] — that's a running total, which you can write as a reduce whose accumulator is the list of accumulators so far (or, in the Scala track, as scanLeft without its first element). The next stream is scan(next, f, lastAcc) where lastAcc is the last emitted accumulator, or z if nothing was emitted.

For startWith: on the first step, step the inner stream, put v in front of its outputs, and return the inner next stream unchanged, without wrapping it again.

Exercise 8.3 / 8.4 — near-solution (try the others first)

JavaScript

export const map = (s, f) => ({
  step: (i) => {
    const [out, next] = s.step(i)
    return [/* ? */, map(/* ? */, f)]
  },
})

export const merge = (a, b) => ({
  step: (i) => {
    const [outA, nextA] = a.step(i)
    const [outB, nextB] = /* ? */
    return [/* ? */, /* ? */]
  },
})

Scala

def map[B](f: A => B): Stream[B] = Stream { e =>
  val (out, next) = step(e)
  (/* ? */, next.map(f))
}

F#

let rec map (f: 'a -> 'b) (Stream step) : Stream<'b> =
    Stream (fun e ->
        let out, next = step e
        ((* ? *), map f next))

Check your understanding

  1. Where does interval keep last? Where does scan keep its accumulator? Neither uses a variable; answer precisely.
  2. Predict run(interval(100), [Time 50, Time 250, KeyDown 'x', Time 300, Time 300]). Why does the second Time 300 emit nothing?
  3. Predict run(startWith(scan(keyDowns, (n, _) => n + 1, 0), 0), [Time 5, KeyDown 'a', KeyDown 'b']). (This exact shape, startWith(scan(...)), is how Module 9 builds the stream of game states.)
  4. Why does map(s, f) have to return map(next, f) and not just next? Write down what run would produce with the wrong version on two key presses.
  5. The test "running the same stream value twice gives the same outputs" would fail for a typical RxJS pipeline with a scan. Explain why in terms of where the accumulator lives.
  6. merge(a, b) steps both streams on every event. What would go wrong with an interval inside b if merge only stepped b on events that a ignored?
  7. The browser reports time about 60 times per second, and the snake ticks every 150 ms. Using interval, explain what happens if the browser stops calling us for 1 second (say, the tab was hidden) and then resumes. Is that what you want for this game?
  8. Our streams never ask what time it is. Why is that important for testing? Where will the time come from?

In production

  • RxJS is the most widely used stream library in JavaScript: fromEvent, interval, map, filter, merge, scan, startWith all exist with the same meaning as ours, but as push-based observables with subscriptions and internal state (RxJS, operators).
  • fs2 (Scala) builds pure, pull-based streams on top of cats-effect: a Stream[IO, A] is a description that does nothing until compiled and run, and can be run any number of times (fs2). It's the industrial version of our "a stream is a value" idea.
  • Elm subscriptions turn the clock and the keyboard into messages delivered to update (Elm guide: Time); Elmish offers the same for F# (Elmish).
  • Yampa and other arrowized-FRP libraries in Haskell represent reactive systems as signal functions stepped by a driver — the same automaton idea, used for games and robotics (Yampa).

Further reading