Files
functional-programming-snake/course/09-wiring-it-together.md
T

31 KiB
Raw Blame History

Module 9 — Wiring it together

Time: ~4 h · You'll edit: JS js/src/input.js, js/src/app.js, js/src/runtime.js, js/src/browser.js · Scala scala/src/Input.scala, scala/src/App.scala, scala/src/Runtime.scala · F# fsharp/src/Input.fs, fsharp/src/App.fs, fsharp/src/Runtime.fs Tests: JS cd js && node --test "test/m09-*.test.js" · Scala cd scala && ./test.sh M09 · F# cd fsharp && ./test.sh M09 Prerequisites: Modules 0–8. You need working update (m05), newGame (m04), view (m06), IO (m07) and streams (m08).

Why this module

You now have every piece of a game, and none of them does anything:

  • update(state, action) says how the game changes (Module 5);
  • view(state) describes a picture (Module 6);
  • IO describes effects such as drawing and asking for a seed (Module 7);
  • Stream describes how to react to time and keys (Module 8).

In this module you connect them into one value that is the whole program, and then write the one small function that brings that value to life in a browser. At the end of the module, Snake is playable.

The connection has a striking shape. Try to read this type out loud:

main : IO<Stream<IO<unit>>>

"main is an effect (ask for a random seed) that produces a stream (react to time and keys) of effects (draw this frame)." Or, in one sentence that sums up the course so far:

A program is an effect that produces a stream of effects.

Everything in that sentence is a value you can test with trace and run. The only thing that is not a value is the runtime that performs it, and you'll write it yourself and see how small it is: one loop, three callbacks and one mutable variable.

Learning objectives

By the end of this module you can:

  • implement keyToAction, the action stream, the state stream (startWith + scan), render and main from the pieces of Modules 4–8;
  • explain the type IO<Stream<IO<unit>>> layer by layer, and why main is a value;
  • implement an interpreter runIO as a loop and a driver runApp that holds the only mutable variable of the program;
  • explain how a Platform interface lets the runtime be tested with a fake instead of a browser;
  • run the game in a browser in your track, and trace one key press from the keyboard to the pixels.

Concepts

1. From keys to actions

The browser tells us which key went down as a string (KeyboardEvent.key: 'ArrowUp', 'w', 'W', 'x' …). The game speaks in Actions. The translation is a pure function:

  • 'ArrowUp', 'w', 'W' → Turn Up; 'ArrowDown', 's', 'S' → Turn Down; 'ArrowLeft', 'a', 'A' → Turn Left; 'ArrowRight', 'd', 'D' → Turn Right;
  • any other key → nothing (null in JS, None in Scala/F#).

It returns "maybe an action" because most keys mean nothing to the game. That's exactly the shape filterMap (F#: choose) from Module 8 wants.

A small JS trap is tested on purpose: if you implement the table as an object lookup, table['constructor'] and table['toString'] are not undefined — every JS object inherits those from Object.prototype. The fixture cases constructor-is-not-a-key and toString-is-not-a-key catch it. A switch, a Map, or an object created with Object.create(null) avoids the problem. (Scala and F# pattern matches don't have this issue.)

2. From events to actions: one stream of everything that can happen

What can happen to the game? A tick every 150 ms, and the actions of recognised key presses. As streams:

ticks   = map(interval(TICK_MS), _ => Tick)            Stream<Action>
turns   = filterMap(keyDowns, keyToAction)              Stream<Action>
classicActions = merge(ticks, turns)                    Stream<Action>

Read it as a diagram:

 events ──┬──▶ interval(150) ──map(→Tick)──────────────┐
          │                                            ├──merge──▶ classicActions : Stream<Action>
          └──▶ keyDowns ──filterMap(keyToAction)───────┘

Each Time event goes to both branches (that's what merge does); interval decides whether a tick happened, keyDowns ignores it. Each KeyDown also goes to both; interval ignores it, keyToAction decides whether it means something.

Check that you can predict the test:

run(classicActions, [time(150), down('ArrowUp'), down('x'), time(300)])
  // → [Tick, Turn('Up'), Tick]     'x' produced nothing

3. From actions to states: a running fold

Module 5's insight was that a game is a fold over actions: replay(newGame(seed), actions). A stream of actions folds the same way, except we want every intermediate state, not only the last one, because we must draw each of them. That's scan:

scan(actions, update, newGame(seed))      Stream<GameState>: one new state per action

There's one more thing: scan emits only new accumulators, never the initial one. If we stopped here, nothing would be drawn until the first action — 150 ms of blank screen, and a board that's never shown in its starting position. So we put the initial state in front with startWith:

states(seed, actions) = startWith(scan(actions, update, newGame(seed)), newGame(seed))

From everyday programming: in the classic game loop, while (running) { input(); update(); render(); }, the game state is one variable that each iteration overwrites, and the sequence of states exists only as the history of that variable. startWith(scan(…), s0) gives you that history as a value: s0, s1, s2, …, one state per action, the way a bank statement lists the opening balance followed by the balance after each transaction. scan alone is the list of balances after each transaction; startWith adds the opening balance in front.

You may have seen this exact shape elsewhere: Redux reducers are (state, action) → state folded over a stream of dispatched actions, and Elm's update : Msg -> Model -> Model is the same fold run by the Elm runtime. (The argument order differs per library; the idea doesn't.)

4. From states to drawings, and main

To draw a state: describe the picture (view, Module 6) and turn the description into a drawing program (drawScene, Module 7):

render(state) = drawScene(view(state))           IO<unit>

Mapping render over the states gives a stream of drawing programs, Stream<IO<unit>>. The only thing still missing is the seed, which must come from the outside world — that's an IO (randomSeed). Put together:

main = map(randomSeed, seed => map(states(seed, classicActions), render))

Here the two maps are different functions: the outer one is IO's map, the inner one is Stream's map. (In JS they come from two modules, so you'll import one of them under another name, e.g. import { map as mapIO } from './io.js'. In Scala they're methods, so the types pick the right one. In F# they're IO.map and Stream.map.) Let's read the type from the outside in:

IO<                    an effect: ask the world for a seed, and then produce…
   Stream<             …a machine that, for every event…
          IO<unit>     …produces the effects that draw a frame.
   >>

Why must the outer layer be an IO? Because the stream of states depends on the seed, and the seed isn't known until the outside world is asked. Why is the inner layer an IO and not a Scene? Because the runtime should only know how to perform IOs; turning scenes into effects is the program's business, not the runtime's. (In Module 10 you could make frames that also play sounds, without touching the runtime.)

And main is a value. Defining it performs nothing — no seed is asked for, no listener is registered. Look at the test:

const { ops, result: stream } = trace(main, 42)
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])   // main asks for a seed, nothing else
const [outs] = stream.step(time(0))                      // the first event…
assert.equal(outs.length, 1)                             // …produces exactly one drawing: the new game

The whole program, with its randomness, time and keyboard, is tested with the pure interpreter from Module 7 and step from Module 8. No browser.

About classicActions and Module 10. In Module 10, main will switch from classicActions to speedActions. The m09 tests that check the 150 ms behaviour therefore use a copy of main built from classicActions (classicMain in the test helpers), so they keep passing after Module 10.

5. The edge: a Platform and a runtime

Now somebody has to do it. Let's list everything the outside world must provide:

  • draw a rectangle; draw a text;
  • give a random seed;
  • call us back with the time, again and again;
  • call us back when a key goes down; when a key goes up.

That list is an interface, which the course calls a Platform:

JavaScript

// Platform = {
//   fillRect(x, y, w, h, color),
//   fillText(x, y, text, color, size, align),
//   randomSeed() → number (uint32),
//   startClock(onTime: (t) => void),
//   onKeyDown(cb: (key) => void),
//   onKeyUp(cb: (key) => void),
// }

Scala (scala/src/Runtime.scala)

trait Platform:
  def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit
  def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit
  def randomSeed(): Long
  def startClock(onTime: Long => Unit): Unit
  def onKeyDown(cb: String => Unit): Unit
  def onKeyUp(cb: String => Unit): Unit

F# (fsharp/src/Runtime.fs) — a record of functions

type Platform =
    { FillRect: int -> int -> int -> int -> string -> unit
      FillText: int -> int -> string -> string -> int -> string -> unit
      RandomSeed: unit -> uint32
      StartClock: (int64 -> unit) -> unit
      OnKeyDown: (string -> unit) -> unit
      OnKeyUp: (string -> unit) -> unit }

The runtime only ever talks to a Platform. The browser implementation (browser.js in JS, web/BrowserPlatform.scala, fsharp/web/BrowserPlatform.fs) wraps a canvas, requestAnimationFrame and crypto.getRandomValues. The tests use a fake platform that records every call in a list and lets the test fire events by hand — a test double in the sense of Martin Fowler's "Test Double":

const fakePlatform = (seed = 42) => {
  const calls = []; const handlers = {}
  return {
    calls,
    fillRect: (...args) => calls.push(['fillRect', ...args]),
    fillText: (...args) => calls.push(['fillText', ...args]),
    randomSeed: () => { calls.push(['randomSeed']); return seed },
    startClock: (cb) => { handlers.time = cb },
    onKeyDown: (cb) => { handlers.down = cb },
    onKeyUp: (cb) => { handlers.up = cb },
    fireTime: (t) => handlers.time(t),
    fireKey: (k) => handlers.down(k),
    fireKeyUp: (k) => handlers.up(k),
  }
}

(Yes, the fake uses push and mutation; it's test code, which is allowed to be impure. It plays the role of the outside world.)

6. runIO: the real interpreter

runIO(io, platform) is trace from Module 7 with real answers:

  • Draw of a Rect → platform.fillRect(x, y, w, h, color); of a Text → platform.fillText(x, y, text, color, size, align); the answer is unit (Done);
  • RandomSeed → platform.randomSeed(); the answer is that number (Seed(n));
  • pass the answer to k, and continue with the program it returns, until you reach Pure(value); return value.

One requirement is new: it must be a loop, not recursion. A single frame is a few hundred draws, a game runs for minutes, and the test runIO handles 100000 effects without overflowing the stack makes sure. In JS that means a while loop over a local "current program" variable (allowed here, and only here). In Scala, write a @annotation.tailrec recursive function: the compiler turns it into a loop, and you keep your one var for runApp. In F#, either a loop or a tail-recursive function works: F# compiles a self-recursive call in tail position into a loop, so let rec runIO p io = match io with ... | Effect (op, k) -> runIO p (k ...) doesn't grow the stack.

Remember section 12 of Module 7: the loop only helps if each continuation does a small, constant amount of work. If your sequence is left-nested, the 100 000-effect test overflows inside k, and the fix is in io, not in the runtime.

7. runApp: one mutable variable

runApp(main, platform) brings the program to life:

  1. Interpret main with runIO. This asks the platform for a seed and gives you the stream (Stream<IO<unit>>).
  2. Keep the current stream in a mutable variable. This is the only mutable variable in the whole program that outlives a function call. In F# it is a ref cell (let stream = ref …, read with stream.Value, written with stream.Value <- next), because F# closures can't capture a let mutable, and the callbacks are closures. (The loop in runIO in JS reassigns a local "current program" variable, but that variable exists only during one call and nobody else can see it; it is a loop counter, not state. The stream variable is the program's entire memory between events.)
  3. Register three callbacks on the platform — key down, key up, clock — each of which turns what it receives into an event (KeyDown key, KeyUp key, Time t) and feeds it:
    • step the current stream with the event → (ios, next);
    • store next in the variable;
    • run each IO in ios with runIO, in order.

That's the whole runtime. Go back to the stateful code at the start of Module 8, with its let state and logic in callbacks, and compare. There, the mutable variable held the game state, and game logic lived in the callbacks. Here, the variable holds the current stream — the runtime doesn't even know there's a game. It has no idea what a snake, a tick or a score is. All the game logic is in pure values; the runtime is a few lines of plumbing. That is what "pushing effects to the edge" means in practice.

                 ┌────────────────────────────── runtime (impure, ~20 lines) ─────────────────────────────────┐
 browser ──key──▶│ feed(KeyDown key):  [ios, next] = stream.step(ev); stream = next; ios.forEach(runIO)       │
         ──time─▶│ feed(Time t)                                                                               │
                 └────────────────────────────────────────────────────────────────────────────────────────────┘
                 ▲ the one `let` / `var` / ref cell: stream

The tests describe the behaviour precisely; read them before writing the code:

  • runApp asks the platform for a seed once, at start, and draws nothing before the first input — after runApp, the only call is ['randomSeed']. Nothing is drawn until an event arrives (remember startWith).
  • runApp draws the initial scene on the first clock tick, then the ticked scene after 150 ms — a single fireTime(150) produces two drawings: the startWith state and the state after one Tick. Can you explain why one event produces two frames?
  • runApp feeds key presses to the game, and runApp ignores key releases and unmapped keys (no redraw).

8. Who is impure, exactly?

Let's take inventory. In JS, three files are allowed to be impure and are excluded from the purity audit: runtime.js (runIO, runApp), browser.js (the canvas Platform) and main.js (one line: runApp(main, browserPlatform(document.getElementById('game'), window))). In Scala it's Runtime.scala plus the browser glue in scala/web/; in F#, Runtime.fs plus fsharp/web/ (whose SnakeMain.fs is the one line Runtime.runApp (BrowserPlatform.create (BrowserPlatform.gameCanvas ())) (App.main ()) — note main (): in F#, main is a function of unit, like IO.randomSeed (), for the reason explained in Module 7). Everything else — rules, randomness, view, IO, streams, main — is pure and passes the audit.

Count the lines of your runtime when you're done. It's probably around twenty. That's the whole imperative shell around a functional core.

9. Running the game

JavaScript. You also write browser.js (see Exercise 9.4). Then, from the repository root:

node scripts/serve.mjs

and open http://localhost:8000/js/. (A static server is needed because browsers refuse to load ES modules from file:// URLs.)

Scala. The browser glue is given (scala/web/BrowserPlatform.scala, scala/web/Main.scala); read it — it's short. Compile your code to JavaScript with Scala.js:

cd scala && ./build-web.sh

The first run downloads the Scala.js linker and takes a while. It writes scala/web/dist/snake.js. Then serve the repository root (node scripts/serve.mjs) and open http://localhost:8000/scala/.

F#. The browser glue is given in fsharp/web/. Compile with Fable:

cd fsharp && ./build-web.sh

The first run restores Fable (a local .NET tool listed in fsharp/.config/dotnet-tools.json) and takes a while. It writes fsharp/web/dist/. Then serve the repository root (node scripts/serve.mjs, run from the repository root) and open http://localhost:8000/fsharp/.

Use the arrow keys or WASD. You built a game in which every line of logic is pure. Play a round. Then, before moving on, do the trace exercise in "Check your understanding": follow one key press from the keyboard to the pixels, naming each function it passes through.

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/M09*.test.scala or fsharp/tests/M09*.fs. Fixture-based tests have the same case names in every track.

Exercise 9.1 — keyToAction

JavaScript (js/src/input.js)

export const keyToAction = (key) => /* Action or null */

Scala (scala/src/Input.scala)

object Input:
  def keyToAction(key: String): Option[Action] = ???

F# (fsharp/src/Input.fs)

// module Snake.Input
let keyToAction (key: string) : Action option = failwith "TODO m09"

Proved by: fixtures/m09-input.json (ArrowUp-turns-up … D-turns-right, x-is-ignored, Enter-is-ignored, empty-string-is-ignored, constructor-is-not-a-key, toString-is-not-a-key).

Exercise 9.2 — the app: classicActions, states, render, main

JavaScript (js/src/app.js)

export const TICK_MS = 150
export const classicActions = /* Stream<Action> */
export const states = (seed, actions) => /* Stream<GameState> */
export const render = (state) => /* IO<undefined> */
export const main = /* IO<Stream<IO<undefined>>> */

Scala (scala/src/App.scala)

object App:
  val TICK_MS: Long = 150
  lazy val classicActions: Stream[Action] = ???
  def states(seed: Long, actions: Stream[Action]): Stream[GameState] = ???
  def render(state: GameState): IO[Unit] = ???
  lazy val main: IO[Stream[IO[Unit]]] = ???

F# (fsharp/src/App.fs)

// module Snake.App
let TICK_MS = 150L
let classicActions : Stream<Action> = (* ... *)
let states (seed: uint32) (actions: Stream<Action>) : Stream<GameState> = failwith "TODO m09"
let render (state: GameState) : IO<unit> = failwith "TODO m09"
let main () : IO<Stream<IO<unit>>> = failwith "TODO m09"

Proved by: the game ticks every 150 ms, classicActions turns Tick into actions on the clock and keys into Turn actions, states starts with the new game, then yields one state per action, render draws the whole view of the state, main asks for a random seed, then builds the stream from it, the program built from classicActions draws the new game first, then the ticked game after 150 ms, main draws again once time has passed.

Watch out for: the JS stubs of classicActions and main are placeholder objects so that the file loads; replace them with real values. In JS, import the two maps under different names. Each of these definitions is one line; if yours is longer, re-read sections 2–4.

Exercise 9.3 — the runtime: runIO and runApp

JavaScript (js/src/runtime.js — let and loops allowed here)

export const runIO = (io, platform) => /* the final value */
export const runApp = (main, platform) => /* undefined; registers callbacks */

Scala (scala/src/Runtime.scala — exactly one var)

object Runtime:
  def runIO[A](io: IO[A], p: Platform): A = ???
  def runApp(main: IO[Stream[IO[Unit]]], p: Platform): Unit = ???

F# (fsharp/src/Runtime.fs — one ref cell allowed here)

// module Snake.Runtime
let runIO (p: Platform) (io: IO<'a>) : 'a = failwith "TODO m09"
let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit = failwith "TODO m09"

Proved by (m09-runtime): runIO of a pure value returns it without touching the platform, runIO maps Draw Rect to fillRect(x, y, w, h, color), runIO maps Draw Text to fillText(x, y, text, color, size, align), runIO answers RandomSeed with platform.randomSeed() and continues with it, runIO performs effects in order, runIO handles 100000 effects without overflowing the stack, and the four runApp … tests from section 7.

Watch out for: registering the callbacks before the stream exists, or reading the variable in a callback without updating it. Each callback must step the current stream and store the next one. In Scala, runIO with an Op.Draw must match on the shape inside (Shape.Rect / Shape.Text).

Exercise 9.4 (JS only) — browserPlatform

JavaScript (js/src/browser.js)

export const browserPlatform = (canvas, win) => /* Platform */

Build a Platform from a canvas and a window: fillRect/fillText use canvas.getContext('2d') (font `${size}px monospace`, textAlign from align, fillStyle from color); startClock calls onTime(t) on every win.requestAnimationFrame, with t = milliseconds since the first frame; onKeyDown/onKeyUp listen on win and call preventDefault() for arrow keys and space so the page doesn't scroll; randomSeed returns crypto.getRandomValues(new Uint32Array(1))[0]. There is no automated test for this file; the test is that the game runs. Scala and F# students: read the given BrowserPlatform in your track and compare it with this description.

Run:

  • JS: cd js && node --test "test/m09-*.test.js", then node scripts/serve.mjs (from the repo root) and open http://localhost:8000/js/
  • Scala: cd scala && ./test.sh M09, then ./build-web.sh; from the repository root run node scripts/serve.mjs and open http://localhost:8000/scala/
  • F#: cd fsharp && ./test.sh M09, then ./build-web.sh; from the repository root run node scripts/serve.mjs and open http://localhost:8000/fsharp/

Hints

Exercise 9.1 — nudge

A switch (JS) or match (Scala/F#) with several keys per case is the most readable. In JS, case 'ArrowUp': case 'w': case 'W': return Action.Turn('Up'), and default: return null. Why is a switch safe from the constructor trap and a plain object lookup isn't?

Exercise 9.2 — nudge

Each definition is a direct translation of a line in sections 2–4. Write them in the order classicActions, states, render, main, and run the tests after each one.

Exercise 9.2 — near-solution (try the others first)

JavaScript

import { map as mapIO, randomSeed, drawScene } from './io.js'
import { interval, keyDowns, map as mapS, filterMap, merge, scan, startWith } from './stream.js'

export const classicActions = merge(mapS(interval(TICK_MS), () => /* ? */), filterMap(/* ? */, /* ? */))
export const states = (seed, actions) => startWith(scan(actions, /* ? */, /* ? */), /* ? */)
export const render = (state) => /* ? */
export const main = mapIO(randomSeed, (seed) => /* ? */)

Scala

lazy val classicActions: Stream[Action] =
  Stream.interval(TICK_MS).map(_ => /* ? */).merge(Stream.keyDowns.filterMap(/* ? */))
def states(seed: Long, actions: Stream[Action]): Stream[GameState] =
  actions.scan(/* ? */)(Game.update).startWith(/* ? */)
lazy val main: IO[Stream[IO[Unit]]] = IO.randomSeed.map(seed => /* ? */)

F#

let classicActions = Stream.merge (Stream.interval TICK_MS |> Stream.map (fun _ -> (* ? *))) (Stream.keyDowns |> Stream.choose (* ? *))
let states seed actions = actions |> Stream.scan (* ? *) (* ? *) |> Stream.startWith (* ? *)
let main () = IO.randomSeed () |> IO.map (fun seed -> (* ? *))

(F#'s Game.update takes state then action, which is the order Stream.scan's folder wants.)

Exercise 9.3 — nudge

runIO: keep a "current program" variable. While it's an Effect, perform its op on the platform and replace the current program with k(answer). When it's Pure, return the value. Write a small helper perform(op, platform) that returns the answer.

runApp: one variable for the stream, one helper feed(event), three registrations. Which of the three registrations should come last, and does it matter?

Exercise 9.3 — near-solution (try the others first)

JavaScript

export const runIO = (io, platform) => {
  let current = io
  while (current.tag === 'Effect') current = current.k(/* perform current.op, get the answer */)
  return /* ? */
}

export const runApp = (main, platform) => {
  let stream = /* ? */
  const feed = (input) => {
    const [ios, next] = /* ? */
    stream = /* ? */
    ios.forEach(/* ? */)
  }
  platform.onKeyDown((key) => feed({ tag: 'KeyDown', key }))
  platform.onKeyUp(/* ? */)
  platform.startClock(/* ? */)
}

Scala

@annotation.tailrec
def runIO[A](io: IO[A], p: Platform): A = io match
  case IO.Pure(a)       => a
  case IO.Effect(op, k) => runIO(k(/* perform op on p, giving an OpResult */), p)

def runApp(main: IO[Stream[IO[Unit]]], p: Platform): Unit =
  var stream = /* ? */
  def feed(e: Event): Unit =
    val (ios, next) = /* ? */
    stream = next
    /* ? */
  p.onKeyDown(key => feed(Event.KeyDown(key)))
  /* ... */

F#

let rec runIO (p: Platform) (io: IO<'a>) : 'a =
    match io with
    | Pure a -> a
    | Effect (op, k) -> runIO p (k ((* perform op on p, giving an OpResult *)))   // tail call: compiled to a loop

let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
    let stream = ref ((* ? *))                 // the one ref cell
    let feed (e: Event) =
        let ios, next = (* step stream.Value with e *)
        stream.Value <- next
        (* run every io in ios, in order *)
    p.OnKeyDown (fun key -> feed (KeyDown key))
    (* ... *)

Check your understanding

  1. Read IO<Stream<IO<unit>>> aloud and explain each layer. Why can't the outer IO be removed? Why isn't the inner one a Scene?
  2. Trace one key press: the player presses ArrowUp. Name, in order, every function and value it passes through until pixels change. When does the snake actually turn on screen, and why not immediately?
  3. One call fireTime(150) right after runApp produces two full drawings. Explain exactly which outputs of which streams cause each.
  4. The runtime's only variable holds a stream, not a game state. What would be worse if it held the game state instead? Which code would have to move into the runtime?
  5. Why must runIO be a loop while trace may be recursive? Is there a program for which even the loop overflows? Whose fault would it be?
  6. What makes the runtime testable without a browser? What would you have to do to test it if it called canvas.getContext('2d') directly?
  7. Count the impure lines in your track (runtime + browser glue + main). Compare with the number of pure lines. What does that ratio tell you?
  8. main is defined at module load time. Prove to yourself that defining it performs no effect. (Hint: what does trace(main, 1).ops contain, and when does the platform's randomSeed get called?)

In production

  • The Elm Architecture is this module, packaged: you provide init, update, view and subscriptions (Elm's version of our input streams); the Elm runtime holds the model, feeds it messages and draws your view. You never write the runtime (The Elm Architecture). Elmish gives F# the same shape (Elmish).
  • Redux is the scan(actions, update, initial) part: a store holds the current state, and a pure reducer computes the next one for each dispatched action (Redux fundamentals: state, actions and reducers).
  • cats-effect IOApp and ZIO's ZIOAppDefault are the "one call at the edge": your run method returns an effect value, and the library's runtime performs it (cats-effect, ZIO). An fs2 application is literally an IO that runs a Stream of effects (fs2).
  • "Functional core, imperative shell" is Gary Bernhardt's name for this architecture: keep decisions in pure functions and push the effects to a thin shell (Functional Core, Imperative Shell). Our shell is thinner than usual: even the wiring is pure, only the interpreter isn't.

Further reading