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

Saliency Strategies

A saliency strategy is the rule Bubbles uses to pick one item from a list of eligible options. It fires whenever a line group or node group needs to choose.

Bubbles ships three. You can also write your own.

FirstAvailable (default)

use bubbles::saliency::FirstAvailable;

runner.set_saliency(FirstAvailable);

Picks the first eligible candidate in declaration order. Fully deterministic, zero state, always safe.

Use this when:

  • You want predictable behaviour for tests and replays.
  • Your groups are ranked by priority (most specific first, fallback last).

RandomAvailable (requires rand feature)

use bubbles::saliency::RandomAvailable;

runner.set_saliency(RandomAvailable);

Picks uniformly at random from every eligible candidate. Great for flavour lines where anything goes.

Use this when:

  • Order doesn’t matter and variety does.
  • You don’t mind hearing the same line twice in a row occasionally.

BestLeastRecentlyViewed (BLRV)

use bubbles::saliency::BestLeastRecentlyViewed;

runner.set_saliency(BestLeastRecentlyViewed::new());

Prefers the candidate you’ve seen least recently. Over time, every candidate comes up before any repeats. This is the strategy that makes NPCs feel alive.

Use this when:

  • You have 3–10 variants and want each one to feel fresh.
  • Repetition would break immersion.
  • You care about variety more than randomness.

Tip: BLRV is usually the right default for ambient barks. Set it on the runner and forget it. The visit tracking lives inside the strategy; it persists for the lifetime of the runner.

Writing your own strategy

Implement the SaliencyStrategy trait. You get a slice of Candidates; return the index of the one you want.

use bubbles::{Candidate, SaliencyStrategy};

/// A strategy that picks the candidate with the most `#important` tags.
pub struct MostImportant;

impl SaliencyStrategy for MostImportant {
    fn choose(&mut self, candidates: &[Candidate]) -> Option<usize> {
        candidates.iter()
            .enumerate()
            .max_by_key(|(_, c)| c.tags.iter().filter(|t| t == &"important").count())
            .map(|(i, _)| i)
    }
}

runner.set_saliency(MostImportant);

That’s the whole surface. Ideas worth trying:

  • Weighted random - each variant carries a weight tag like #weight:5.
  • Mood-aware - boost candidates whose tags match the current scene’s mood.
  • Player-preferring - prefer ones tagged #player_class_<x> when the player is that class.
  • Exhaustive - walk every candidate in order, then loop.

The strategy is called synchronously during next_event. Keep it fast - no network calls, no disk reads.

Strategy scope

The strategy on the runner applies to all groups: line groups, node groups, and anything that uses the saliency machinery. If you need per-scene variation (e.g. “BLRV for barks, random for greetings”), the cleanest path is writing a dispatching strategy that reads tags off the candidate and picks a behaviour accordingly.

A complete setup

Most games want something like this:

use bubbles::saliency::BestLeastRecentlyViewed;

let mut runner = Runner::new(program, HashMapStorage::new());
runner.set_saliency(BestLeastRecentlyViewed::new());

Two lines, every line and node group now picks fresh variants. Writers author => lines freely; players hear the world talk without ever clocking the repetition.


Next: Save and Load