A deterministic, turn-based game engine for Rust.
HerdingCats brings order to complex rule interactions by evaluating an input through a
statically known, ordered set of Behaviors. The engine dispatches each Input atomically:
behaviors run in deterministic (order_key, name) order, each contributing Diffs to a
speculative working State. If dispatch succeeds and produces at least one Diff, the engine
commits a Frame and returns Outcome::Committed. If anything stops dispatch early, the
working State is discarded and no committed state changes occur.
The engine never advances on its own. It only transitions when the caller submits an Input or explicitly calls undo/redo.
A user-defined message submitted to the engine. Every engine transition begins with an
Input. Examples: Move, Attack, BeginTurn, RollDice.
The full game state, user-defined. Provided to Engine::new() — the engine does not
require a Default implementation. Behavior-local state lives inside the main State tree
(not hidden inside the engine), ensuring undo/redo correctness and serializability.
A single game rule expressed as a BehaviorDef struct with a name, order_key, and
evaluate fn pointer. During dispatch the engine calls each entry's evaluate fn pointer
with an immutable borrow of the current working State and the Input, then either:
- returns
Continue(diffs)— zero or more Diffs to apply, dispatch continues - returns
Stop(outcome)— halts dispatch immediately with a non-committed Outcome
BehaviorDef entries are sorted once at construction by (order_key, name) and run in
that order for every dispatch call. The shared borrow prevents mutation — behaviors cannot
mutate State directly.
An atomic, user-defined description of a state mutation. Behaviors emit Diffs; the engine
applies them. Each Diff implements the Apply trait, which mutates the working State and
returns at least one Trace entry. Applied immediately and sequentially — later Behaviors
see earlier Diffs.
Per-Diff metadata generated at the exact moment each Diff is applied. Trace exists only for successful committed transitions and records what happened in execution order. Useful for UI presentation, replay explanation, and isolating UI logic from direct State inspection.
The canonical record of a successful, non-empty state transition:
Frame { input, diffs, traces }Frames are immutable records. The same Frame struct is returned by Committed, Undone,
and Redone outcomes — the variant carries the meaning, not the frame.
The result of every dispatch, undo, or redo call:
pub enum Outcome<F, N> {
Committed(F), // dispatch succeeded, frame committed
Undone(F), // undo succeeded, frame returned
Redone(F), // redo succeeded, frame returned
NoChange, // dispatch completed with zero diffs
InvalidInput(N), // input does not make sense in current context
Disallowed(N), // input meaningful but an active rule forbids it
Aborted(N), // dispatch started but could not safely complete
}The central coordinator. Holds the committed State, the ordered list of BehaviorDef
entries, and the undo/redo history stacks. Exposes dispatch, undo, and redo.
- Caller submits an Input with a
Reversibilitydeclaration (ReversibleorIrreversible). - Engine creates a copy-on-write working State — no clone until the first Diff is applied.
- Behaviors are evaluated in deterministic
(order_key, behavior_name)order. - Each Behavior either emits Diffs (
Continue) or halts dispatch (Stopwith aNonCommittedOutcome). - Diffs are applied immediately to the working State; Trace entries are appended at application time.
- If any Behavior returns
Stop, the working State is discarded and the corresponding non-committed Outcome is returned — no state change is visible. - If dispatch completes with no Diffs,
Outcome::NoChangeis returned. - If one or more Diffs were applied, the working State is committed atomically, a Frame
is created, and
Outcome::Committed(frame)is returned.
Undo and redo operate on stored Frames, not reverse-diff computations. No Reversible
trait is required on your types — the engine stores a prior-State snapshot alongside each
Frame. Irreversible dispatches erase undo/redo history across that boundary (once dice
are rolled, you cannot undo before the roll).
use herdingcats::{Engine, EngineSpec, Apply, BehaviorDef, BehaviorResult, Reversibility, Outcome};
// 1. Define your spec (bundles all associated types)
struct MySpec;
#[derive(Debug, Clone)]
struct AppendDiff(u8);
// 2. Implement Apply so each Diff can mutate State and emit Trace
impl Apply<MySpec> for AppendDiff {
fn apply(&self, state: &mut Vec<u8>) -> Vec<String> {
state.push(self.0);
vec![format!("appended {}", self.0)]
}
}
impl EngineSpec for MySpec {
type State = Vec<u8>;
type Input = u8;
type Diff = AppendDiff;
type Trace = String;
type NonCommittedInfo = String;
type OrderKey = u32;
}
// 3. Define a BehaviorDef with an evaluate fn pointer
fn append_eval(input: &u8, _state: &Vec<u8>) -> BehaviorResult<AppendDiff, String> {
BehaviorResult::Continue(vec![AppendDiff(*input)])
}
// 4. Construct the Engine with initial State and BehaviorDef entries
let behaviors = vec![
BehaviorDef::<MySpec> { name: "append", order_key: 0, evaluate: append_eval },
];
let mut engine = Engine::<MySpec>::new(vec![], behaviors);
// 5. Dispatch an Input
let outcome = engine.dispatch(42u8, Reversibility::Reversible).unwrap();
match outcome {
Outcome::Committed(frame) => println!("committed {} diffs", frame.diffs.len()),
other => println!("outcome: {:?}", other),
}v0.5.0 implements the full MVP architecture described in ARCHITECTURE.md:
- Static behavior set via
BehaviorDefstructs (fn pointers, no trait objects) - Copy-on-write working state (zero-clone until first diff)
- Snapshot-based undo/redo (no
Reversibletrait burden) - Deterministic
(order_key, name)behavior ordering Applytrace contract enforced bydebug_assert!in dispatch
HerdingCats has no runtime dependencies. proptest is a dev-only dependency used for
property-based testing.