Skip to content

Integrate and drive the simulation

This page shows how to embed Canwu in a Rust application: add the crates, create a simulation, send a command, and advance time from your own loop. Start here before the reading, persistence, and extension guides.

Your host application owns windows, maps, input, networking, AI scheduling, and the frame loop. Canwu owns authoritative state, validation, deterministic time, events, and evidence. A domain integration supplies the concrete world model and its commands; this page uses the example integration canwu-reference-world. For unfamiliar terms, see the terminology reference.

Canwu needs Rust 1.88 or newer. Add the published canwu-api crate from crates.io to your host application’s Cargo.toml:

[dependencies]
canwu-api = "=0.13.0"
  • canwu-api is the supported public API; its API docs are on docs.rs. Application code depends on it and on any extension crates it uses, such as canwu-resource. Extension crates are also published on crates.io; use the same version number as canwu-api. Do not depend on canwu-sim or other runtime crates directly.
  • canwu-reference-world is a small example world you can replace, and the minimal flow below uses it. It is not published to crates.io, so the only way to get its source is to clone the GitHub repository (check out the v0.13.0 tag to match the version above). Run the examples inside the clone, or copy the crate into your project as a template and point it at the same crates.io canwu-api. If canwu-api comes from crates.io in one place and from Git in another, Cargo builds two separate copies whose types do not mix.

=0.13.0 pins the engine to exactly this release. If your application keeps saves, upgrade only once a save migration is ready (see persistence).

This program creates the reference world, orders an army east, advances 19 hours, and prints where the army ended up:

use canwu_api::{Canwu, CommandRequest, CommandRequestId, EntityRef, Issuer, SimDuration};
use canwu_reference_world::{
MovementCommand, ReferenceWorldPlugin, demo_scenario, order_movement,
snapshot as reference_snapshot,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let (scenario, ids) = demo_scenario()?;
let plugin = ReferenceWorldPlugin;
let mut canwu = Canwu::new_with_plugins(35, scenario, &[&plugin])?;
let command = order_movement(
Issuer::Actor(ids.commander),
&MovementCommand {
subject: EntityRef::Army(ids.army),
destination: ids.eastern_territory,
cargo: Vec::new(),
},
)?
.at_time(canwu.time());
canwu.enqueue_command(
canwu.time(),
0,
CommandRequest::new(CommandRequestId::new(1), canwu.revision(), command),
)?;
canwu.advance_canonical(SimDuration::hours(19))?;
println!(
"army_location={}",
reference_snapshot(&canwu)?
.army(ids.army)
.expect("demo army exists")
.location
);
Ok(())
}

What each step does:

  1. demo_scenario() builds a three-territory world and returns the IDs of its people, army, and territories. ReferenceWorldPlugin registers the movement command and the boundary system that moves armies.
  2. Canwu::new_with_plugins(35, …) creates the run. 35 is the random seed; the same seed and inputs always give the same result.
  3. order_movement builds a typed domain command. .at_time(canwu.time()) sets the simulation time at which the command must be admitted; admission at any other time rejects it with SimulationTimeConflict.
  4. CommandRequest::new(id, canwu.revision(), command) adds a request ID and the state revision the command was written against.
  5. enqueue_command(time, 0, request) queues the command at the current time with priority 0.
  6. advance_canonical(SimDuration::hours(19)) settles a boundary (one atomic settlement step) whenever queued work falls due. The road east takes 18 hours, so 19 hours covers the arrival.
  7. reference_snapshot(&canwu) reads the reference world’s trusted projection.

Run the full example from the repository root:

Terminal window
cargo run -p canwu-reference-world --example starter

The full example also saves, loads, forks, and replays the run and checks that every checkpoint hash matches. A real application builds its own Scenario and plugins; use demo_scenario() as a template.

Ingress is how outside input enters the simulation. Canwu records every input in order, which is what makes saves and exact replay possible. Your integration turns UI or agent intent into a typed command, then sends it through one of these methods:

Method Returns Use it when
submit(CommandEnvelope) CommandReceipt Quick tools and tests. The command applies at once, with no request ID. Runs created with a declared RunConfiguration reject it.
process_command(CommandRequest) CommandOutcome A player or service sends a command that should apply now. Expected rejections, such as InvalidAuthority, IssuerUnavailable, or a stale expected_revision (SimulationRevisionConflict), are recorded and returned as CommandOutcome::Rejected.
enqueue_command(due_at, priority, CommandRequest) IngressReceipt The command should take effect at a set simulation time. It is admitted at the boundary for due_at; among commands due at the same time, higher priority goes first.

All three validate the command and its authority before anything changes; the host has no mutable reference to the world.

Use one style per run. Once a run has queued ingress, process_command returns MixedCommandIngress, and queued ingress cannot start after commands were applied directly. submit cannot share a run with tracked requests (commands sent as a CommandRequest) either. If the run also uses plugin ingress or queued decisions, send commands with enqueue_command.

Host loop: the host converts wall time into simulation minutes, advance_canonical settles due boundaries, and the host refreshes presentation from receipts, events, and projections. View diagram source.
View diagram source
flowchart LR
  Wall["Wall clock or turn input"] --> Convert["Host speed policy: whole SimDuration minutes"]
  Input["Player or agent input"] --> Queue["enqueue_command: queued input"]
  Convert --> Advance["advance_canonical(duration)"]
  Queue --> Advance
  Advance --> Out["BoundaryReceipt list and new events"]
  Out --> Render["Host refreshes UI from projections"]
  1. Read wall time, turn input, or research-workflow progress.
  2. Convert it to a SimDuration with your own speed policy. Simulation time counts whole minutes, so keep any sub-minute remainder in the host.
  3. Call advance_canonical(duration). It settles a boundary at each due time of queued work inside the window, moves the clock to the end, and returns one BoundaryReceipt per boundary. Call step_canonical() to settle only the next due boundary, or settle_boundary(BoundaryRequest) to settle one at a time you choose.
  4. Refresh presentation from the new events, the receipts, or a read-only projection from your domain integration.

advance(duration) is the older, event-only path. It runs scheduled actions but returns InvalidBoundary if queued ingress falls due inside the window, so prefer advance_canonical.

Pass only whole SimDuration values to Canwu; keep floating-point frame deltas and interpolation in the host. The continuous-time game loop tutorial and its example show the full pattern, including game speed, pause, and an accumulator for partial minutes.

enqueue_plugin_ingress queues a plugin packet (PluginIngressRequest) for a later simulation time. While the packet is still queued and its due time has not arrived, its issuer can withdraw it:

let queued = canwu.enqueue_plugin_ingress(request)?;
// The player withdraws the order before it is due.
canwu.cancel_plugin_ingress(queued.ingress_id, "withdrawn by player")?;

Only the issuer can withdraw an item. Pick the call that matches who queued it:

Who queued the item How to withdraw it
The host, for a public packet type canwu.cancel_plugin_ingress(ingress_id, reason)
The host, for an internal packet type, or the owning plugin inside the engine canwu.cancel_permitted_plugin_ingress(ingress_id, &permit, reason) with the owning plugin’s PluginIngressPermit
A plugin’s boundary system Return BoundaryDirective::CancelPluginIngress from a boundary system of the same plugin. SimulationView::cancellable_plugin_ingress lists the items it may withdraw.

The reason must be non-empty, have no leading or trailing spaces, and be at most 1,024 bytes (MAX_INGRESS_CANCELLATION_REASON_BYTES).

A withdrawal is written to the ingress journal as its own IngressPayload::PluginCancellation record. The record names the withdrawn item, the IngressCancellationAuthority (Host, PluginPermit, or BoundarySystem), and the reason. The withdrawn item never reaches a boundary, and nothing else changes. Snapshots, checkpoint journals, and exact replay keep the withdrawal.

Errors from the host methods:

Error Cause
LateIngress The item is already due, admitted, archived, or withdrawn.
InvalidAuthority The caller did not issue the item, for example the host trying to withdraw an item a plugin scheduled.
EvidenceUnavailable No item has that ID.
InvalidPayload The reason is invalid, or the ID names something other than plugin ingress.

A CancelPluginIngress directive with an invalid target fails the whole boundary.

Canwu stores one PersonAvailability value per person. It has a life state (LifeState: Alive, Dead, Missing), a custody state (CustodyState: Free, Detained, Hostage, Captive, Hiding, Exile), an optional custodian, and the time it took effect. A person with no stored value is alive and free.

Your application decides when someone dies, is captured, or is released. Record the change from a phase-7 or phase-10 boundary system that declares StateKey::core_person_availability() as a write:

BoundaryDirective::SetPersonAvailability {
person,
availability: PersonAvailability::new(LifeState::Alive, CustodyState::Captive, at)
.with_custodian(EntityRef::Army(captor)),
summary: "Captured at the river crossing".to_owned(),
}

Two changes for the same person in one boundary fail that boundary. The host reads the value with canwu.person_availability(person); a boundary system reads it with SimulationView::person_availability after declaring the same key as a read.

A person is unavailable when they are not Alive, or when they are Detained or Captive (PersonAvailability::is_available returns false). Hostage, hiding, and exile still allow action; add stricter rules in your own systems if your setting needs them.

A controller’s authority person is the actor of DecisionAuthority::Actor, or the named responsible actor of DecisionAuthority::Institution. Council and NoResponsibleActor authorities have none. For decision tickets, controllers, and seats in context, see the warlord aid decision.

Situation Result
A command’s issuer, decision-origin actor, or institution’s responsible actor is unavailable Admission rejects it with IssuerUnavailable. A tracked request records the rejection.
A ticket’s person decision maker is unavailable The ticket cannot be opened or prepared (DecisionMakerUnavailable).
A ticket’s controller acts for an unavailable authority person The ticket cannot be opened, prepared, or resolved (IssuerUnavailable).
A person becomes unavailable during a boundary At the end of that boundary, their open tickets are cancelled: decision_maker_unavailable where they are the decision maker, controller_authority_unavailable where the controller acts for them.

A cancelled ticket stays cancelled. To continue the decision, open a new ticket whose parent_ticket names the cancelled one:

  • Only the controller’s authority person is unavailable. Open a new ticket for the same decision maker, assigned to a controller whose authority person is available.
  • The decision maker died or was captured. Use seat succession: make the successor the decision maker, and assign the ticket to a controller bound to the same seat_id as the cancelled ticket’s controller.

The parent must be a terminal ticket that is still in hot decision history, meaning it has not been moved to the decision archive; an archived parent is rejected with TicketNotFound. A parent with a different decision maker and a different seat is rejected. You cannot open a ticket for a decision maker who is still unavailable.

Boundary systems in phases 7 and 12 can record evaluation traces: term-by-term breakdowns of how a rule produced a number. Players read them through the actor-relative view. The run configuration limits traces for all systems in one boundary together:

Limit Default Maximum
traces_per_boundary 4,096 65,536
terms_per_trace 32 256

To use other limits, call RunConfiguration::with_evaluation_limits(EvaluationLimitsV1 { traces_per_boundary, terms_per_trace }) on the configuration you pass to Canwu::new_with_run_configuration_and_plugins. A traces_per_boundary of 0 turns traces off for the run.

If the systems in one boundary exceed either limit, the boundary fails with EvaluationTraceLimitExceeded and commits nothing. Size the limits for every system and seat in a boundary combined. Traces also add to journal size.