Header menu logo Mibo

Scaling Mibo (Simple → Complex)

Mibo is designed to stay fun for small games while still giving you an upgrade path for “serious” games. This document is a practical ladder you can climb as complexity increases—without rewriting your engine.

The recurring theme is:

Level 0 — Pure MVU

Best for: card games, menus, puzzle games.

Goal: maximum simplicity.

Model: mostly immutable records.

Update discipline: handle one message at a time, return Cmd.none most of the time.

Mibo helpers you’ll use:

What you gain:

type Model = { Position: Vector2 }
type Msg = Teleport of Vector2

let update msg model =
    match msg with
    | Teleport pos -> { model with Position = pos }, Cmd.none

let view ctx model buffer =
    Draw2D.sprite texture (Rectangle(int model.Position.X, int model.Position.Y, 32, 32))
    |> Draw2D.submit buffer

Level 1 — Add semantic input

Best for: Action-heavy games (platformers, arcade) where rebindable keys and state queries (is "Jump" held?) are essential.

Goal: stop sprinkling device-specific checks across gameplay.

Pattern: map hardware input → semantic actions → update your model.

Mibo helpers you’ll use:

Recommendation: treat input as data for the next simulation step.

That usually looks like:

type Action = MoveLeft | MoveRight | Jump

let inputMap =
    InputMap.empty
    |> InputMap.key MoveLeft Keys.Left
    |> InputMap.key MoveRight Keys.Right
    |> InputMap.key Jump Keys.Space

type Model = {
    Position: Vector2
    Actions: ActionState<Action>
}

let update msg model =
    match msg with
    | InputMapped actions ->
        { model with Actions = actions }, Cmd.none

    | Tick gt ->
        // Simulation now depends on the stored 'Actions'
        let dt = float32 gt.ElapsedGameTime.TotalSeconds
        let dx =
            if model.Actions.Held.Contains MoveRight then 100.0f * dt
            elif model.Actions.Held.Contains MoveLeft then -100.0f * dt
            else 0.0f

        { model with Position = model.Position + Vector2(dx, 0.0f) }, Cmd.none

Level 2 — Establish a simulation “transaction”

Best for: Growing projects where you need to prevent "spaghetti logic." By forcing all gameplay changes into Tick, you avoid race conditions caused by random events mutating state unpredictably.

Goal: keep your mental model simple when the game grows.

Rule of thumb:

Non-Tick messages update buffers (input snapshots, event queues, pending requests). Only Tick mutates the “world”.

This gives you an explicit boundary:

Why it helps:

type Msg =
    | InputMapped of ActionState<Action> // Just updates the input buffer
    | NetworkPacket of byte[]            // Just updates the network buffer
    | Tick of GameTime                   // The ONLY place physics/gameplay runs

let update msg model =
    match msg with
    | InputMapped actions ->
        { model with Actions = actions }, Cmd.none

    | NetworkPacket data ->
        // Buffering network data, not processing it yet
        model.NetworkBuffer.Enqueue(data)
        model, Cmd.none

    | Tick gt ->
        // 1. Read buffers (Input, Network)
        // 2. Run simulation (Physics, AI)
        // 3. Update world
        let newPos = Physics.integrate model.Position model.Actions gt
        { model with Position = newPos }, Cmd.none

Level 3 — Phase pipelines + snapshot barriers

Best for: Complex simulations (ARPG, RTS) where update order matters. E.g., Physics must run before Collision, which must run before AI.

Goal: support many subsystems without turning update into spaghetti.

Mibo provides a type-guided pipeline in Mibo.Elmish.System:

The pipeline accumulates a single Cmd<'Msg> (not a list), so it stays allocation-friendly even as you add phases.

See: System pipeline (phases + snapshot)

Typical layout:

  1. Integrate physics / movement (mutable)
  2. Update particles / animation state (mutable)
  3. Snapshot
  4. AI decisions, queries, overlap detection (readonly)
  5. Emit commands/messages

This is an “ECS-ish” approach that works well even if your storage is still dictionaries/arrays.

Performance Implementation: As you add more subsystems and entities, you will likely need to move from immutable lists to mutable collections to avoid GC pressure. See F# For Perf: Level 3 (Mutable Collections) for the implementation details.

// Example from MiboSample: splitting mutable physics from readonly logic

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

    System.start model
    // Phase 1: Mutable systems (can mutate positions, particles)
    |> System.pipeMutable (Physics.update dt)
    |> System.pipeMutable (Particles.update dt)
    // SNAPSHOT: transition to readonly
    |> System.snapshot Model.toSnapshot
    // Phase 2: Readonly systems (work with immutable snapshot)
    |> System.pipe (HueColor.update dt 5.0f)
    |> System.pipe (Player.processActions (fun id pos -> PlayerFired(id, pos)))
    // Finish: convert back to Model
    |> System.finish Model.fromSnapshot

Level 4 — Fixed timestep and determinism

Best for: Networked games or physics-heavy simulations that require deterministic behavior independent of the user's framerate.

Goal: stable simulation independent of framerate.

Pattern: run your simulation in fixed slices.

You can do this manually (accumulator in the model), or use Mibo's framework-managed fixed timestep:

Program.mkProgram init update
|> Program.withFixedStep {
	StepSeconds = 1.0f / 60.0f
	MaxStepsPerFrame = 5
	MaxFrameSeconds = ValueSome 0.25f
	Map = FixedStep
}

See: The Elmish Architecture (fixed timestep + dispatch modes)

Guidelines for determinism:

Performance Implementation: Physics logic executed multiple times per frame is the "hottest" path in your game. To keep this zero-allocation, see F# For Perf: Level 5 (ByRef/InRef).

// Using framework-managed fixed step
type Msg =
    | FixedStep of dt: float32
    | Tick of GameTime // Still used for interpolation/rendering

let update msg model =
    match msg with
    | FixedStep dt ->
        // Run deterministic physics
        let newPos = model.Pos + model.Vel * dt
        { model with Pos = newPos }, Cmd.none

    | Tick gt ->
        // Only update visual/interpolation state
        let alpha = float32 gt.ElapsedGameTime.TotalSeconds / fixedStep
        let visualPos = Vector2.Lerp(model.PrevPos, model.Pos, alpha)
        { model with VisualPos = visualPos }, Cmd.none

Level 5 — Frame-stable message processing

Best for: Strict lockstep architectures or rollback networking where you need a guarantee that no "stray" messages can slip into the current frame after processing starts.

By default, Mibo processes messages immediately: a message dispatched while the runtime is draining the queue can be processed in the same MonoGame Update call.

For some advanced architectures (strict frame boundaries, rollback/lockstep friendliness, avoiding re-entrant cascades), you may want:

messages dispatched while processing frame N are not eligible until frame N+1.

Mibo supports this via DispatchMode:

Enable it like this:

Program.mkProgram init update
|> Program.withDispatchMode DispatchMode.FrameBounded

Interaction with Cmd.deferNextFrame

Cmd.deferNextFrame delays an effect until the next MonoGame Update call. In FrameBounded mode:

This is not a bug; it’s the natural result of combining “defer effect execution” with “frame-bounded message eligibility”.

// Example: Spawning entities safely at the start of the next frame
// to avoid mutating the list while iterating it in the current frame.
let update msg model =
    match msg with
    | EnemyDied id ->
        let cleanup = Cmd.ofMsg (RemoveEntity id)
        // Ensure spawn happens cleanly next frame
        let spawnLoot = Cmd.ofMsg (SpawnLoot id) |> Cmd.deferNextFrame
        model, Cmd.batch [ cleanup; spawnLoot ]

Choosing the right rung

You can ship a lot of games at Level 2–3.

Pick the simplest level that fits your game today, and add the next pieces only when you feel the need.

Once you have chosen your architecture, check out F# For Perf to ensure your implementation stays fast as you scale.

type Model = { Position: obj }
type Msg = | Teleport of obj
val update: msg: Msg -> model: Model -> Model * 'a
val msg: Msg
val model: Model
union case Msg.Teleport: obj -> Msg
val pos: obj
val view: ctx: 'a -> model: 'b -> buffer: 'c -> 'd
val ctx: 'a
val model: 'b
val buffer: 'c
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

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

--------------------
type int<'Measure> = int
type Action = | MoveLeft | MoveRight | Jump
val inputMap: obj
union case Action.MoveLeft: Action
union case Action.MoveRight: Action
union case Action.Jump: Action
val update: msg: 'a -> model: Model -> Model * 'b
val msg: 'a
val actions: obj
val gt: obj
val dt: float32
Multiple items
val float32: value: 'T -> float32 (requires member op_Explicit)

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

--------------------
type float32<'Measure> = float32
val dx: float32
Model.Position: obj
Multiple items
val byte: value: 'T -> byte (requires member op_Explicit)

--------------------
type byte = System.Byte

--------------------
type byte<'Measure> = byte
val data: obj
val newPos: obj
namespace System
val id: x: 'T -> 'T
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 update: msg: 'a -> model: 'b -> 'b * 'c
val dt: obj
val alpha: float32
val visualPos: obj
val id: obj
val cleanup: obj
val spawnLoot: obj

Type something to start searching.