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.
Add dependencies
Section titled “Add dependencies”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-apiis the supported public API; its API docs are on docs.rs. Application code depends on it and on any extension crates it uses, such ascanwu-resource. Extension crates are also published on crates.io; use the same version number ascanwu-api. Do not depend oncanwu-simor other runtime crates directly.canwu-reference-worldis 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 thev0.13.0tag 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.iocanwu-api. Ifcanwu-apicomes 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).
Minimal host flow
Section titled “Minimal host flow”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:
demo_scenario()builds a three-territory world and returns the IDs of its people, army, and territories.ReferenceWorldPluginregisters the movement command and the boundary system that moves armies.Canwu::new_with_plugins(35, …)creates the run.35is the random seed; the same seed and inputs always give the same result.order_movementbuilds 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 withSimulationTimeConflict.CommandRequest::new(id, canwu.revision(), command)adds a request ID and the state revision the command was written against.enqueue_command(time, 0, request)queues the command at the current time with priority0.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.reference_snapshot(&canwu)reads the reference world’s trusted projection.
Run the full example from the repository root:
cargo run -p canwu-reference-world --example starterThe 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.
Choose an ingress path
Section titled “Choose an ingress path”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.
Drive time
Section titled “Drive time”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"]
- Read wall time, turn input, or research-workflow progress.
- Convert it to a
SimDurationwith your own speed policy. Simulation time counts whole minutes, so keep any sub-minute remainder in the host. - 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 oneBoundaryReceiptper boundary. Callstep_canonical()to settle only the next due boundary, orsettle_boundary(BoundaryRequest)to settle one at a time you choose. - 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.
Withdraw queued plugin input
Section titled “Withdraw queued plugin input”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.
People who can no longer act
Section titled “People who can no longer act”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.
What an unavailable person cannot do
Section titled “What an unavailable person cannot do”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. |
Continue a cancelled decision
Section titled “Continue a cancelled decision”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_idas 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.
Limit evaluation traces
Section titled “Limit evaluation traces”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.