Skip to content

API Shuttle

Maria Aurelia Heine edited this page Jul 31, 2026 · 12 revisions

🧬 API β€Ί Shuttle

🧬 Shuttle - Async Request/Response

πŸ“œ Shuttles are neat little fairies that specialized in questions that need some time before they can be answered. You interact with them by simply .Ask()ing.

Shuttle is a pattern for triggering operations and awaiting their outcomes – save/load, network calls, path computation, anything with latency or a success/failure result.

  • For sync live state (player health, game settings, etc.), prefer [HasState] matter types instead.
  • Shuttle operates on two specialised Matter types (Request and Response<T>), it is a round-trip pair so you need to define both.
/* 
 * πŸ“œπŸ§­ Since the two types are useless apart, it helps to keep them in one .cs file.
 * SaveGameRR or SaveGameRequestResponse, whichever you prefer.
 */ 

class SaveGameRequest : Request { }

class SaveGameResponse : Response<SaveGameRequest>
{
    public SaveGameResponse(SaveGameRequest request, bool wasSuccessful)
        // The `: base(request, wasSuccessful)` constructor call is required.
        : base(request, wasSuccessful) { }
}
  • The response carries a reference back to the original request.
    • That's what lets Ask route reply to the correct caller (via IsRespondingTo) when multiple requests of same type are in flight.
    • It's also how the request is recorded as response's cause automatically, correct even across an async boundary.

Usage

Register a handler with Shuttle.

  • Shuttle's primary use case crosses an async boundary, so wrap the operation in Observable.Create.
  • Note there is no manual circumstance stamping here - the response carries req, and rzeka records it as the cause for you:
Q += rzeka.Shuttle<SaveGameRequest, SaveGameResponse>(
    this,
    reqs => reqs.SelectMany(req =>
        Observable.Create<SaveGameResponse>(observer =>
        {
            _saveSystem.SaveAsync(success =>
            {
                observer.OnNext(new SaveGameResponse(req, success));
                observer.OnCompleted();
            });
            return Disposable.Empty;
        })
    )
);

πŸ“œ You only stamp circumstances on a Shuttle response when it has causes beyond the request - e.g. ambient state you pulled in via Scry (see Multi-context Response using Scry). The request itself is always handled automatically. If your async callback also does engine work (touching the scene tree, etc.), that's a separate concern - see the threading rules in Async Operations.

Ask - Request Side

πŸ“œ Send a request into the river and receive an observable that emits only the response to your specific request - not responses to other concurrent requests of the same type.

When Ask(ing) you also have to manually stamp the request matter circumstances with .WithCircumstances(...circumstances).

// Inside a Loom: on level completion, save then show results
Q += rzeka.Loom<LevelCompletedEvent, ResultsScreenRequested>(
    this,
    levelCompletedEvent => levelCompletedEvent.SelectMany(evt =>
        rzeka.Ask<SaveGameRequest, SaveGameResponse>(
                this,
                new SaveGameRequest().WithCircumstances(evt))
             .Select(save => new ResultsScreenRequested(evt.Score, save.WasSuccessful)
                 .WithCircumstances<ResultsScreenRequested>(evt, save)))
);

πŸ“œπŸ§¨ Ask responses are invisible to Loom's auto-tracking. Loom only auto-stamps matter from its declared input slots - the LevelCompletedEvent here. Anything pulled in inside the lambda (an Ask response, a Scry'd state, an async callback result) is not seen by auto-tracking, so without a manual stamp the SaveGameResponse above would be silently dropped from the causal graph. Whenever the output's true causes include something from outside the declared input slots, stamp it yourself via .WithCircumstances<T>(...) - same rule as Async Operations and the multi-context Shuttle response.

πŸ“œπŸ§¨ Avoid nesting Asks. Multi-step chains written as nested SelectMany / Zip inside a single Loom are hard to read and fight rzeka's model. Decompose them into separate Looms and Shuttles instead - each step stays readable, owns one responsibility, and causality flows automatically through the river.

Multi-context Response using Scry

πŸ“œ When the response depends on more than just the request, pull the additional matter in via Scry inside the lambda.

Q += rzeka.Shuttle<LoadSceneRequest, LoadSceneResponse>(
    this,
    // The request remains the trigger, Scry'd matter is read as ambient context
    reqs => reqs.WithLatestFromMatter(rzeka.Scry<GameState>(), rzeka.Scry<Settings>())
        .Select(ctx =>
        {
            var (req, state, settings) = ctx;
            // Stamp only the extra Scry'd context. The request is recorded
            // automatically, so the response ends up with [request, state, settings].
            return new LoadSceneResponse(req, wasSuccessful: true)
                .WithCircumstances<LoadSceneResponse>(state, settings);
        })
);

See also: Scry · Loom · Async Operations · circumstance rules · 🧬 API overview

Clone this wiki locally