Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

External Functions (ink ↔ engine)

The binding facility connects ink EXTERNAL functions to engine code, and lets engine code call ink functions. The boundary is split by World access, not by sync/async return shape: bindings that need no World resolve inline; those that do (or that take time) pause the flow and resume out-of-band.

Register bindings at app-build time via BrinkBindingsAppExt (they live in a BrinkBindings<M> resource). Each verb takes the marker M as its first explicit type parameter — use () for the default single-story case.

ink → engine: the five kinds

VerbForResolution
bind_brink_fnpure compute, no Worldinline while the VM steps
bind_brink_commandfire-and-forget event (with optional return)buffered during the step, flushed after
bind_brink_queryread live World stateflow pauses; a resolver runs the binding system, then resumes
bind_brink_asyncmulti-frame World interaction (UI, input)flow parks; BrinkExternalAwaited fires; you resolve when ready
bind_brink_taskoff-thread compute / IOflow parks; the future runs on the task pool; resolved on completion

bind_brink_fn — pure functions

A side-effect-free function of the ink args, resolved inline (no World, no latency). The return type is anything Into<Value>:

app.bind_brink_fn::<(), _, _>("clamp01", |args| {
    args.first().and_then(Value::as_float).unwrap_or(0.0).clamp(0.0, 1.0)
});

bind_brink_command — fire-and-forget events

Parse the ink args into a Bevy Event and trigger it. Derive BrinkCommand for structs whose fields are i32/f32/bool/String; react with a normal observer:

#[derive(Event, BrinkCommand)]
struct PlaySound { name: String }

app.bind_brink_command::<(), PlaySound>("play_sound")
   .add_observer(|on: On<PlaySound>| { /* play on.event().name */ });

The event is buffered while the VM steps (the handler can’t touch the World mid-step) and emitted when the flow’s handler is flushed. To return a value to ink, hand-implement BrinkCommand and override reply().

bind_brink_query — read the World

A Bevy system with arbitrary SystemParams that reads the World and returns a Value. It takes In<BrinkQueryInput>(Entity, Vec<Value>), the calling flow plus the ink args — so a binding can query anything, with no upfront declaration:

fn enemy_count(In((_flow, _args)): In<BrinkQueryInput>, q: Query<&Enemy>) -> Value {
    Value::Int(q.iter().count() as i32)
}
app.bind_brink_query::<(), _, _>("enemy_count", enemy_count);

Resolving a query needs World access, so it can’t run inline. The flow pauses (ExternalResult::Pending); from a normal system it yields Advance::AwaitingQuery and the plugin’s resolve_pending_externals system (gated on any_flow_awaiting_external) runs the binding via run_system_with and resumes it. From an exclusive &mut World context, advance_flow resolves it inline in one frame.

ink → engine: async (defer-across-frames) bindings

Some externals can’t resolve in one pass — a targeting UI that waits for a click, or a network round-trip. The flow parks on the pending external and is frozen until resolved, so the flow entity itself is the correlation key (no per-call id needed).

Async bindings are a step-loop feature only: the one-pass exclusive drivers (advance_flow, call_ink_function) return BrinkCallError::AsyncExternalUnsupported on them.

bind_brink_async — the event primitive (World interaction)

When ink calls the external, the flow parks and BrinkExternalAwaited<M> (an EntityEvent carrying name + args) fires once at the flow entity. Do your multi-frame work and resolve via resolve_brink_external whenever ready:

app.bind_brink_async::<()>("pick_target");

app.add_observer(|on: On<BrinkExternalAwaited<()>>, mut commands: Commands| {
    if on.event().name == "pick_target" {
        // … open a targeting UI; many frames later, when the player clicks:
        commands.resolve_brink_external::<()>(on.event().entity, Value::Int(7));
    }
});

resolve_brink_external is guarded by has_pending_external, so a stale or double resolve is a safe no-op. Runnable demo: cargo run --example async_external.

bind_brink_task — off-thread compute

Sugar over AsyncComputeTaskPool. You hand it an async closure; bevy-brink spawns the future, parks a BrinkPendingTask<M>, and resolves the flow with the output once it completes (polled each frame by poll_brink_tasks):

app.bind_brink_task::<(), _, _>("expensive_roll", |args: Vec<Value>| async move {
    let sides = args.first().and_then(Value::as_int).unwrap_or(6);
    Value::Int(compute_roll(sides).await)
});

The future is Send + 'static and runs off the main thread, so it cannot access the World — it computes from the ink args only. For World-dependent async, use bind_brink_async. Runnable demo: cargo run --example async_task.

engine → ink: calling ink functions

Evaluate an ink function from engine code, out-of-band (output isolated, transcript untouched, visit counts not bumped). The function may itself call world-access query bindings — they resolve as part of the call.

From an exclusive system — call_ink_function

let can_advance = call_ink_function::<()>(world, flow_entity, "can_advance", &[])?;

Synchronous; resolves world-access query bindings inline because it holds &mut World.

From a normal system — commands.brink_call(...).observe(...)

A normal (non-exclusive) system can’t call call_ink_function directly, so it requests a deferred call. The result is delivered to an observer scoped to a unique per-call entity — it can never be mis-correlated with another call:

commands
    .brink_call::<()>(flow_entity, "can_advance", (in_combat,))
    .observe(|on: On<BrinkCallResolved<()>>| {
        let result = on.event().value.as_bool();
        // …
    });

brink_call accepts (), tuples of Into<Value> (up to 4), Vec<Value>, or &[Value] as args. The plugin’s resolver fires BrinkCallResolved<M> (with value) or BrinkCallFailed<M> (with an error string) at the call entity, then despawns it. Runnable demo: cargo run --example engine_bindings.

Wiring summary

app.bind_brink_fn::<(), _, _>("clamp01", clamp01)
   .bind_brink_command::<(), PlaySound>("play_sound")
   .bind_brink_query::<(), _, _>("enemy_count", enemy_count)
   .bind_brink_async::<()>("pick_target")
   .bind_brink_task::<(), _, _>("expensive_roll", expensive_roll);

Unknown EXTERNAL names fall through to ExternalResult::Fallback, so the story’s in-ink fallback body (if any) runs.