Composable Systems
The problem
As a game grows, the update function becomes a dumping ground — input, physics, AI, particles, audio, UI state, all tangled together. Changing one thing breaks another. Testing is impossible because everything depends on everything else. The function grows to hundreds of lines and nobody wants to touch it.
The solution is to split the game into independent sub-systems that each own one concern, and coordinate them through a router — not a god-function that reaches into every piece of state.
The routed sub-system architecture
The unit of decomposition is a sub-system: an independent module that owns its model, its message type, and its update function. Sub-systems never call or import each other. The root update is a router that dispatches messages to sub-systems and translates the declarative values they emit into commands for the consumers that care.
Msg
WeaponEvent
EnemyEvent
The router is the only place that knows which systems consume which events. Each sub-system stays independently testable because it has no dependencies on its siblings.
1. The root update is a router, not game logic
The root update function (wherever you wire up Program.mkProgram) routes messages to the matching sub-system and translates emitted events into Cmd<Msg> for other systems. It contains no game logic — only dispatch and translation.
let update msg model =
match msg with
| WeaponMsg wmsg ->
let weapon, events = Weapon.update wmsg model.Weapon
// router: translate the sub-system's events into commands for consumers
let cmd = events |> Seq.collect translateWeaponEvent |> Cmd.batch
{ model with Weapon = weapon }, cmd
| Tick gt -> runTickPipeline gt model
// ...
2. Each sub-system owns its slice
A sub-system owns its model, its message type, and its update. It mutates/returns only its own state. It never imports another sub-system's update or reaches into another sub-system's model.
module Weapon =
type Model = { Ammo: int; Cooldown: float32; ... }
type Msg = | Fire | Reload | RefillAmmo
let update (msg: Msg) (model: Model) : Model * WeaponEvent seq =
// touches only model.Ammo / model.Cooldown — nothing else
...
3. Cross-system communication is declarative
When a sub-system needs to affect another, it returns declarative values — Events (what happened) or Intents (what should happen). These are pure data. The router translates each into Cmd<Msg> for the relevant systems. The emitting system does not know (or import) its consumers.
type WeaponEvent =
| Fired of pos: Vector3 * dir: Vector3
| EnemyKilled of pos: Vector3
// router-side translation:
let translateWeaponEvent = function
| WeaponEvent.Fired(pos, dir) ->
[| AudioMsg.OneShot(fire, pos); EffectMsg.SpawnSmoke(pos, dir) |]
| WeaponEvent.EnemyKilled(pos) ->
[| AudioMsg.OneShot(injured, pos); PlayerMsg.AddScore 100 |]
The weapon system never imports audio or effects. It just emits Fired and moves on. Add a new consumer (a screen-shake system, an achievement tracker) by adding a translation in the router — the weapon system is untouched.
4. Read access goes through a read-only query — but mind the hot path
When a sub-system needs to read another's state, the router passes it read-only access — never a direct mutable reference to another sub-system's model. There are two forms, and the choice depends on call frequency.
Cold path (event-driven, turn-based): a closure query record. The query hides the source model behind function fields. Building it per-message is acceptable. Each field is a closure over the root model:
[<Struct>]
type TargetingQuery = {
UnitAt: Vector2 -> UnitId voption
IsReachable: Vector2 -> bool
CurrentFaction: Faction
}
let query = {
UnitAt = fun cell -> model.Units |> Map.tryFind cell
IsReachable = fun cell -> model.Map.Reachable.Contains cell
CurrentFaction = model.Turn.CurrentFaction
}
Hot path (per-tick, real-time): direct values. Function-typed record fields are boxed FSharpFunc closures — each fun allocates, and calls dispatch indirectly (the JIT will not inline across them). Building a closure-bearing query inside Tick allocates every frame and defeats inlining. For real-time AI, pass the needed values directly instead:
// signature: direct values, no closures, no query record
let update (dt: float32) (playerPos: Vector3) (enemies: Enemy[]) (colliders: BoundingBox[]) : EnemyEvent seq
No closures — playerPos is a struct value, enemies/colliders are direct array references. Every read inside is a direct field access or an inline function call. The caller extracts values once:
let playerPos = model.Player.Position // one struct copy
let events = EnemyAi.update dt playerPos model.Enemy.Items model.Colliders
The read-only contract still holds — the AI receives playerPos (a value, it cannot mutate the player) and mutates only its own enemies. Decoupling is achieved by passing values, not by wrapping reads in closures.
Rule: closure query = cold path only (event-driven, turn-based). Per-tick reads = direct values (real-time). Never construct a closure-bearing query inside
Tick— it allocates per frame and cannot be inlined.
5. Cmd.map lifts sub-commands
Sub-system commands are Cmd<SubMsg>. The router lifts them into the root Msg via Cmd.map:
let cmd = Weapon.update wmsg model.Weapon |> snd
|> Seq.collect translateWeaponEvent |> Cmd.batch
For a sub-system whose commands don't need cross-system translation, lift directly:
let childCmd = Child.update cmsg model.Child |> snd
model, Cmd.map ChildMsg childCmd
The Tick pipeline: composing sub-systems per frame
Real-time games run many sub-systems every tick in a fixed order (physics before AI, AI before effects). Mibo's System pipeline makes that ordering explicit and enforces a snapshot boundary between mutation and query phases. Each phase calls a sub-system that owns its slice; the pipeline is the composition mechanism, not a replacement for the architecture.
| Tick gt ->
let dt = float32 gt.ElapsedGameTime.TotalSeconds
System.start model
// ── mutation phases: each sub-system mutates only its own slice ──
|> System.pipeMutable (Physics.update dt) // model.Player
|> System.pipeMutable (weaponSystem dt) // model.Weapon → WeaponEvent → Cmd
|> System.pipeMutable (enemySystem dt) // model.Enemy → EnemyEvent → Cmd
|> Model.toSnapshot // ── readonly boundary ──
// ── readonly phases: backend services read a consistent this-frame state ──
|> System.pipe (fun snap ->
audio.Update(dt, snap)
snap, Cmd.none)
|> System.finish (fun _ -> model)
The pipeline and the routed-sub-system architecture compose. A sub-system in the pipeline still owns its slice and emits events; the router still translates them. The pipeline just makes per-tick ordering and the mutable/readonly boundary explicit.
Snapshot boundary
The snapshot call changes the pipeline's type from the mutable Model to a readonly Snapshot — a struct record sharing sub-model references (zero allocation). After it, only System.pipe (readonly) phases are allowed. The compiler prevents a query phase from accidentally mutating state that a later mutation phase expects untouched.
System.start model
|> System.pipeMutable (Physics.update dt)
|> System.pipeMutable (Particles.update dt)
|> System.snapshot Model.toSnapshot
|> System.pipe (Ai.decide dt) // readonly — reads the snapshot
|> System.finish Model.fromSnapshot
When to use
- Your
updatehas grown past ~50 lines, or a single concern (physics, AI, combat) is hard to change in isolation. - You have cross-cutting interactions ("enemy died" should trigger a sound, a score bump, and a particle burst) and they're currently implemented by one system reaching into several models.
- You want sub-systems to be unit-testable without standing up the whole game.
You don't need it for a small game where a single update with pattern matching is still easy to read — see Scaling Mibo for when each rung pays off.
See also
- System Pipeline — the
System.start,pipeMutable,snapshotAPI. - Commands —
Cmd.mapandCmd.batchfor lifting/combining sub-system commands. - Scaling Mibo — where this pattern sits on the complexity ladder.
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 seq: sequence: 'T seq -> 'T seq
--------------------
type 'T seq = System.Collections.Generic.IEnumerable<'T>
type StructAttribute = inherit Attribute new: unit -> StructAttribute
--------------------
new: unit -> StructAttribute
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