Logo Mibo

Organizing a Growing Game

A one-record world is fine until it isn't. When update starts touching bees, flowers, weather and score in the same function, split the game into features. A feature owns its data and its logic; features don't reach into each other. (The Elmish runtime's version of this pattern is Composable Systems: same rules, different plumbing.)

One feature, one owner

Give each feature its own model, its own tick, and its own events. The bees module owns the bees and nothing else:

module Bees =
    type Model = { Bees: cmap<int, Bee>; NextId: int }

    type Event =
        | Left of id: int        // a bee flew off: someone may care

    let tick (dt: float32) (model: Model) : Event list =
        // move bees, despawn the ones that left, report the leavers
        ...

    let spawn (bee: Bee) (model: Model) = ...

Event is a discriminated union: one type with a fixed set of cases, each carrying its data (here, the id of the bee that left). Your update function becomes a schedule plus a translator: it calls each feature's tick in order, then reacts to what the features reported.

let update world ctx gameTime =
    let dt = float32 gameTime.ElapsedGameTime.TotalSeconds

    let beeEvents = Bees.tick dt world.Bees
    let flowerEvents = Flowers.tick dt world.Flowers

    for event in beeEvents do
        match event with
        | Bees.Left id ->
            // a bee that leaves might have pollinated something
            Flowers.onVisitorLeft id world.Flowers
            world.Honey.UpdateTo((world.Honey |> AVal.getValue) + 1) |> ignore

The rule that keeps this sane: events are data, and one place handles them. A feature returns plain values describing what happened; it never calls another feature directly. When you add a third feature that cares about bees leaving, you add one line to the translator; the bees module doesn't change.

Reading another feature's data

Features still need to read each other: flowers need to know where the bees are. Do it through the update function: it reads one feature's containers and passes plain values to another feature's tick.

let update world ctx gameTime =
    let dt = float32 gameTime.ElapsedGameTime.TotalSeconds

    // Bees move first...
    let beeEvents = Bees.tick dt world.Bees

    // ...so flowers get this step's bee positions as a plain argument
    let beePositions = world.Bees.Bees |> AMap.getValue
    let flowerEvents = Flowers.tick dt beePositions world.Flowers

Two habits worth keeping:

Derived values that span features

Some values read more than one feature: "flowers with a bee nearby", the scoreboard. Build those from both containers once, at startup, and read them wherever you need them; they recompute when their inputs change.

// plain function: is any bee close to this flower?
let isPollinated (beePositions: IReadOnlyDictionary<int, Bee>) _key (flower: Flower) =
    ... // your distance check over beePositions.Values

// derived once, at startup, from both containers
let pollinated =
    flowers.Flowers
    |> AMap.filter (isPollinated (bees.Bees |> AMap.getValue))

Where such a value lives (next to its feature, or at the top level when it mixes features) is the one placement rule, and Derived State covers it with the full example.

When a decision spans features

Questions like "can the player afford this?" or "is this spot free?" read several features at once. Write them as small functions that take the relevant values and return an answer, and call them from your update. Keep the yes/no logic out of the update function itself; update stays a schedule and a translator.

See also

type Model = { Bees: obj NextId: int }
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

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

--------------------
type int<'Measure> = int
Multiple items
module Event from Microsoft.FSharp.Control

--------------------
type Event = | Left of id: int

--------------------
type Event<'T> = new: unit -> Event<'T> member Trigger: arg: 'T -> unit member Publish: IEvent<'T>

--------------------
type Event<'Delegate,'Args (requires delegate and 'Delegate :> Delegate and reference type and 'Delegate: not null)> = new: unit -> Event<'Delegate,'Args> member Trigger: sender: objnull * args: 'Args -> unit member Publish: IEvent<'Delegate,'Args>

--------------------
new: unit -> Event<'T>

--------------------
new: unit -> Event<'Delegate,'Args>
val id: x: 'T -> 'T
val tick: dt: float32 -> model: Model -> Event list
val dt: float32
Multiple items
val float32: value: 'T -> float32 (requires member op_Explicit)

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

--------------------
type float32<'Measure> = float32
val model: Model
type 'T list = List<'T>
val spawn: bee: 'a -> model: Model -> 'b
val bee: 'a
val update: world: 'a -> ctx: 'b -> gameTime: 'c -> unit
val world: 'a
val ctx: 'b
val gameTime: 'c
val beeEvents: Bees.Event list
module Bees from systems
val tick: dt: float32 -> model: Bees.Model -> Bees.Event list
val flowerEvents: obj
val event: Bees.Event
union case Bees.Event.Left: id: int -> Bees.Event
val id: int
val ignore: value: 'T -> unit
val update: world: 'a -> ctx: 'b -> gameTime: 'c -> 'd
val beePositions: obj
val isPollinated: beePositions: 'a -> _key: 'b -> flower: 'c -> 'd
val beePositions: 'a
val _key: 'b
val flower: 'c
val pollinated: obj

Type something to start searching.