Repository navigation
API Shuttle
𧬠API ⺠Shuttle
π 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 (
RequestandResponse<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
Askroute reply to the correct caller (viaIsRespondingTo) 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.
- That's what lets
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.
π 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)))
);ππ§¨
Askresponses are invisible to Loom's auto-tracking. Loom only auto-stamps matter from its declared input slots - theLevelCompletedEventhere. Anything pulled in inside the lambda (anAskresponse, aScry'd state, an async callback result) is not seen by auto-tracking, so without a manual stamp theSaveGameResponseabove 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/Zipinside 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.
π When the response depends on more than just the request, pull the additional matter in via
Scryinside 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