Logo Mibo

Derived State: Organizing Projections

The P in SPU scales past one projection: a growing game accumulates values that aren't facts but functions of facts, like the alive count, a tower's upgraded stats, "is a boss near this tower", or the scoreboard string. These are projections, derived values computed from the facts, and the question that matters is not how to build one (that's the Mibo.Adaptive section) but where it lives and when it stays cheap.

The two homes

Every projection has one correct home, decided by a single question: does it touch more than one feature's data?

Inside the feature when it reads only that feature's own containers. Build it in the feature's init, store it on the model, done. The example uses a mutable class model (some features prefer that to records; see Scaling) plus one F# shorthand: voption is F#'s allocation-free option type:

module Towers =
    let inline private withLevel (statics: TowerStatic) (level: int voption) : TowerDef =
        effectiveDef statics.Def (level |> ValueOption.defaultValue 1)

    // joinOn pieces: the key both sides share, then the merge of a
    // tower's statics and its current level
    let sameTower (tid: int<TowerId>) _ = tid

    let toEffective _ (statics: aval<TowerStatic>) (level: aval<int voption>) =
        let merge (st: TowerStatic) (lvl: int voption) =
            withLevel st lvl |> ValueSome

        AVal.map2 merge statics level

    let init () : TowersModel =
        let m = TowersModel()

        m.EffectiveDef <- AMap.joinOn m.Statics m.Levels sameTower toEffective

        m

At the top level when it joins two features that don't know about each other. Two unrelated systems each own their data; a third party (the frame, another system, a test) needs the combination. That cross-system projection goes in one Projections object the composition root owns (your top-level setup code), not inside either system: putting it in one would make that system know the other exists:

let bossNearTower (tower: TowerStatic) _key (pos: Vector2) =
    Vector2.Distance(pos, tower.Cell |> center) <= BossAura.Radius

let suppressionFactor (n: int) =
    if n > 0 then BossAura.Factor else 1f

let inline suppressedBy (tower: TowerStatic) (enemies: EnemiesModel) : aval<float32> =
    enemies.BossPositions
    |> AMap.filter (bossNearTower tower)
    |> AMap.count
    |> AVal.map suppressionFactor

type Projections(enemies: EnemiesModel, towers: TowersModel, ...) =

    let suppressedFor _key (t: TowerStatic) = suppressedBy t enemies

    // Towers × Bosses: neither system knows the other. The frame and
    // Towers.tick both need the per-tower suppression factor.
    member val Suppression: amap<int<TowerId>, float32> =
        AMap.mapA suppressedFor towers.Statics

Reserve the top-level Projections for cross-system data only. If a projection can live next to its feature, it should; putting a single-feature projection at the top level scatters that feature's logic for no benefit.

Build once, never per frame

Construct each projection once, at startup, or when the feature's model is created. A projection built inside update allocates a new graph node every frame and throws away the whole point: a node's reads are free exactly because the node persists and re-uses its last result. Build it in init, keep it, read it as often as you want.

Performance, in practice

The graph is cheap enough for real games. In a live simulation-shaped game (several systems, a spatial join per entity, HUD values that follow the world every frame) the adaptive machinery runs at a small fraction of a millisecond per frame at 60 fps. You don't need to ration projections.

The costs that do show up in a profile are almost always one of these two, and both are yours to control:

For the per-combinator cost model, see Mibo.Adaptive Performance.

val private withLevel: statics: 'a -> level: int voption -> 'b
val statics: 'a
val level: int voption
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

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

--------------------
type int<'Measure> = int
type 'T voption = ValueOption<'T>
Multiple items
module ValueOption from Microsoft.FSharp.Core

--------------------
type ValueOption<'T> = | ValueNone | ValueSome of 'T static member Some: value: 'T -> 'T voption static member op_Implicit: value: 'T -> 'T voption member IsNone: bool member IsSome: bool member Value: 'T static member None: 'T voption
val defaultValue: value: 'T -> voption: 'T voption -> 'T
val sameTower: tid: 'a -> 'b -> 'a
val tid: 'a
val toEffective: 'a -> statics: 'b -> level: 'c -> 'd
val statics: 'b
val level: 'c
val merge: st: 'e -> lvl: int voption -> 'f voption
val st: 'e
val lvl: int voption
union case ValueOption.ValueSome: 'T -> ValueOption<'T>
val init: unit -> 'a
val m: 'a
val bossNearTower: tower: 'a -> _key: 'b -> pos: 'c -> bool
val tower: 'a
val _key: 'b
val pos: 'c
val suppressionFactor: n: int -> float32
val n: int
val suppressedBy: tower: 'a -> enemies: 'b -> 'c
val enemies: 'b
Multiple items
val float32: value: 'T -> float32 (requires member op_Explicit)

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

--------------------
type float32<'Measure> = float32
Multiple items
type Projections = new: enemies: obj * towers: obj * obj -> Projections member Suppression: obj

--------------------
new: enemies: obj * towers: obj * obj -> Projections
val enemies: obj
val towers: obj
val suppressedFor: _key: 'a -> t: 'b -> 'c
val _key: 'a
val t: 'b

Type something to start searching.