Files
snow_trail/docs/self-gating-systems.md
2026-03-28 13:23:36 +01:00

5.2 KiB

Intent-Based Architecture

Systems communicate through intents — one-frame typed data in shared queues — not through direct function calls. This decouples producers from consumers: the system that wants something to happen does not know or care which system processes it.

The Pattern

Producer                    World (shared state)          Consumer
────────                    ────────────────────          ────────
detects dialog change
  ↓
insert(camera, CameraTransitionIntent)
                            ↓
                            camera_transition_intents
                                                          camera_intent_system reads it
                                                          sets up CameraTransition component
                                                          removes intent

An intent is ephemeral (one frame). The state change it triggers (a component mutation) persists.

Intent Types

Intents are plain structs stored in Storage<T> on World, keyed by target entity:

// components/intent.rs
pub struct FollowPlayerIntent;
pub struct StopFollowingIntent;
pub struct CameraTransitionIntent { pub duration: f32 }
// world.rs
pub follow_player_intents: Storage<FollowPlayerIntent>,
pub stop_following_intents: Storage<StopFollowingIntent>,
pub camera_transition_intents: Storage<CameraTransitionIntent>,

Adding a new intent = one struct + one storage field. Nothing else changes.

Producing Intents

Any code with access to the storage can submit. The entity is the target:

// Event handler wants camera to follow player — doesn't call camera functions
world.follow_player_intents.insert(camera_entity, FollowPlayerIntent);

// Dialog system detects state change — doesn't know how transitions work
world.camera_transition_intents.insert(camera_entity, CameraTransitionIntent { duration: 0.8 });

Producers don't import consumer modules. They only know about the intent type and the storage.

Consuming Intents

A consumer system reads, acts, and removes:

pub fn camera_intent_system(world: &mut World) {
    // 1. Read
    let follow_entities = world.follow_player_intents.all();
    for entity in follow_entities {
        // 2. Act — all the follow setup logic lives here
        start_camera_following(world, entity);
        // 3. Remove (consume)
        world.follow_player_intents.remove(entity);
    }
    // ... same for stop_following_intents, camera_transition_intents
}

The consumer owns the implementation. start_camera_following is a private function inside the camera module — no other module can call it directly.

Why This Matters

Decoupling. The dialog system doesn't import camera functions. It inserts a CameraTransitionIntent. If the camera system changes how transitions work, the dialog system is unaffected.

Multiple producers, same path. Editor toggle, dialog state changes, and future cutscene systems all produce the same CameraTransitionIntent. They all go through the same processing. No special cases.

Testability. To test camera transitions: insert a CameraTransitionIntent, call camera_intent_system, check the result. No need to simulate dialog state or editor toggles.

Additive behavior. To add a new reaction to StopFollowingIntent (e.g., play a sound), write a new system that reads the intent before the camera system consumes it. No existing code changes.

Execution Order

The main loop is a flat pipeline. Order encodes causality:

Input + intent generation    (camera_input, player_input, dialog_transition_detect)
      ↓
Intent processing            (camera_intent_system)
      ↓
Camera behavior              (noclip, dialog_camera, follow, transition, ground_clamp)
      ↓
Editor overlay               (UI only — not a game system)
      ↓
Fixed-step physics           (state_machine, physics, triggers, dialog)
      ↓
Per-frame systems            (state_machine, rotate, trees, spotlights, snow)
      ↓
Render

Moving a system changes the data flow. The order is the contract.

Systems Also Self-Gate

Because intents express what should happen, systems naturally have nothing to do when their data is absent:

  • camera_follow_system — no FollowComponent = no work
  • dialog_camera_system — no active bubbles = no work
  • player_input_system — camera not following = no player input
  • camera_noclip_system — camera has FollowComponent = skip

The main loop doesn't branch on mode flags. Systems check their own data.

When to Use Intents vs. Direct Mutation

Situation Approach
One system wants another to do something Intent
A system updating its own components Direct mutation
Per-frame continuous computation Components + tick system
Persistent state (is the camera following?) Component (FollowComponent)
One-shot request (start following) Intent (FollowPlayerIntent)

Adding New Intents

  1. Define the struct in components/intent.rs
  2. Add a Storage<T> field to World (+ new() + despawn())
  3. Producers insert into the storage
  4. A consumer system reads, acts, and removes
  5. Done — no other code changes