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

Spawning & Driving Flows

A flow is one live conversation — a FlowInstance and its private FlowLocal override layer, attached to an entity. You spawn flows with a request component and advance them from your own systems.

The request-component pattern

You don’t construct a flow directly. You spawn an entity carrying a BrinkFlowRequest<M> (a bon-built builder) pointing at a story handle, and the plugin’s fulfill_flow_requests system materializes the flow once the assets finish loading — no polling, no readiness latch:

    commands.spawn(
        BrinkFlowRequest::<()>::builder()
            .story(assets.load("dialogue.inkb"))
            .start(FlowStart::Address("intro_scene".into())) // optional
            .build(),
    );

On fulfillment the request component is removed and replaced with the live flow components (below). Re-inserting the request afterward is a no-op (a debug build warns); to restart, despawn the entity and spawn a fresh request.

FlowStart — where execution begins

VariantMeaning
Root (default)The file’s root container. Fine for demos/tests; does not auto-enter a named knot.
Address(String)Start at a knot/stitch by name. If the name is unknown, the request is dropped at fulfillment.

Spawning a flow takes no seed/policy parameter: its FlowLocal always starts fresh and empty. What’s shared vs. private is a property of the world, set up once — see below.

Flow components & resources

After fulfillment the entity carries:

TypeKindHolds
BrinkFlow<M>Componentthe FlowInstance (.inner) — call stacks, output buffer, pending choices, transcript
BrinkContext<M>Componentthis flow’s private FlowLocal (.inner) — overrides for whatever units the policy homes to Local
BrinkProgram<M>ComponentHandle<ProgramAsset> the flow runs against
BrinkLocale<M>ComponentHandle<LineTablesAsset> the flow renders with
BrinkGlobals<M>Resourcethe one shared World for marker M — globals, visit/turn counts, RNG; auto-inserted on first fulfillment

World vs. Local: one shared World, opt-in private state

Every flow spawned under a marker advances against the same BrinkGlobals<M> World. By default every unit of story-state (VARs, visit/turn counts, RNG) is World-scoped — reads and writes are immediately visible to every flow sharing it, with no “commit” step, because nothing was ever forked. This is byte-identical to plain ink and is almost certainly what you want for a single-flow game or for genuinely shared globals (inventory, quest flags) across concurrent NPC conversations.

For per-entity private state (an NPC’s own mood, its own “have I greeted them before” history), install a policy at plugin setup naming exactly the VARs and knots that should be private — everything else stays shared:

    let mut policy = WorldPolicy::default(); // default: every unit World-scoped
    policy.overrides.insert("mood".to_string(), Scope::Local); // this VAR is private per flow
    policy
        .overrides
        .insert("greeting".to_string(), Scope::Local); // this knot's visit count too

    let mut app = App::new();
    app.add_plugins((
        AssetPlugin::default(),
        BrinkPlugin::<()>::default().with_policy(policy),
    ));

A knot override covers its own visit/turn count and everything nested under it, so sequence/cycle/stopping content ({ Hello | Welcome back }) inside a Local-scoped knot varies per flow too. There is no “commit private state back to shared” verb — if a private counter should eventually raise a shared flag, write that promotion in ink, where it’s visible (~ if mood > 10: ~ reputation += 1), not as a Bevy-side merge helper.

Driving a flow

Two ways to advance, depending on whether you have &mut World.

From a normal system — step_one / advance_until_terminal

These take the program + line tables (looked up from the assets via the entity’s handles), a &mut routing view built with flow_context_view over the entity’s BrinkContext and the marker’s shared BrinkGlobals, an ExternalFnHandler, the entity, and Commands. They return Advance:

AdvanceMeaning
Step(Step)a step was produced and its observer event fired
AwaitingQuerythe flow paused on a world-access binding; the plugin resolver handles it — skip this flow and resume next frame
fn drive(
    mut flows: Query<(
        Entity,
        &mut BrinkFlow<()>,
        &mut BrinkContext<()>,
        &BrinkProgram<()>,
        &BrinkLocale<()>,
    )>,
    globals: Option<ResMut<BrinkGlobals<()>>>,
    programs: Res<Assets<ProgramAsset>>,
    tables: Res<Assets<LineTablesAsset>>,
    bindings: Res<BrinkBindings<()>>,
    mut commands: Commands,
) {
    let Some(mut globals) = globals else {
        return; // no flow fulfilled yet
    };
    for (entity, mut flow, mut ctx, prog, loc) in &mut flows {
        if flow.inner.has_pending_external() {
            continue;
        } // paused; resolver will resume it
        let (Some(p), Some(t)) = (programs.get(&prog.handle), tables.get(&loc.handle)) else {
            continue;
        };
        let handler = bindings.handler();
        // World-scoped units (the default) route to the shared `globals`;
        // Local-scoped units (opted into via a policy override) route to
        // this flow's own `ctx`.
        let mut view = flow_context_view(&mut globals, &mut ctx);
        let _ = flow.advance_until_terminal(
            &p.program,
            &t.tables,
            &mut view,
            &handler,
            entity,
            &mut commands,
        );
        handler.flush(&mut commands); // emit any buffered command events
    }
}
  • step_one produces one line — for typewriter UIs that animate fragments.
  • advance_until_terminal runs until a terminal line (Done / Choices / End), firing events for every line along the way — for click-to-continue dialogue. It’s bounded by a 10,000-line safety cap per call (FlowInstance::LINE_LIMIT).

If you have no bindings, pass &bevy_brink::FallbackHandler instead of building one from BrinkBindings.

From an exclusive system — advance_flow

advance_flow::<M>(&mut World, entity) -> Result<Step, BrinkCallError> is the counterpart for &mut World contexts. It resolves world-access query bindings inline (so a line like Enemies near: {enemy_count()}. works in one frame) and never yields AwaitingQuery. See External Functions.

Choices

A Step::Choices (or a BrinkChoicesPresented event) means the flow is waiting for a pick. Select with choose:

    let mut view = flow_context_view(globals, ctx);
    flow.choose(&mut view, index)?;

For keyboard UIs, digit_key_to_choice_index(&keys, choices.len()) maps Digit1..=Digit9 to a 0-based choice index:

    if let Some(idx) = digit_key_to_choice_index(&keys, choices.len()) {
        let mut view = flow_context_view(globals, ctx);
        flow.choose(&mut view, idx)?;
    }

Observer events

step_one/advance_until_terminal fire one EntityEvent per produced step, targeted at the flow entity, so observers react to exactly the situation they care about (no match on a Step):

EventFires forCarries
BrinkLineDelivered<M>Step::Line (mid-stream)text, tags
BrinkChoicesPresented<M>Step::Choicestext, tags (always empty), choices: Vec<Choice>
BrinkTurnDone<M>Step::Done (turn complete, -> DONE)text, tags (always empty)
BrinkStoryEnded<M>Step::End (-> END)text, tags (always empty)
BrinkFlowReset<M> (dev)a hot-reload is about to rebuild the flowentity
    app.add_observer(|on: On<BrinkChoicesPresented<()>>| {
        for (i, choice) in on.event().choices.iter().enumerate() {
            println!("  [{}] {}", i + 1, choice.text);
        }
    });

Terminal lines bundle their accumulated text in their own text field — a Choices/Done/End event already contains the passage text leading up to it, so a click-to-continue UI can render from terminal events alone.

Transcripts

For a “show the whole conversation so far” view rather than per-event reaction, add a BrinkTranscript<M> component (opt-in) to a flow entity. The plugin re-renders it whenever the flow grows, the locale changes, or line tables hot-reload:

    commands
        .entity(flow)
        .insert(BrinkTranscript::<()>::default());
    // later, from a system that reads the component:
    let text = transcript.text(); // all lines joined with '\n'
    let lines = &transcript.lines; // Vec<(String, Vec<String>)> — (text, tags)
    render_conversation(&text, lines);

Hot-reload (dev)

With the dev feature, flows fulfilled from a .ink source carry a BrinkReplayLog<M>. When the source changes, the plugin rebuilds the flow against the new program, fires BrinkFlowReset<M> (clear your UI), and replays recorded choices to restore position. Record choices with choose_recording instead of choose to feed that log.