Skip to content

An encounter that replays roll for roll

A tester says the boss one-shot them. You load the same save, fight the same fight, and it does not happen — because ten CritRule components rolled ten independent streams and none of them will ever line up that way again. What you want is one stream, one seed, and a fight that reproduces.

Recipe

Systems required: Health. Package: Health & Status Effects, or Complete. Shape: one class you drop into a project that already exists. No prefab, no bespoke scene. Public API only. It assumes: you have CritRule components on your damage rule hubs. You point each one at this component in the inspector. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.

The part that is not obvious

CritRule gives you two ways to supply an RNG, and they do not behave the same way.

public void SetRng(IRng custom);                          // assigns the active RNG directly
public void SetExternalProvider(MonoBehaviour provider);  // assigns the serialized field

The first one looks like the obvious choice. It is the wrong one for anything that has to last.

SetRng does not survive the next enable

ConfigureRng() rebuilds the active RNG from the serialized policy, and it runs from both Awake and OnEnable:

private void Awake()    { ConfigureRng(); }
private void OnEnable() { ConfigureRng(); ... }

Nothing in there knows that you called SetRng. So your generator is installed, works perfectly, and is silently replaced the next time that object is enabled. On a pooled enemy that is the second spawn — the first fight reproduces, every fight after it does not, and nothing logs anything.

The durable door is the serialized one. Set RngSource to ExternalProvider and drag this component into rngProvider. ConfigureRng re-resolves that field on every run, so being re-enabled reinstates the provider instead of dropping it.

Which is also why this is a MonoBehaviour

The serialized field is typed MonoBehaviour, and SetExternalProvider takes one. So the door that holds only accepts a component. A plain C# class implementing IRng can only be installed through SetRng — the door that does not.

Set the policy and forget the field, and you get silent non-determinism

This is the one mistake worth checking for, because nothing tells you about it. ConfigureRng warns only when the field holds something that isn't an IRng:

_rng = rngProvider as IRng;
if (_rng == null && rngProvider != null)
    Debug.LogWarning("[CritRule] ExternalProvider selected, but rngProvider doesn't implement IRng.");

Leave it empty and the condition is false, so there is no warning at all — _rng stays null, and Apply falls through to UnityEngine.Random.value. Your crits are back to being unreproducible, from the one component whose entire job is reproducing them, and the only symptom is that a replay quietly disagrees.

Check Draws after a fight. If it is zero, nothing drew from this and the field is not wired up.

What this buys that the built-in seed does not

CritRule already has a seed field, so it is fair to ask what a custom provider adds.

Each rule seeds its own generator. Ten actors are ten independent streams, and "the encounter" is not a thing any of them can reproduce. Point them all at one of these and they draw from a single sequence, so the run replays as a unit: same seed, same hits, same crits, in that order.

Draw order is hit order — this is the real limit

A shared stream only reproduces if the hits arrive in the same order. Replaying the encounter means replaying whatever drove it; nothing here makes an out-of-order run match. If your combat is already deterministic given the same inputs, this closes the last gap. If it is not, this makes the gap visible rather than fixing it.

Two details in Next01 that are not style

Both of these are one line, and both are load-bearing.

The range is half-open, and the framework depends on it. CritRule compares roll < chance. A generator that can return exactly 1.0f would make a chance of 1 — the natural way to write "this always crits" — fail to crit on that draw.

return (_state >> 8) * (1f / 16777216f);

Twenty-four bits over 2²⁴ is exactly representable in a float, and its largest value is 16777215/16777216. It cannot round up to 1.0f the way dividing by uint.MaxValue can.

Zero is xorshift's one dead state. It maps to itself forever, so an unseeded generator returns 0f on every draw — and 0f is below every chance above zero, so everything crits. It is a stuck stream that still looks like a stream.

And zero is exactly the seed a user reaches for

It is the obvious value to type, and it is also CritRule's own sentinel for unspecified seed. So Restart() folds 0 to a fixed non-zero constant rather than refusing it.

There is a second way in: seeding happens in Awake, and Awake is not guaranteed to have run. A component added by editor tooling — or by an EditMode fixture — is a live IRng that never awoke, with _state still at its default of zero. Next01 therefore re-seeds itself if it finds that state. Zero is unreachable once seeded, because the xorshift step is a bijection on the non-zero states, so it is a sound sentinel for never initialised rather than a value to defend against.

Previews do not consume the stream, and this recipe leans on that

CritRule.Apply returns before the draw when the context is a preview:

if (ctx.IsPreview) return true;

That was fixed in the framework because drawing there meant every HUD repaint advanced the sequence the real hits drew from — two previews of the same hit disagreed, and a seeded run stopped reproducing. Worth knowing here because a shared stream would be the worst case for it: one previewing HUD would reshape the crit stream for every actor in the scene, at whatever rate it happened to repaint.

Drop it in

ReplayableCrits.cs
using UnityEngine;

using RevGaming.RevFramework.Health.Rules.Abstractions;

namespace RevGaming.RevFramework.Cookbook.ReplayableCrits
{
    /// <summary>
    /// One seeded crit stream shared by every <c>CritRule</c> that points at it, so a whole encounter
    /// replays roll for roll.
    /// </summary>
    /// <remarks>
    /// <para><b>There are two doors into this seam and only one of them holds.</b> <c>CritRule</c>
    /// exposes <c>SetRng(IRng)</c>, which assigns the active RNG directly — and <c>ConfigureRng()</c>,
    /// which reassigns it from the serialized policy, runs from <b>both</b> <c>Awake</c> and
    /// <c>OnEnable</c>. So a <c>SetRng</c> install survives until the next time that object is
    /// enabled and is then silently replaced, which for anything pooled means it is gone by the
    /// second spawn and the crits quietly stop being reproducible. The durable door is the serialized
    /// one: set <c>RngSource</c> to <c>ExternalProvider</c> and assign this component to
    /// <c>rngProvider</c>. <c>ConfigureRng</c> re-resolves that field every time it runs, so
    /// re-enabling reinstates the provider instead of dropping it.</para>
    ///
    /// <para><b>That is also why this is a <c>MonoBehaviour</c> rather than a plain class.</b> The
    /// serialized field is typed <c>MonoBehaviour</c> and <c>SetExternalProvider</c> takes one, so
    /// the durable door only accepts a component. A plain C# object implementing <see cref="IRng"/>
    /// can only be installed through <c>SetRng</c> — the door that does not hold.</para>
    ///
    /// <para><b>Selecting the policy and leaving the field empty is silent.</b> <c>ConfigureRng</c>
    /// warns only when <c>rngProvider</c> holds something that is not an <see cref="IRng"/> — an
    /// empty field fails that condition, so <c>_rng</c> stays null with no warning and <c>Apply</c>
    /// falls through to <c>UnityEngine.Random.value</c>. The crits are unreproducible again, from the
    /// component whose whole purpose is reproducing them, and the only symptom is a replay that
    /// disagrees. <see cref="Draws"/> is the check: zero after a fight means nothing drew from
    /// this.</para>
    ///
    /// <para><b>What this buys that a per-rule seed cannot.</b> <c>CritRule</c> already has a seed
    /// field, but each component seeds its own generator, so ten actors are ten independent streams
    /// and the encounter as a whole is not reproducible. Every rule pointed at one of these draws
    /// from a single sequence, so the run replays as a unit: same seed, same hits, same crits.</para>
    ///
    /// <para><b>Draw order is hit order, and that is the honest limit.</b> A shared stream is
    /// reproducible only if the hits arrive in the same order — replaying the encounter requires
    /// replaying the inputs that drove it. Nothing here makes an out-of-order run match.</para>
    ///
    /// <para><b>Previews do not disturb the stream, and this recipe depends on that.</b>
    /// <c>CritRule.Apply</c> returns before the draw when the context is a preview, because drawing
    /// there meant every HUD repaint advanced the sequence the real hits drew from. Were that not
    /// already true, a shared stream would be reshaped continuously by whatever happened to be
    /// previewing, at repaint rate.</para>
    /// </remarks>
    [AddComponentMenu("RevFramework/Cookbook/Replayable Crits")]
    public sealed class ReplayableCrits : MonoBehaviour, IRng
    {
        [Tooltip("Any non-zero value. The same seed replays the same sequence of crit rolls.")]
        [SerializeField] private int seed = 12345;

        [Tooltip("Restart the sequence from the seed whenever this component is enabled. Leave off " +
                 "to let one stream run across the whole session.")]
        [SerializeField] private bool restartOnEnable = true;

        private uint _state;
        private int _draws;

        /// <summary>The configured seed. The same seed reproduces the same sequence.</summary>
        public int Seed => seed;

        /// <summary>How many values have been drawn since the last restart.</summary>
        /// <remarks>
        /// The stream's position. Two runs that agree on seed and draw count have drawn the same
        /// numbers, which makes this the cheapest way to see that a replay actually diverged and
        /// where.
        /// </remarks>
        public int Draws => _draws;

        private void Awake() => Restart();

        private void OnEnable()
        {
            if (restartOnEnable)
                Restart();
        }

        /// <summary>Restarts the sequence from the configured seed.</summary>
        public void Restart()
        {
            // xorshift32 has one dead state: zero maps to zero forever, so a seed of 0 -- the
            // obvious value to type, and CritRule's own "unspecified seed" sentinel -- would return
            // 0f on every draw and crit at any chance above zero. Fold it to a fixed non-zero
            // constant rather than refusing it.
            _state = seed == 0 ? 0x9E3779B9u : unchecked((uint)seed);
            _draws = 0;
        }

        /// <summary>Re-seeds and restarts the sequence.</summary>
        /// <param name="value">The new seed. Zero is folded to a fixed non-zero state.</param>
        public void Reseed(int value)
        {
            seed = value;
            Restart();
        }

        /// <summary>Returns the next value in the sequence, in the range [0, 1).</summary>
        /// <remarks>
        /// <para><b>The half-open range is load-bearing rather than a formality.</b>
        /// <c>CritRule</c> compares <c>roll &lt; chance</c>, so a generator that can return exactly
        /// 1.0f would make a chance of 1 — the natural way to say "this always crits" — fail to crit
        /// on that draw. Twenty-four bits over 2^24 is exactly representable in a float and its
        /// largest value is 16777215/16777216, so this cannot round up to 1.0f the way dividing by
        /// <c>uint.MaxValue</c> can.</para>
        /// </remarks>
        public float Next01()
        {
            // Seeding lives in Awake, which is not guaranteed to have run: a component added from
            // editor tooling or an EditMode fixture is a live IRng that never awoke. Zero is the one
            // state xorshift32 cannot leave, so an unseeded provider would return 0f forever and crit
            // at any chance above zero -- a stuck stream that still looks like a stream. Zero is also
            // unreachable once seeded, because the step is a bijection on the non-zero states, so it
            // is a sound sentinel for "never initialised" rather than a value to defend against.
            if (_state == 0)
                Restart();

            _state ^= _state << 13;
            _state ^= _state >> 17;
            _state ^= _state << 5;

            _draws++;

            return (_state >> 8) * (1f / 16777216f);
        }
    }
}

Wiring it up

  1. Put one of these in the scene — one per stream. Every CritRule you point at it draws from that single sequence, which is the whole difference between this and each rule's own seed.
  2. On each CritRule, set Rng Source to ExternalProvider and drag this component into Rng Provider. That is the durable door: ConfigureRng re-resolves the field from Awake and OnEnable, so a re-enable reinstates it.
  3. Do not install it with SetRng. It compiles, it works once, and the next enable of that object throws it away — see the box above. The field is the supported route.
  4. Pick a seed. Any non-zero int; zero is folded to a fixed non-zero state rather than refused, because zero is xorshift32's one dead state.
  5. Decide restartOnEnable. On means each enable replays the same fight from the top, which is what you want while debugging one encounter. Off means one stream runs for the session.
  6. Check Draws after a fight. Zero means nothing drew from this — the usual cause is a CritRule left on InternalSystem, or one set to ExternalProvider with an empty provider field, which ConfigureRng does not warn about.