Skip to content

API Loom

kimja edited this page Sep 29, 2026 · 4 revisions

🧬 API β€Ί Loom

🧬 Loom - transform

Looms one or more streams and into a single new stream. Mapping, combining, and transforming matter.

πŸ“œ Single input example:

// Transform health change events into UI update events
Q += rzeka.Loom<MoonPositionChanged, SpellPowerChanged>(
    this,
    moon => moon.Select(e => {
        var spellPower = EstimateSpellPower(e.MoonPosition);
        return new SpellPowerChanged(spellPower);
    })
);

πŸ“œ Circumstance tracking

  • Loom automatically attaches the triggering input matter as a circumstance on the output – but only matter arriving through its declared input slots.
  • Anything you reach for from within the lambda (an Ask response, Scry'd state or async callback result) is invisible to the automatic tracking and must be stamped manually (see circumstance rules).

_πŸ“œ Two inputs using CombineLatestMatter:

Q += rzeka.Loom<SpellPowerChanged, ActiveBuffsState, BuffsStrengthUpdateRequested>(
    this,
    (powerChange, buffsState) => powerChange.CombineLatestMatter(buffsState)
        .Where(t => t.Item2.ActiveBuffs.Length != 0)
        .Select(t => new BuffsStrengthUpdateRequested(t.Item2.ActiveBuffs))
);

Three inputs:

Q += rzeka.Loom<InputEvent, PhysicsState, GameState, MovementCommand>(
    this,
    (inputs, physics, game) => inputs
        .CombineLatestMatter(physics, game)
        .Select(/* ... */)
);

🧬 Loom input chaining requirement

πŸ“œ The lambda must chain/eat/yumπŸ˜‹ from its every declared input observable.

This means:

1. Don't just Observable.Return(...) (or similar) in Looms. If you don't care about the contents of matter that triggered this Loom, just use a discard in its Select lambda.

// Wrong: Observable.Return fires at internal wiring time without waiting for GameReadyToLoad to actually arrive
Q += rzeka.Loom<GameReadyToLoad, PlayerScoreState>(
    this,
    events => Observable.Return(new PlayerScoreState(0))
);

// Correct: use _ to discard the value when you only care about the trigger
Q += rzeka.Loom<GameReadyToLoad, PlayerScoreState>(
    this,
    events => events.Select(_ => new PlayerScoreState(0))
);

πŸ“œπŸ‘©πŸ»β€πŸ”¬ Every matter type you request in your Loom call is passed to your lambda spell as an IObservable<T>. If you ignore an input and return an independent Observable generator instead, that generator fires at an incorrect time, not when the requested input actually arrives. You will not accidentally miss out on this, rzeka tracks if declared inputs were subscribed to and if the output fires without that – a Horror is published to Eris telling you where it happened.

2. For multi-input Looms, all declared inputs must be subscribed to. If your Loom2 or Loom3 lambda only chains from a part of the declared types, connect the rest in using WithLatestFromMatter or CombineLatestMatter extensions (or your own idea of combining them):

// Wrong: state is declared but never subscribed to - only collected is used
rzeka.Loom<PlayerScoreState, StarCollected, PlayerScoreState>(
    this,
    (state, collected) => collected.Select(_ => new PlayerScoreState(99))
);

// Correct: subscribe to state via WithLatestFromMatter so both inputs are wired in
rzeka.Loom<PlayerScoreState, StarCollected, PlayerScoreState>(
    this,
    (state, collected) =>
        collected
            .WithLatestFromMatter(state)
            .Select(pair => new PlayerScoreState(pair.Item2.Score + 1))
);

🧬 Side-effects

  • Most side effects do not belong in a Loom at all.
  • If such effect is a consequence of the matter produced by that loom, it simply belongs in a Weave.
  • Only when this effect is what makes the emitted matter true then you might need side-effects.
  • However!
    • A Loom performing side-effects is often a Shuttle in disguise.
    • If it both performs work and then reports it – it is basically a Shuttle typed out by hand.
    • So consider Shuttling instead. It will give you request/response correlation for free – with .Ask() there is no need for manual.IsCircumstancedBy().

πŸ“œ Perform

If you really need that side-effect, or the case is too small to bother with an entire Shuttle, prefer .Perform() extension method. It is nothing more but syntax-sugar over .Do and .Select, but it will help you see at first glance where the side-effects are happening.

Two overloads:

  • .Perform(Action<T>) - run the effect, pass matter through unchanged. Sugar over .Do().
  • .Perform(Func<T, TOut>) - run the effect and produce output matter in one step. Sugar over .Select().

Warnings:

  • It is invisible to Eris. A Weave is a spell and gets a spell occurrence. .Perform is a lambda – nothing records it. This means you are trading graph visibility for sequencing.
  • It re-runs inside inner sequences. A spell's outer chain is subscribed exactly once, so the classic .Do hazards mostly do not apply. Inner sequences are different: anything inside a SelectMany, or under a user-side .Retry, runs again per attempt.

Two examples:

// Correct. ScreenShook asserts the screen shook – which is only
// true because Shake() already ran. The effect makes the matter true.
Q += rzeka.Loom<DamageReceived, ScreenShook>(
    this,
    damage => damage
        .Where(dmg => dmg.Amount > 15f)
        .Perform(dmg =>
        {
            _camera.Shake(dmg.Amount);
            return new ScreenShook(dmg.Amount);
        })
);
// Wrong. XpGranted is not made true by writing a log line, 
// so the log should simply be a Weave consequence.
Q += rzeka.Loom<EnemyDefeated, XpGranted>(
    this,
    events => events
        .Perform(e => _combatLog.Record(e))
        .Select(e => new XpGranted(e.XpReward))
);

// Corrected.
Q += rzeka.Loom<EnemyDefeated, XpGranted>(
    this,
    events => events.Select(e => new XpGranted(e.XpReward))
);

Q += rzeka.Weave<EnemyDefeated>(
    this,
    events => events.Subscribe(e => _combatLog.Record(e))
);

See also: Weave · Shuttle · Async Operations · Extension Methods · 🧬 API overview

Clone this wiki locally