Branching sequences of events as plain data — dialog, quests, cutscenes,
tutorials — for any engine. The host defines the steps; plotline runs
order, branches, subroutines, jumps, and waits.
It also answers what gates those flows: does an entity hold what a thing demands, and if not, what is it short of?
[dependencies]
plotline = "0.3"use core::task::Poll;
use plotline::{Completion, Library, Outcome, Runner, Sequence, TypeMap, steps};
let ready = Completion::new();
let waiting_on = ready.clone();
let mut library = Library::new();
let farewell = library.insert(
Sequence::new("farewell")
.with_step(steps::run("Say goodbye", |_ctx| println!("Safe roads."))),
);
let greeting = library.insert(
Sequence::new("greeting")
.with_step(steps::run("Say hello", |_ctx| println!("Hello, traveler.")))
.with_step(steps::run("Wait for the world", move |_ctx| waiting_on.clone()))
.with_step(steps::Branch {
condition: None,
if_true: Some(farewell),
if_false: None,
}),
);
let mut runner = Runner::default();
let mut services = TypeMap::new();
runner.start(greeting, None).unwrap();
assert_eq!(runner.advance(&mut library, &mut services), Poll::Pending);
ready.signal();
assert_eq!(
runner.advance(&mut library, &mut services),
Poll::Ready(Outcome::Finished),
);A requirement is a rule held as data, over capability keys you define. The crate never interprets a key. It answers whether a set holds one, and explains what failed.
use plotline::{CapabilitySet, Requirement};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Cap {
FrozenHabitation,
HighGravityHabitation,
Shipyard,
}
let humans = CapabilitySet::from([Cap::FrozenHabitation, Cap::Shipyard]);
let deneb_iv = Requirement::all([
Requirement::has(Cap::FrozenHabitation),
Requirement::has(Cap::HighGravityHabitation),
Requirement::any([
Requirement::has(Cap::Shipyard),
Requirement::named("borrowed-fleet"),
]),
]);
assert!(!deneb_iv.satisfies(&humans));
let result = deneb_iv.evaluate(&humans);
assert_eq!(result.missing(), vec![Cap::HighGravityHabitation]);The same value is a Condition, so a Branch or when step can hold one.
That path also reads chain flags and a Checks registry, which is how a
rule loaded from a file calls a host closure by name.
Use it for technology prerequisites, equipment requirements, habitability, crafting recipes, policies, dialogue choices, and skill unlocks.
Unlocks collects nodes that require capabilities and grant them. Nobody
authors the edges. One node grants a key, another requires it, and that is
the arrow, so a rule and its graph can never drift apart.
use plotline::{CapabilitySet, Requirement, Unlock, Unlocks};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Cap {
Fusion,
Warp,
}
let mut tree = Unlocks::new();
tree.insert("fusion-power", Unlock::free().granting([Cap::Fusion]));
tree.insert(
"warp-drive",
Unlock::new(Requirement::has(Cap::Fusion)).granting([Cap::Warp]),
);
assert_eq!(tree.dependencies(&"warp-drive"), vec![&"fusion-power"]);
assert_eq!(tree.rank(&"warp-drive"), Some(1));
let mut held = CapabilitySet::new();
tree.take(&"fusion-power", &mut held);
assert!(tree.is_available(&"warp-drive", &held));rank is the column a tree layout draws in. validate reports cycles and
content nothing can reach.
- Plain data. A sequence is an ordered list of steps the host defines — closures for the simple cases, structs where a step carries state.
- Rules you can read. A
Requirementclones, compares, prints, and serializes. Tools can walk it without running it. - Graphs you do not maintain. An unlock graph is derived from the rules themselves, so it cannot disagree with them.
- Subroutines and jumps.
Callenters a subroutine and falling off its end returns to the caller;Returnexits early;Gotoclears the whole call chain before starting its target. - No clock. A waiting step returns a
Completionhandle. The host signals it and callsadvance(). The crate does not define timed waits. - No engine or runtime dependencies.
no_stdwithalloc; the defaultstdfeature adds panic isolation only. - Tooling-ready. Whole-library validation, per-step reference facts, and reachability analysis feed editors and linters.
- Rust 1.85 or later, edition 2024.
no_stdwithalloc; no required dependencies.std(default): catches panics in steps. It requirespanic = "unwind".serde(off): derivesSerializeandDeserializeforRequirement,CapabilitySet, andEvaluation. It works withoutstd.--no-default-featuresbuilds withoutstdand does not catch panics.
- API documentation — rustdoc is the manual: steps, built-ins, validation, analysis, and diagnostics.
examples/dialog.rs— a branching conversation. Run it withcargo run --example dialog.examples/unlocks.rs— requirements gating what an empire can do. Run it withcargo run --example unlocks.examples/techtree.rs— a technology tree drawn from derived edges. Run it withcargo run --example techtree.- CHANGELOG
- Issue tracker
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
Banner artwork: “Subway” (1934) by Lily Furedi, via Wikimedia Commons. The digital image is credited to the Smithsonian American Art Museum; the file is marked public domain.
