Authored sequences, the rules that gate them, and unlock trees, as plain data for any engine. Use it for dialog, quests, cutscenes, tutorials, and technology or ability trees.
A step is a value. The runner walks the steps and hands each action to your code. Your code performs the action and answers, and the runner moves on.
[dependencies]
plotline = "0.3"use std::collections::BTreeSet;
use plotline::{Answer, Library, Requirement, Runner, Status, Step};
// Your vocabulary. The crate never interprets it.
enum Action {
Say(&'static str),
Choose(Vec<&'static str>),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Key {
HasRing,
}
let mut library = Library::new();
library.insert("hub", [
Step::act(Action::Say("Have you found my ring?")),
Step::when(Requirement::has(Key::HasRing), Step::goto("thanks")),
Step::act(Action::Choose(vec!["later", "hint"])),
]);
library.insert("thanks", [Step::act(Action::Say("You have my thanks."))]);
library.insert("later", [Step::act(Action::Say("Come back when you have."))]);
library.insert("hint", [Step::act(Action::Say("Near the old well."))]);
let held = BTreeSet::new(); // The player has no ring yet.
let mut runner = Runner::default();
runner.start("hub").unwrap();
let mut status = runner.advance(&library, &held);
while let Status::Act(action) = status {
let answer = match action {
Action::Say(line) => {
println!("{line}");
Answer::Done
}
// The player picks the second reply.
Action::Choose(replies) => Answer::goto(replies[1]),
};
status = runner.resume(answer, &library, &held);
}
assert!(matches!(status, Status::Finished));A wait is the gap between advance and resume, so the crate needs no
clock, callback, or async runtime.
Unlocks holds nodes that require keys 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 cannot drift apart.
use std::collections::BTreeSet;
use plotline::{NodeStatus, Requirement, Unlock, Unlocks};
let mut tree = Unlocks::new();
tree.insert("fusion-power", Unlock::free().granting(["fusion"]));
tree.insert(
"warp-drive",
Unlock::new(Requirement::has("fusion")).granting(["warp"]),
);
let mut held = BTreeSet::new();
let mut taken = BTreeSet::new();
tree.take(&"fusion-power", &mut held);
taken.insert("fusion-power");
for node in tree.view(&held, &taken) {
let mark = match node.status {
NodeStatus::Unlocked => '●',
NodeStatus::Available => '○',
NodeStatus::Locked => '·',
};
println!("{:?} {mark} {} after {:?}", node.rank, node.id, node.dependencies);
}view gives each node's status, layout column, and edges in one call.
validate checks the required keys of each node. It reports a key that
nothing grants, and a node that a loop or a missing key blocks.
- Plain data. A script is a list of
Stepvalues. It prints, compares, validates, and loads from JSON, RON, or YAML. - Your loop, your code. The runner reads keys through your
Hasholder and hands every action back to you. Every effect lives in onematchin the host, so a test can check the actions a script produces. - One answer per key. Stored and computed keys go through one
Hastrait, so a dialog gate, a tooltip, and a tech tree always agree. - Rules that explain themselves. An
Evaluationprints as a ✓/✗ tree and lists what is missing. - Small. 19 public items,
no_std, no required dependencies. Runaway content aborts instead of hanging, and a runner saves in the middle of a chain.
- Rust 1.85 or later, edition 2024.
no_stdwithalloc; no required dependencies.serde(off): derivesSerializeandDeserializefor the data types, includingLibraryandRunner. Loading fails on an unknown field and on a name that appears twice. It works withoutstd.
- API documentation — rustdoc is the manual.
examples/dialog.rs— a conversation with a choice, an event, and a quest gate. Run it withcargo run --example dialog.examples/unlocks.rs— requirements that explain themselves and gate a sequence. 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.
