Adaptive Programs & Hosts
An adaptive game is three things you write: State, Projection, Update (SPU), plus one loop the framework runs. This page shows all three in one complete game, then how to run it on each host. Everything else (intents, subscriptions, services) has its own page, linked at the end.
The three things you write
The state. A record holding the changeable containers (cval, cmap) plus whatever plain data your game wants. This is where the facts live: you write them, the framework tracks when they changed.
The projection. Everything the game derives from the state instead of maintaining it by hand: the scoreboard string that follows the honey count, the "bees near this flower" view, and the one every game has, the frame projection that packs what the renderer needs into a single value (the Frame). Projections are built once and recompute when their inputs change; the runner forces the frame projection after update, once per step, via AdaptiveInit.ofFrameBuilder. The Frame itself is an implementation detail of your renderer; the projections are what make the architecture tick.
The update. A function that runs once per step and advances the game by writing to the state's containers.
Here is a complete game: bees fly around, and each one that leaves the meadow scores a honey:
open System.Numerics
open Mibo.Adaptive
open Mibo.Elmish
type Bee = { Pos: Vector2; Dir: Vector2 }
// S: the state
type World = {
Bees: cmap<int, Bee>
Honey: cval<int>
}
let world = { Bees = CMap.ofSeq [ 0, { Pos = Vector2.Zero; Dir = Vector2.One } ]
Honey = CVal.create 0 }
// the projection's output: everything the renderer needs, once per step
type Frame = {
Bees: System.Collections.Generic.IReadOnlyDictionary<int, Bee>
Honey: int
Status: string
}
// P: the projection. First the derived values, built once from the
// state: statusText is a plain function over one fact, and status
// recomputes only when the honey fact moves.
let statusText h = $"Honey: {h}"
let status = world.Honey |> AVal.map statusText
// Then the frame projection: the `unit -> Frame` the runner forces
// once per step, after update. It packs state reads and derived
// values alike.
let frame () : Frame =
{ Bees = world.Bees |> AMap.getValue
Honey = world.Honey |> AVal.getValue
Status = status |> AVal.getValue }
let meadowWidth = 40f
// Scratch buffer, created once and reused every step; the posted
// intent below clears it after draining, never during update
let leavers = ResizeArray<int>()
// U: the update
let update (world: World) (ctx: AdaptiveContext) (gameTime: GameTime) =
let dt = float32 gameTime.ElapsedGameTime.TotalSeconds
// Move every bee; remember who flew past the edge
for KeyValue(id, bee) in world.Bees |> AMap.getValue do
let moved = { bee with Pos = bee.Pos + bee.Dir * dt }
world.Bees |> CMap.addOrUpdate id moved
if moved.Pos.X > meadowWidth then leavers.Add id
// Removing mid-loop would invalidate the enumeration, so the
// despawn (and the score that reacts to it) is posted work:
// the queue runs it after update, in order, before the frame is forced
let despawnLeavers () =
world.Honey.UpdateTo((world.Honey |> AVal.getValue) + leavers.Count) |> ignore
for id in leavers do
world.Bees |> CMap.remove id
leavers.Clear()
if leavers.Count > 0 then ctx.Intents.post despawnLeavers
/// init: called once at startup with the frame context;
/// registers the projection with the program.
let init (world: World) (ctx: AdaptiveFrameContext) : AdaptiveInit<Frame> =
AdaptiveInit.ofFrameBuilder frame
// your drawing code: turns a Frame into drawing commands
// (see rendering.html)
let draw frameBuffer buffer = ...
let createRenderer () = Renderer2D.create draw
let program =
AdaptiveProgram.mkProgram (init world) (update world)
|> AdaptiveProgram.withConfig (GameConfig.withTitle "Bees")
|> AdaptiveProgram.withRenderer createRenderer
AdaptiveRaylibGame<Frame>(program).Run()
update world and init world are partially applied: the world argument is already fixed, and what is left is exactly the shape mkProgram asks for (init world : AdaptiveFrameContext -> AdaptiveInit<Frame> and update world : AdaptiveContext -> GameTime -> unit). The renderer's draw is your drawing function, reading the Frame; rendering covers how to write one.
Notice what the game does not have: no code that recomputes the status string when honey changes. status is built once, when the program starts; from then on it recomputes only when the honey fact moves, and the frame just reads it. That is the P doing its job. Derived State covers where projections live as the game grows, and the full combinator catalog is in Mibo.Adaptive.
NOTE: when the game grows, the world record is still the thing you apply;
init worldandupdate worldkeep working as features get added toWorld. See Systems.
The loop the framework runs
Every step, in order:
- Poll input, apply values posted from other threads, and refresh subscriptions.
- Write the current game time into the time root (
ctx.Time). - Run your
update. - Run the intents you queued during update.
- Force the projection and hand the frame it produces to the renderers.
- Draw.
You don't write this loop. The part that matters for your code: update always runs before the projection is forced, so the renderer never sees a half-updated world.
Before the first step, the runner drains the intent queue once at startup: work init posted through its context (ctx.Intents.post, postNextFrame, postTask, postAsync) runs right after init returns and before the first frame is forced, so the first frame includes its effects. That is the adaptive counterpart of the Cmd the MVU init returns — startup setup can react like any other phase.
The runner writes the game time into ctx.Time every step, so you can read dt from it. If you want animations to pause when the game does, keep a clock of your own on the world instead (write it in update unless paused, read it in the projection); the projection then stays a plain state-to-frame mapping.
Reading state in the projection
The projection reads your containers with getValue (AMap.getValue, AVal.getValue), and for the normal game that is the whole story: the frame is consumed by the renderer on the same thread before the next step, which is exactly the lifetime a getValue result promises.
The one exception: data that has to outlive the frame or leave the game thread (a server frame sent over the network, a save written to disk, a render thread of your own). For that, force builds an immutable copy that is yours to keep, at the cost of an allocation. The rule of thumb: force at the boundary where data leaves the frame's lifetime, and only there. The full comparison, per situation, is in Mibo.Adaptive: which read, when.
Running it: hosts
The program is the same on every backend; each one hands it to its own host. MonoGame wraps it first (the wrapper is where device-level setup hooks go):
// MonoGame:
let mgProgram = AdaptiveMonoGameProgram.ofProgram program
AdaptiveMonoGameGame<Frame>(mgProgram).Run()
// No window at all: tests and servers
let runner = AdaptiveHeadless(program)
Host |
Backend |
|---|---|
|
raylib |
|
MonoGame |
|
none (Headless Mode) |
Where to go next
- Work queued during update (the
ctx.Intents.postin the game above), including next-frame and background variants: Intents. - Input, timers, and network events, registered in
init: Subscriptions. - Splitting the game into features once
updategrows: Systems. - Sharing audio, save data, and other services: Services.
- Setup that must run before the first frame (connect a socket, warm a cache) goes in
init: it receives the context, so framework services like the asset cache are already available, and work it defers throughctx.Intentsruns at the startup drain, before the first frame is forced (see Intents).
type Vector2 = new: value: float32 -> unit + 2 overloads member CopyTo: array: float32 array -> unit + 2 overloads member Equals: other: Vector2 -> bool + 2 overloads member GetHashCode: unit -> int member Length: unit -> float32 member LengthSquared: unit -> float32 member ToString: unit -> string + 2 overloads member TryCopyTo: destination: Span<float32> -> bool static member (&&&) : left: Vector2 * right: Vector2 -> Vector2 static member ( * ) : left: Vector2 * right: Vector2 -> Vector2 + 2 overloads ...
<summary>Represents a vector with two single-precision floating-point values.</summary>
--------------------
Vector2 ()
Vector2(value: float32) : Vector2
Vector2(values: System.ReadOnlySpan<float32>) : Vector2
Vector2(x: float32, y: float32) : Vector2
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
<summary>Returns a vector whose 2 elements are equal to zero.</summary>
<returns>A vector whose two elements are equal to zero (that is, it returns the vector <code data-dev-comment-type="c">(0,0)</code>).</returns>
<summary>Gets a vector whose 2 elements are equal to one.</summary>
<returns>A vector whose two elements are equal to one (that is, it returns the vector <code data-dev-comment-type="c">(1,1)</code>).</returns>
<summary>Represents a generic read-only collection of key/value pairs.</summary>
<typeparam name="TKey">The type of keys in the read-only dictionary.</typeparam>
<typeparam name="TValue">The type of values in the read-only dictionary.</typeparam>
val string: value: 'T -> string
--------------------
type string = System.String
val float32: value: 'T -> float32 (requires member op_Explicit)
--------------------
type float32 = System.Single
--------------------
type float32<'Measure> = float32
<summary>The X component of the vector.</summary>
init: called once at startup with the frame context;
registers the projection with the program.
Mibo