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:
- keep state changes serialized (Elmish)
- keep expensive logic data-oriented (snapshots + mutable hot paths when needed)
- introduce explicit boundaries (per-tick phases, and optionally frame-bounded dispatch)
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:
Program.mkProgram,Program.withRenderer,Program.withSubscriptionCmd.ofMsg,Cmd.batch
What you gain:
- trivially testable logic
- deterministic replay (record the message stream)
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:
InputMap+InputMapper.subscribe(orProgram.withInputMapperif you prefer services)- model field like
Actions: ActionState<_>updated by anInputMappedmessage
Recommendation: treat input as data for the next simulation step.
That usually looks like:
InputMapped actionsupdates a field (model.Actions <- actions)Tick gtconsumesmodel.Actionsto advance simulation
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-
Tickmessages update buffers (input snapshots, event queues, pending requests). OnlyTickmutates the “world”.
This gives you an explicit boundary:
- gather external events during the frame
- run simulation once on
Tick - commit results
Why it helps:
- fewer ordering surprises
- easier to reason about “what changed this frame”
- makes later deterministic/multiplayer work much easier
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:
- Small messages (1-2 simple fields like
int,float32,Vector2): Use[<Struct>] - Large messages (arrays, large structs, many fields): Keep as reference types
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:
- Pros: Zero GC pressure from Model updates
- Cons: Less pure than immutable records, more potential for bugs if you mutate unexpectedly
Hybrid approach: gradual mutability
You don't need to go all-in on mutability. Many games work well with a hybrid approach:
- Small models (child subsystems, simple components): Keep as immutable structs
- Large models (root state, complex subsystems): Use mutable reference types
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:
System.pipeMutablefor mutation-heavy phasesSystem.snapshotto freeze a readonly viewSystem.pipefor readonly/query/decision phases
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:
- Integrate physics / movement (mutable)
- Update particles / animation state (mutable)
- Snapshot
- AI decisions, queries, overlap detection (readonly)
- 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
}
- variable
GameTimearrives once per frame - your simulation runs in fixed steps (e.g. 1/60s) potentially multiple times
See: The Elmish Architecture (fixed timestep + dispatch modes)
Guidelines for determinism:
- put RNG state (seed) in the model (don’t call ambient
System.Random()from update) - avoid reading mutable global state from
update - represent time as data (the
Tickmessage already does this)
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:
DispatchMode.Immediate(default): maximum responsivenessDispatchMode.FrameBounded: stronger frame boundary, up to 1-frame extra latency for cascades
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:
- if the deferred effect dispatches immediately when it runs (synchronous dispatch), it will typically be processed next frame as expected
- if it dispatches later (async completion), and that completion happens while the runtime is draining messages, it may be deferred one more frame
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.
- Card/turn-based: Level 0–1
- Platformer/shooter: Level 1–2.5
- ARPG: Level 2.5–3 (+ maybe Level 4)
- RTS: Level 2.5–4 (+ Level 5 if you want strict boundaries)
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.
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
val float32: value: 'T -> float32 (requires member op_Explicit)
--------------------
type float32 = System.Single
--------------------
type float32<'Measure> = float32
val byte: value: 'T -> byte (requires member op_Explicit)
--------------------
type byte = System.Byte
--------------------
type byte<'Measure> = byte
type StructAttribute = inherit Attribute new: unit -> StructAttribute
--------------------
new: unit -> StructAttribute
type GameModel = new: unit -> GameModel member Player: Player with get, set member Time: obj with get, set
--------------------
new: unit -> GameModel
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>
Mibo