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 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
- ARPG: Level 3 (+ maybe Level 4)
- RTS: Level 3–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
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