Logo Mibo

Subscriptions (external events)

Your update function runs once per step, but games also react to things that arrive on their own schedule: the mouse moves, a key goes down, a network message lands, a timer fires. Subscriptions are how those events get into the loop.

What a subscription is

A subscription is two things: a stable id, and a function that attaches to an event source and returns a detacher. The attach function receives a SubPosting, a posting surface whose Post member schedules work to run inside the game loop; it does not run anything itself.

type AdaptiveSub = {
    Id: SubId
    Attach: SubPosting -> IDisposable
}

Read Attach inside out: it receives the posting surface, and Post receives the work to run later. The IDisposable that attaching returns is the detacher; when the runner detaches the subscription, the source stops feeding the loop.

Here's one for the mouse, using the framework's input service:

let mouseSub (ctx: AdaptiveFrameContext) : AdaptiveSub =
    let input = ctx.Context |> GameContext.getService<IInput>

    // ready-to-post work: collect this cell when the loop runs it
    let collectAtCell (cell: Cell) () = collectAt cell

    let postCellClick (posting: SubPosting) (cell: Cell) =
        posting.Post(collectAtCell cell)

    let onMouseMove (posting: SubPosting) (delta: InputDelta) =
        // Hover: remember where the cursor is. A cval write is enough;
        // anything derived from it updates automatically.
        hoverCell |> CVal.set (pickCell delta.Position)

        // Click: this changes game state, so run it in the loop
        if delta.Buttons.Pressed |> Array.contains MouseButtonCode.Left then
            pickCell delta.Position
            |> ValueOption.iter (postCellClick posting)

    let attachMouse (posting: SubPosting) : IDisposable =
        input.MouseDelta.Subscribe(onMouseMove posting)

    {
      Id = SubId.ofString "mouse"
      Attach = attachMouse
    }

The input service is opt-in: the host registers IInput only when your program applies AdaptiveProgram.withInput. Without it, getService<IInput> above fails at startup:

let program =
    AdaptiveProgram.mkProgram (init world) (update world)
    |> AdaptiveProgram.withInput

The split in that example is the guideline for input specifically:

Why attach only gets a posting surface

Event sources don't run on your thread. A network callback arrives on a socket thread; a task completes on the thread pool. Your state containers belong to the game thread; touching them from anywhere else is not allowed.

The shape of AdaptiveSub makes the safe thing the only thing: the callback receives the posting surface and nothing else that runs game code. Whatever thread the event fires on, the reaction runs on the game thread at a framework-chosen moment.

Registering subscriptions

AdaptiveInit.withSubscriptions takes a function that returns the subscription set as an amap<SubId, AdaptiveSub>, a map keyed by the subscription ids. Build the map once, in init, and return the same instance from the projection:

let init (world: World) (ctx: AdaptiveFrameContext) : AdaptiveInit<Frame> =
    let subMap =
        [ SubId.ofString "mouse", mouseSub ctx
          SubId.ofString "keys", keyboardSub ctx ]
        |> AMap.ofList

    let subscriptions _ = subMap

    AdaptiveInit.ofFrameBuilder (frame world)
    |> AdaptiveInit.withSubscriptions subscriptions

The map has to be a stable adaptive map. Every adaptive value carries a version counter that moves when it is written; comparing versions is how the runner tells "changed" from "unchanged". The runner calls your function every step but only re-reads the map when its version moved, and it identifies subscriptions by key: a key that survives a change keeps its attachment, a key that vanishes gets detached (at the next step's boundary, so a detachment lags one frame behind the change).

The hard rule: never build the map inside the projection. A fresh AMap.ofList (or any other constructor) per call creates a new graph every step; the version gate can never hold, so every step pays a full diff, and the dead graphs are garbage for the GC. Build once; return the same instance.

Dynamic subscription sets

When the set should follow game state (menus vs. gameplay, connected players), derive the map from your state, still built once, in init. The projection the runner calls every step returns that instance:

let subsForMode (ctx: AdaptiveFrameContext) (mode: GameMode) =
    match mode with
    | Menu -> [ SubId.ofString "menu-keys", menuKeySub ctx ]
    | Playing ->
        [ SubId.ofString "mouse", mouseSub ctx
          SubId.ofString "keys", keyboardSub ctx ]

let init (world: World) (ctx: AdaptiveFrameContext) : AdaptiveInit<Frame> =
    let subMap =
        world.Mode                               // cval<GameMode>
        |> AVal.map (subsForMode ctx)
        |> AMap.ofAVal

    let subscriptions _ = subMap

    AdaptiveInit.ofFrameBuilder (frame world)
    |> AdaptiveInit.withSubscriptions subscriptions

When world.Mode changes, the map's version moves, the diff runs, and the right subscriptions attach or detach. Clean steps skip the read entirely.

For full control there is AMap.custom: its compute function receives the current entries and appends the operations describing what changed, useful when your subscription source is an event queue rather than state. The map contract is the same either way: keys are identity, version gates the diff.

let applyQueue (current: amap<SubId, AdaptiveSub>) (delta: MapDeltaBuilder<SubId, AdaptiveSub>) =
    // consume your own event queue, appending adds/removes to delta
    ...

let subMap : amap<SubId, AdaptiveSub> = AMap.custom applyQueue

Gameplay keys with the mapper

For gameplay keys, the shared input guide (InputMap, ActionState) applies here too; the mapper has an adaptive subscription that writes the current action state into a cval for you:

type World = {
    ...
    Actions: cval<ActionState<GameAction>>
}

// in init, beside the other subscriptions (needs AdaptiveProgram.withInput):
let getInputMap () = inputMap

let inputSub =
    InputMapper.subscribeAdaptive getInputMap world.Actions ctx.Context

let subMap =
    [ inputSub.Id, inputSub
      SubId.ofString "mouse", mouseSub ctx ]
    |> AMap.ofList

subscribeAdaptive takes a map factory; when your bindings never change, InputMapper.subscribeStaticAdaptive takes the InputMap directly. Read the cval once at the top of your update.

One practical note on the mouse wheel: raylib reports ±1 per notch and MonoGame reports ±120. If zoom matters to your game, fold a scale factor in where you handle the wheel, so the feel matches on both backends.

For gameplay keys, the shared input guide (InputMap, ActionState) applies here too; the mapper has an adaptive subscription that writes the current action state into a cval for you:

type World = {
    ...
    Actions: cval<ActionState<GameAction>>
}

// in init, beside the other subscriptions (needs AdaptiveProgram.withInput):
let getInputMap () = inputMap

let inputSub =
    InputMapper.subscribeAdaptive getInputMap world.Actions ctx.Context

let subMap =
    [ inputSub.Id, inputSub
      SubId.ofString "mouse", mouseSub ctx ]
    |> AMap.ofList

subscribeAdaptive takes a map factory; when your bindings never change, InputMapper.subscribeStaticAdaptive takes the InputMap directly. Read the cval once at the top of your update.

Edges, not just held keys

The mapper maintains an ActionState root with Started, Released, and Held sets. Edges accumulate within a step; a mouse event between a key press and its release doesn't drop the key's edges. Your update consumes the edges: one-shots read Started, continuous movement reads Held. The subscription clears Started and Released after Update, before the frame is forced, so every step reads fresh edges.

let update (world: World) (ctx: AdaptiveContext) (gameTime: GameTime) =
    let actions = world.Actions |> AVal.getValue

    // One-shots: started this step
    for a in actions.Started do
        match a with
        | GameAction.SelectTower slot -> selectTower slot
        | _ -> ()

    // Continuous: recomputed each frame from what's held right now
    // (panStep : GameAction -> Vector2, your per-action pan vector)
    let mutable pan = Vector2.Zero

    for a in actions.Held do
        pan <- pan + panStep a

    world.Pan.Set pan

Two habits from that example:

Timers and network

The same shape covers any event source. A timer that spawns a wave every thirty seconds:

let onWaveTick (posting: SubPosting) = posting.Post spawnWave

let waveTimer (interval: float32) : AdaptiveSub =
    AdaptiveSub.ofTimer
        (SubId.ofString "wave-timer")
        (TimeSpan.FromSeconds(float interval))
        onWaveTick

A network client pushing opponent moves:

let postMove (posting: SubPosting) (move: Move) =
    let run () = applyMove move
    posting.Post run

let opponentMoves (client: IGameClient) : AdaptiveSub =
    AdaptiveSub.ofObservable
        (SubId.ofString "opponent")
        client.OnMove
        postMove

ofTimer and ofObservable cover the common sources; the record with Attach is still there when you need full control. In every case the handler receives the posting surface and only queues work: Post runs it on the owner thread at the next step's boundary, before Update.

type AdaptiveSub = { Id: obj Attach: (obj -> obj) }
val mouseSub: ctx: 'a -> AdaptiveSub
val ctx: 'a
val input: obj
val collectAtCell: cell: 'b -> unit -> 'c
val cell: 'b
val postCellClick: posting: 'b -> cell: 'c -> 'd
val posting: 'b
val cell: 'c
val onMouseMove: posting: 'b -> delta: 'c -> unit
val delta: 'c
module Array from Microsoft.FSharp.Collections
val contains: value: 'T -> array: 'T array -> bool (requires equality)
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 iter: action: ('T -> unit) -> voption: 'T voption -> unit
val attachMouse: posting: 'b -> 'c
val program: obj
val init: world: 'a -> ctx: 'b -> 'c
val world: 'a
val ctx: 'b
val subMap: obj
val subscriptions: 'd -> obj
val subsForMode: ctx: 'a -> mode: 'b -> ('c * AdaptiveSub) list
val mode: 'b
val Menu: 'b
val Playing: 'b
val applyQueue: current: 'a -> delta: 'b -> 'c
val current: 'a
val delta: 'b
type World = { }
val getInputMap: unit -> 'a
val inputSub: AdaptiveSub
AdaptiveSub.Id: obj
val update: world: World -> ctx: 'a -> gameTime: 'b -> 'c
val world: World
val gameTime: 'b
val actions: obj
val a: obj
val mutable pan: obj
val onWaveTick: posting: 'a -> 'b
val posting: 'a
val waveTimer: interval: float32 -> AdaptiveSub
val interval: float32
Multiple items
val float32: value: 'T -> float32 (requires member op_Explicit)

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

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

--------------------
type float = System.Double

--------------------
type float<'Measure> = float
val postMove: posting: 'a -> move: 'b -> 'c
val move: 'b
val run: unit -> 'd
val opponentMoves: client: 'a -> AdaptiveSub
val client: 'a

Type something to start searching.