Header menu logo Mibo

System Pipeline

When update grows, the hardest part is maintaining a clear mental model of:

Mibo.Elmish.System is a small pipeline helper that gives you:

The idea

You run mutation-heavy phases first, then take a snapshot (often a smaller readonly view), then run readonly phases.

| Tick gt ->
    let dt = float32 gt.ElapsedGameTime.TotalSeconds

    System.start model
    |> System.pipeMutable (Physics.update dt)
    |> System.pipeMutable (Particles.update dt)
    |> System.snapshot Model.toSnapshot
    |> System.pipe (Ai.decide dt)
    |> System.finish Model.fromSnapshot

What a “system” looks like

A system is just a function that returns an updated state and a Cmd:

let physics (m: Model) : struct (Model * Cmd<Msg>) =
  // mutate-ish logic (still functional at the boundary)
  struct ({ m with ... }, Cmd.none)

Emitting commands

Sometimes a system doesn't need to change state at all—it just needs to trigger a sound, log an event, or dispatch a message. The dispatch variants allow you to run logic that only returns Cmd<'Msg>.

Because they don't return a new state, the pipeline passes the snapshot through as-is, making them perfect for "fire-and-forget" side-effects and autonomous subsystems.

Simple dispatch

Use dispatch for quick checks against the snapshot that only produce messages.

|> System.snapshot Model.toSnapshot
|> System.dispatch (fun snap ->
    if snap.Health <= 0f then Cmd.ofMsg PlayerDied else Cmd.none)

Selective dispatch

Use dispatchWith for autonomous subsystems that track their own internal state (e.g. via closures or external services).

The selector bridges the parent snapshot to the subsystem's input, keeping the internal logic decoupled from your main model structure.

// Autonomous subsystem with its own state
let healthTracker =
    let mutable hp = 100f
    fun input snap ->
        input |> ValueOption.iter (fun amt -> hp <- hp - amt)
        if hp <= 0f then Cmd.ofMsg PlayerDied else Cmd.none

// Usage in pipeline
|> System.dispatchWith
    (fun snap -> if snap.PlayerWasHit then ValueSome 10f else ValueNone)
    healthTracker

Why the snapshot boundary matters

The key is the type change:

That means you can’t accidentally call a “mutable phase” after you’ve committed to readonly.

When to use this (and when not)

Use it when:

Skip it when:

See also: Scaling Mibo (Simple → Complex) (how this fits into the ladder).

Multiple items
val float32: value: 'T -> float32 (requires member op_Explicit)

--------------------
type float32 = System.Single

--------------------
type float32<'Measure> = float32
namespace System
Multiple items
module ValueOption from Microsoft.FSharp.Core

--------------------
[<Struct>] type ValueOption<'T> = | ValueNone | ValueSome of 'T static member Some: value: 'T -> 'T voption static member op_Implicit: value: 'T -> 'T voption member IsNone: bool with get member IsSome: bool with get member Value: 'T with get static member None: 'T voption with get
val iter: action: ('T -> unit) -> voption: 'T voption -> unit
union case ValueOption.ValueSome: 'T -> ValueOption<'T>
union case ValueOption.ValueNone: ValueOption<'T>

Type something to start searching.