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:
- Allocating unknowingly. Building a projection node per frame, or calling
force/toMapin the frame loop "to be safe", allocates real garbage at 60 fps. Steady-state reads allocate nothing; the drip only appears when you create nodes or materialize copies on the hot path. - A live join that rescans every frame. A
mapAover one collection that filters another collection rechecks the inner one whenever it changes. Mixing something that changes every frame (positions, time) with a join means the inner scan re-runs constantly, and it grows linearly with the collections. At some size it stops being worth it. The fix is not to fear joins, it's to notice, and drop to a plain loop over the data inupdatewhere you control the cost directly. A projection is a convenience for reads, not a rule that everything must stay derived.
For the per-combinator cost model, see Mibo.Adaptive Performance.
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
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 float32: value: 'T -> float32 (requires member op_Explicit)
--------------------
type float32 = System.Single
--------------------
type float32<'Measure> = float32
type Projections = new: enemies: obj * towers: obj * obj -> Projections member Suppression: obj
--------------------
new: enemies: obj * towers: obj * obj -> Projections
Mibo