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 2.5 — Performance optimization

Best for: Games with frequent message dispatch or large Models where measurable GC pressure appears.

Goal: Reduce GC allocations while keeping the Elmish architecture.

Struct messages

Messages are dispatched frequently throughout your game. Each message allocation adds pressure to the GC. For small messages, marking them as [<Struct>] eliminates heap allocation entirely.

Simple guideline:

Profile-driven: Use a profiler to identify what's "small" vs "large" in your context. What works for one game may differ for another.

[<Struct>]
type Message =
    | Tick of GameTime              // Small - struct candidate
    | Damage of int                  // Small - struct candidate
    | ChildMsg of Child.Msg          // Works fine with struct

Struct messages work seamlessly with Cmd.map and Sub.map—they just wrap the dispatch function with the mapping function.

Mutable Model for large state

When your Model grows large (10+ properties or contains substantial nested state), immutable updates allocate new objects each update cycle. While you might be tempted to make the Model a struct to avoid GC, this doesn't help: the runtime passes the Model by value (see Elmish.Runtime.fs:218), so a large struct would be copied every update.

Instead, use a reference type (class) with mutable members. This pattern avoids GC pressure while maintaining the Elmish contract—you still return Model * Cmd<'Msg> from your update function. The runtime simply re-assigns the state variable.

type Model() =
    // Top-level state fields (10+ properties in production games)
    member val Time = Time.Zero with get, set
    member val PlayerId = 0 with get, set
    member val PlayerPosition = Vector2.Zero with get, set
    member val PlayerVelocity = Vector2.Zero with get, set
    member val Actions = ActionState.empty with get, set
    // ... more large fields

    // Child subsystem state (initialized in init)
    member val ChildState = Unchecked.defaultof<_> with get, set

[<Struct>]
type Message =
    | Tick of GameTime
    | ChildMsg of Child.Msg

let update msg model =
    match msg with
    | Tick gt ->
        model.Time <- Time.ofGameTime gt
        model, Cmd.none
    | ChildMsg msg ->
        let childState, childCmd = Child.update msg model.ChildState
        model.ChildState <- childState
        model, Cmd.map ChildMsg childCmd

Tradeoffs:

Hybrid approach: gradual mutability

You don't need to go all-in on mutability. Many games work well with a hybrid approach:

This lets you apply the right tool at the right level. A Player component with 3-4 fields works great as an immutable struct, while the root Model with 10+ fields benefits from mutability.

The key is that mutability stays encapsulated and predictable:

// Small, immutable child model (struct)
[<Struct>]
type Player = {
    Position: Vector2
    Velocity: Vector2
    Health: int
}

// Large, mutable parent model (class)
type GameModel() =
    member val Time = Time.Zero with get, set
    member val Player: Player = { Position = Vector2.Zero; Velocity = Vector2.Zero; Health = 100 } with get, set
    // ... 10+ more large fields

// Update still returns the same Model instance
let update msg model =
    match msg with
    | Tick gt ->
        model.Time <- Time.ofGameTime gt
        // Player can be updated immutably when needed
        model.Player <- { model.Player with Position = model.Player.Position + Vector2(1f, 0f) }
        model, Cmd.none

The Elmish contract is preserved: your update function still returns Model * Cmd<'Msg>. The mutability is an internal implementation detail that doesn't leak into your architecture. You get zero GC pressure where it matters most, without sacrificing the benefits of the functional model elsewhere.

When to apply these patterns

Profile first, optimize second. Start with idiomatic code and apply these patterns only when measurements show a need. Struct messages and mutable Models complement each other—both reduce allocation but at different points in the update cycle.

Performance Implementation: For more on struct patterns, see F# For Perf: Level 1 (Structs for Small Data).

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
Multiple items
type StructAttribute = inherit Attribute new: unit -> StructAttribute

--------------------
new: unit -> StructAttribute
[<Struct>] type Message = | Tick of obj | Damage of int | ChildMsg of obj
val set: elements: 'T seq -> Set<'T> (requires comparison)
module Unchecked from Microsoft.FSharp.Core.Operators
val defaultof<'T> : 'T
val update: msg: Message -> model: 'a -> 'a * 'b
val msg: Message
val model: 'a
union case Message.Tick: obj -> Message
union case Message.ChildMsg: obj -> Message
val msg: obj
val childState: obj
val childCmd: obj
[<Struct>] type Player = { Position: obj Velocity: obj Health: int }
Multiple items
type GameModel = new: unit -> GameModel member Player: Player with get, set member Time: obj with get, set

--------------------
new: unit -> GameModel
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 dt: obj
val alpha: float32
val visualPos: obj
val update: msg: 'a -> model: 'b -> 'b * 'c
val id: obj
val cleanup: obj
val spawnLoot: obj

Type something to start searching.