Logo Mibo

Collections: CSet/CMap/CList and their views

Sets, maps, and lists propagate element-level deltas (added/removed/updated) instead of recomputing wholesale. Writes are journaled (zero allocation); nodes process pending deltas on read.

let items = CSet.ofSeq [ 1; 2; 3 ]

let double (x: int) = x * 2
let isBig (x: int) = x > 2

let doubled = items |> ASet.map double
let filtered = items |> ASet.filter isBig

items.Add(4)    // downstream nodes process one element, not the whole set
let entries = CMap.empty<int, string>
let lookup = entries |> AMap.tryFind 1          // aval<string voption>
let lengths = entries |> AMap.mapV String.length
let sequence = CList.empty<int>
let total = sequence |> AList.sum               // aval<int>, tracks the list
let sorted = sequence |> AList.sort             // stable, positional

Per-element adaptive mapping

mapA / filterA / chooseA (plus the positional mapiA on lists) map each element to an aval; the output follows each element's aval, and entries whose aval holds None/ValueNone are dropped:

let entityView id = world |> AMap.tryFind id

// chooseV with `id` (F#'s identity function) drops the ValueNones:
// ids the world no longer has fall out of the view
let statuses =
    entities
    |> ASet.mapA entityView
    |> ASet.chooseV id

Joins

AMap.joinOn is the same-key join for maps, the recommended low-churn form:

// Healths × Motions per enemy: same key, combined row
let sameEnemy (eid: int<EnemyId>) _ = eid

let combineRow _ (healthV: aval<float32>) (motionV: aval<Vector2 voption>) =
    let merge (h: float32) (m: Vector2 voption) =
        match m with
        | ValueSome motion -> combine h motion |> ValueSome
        | ValueNone -> ValueNone

    AVal.map2 merge healthV motionV

let views = AMap.joinOn healths motions sameEnemy combineRow

Note the shape: the two maps come first; the pipe form does not apply, a piped value would land in the mapping slot. The right-hand value arrives as aval<Vector2 voption> (ValueNone when the key has no entry in the right map), and the mapping returns aval<'U voption>: a ValueNone result drops the entry from the join.

Nested joins compose: a three-way view joins the two-way result with a third map the same way. If a join spans two features of your game, build it at the top level rather than inside one feature, so each feature stays understandable alone (Adaptive Systems covers the split).

Reading: snapshots vs. immutable copies

Which one, when

getValue is the default, and the per-frame rule in a game is this: if the value is consumed before the next write, read it with getValue. The projection in a Mibo game runs right before drawing and nothing writes during it, so it packs with getValue and that's the end of it.

Reach for force when the data has to survive past the next write, or leave the thread that owns the graph:

Situation

Use

Why

Packing the frame for the renderer

getValue

Free, consumed immediately on the same thread

Handing data to another thread (your own render thread, a worker)

force

Snapshots belong to the graph's owner thread; an immutable copy doesn't

Sending state over the network / saving to disk

force

You serialize an immutable value that can't change under you

Keeping a value for later frames (history, interpolation buffers)

force

getValue results are invalid after the next write

A one-time read in setup code, F# pattern matching on the structure

toSet/toMap

The immutable F# collections integrate with the rest of F#

Iteration speed itself is comparable; both enumerate the current contents. The difference is lifetime: a getValue snapshot is borrowed, a forced copy is yours.

The one thing not to do is call force per frame out of caution: it allocates on every call, and at 60 fps that is real garbage for nothing.

Lifetimes and capabilities

For the cost profile of joins and mapA (the coarse scan), see Performance.

val items: obj
Multiple items
val double: x: int -> int

--------------------
type double = System.Double

--------------------
type double<'Measure> = float<'Measure>
val x: int
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

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

--------------------
type int<'Measure> = int
val isBig: x: int -> bool
val doubled: obj
val filtered: obj
val entries: obj
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
val lookup: obj
val lengths: obj
module String from Microsoft.FSharp.Core
val length: str: string -> int
val sequence: obj
val total: obj
val sorted: obj
val entityView: id: 'a -> 'b
val id: 'a
val statuses: obj
val id: x: 'T -> 'T
val sameEnemy: eid: 'a -> 'b -> 'a
val eid: 'a
val combineRow: 'a -> healthV: 'b -> motionV: 'c -> 'd
val healthV: 'b
Multiple items
val float32: value: 'T -> float32 (requires member op_Explicit)

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

--------------------
type float32<'Measure> = float32
val motionV: 'c
type 'T voption = ValueOption<'T>
val merge: h: float32 -> m: 'e voption -> 'f voption
val h: float32
val m: 'e voption
union case ValueOption.ValueSome: 'T -> ValueOption<'T>
val motion: 'e
union case ValueOption.ValueNone: ValueOption<'T>
val views: obj

Type something to start searching.