Header menu logo Mibo

Headless Mode

The headless runtime runs your Elmish update loop without graphics, input polling, or Raylib initialization. Use it for unit testing, server-side simulation, and CLI debugging.

Core Definition

Start with HeadlessProgram.mkHeadless init update — same Init and Update signatures as Program, but without renderers or window configuration.

let program =
  HeadlessProgram.mkHeadless init update
  |> HeadlessProgram.withSubscribe subscribe
  |> HeadlessProgram.withTick Tick

use runner = new HeadlessRunner<Model, Msg>(program)

Building a Program

HeadlessProgram supports the same builder DSL as Program:

Function

Description

mkHeadless init update

Create a program with init and update functions

withSubscribe

Add a subscription function

withTick

Add a per-frame tick message

withFixedStep

Enable framework-managed fixed timestep

withDispatchMode

Set Immediate or FrameBounded dispatch

withObserver

Register an observer for per-frame model snapshots

Running the Simulation

HeadlessRunner provides explicit frame control with virtual time:

use runner = new HeadlessRunner<_,_>(program)

// Advance one frame
runner.Step(TimeSpan.FromMilliseconds(16))

// Advance multiple frames
runner.StepN(10, TimeSpan.FromMilliseconds(16))

// Run until a condition is met
let met = runner.StepUntil(
    (fun model -> model.Count >= 100),
    TimeSpan.FromMilliseconds(16))

Run and RunAsync

For server scenarios and real-time simulation, Run and RunAsync pace the loop automatically and yield a (GameTime * 'Model) snapshot each tick:

// Synchronous: spin-wait with Thread.Sleep(1) for timing precision.
// Use for game servers where the loop owns the main thread.
for (time, model) in runner.Run(TimeSpan.FromMilliseconds(16)) do
    printfn "Tick: %A" model

// Asynchronous: PeriodicTimer for efficient, cancellation-friendly pacing.
// Iterate with F# 8+ `for .. in` over IAsyncEnumerable.
let cts = new CancellationTokenSource()

async {
    for (time, model) in runner.RunAsync(TimeSpan.FromMilliseconds(16), cts.Token) do
        printfn "Tick: %A" model
}
|> Async.RunSynchronously

Warning: Do not mix Run/RunAsync with Step/StepN/StepUntil on the same runner — they all advance the simulation and will corrupt state.

Dispatching Messages

Send messages into the runner from outside the update loop:

runner.Dispatch(Increment)
runner.DispatchMany [ Increment; Increment; Increment ]

This is useful for:

Accessing State

let model = runner.Model          // Current model state
let time = runner.GameTime        // GameTime struct (TotalTime + ElapsedGameTime)
let quit = runner.ShouldQuit      // Whether Quit was signaled

Time Control

Unlike RaylibGame which uses real wall-clock time, HeadlessRunner uses virtual time controlled by the caller:

// Each Step advances virtual time by the given elapsed
runner.Step(TimeSpan.FromMilliseconds(16))  // ~60fps
runner.Step(TimeSpan.FromSeconds(1))        // 1 second

// GameTime accumulates across steps
runner.Step(TimeSpan.FromMilliseconds(100))
runner.Step(TimeSpan.FromMilliseconds(200))
// runner.GameTime.TotalTime.TotalSeconds = 0.3

Fixed Step with Headless

withFixedStep works identically to the graphical runtime. The runner accumulates time and dispatches fixed-step messages at the configured rate:

let program =
  HeadlessProgram.mkHeadless init update
  |> HeadlessProgram.withFixedStep {
    StepSeconds = 1f / 60f
    MaxStepsPerFrame = 5
    MaxFrameSeconds = ValueSome 0.25f
    Map = PhysicsTick
  }

use runner = new HeadlessRunner<_,_>(program)
runner.Step(TimeSpan.FromMilliseconds(16))

DispatchMode

Control when dispatched messages are processed:

let program =
  HeadlessProgram.mkHeadless init update
  |> HeadlessProgram.withDispatchMode FrameBounded

Subscriptions

Subscriptions work the same as in the graphical runtime — they start, stop, and restart based on model changes:

let subscribe (ctx: GameContext) (model: Model) =
    if model.IsActive then
        Sub.batch [
            // Subscriptions work without graphics
        ]
    else
        Sub.none

let program =
  HeadlessProgram.mkHeadless init update
  |> HeadlessProgram.withSubscribe subscribe

Observers

Observers receive a (GameContext * 'Model * GameTime) snapshot every frame, after the update loop completes. Use them to react to model changes without modifying the update function — e.g. broadcasting state to clients, logging telemetry, or recording replays.

let program =
  HeadlessProgram.mkHeadless init update
  |> HeadlessProgram.withObserver(fun () ->
    HeadlessProgram.observe(fun struct (ctx, model, time) ->
      printfn "Frame at %.2fs: %A" time.TotalTime.TotalSeconds model))

use runner = new HeadlessRunner<_,_>(program)
runner.Step(TimeSpan.FromMilliseconds(16))

HeadlessProgram.observe creates an IObservable from a single onNext callback, hiding the OnError/OnCompleted boilerplate. Observers implementing IDisposable are disposed when the runner is disposed.

Multiple observers can be registered — they fire in registration order each frame.

Cleanup

HeadlessRunner implements IDisposable. Disposing it cleans up active subscriptions:

use runner = new HeadlessRunner<_,_>(program)
// ... run simulation ...
// Subscriptions are disposed when runner goes out of scope

Example: Unit Testing

open Mibo.Elmish

type Msg = Increment | Decrement
type Model = { Count: int }

let init _ctx = struct ({ Count = 0 }, Cmd.none)

let update msg model =
  match msg with
  | Increment -> struct ({ model with Count = model.Count + 1 }, Cmd.none)
  | Decrement -> struct ({ model with Count = model.Count - 1 }, Cmd.none)

[<Test>]
let ``increment increases count`` () =
  use runner =
    new HeadlessRunner<_,_>(
      HeadlessProgram.mkHeadless init update
    )

  runner.Dispatch(Increment)
  runner.Step(TimeSpan.FromMilliseconds(16))

  Assert.Equal(1, runner.Model.Count)

Example: Server Simulation

A headless runner is an authoritative simulation server: RunAsync paces the tick loop, observers broadcast state to clients, and Dispatch injects client inputs from the network layer.

let program =
  HeadlessProgram.mkHeadless init update
  |> HeadlessProgram.withFixedStep {
    StepSeconds = 1f / 20f  // 20 ticks/sec
    MaxStepsPerFrame = 4
    MaxFrameSeconds = ValueSome 0.5f
    Map = GameTick
  }
  // Broadcast the model to all clients every frame
  |> HeadlessProgram.withObserver(fun () ->
    HeadlessProgram.observe(fun struct (_, model, _) ->
      serialize model |> server.Broadcast))

use runner = new HeadlessRunner<_,_>(program)

// Feed client inputs as they arrive from the network
server.MessageReceived.Add(fun (clientId, bytes) ->
  runner.Dispatch(ClientInput(clientId, deserialize bytes)))

// Run the server loop — RunAsync paces ticks, the observer broadcasts
for (_, _) in runner.Run(TimeSpan.FromMilliseconds(50)) do
  () // The observer handles the broadcast
val program: obj
val runner: obj
val met: obj
val time: obj
val model: obj
val printfn: format: Printf.TextWriterFormat<'T> -> 'T
val cts: obj
val async: AsyncBuilder
Multiple items
type Async = static member AsBeginEnd: computation: ('Arg -> Async<'T>) -> ('Arg * AsyncCallback * objnull -> IAsyncResult) * (IAsyncResult -> 'T) * (IAsyncResult -> unit) static member AwaitEvent: event: IEvent<'Del,'T> * ?cancelAction: (unit -> unit) -> Async<'T> (requires delegate and 'Del :> Delegate) static member AwaitIAsyncResult: iar: IAsyncResult * ?millisecondsTimeout: int -> Async<bool> static member AwaitTask: task: Task<'T> -> Async<'T> + 1 overload static member AwaitWaitHandle: waitHandle: WaitHandle * ?millisecondsTimeout: int -> Async<bool> static member CancelDefaultToken: unit -> unit static member Catch: computation: Async<'T> -> Async<Choice<'T,exn>> static member Choice: computations: Async<'T option> seq -> Async<'T option> static member FromBeginEnd: beginAction: (AsyncCallback * objnull -> IAsyncResult) * endAction: (IAsyncResult -> 'T) * ?cancelAction: (unit -> unit) -> Async<'T> + 3 overloads static member FromContinuations: callback: (('T -> unit) * (exn -> unit) * (OperationCanceledException -> unit) -> unit) -> Async<'T> ...

--------------------
type Async<'T>
static member Async.RunSynchronously: computation: Async<'T> * ?timeout: int * ?cancellationToken: System.Threading.CancellationToken -> 'T
val quit: obj
union case ValueOption.ValueSome: 'T -> ValueOption<'T>
Multiple items
module Map from Microsoft.FSharp.Collections

--------------------
type Map<'Key,'Value (requires comparison)> = interface IReadOnlyDictionary<'Key,'Value> interface IReadOnlyCollection<KeyValuePair<'Key,'Value>> interface IEnumerable interface IStructuralEquatable interface IComparable interface IEnumerable<KeyValuePair<'Key,'Value>> interface ICollection<KeyValuePair<'Key,'Value>> interface IDictionary<'Key,'Value> new: elements: ('Key * 'Value) seq -> Map<'Key,'Value> member Add: key: 'Key * value: 'Value -> Map<'Key,'Value> ...

--------------------
new: elements: ('Key * 'Value) seq -> Map<'Key,'Value>
val subscribe: ctx: 'a -> model: 'b -> 'c
val ctx: 'a
val model: 'b
type Msg = | Increment | Decrement
type Model = { Count: int }
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
val init: _ctx: 'a -> struct (Model * 'b)
val _ctx: 'a
val update: msg: Msg -> model: Model -> struct (Model * 'a)
val msg: Msg
val model: Model
union case Msg.Increment: Msg
Model.Count: int
union case Msg.Decrement: Msg
val runner: System.IDisposable

Type something to start searching.