Logo Mibo

System Pipeline

The Mibo.Elmish.System module provides a generic pipeline pattern for composing frame updates with type-enforced snapshot boundaries.

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 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 needs to trigger a sound, log an event, or dispatch a message. The dispatch variants let you run logic that only returns Cmd<'Msg>.

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

Simple dispatch

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

let checkPlayerHealth (snap: Snapshot) =
    if snap.Health <= 0f then Cmd.ofMsg PlayerDied else Cmd.none

// in the pipeline:
|> System.snapshot Model.toSnapshot
|> System.dispatch checkPlayerHealth

Selective dispatch

Use dispatchWith for autonomous subsystems that track their own internal state (a mutable counter here, or an external service), fed through a selector that extracts what they need from the snapshot:

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

// The selector bridges the parent snapshot to the subsystem's input,
// keeping the internal logic decoupled from your main model structure
let hitAmount (snap: Snapshot) =
    if snap.PlayerWasHit then ValueSome 10f else ValueNone

// in the pipeline:
|> System.dispatchWith hitAmount 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
type 'T voption = ValueOption<'T>
Multiple items
module ValueOption from Microsoft.FSharp.Core

--------------------
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 member IsSome: bool member Value: 'T static member None: 'T voption
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.