Skip to content

Buffs that run down while the game is closed

The ten-minute potion you left running has four minutes on it when you come back six minutes later. The one-minute one is gone.

Recipe

Systems required: Status Effects, plus Core for the save side. Package: Health & Status Effects, or Complete. Core ships in every package, so the save side costs nothing extra. Shape: one class you drop into a project that already exists. No prefab, no bespoke scene. Public API only. It assumes: the actor has a status effect controller, the scene has a RevSaveManager, and the framework's own StatusEffectsSaveParticipant is registered — this ages what that one restores, and does nothing without it. 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

Two systems in this framework answer the same question in opposite directions, and both are right.

Crafting credits shut-down time. A job whose timer elapsed while the game was closed delivers on the next load; CraftingSaveParticipant reconciles it as it restores, and While you were away is the recipe for reporting what happened.

Status Effects does not. Its participant restores each effect's remaining time exactly as it was written, so a potion saved with four minutes left has four minutes left a week later.

Neither is a defect. Whether a buff keeps burning while the player is logged off is a game-design decision, and the framework declines to make it for you — a survival game and a session-based shooter want opposite answers. Making it is a component, and the whole component is a subtraction.

The seam that looks right is the clock, and the clock is the wrong one

StatusEffectController takes a custom ITimeSource through SetTimeMode(StatusTimeMode.Custom, this). It is public, it is wired, and handing it the shut-down seconds as one enormous delta is the obvious implementation.

It is also how you kill a player who logged off poisoned

The controller ticks with whatever delta the clock hands over. A damage-over-time effect therefore delivers six hours of damage in one call, to a player who is still looking at the loading screen.

The framework's own remarks warn about this. TimedStatusEffect.Tick notes that the overshoot is normally bounded by Time.maximumDeltaTime, and then: "A custom ITimeSource is not bounded by anything, and can hand over an arbitrary step."

Slicing the debt across frames only spreads the damage out — it does not stop it — and at any slice size small enough to be safe, six hours of debt takes minutes of real time to pay off.

So this ages rather than runs

IStatusEffect.Refresh(float?) takes a new remaining time. Subtracting the elapsed seconds expires what should have expired and shortens what should be shorter, without a single tick firing.

Offline time expires effects; it does not run them. That is the decision this recipe makes, and it is stated rather than hidden: your poison is gone when you come back, not waiting with six hours of damage banked. If your game wants the damage, that is a different recipe and it needs a design answer for the loading-screen death first.

This is the fourth time in this Cookbook that a seam whose name matched the problem turned out to answer a different question — after ILootModifier (adjusts what was won, not what could drop), ICraftingOutputRouter (answers with a container name), and IHealable (implemented by nothing). Here the name and the signature are both fine; it is the verb that is wrong. A clock runs time. This feature needs time to have passed.

Nothing is removed here, and that is deliberate

An effect aged to zero reports IsExpired, and the controller reaps it on its next update with the full set of expiry events. That is better than this component could manage on its own:

RemoveStatus is the blunt instrument

IStatusEffectController.RemoveStatus takes a StatusId and removes every application sharing it. A hand-rolled removal would take the potion off along with the curse — the same limitation a cursed item ran into from the other direction.

Ageing to zero and letting the controller reap is per-instance, raises the right events, and is less code.

The catch: a paused controller reaps nothing

StatusEffectController.Update returns early when its delta is zero. So a game that loads while paused — timeScale at zero, or the controller in StatusTimeMode.Paused — shows expired buffs sitting at zero remaining until the first unpaused frame. Loading from a menu with the game paused is the common way to see this.

The same applies while a status authority is denying, which is what one switch that refuses everything does.

RevSaveOrder.Late is load-bearing, not decoration

This has to run after the status participant has put the effects back, or there is nothing to age and the buffs arrive at full time.

That is exactly the case IRevSaveOrdered documents: "a restore that writes into another system". Crafting is the framework's own instance of it. This is the same shape from a customer component, and it is four lines — one interface and one property.

Register the framework's status participant too, or this does nothing

This recipe deliberately does not save the effects. Duplicating what StatusEffectsSaveParticipant already does would be a second copy of a shipped feature, and it would drift from it.

The consequence is a dependency worth stating plainly: with no status participant registered, there is nothing to age, and two loads in a row would age the same live effects twice. With one registered — the normal setup — each load re-restores the effects first and the ageing is correct every time.

The envelope has a timestamp, and you cannot use it

RevSaveEnvelope.savedAtUtc is written on every save. Its own documentation says it is recorded "for diagnostics rather than logic", nothing in the coordinator branches on it, and no participant is handed it.

So carrying your own timestamp is the supported way to know how long the game was shut. It is two fields, and it means this section is self-contained.

The clock is a seam on purpose

The default is the device clock. A player can move that: winding it forward clears debuffs, winding it back freezes buffs.

Cap the offline credit, whichever clock you use

maxOfflineSeconds defaults to a day. It is not tuning — it is what stops one wrong clock reading wiping every buff in the game. Assign a server-backed IWallClockProvider to Clock if the device clock is genuinely untrusted; every offline-timer feature ever built has had to make this decision.

Effects that ignore Refresh are counted, not assumed away

Every shipped effect derives from TimedStatusEffect, whose Refresh assigns the new remaining time — and nothing in the framework overrides it, which was checked rather than assumed. A hand-written IStatusEffect need not honour it.

So the ageing compares the remaining time against where it started and reports what did not come down. An effect parked at float.MaxValue to mean "permanent" survives too — not because it is special-cased, but because float precision swallows the subtraction. Do not lean on that: an effect that means to be permanent should say so by ignoring Refresh.

Checking "did it take the value I asked for" instead reports the float.MaxValue case as successfully shortened, which is how this was originally written and what a probe caught.

Duration is left alone, which the framework's own restore cannot manage

StatusEffectsSaveParticipant has to make the saved remaining time be the effect's Duration, because applying overwrites TimeRemaining twice — so a restored effect reads full on Progress01 and NormalizedRemaining. Its remarks say so.

Ageing afterwards moves only TimeRemaining, so a buff bar filled from NormalizedRemaining is correct again after this component has run. A small thing, but it is the difference between a UI that looks right and one that has to be worked around.

Drop it in

OfflineBuffDecay.cs
using System;
using System.Collections.Generic;

using RevGaming.RevFramework.Core.Abstractions.Time;
using RevGaming.RevFramework.Core.Save;
using RevGaming.RevFramework.Core.Time;
using RevGaming.RevFramework.StatusEffects.Abstractions;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.OfflineBuffs
{
    /// <summary>
    /// Buffs that run down while the game is closed — the ten-minute potion you left running has four
    /// minutes on it when you come back six minutes later, and the one-minute one is gone.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Status Effects</b>, plus Core for the save side. Public API only. Put it on the actor whose
    /// buffs should age, give it a save key of its own, and register the framework's
    /// <c>StatusEffectsSaveParticipant</c> as well — this recipe ages what that one restores and does
    /// nothing without it.</para>
    ///
    /// <para><b>The gap this fills, and it is a real asymmetry.</b> Crafting credits shut-down time:
    /// a job whose timer elapsed while the game was closed delivers on the next load, and
    /// <c>CraftingSaveParticipant</c> says so. Status Effects does not — its participant restores each
    /// effect's remaining time exactly as it was written, so a potion saved with four minutes left has
    /// four minutes left a week later. Two systems in one framework, opposite answers to the same
    /// question, and neither is wrong: this is a game-design decision the framework declines to make
    /// for you. Making it is a component.</para>
    ///
    /// <para><b>The seam that looks right is the wrong one — the clock.</b>
    /// <c>StatusEffectController</c> takes a custom <c>ITimeSource</c> through
    /// <c>SetTimeMode(StatusTimeMode.Custom, this)</c>, and handing it the shut-down seconds as one
    /// enormous delta is the obvious implementation. It is also how you kill a player who logged off
    /// poisoned: the controller <i>ticks</i> with that delta, so a damage-over-time effect delivers
    /// six hours of damage in one call. <c>TimedStatusEffect</c>'s own remarks warn about it —
    /// <i>"a custom <c>ITimeSource</c> is not bounded by anything, and can hand over an arbitrary
    /// step"</i>. Slicing the debt across frames only spreads the damage out; it does not stop it, and
    /// at any sane slice size six hours takes minutes of real time to pay off.</para>
    ///
    /// <para><b>So this ages rather than runs.</b> <see cref="IStatusEffect.Refresh"/> takes a new
    /// remaining time, so subtracting the elapsed seconds expires what should have expired and
    /// shortens what should be shorter, without a single tick firing. <b>Offline time expires effects;
    /// it does not run them.</b> That is the decision, stated rather than hidden: your poison is gone
    /// when you come back, not waiting with six hours of damage.</para>
    ///
    /// <para><b>Nothing is removed here, and that is deliberate.</b> An effect aged to zero reports
    /// <see cref="IStatusEffect.IsExpired"/>, and the controller reaps it on its next update with the
    /// full expiry events — which is better than this component could manage:
    /// <c>IStatusEffectController.RemoveStatus</c> takes a <c>StatusId</c> and removes <i>every</i>
    /// application sharing it, so a hand-rolled removal would take the potion off with the curse.
    /// <b>The catch is that the controller's update returns early when its delta is zero</b>, so a
    /// game that loads while paused — <c>timeScale</c> at zero, or the controller in
    /// <c>StatusTimeMode.Paused</c> — shows expired buffs sitting at zero until the first unpaused
    /// frame. The same applies while a status authority is denying.</para>
    ///
    /// <para><b><see cref="RevSaveOrder.Late"/> is load-bearing.</b> This has to run after the status
    /// participant has put the effects back, or there is nothing to age. That is exactly what
    /// <see cref="IRevSaveOrdered"/> is for and the case its own documentation describes: a restore
    /// that reaches into another system's state.</para>
    ///
    /// <para><b>The timestamp is this component's own, because the envelope's is not for logic.</b>
    /// <c>RevSaveEnvelope.savedAtUtc</c> exists and is documented as diagnostic — nothing branches on
    /// it and no participant is handed it. Carrying your own is the supported way to know how long the
    /// game was shut, and it is two fields.</para>
    ///
    /// <para><b>The clock is a seam on purpose.</b> <see cref="Clock"/> defaults to the device clock,
    /// which a player can move. Winding it forward clears debuffs; winding it back freezes buffs. If
    /// that matters, assign a provider that reads your server's time — the same decision every
    /// offline-timer feature has to make, and the reason it is a property rather than a
    /// <c>DateTime.UtcNow</c> buried in the middle of a method.</para>
    ///
    /// <para><b>Effects that ignore <c>Refresh</c> are counted rather than assumed away.</b> Every
    /// shipped effect derives from <c>TimedStatusEffect</c>, whose <c>Refresh</c> assigns the new
    /// remaining time and which nothing in the framework overrides. A hand-written
    /// <see cref="IStatusEffect"/> need not honour it, so the ageing checks whether the time actually
    /// moved and reports the ones that did not. An effect parked at <c>float.MaxValue</c> to mean
    /// "permanent" also survives — not because it is special-cased, but because float precision
    /// swallows the subtraction. Do not lean on that: an effect that means to be permanent should say
    /// so by ignoring <c>Refresh</c>.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class OfflineBuffDecay : MonoBehaviour, IRevSaveParticipant, IRevSaveOrdered
    {
        /// <summary>What one ageing pass did.</summary>
        public readonly struct DecayReport
        {
            /// <summary>Seconds of shut-down time that were applied, after any cap.</summary>
            public readonly double secondsApplied;

            /// <summary>Effects whose remaining time was reduced but which are still running.</summary>
            public readonly int shortened;

            /// <summary>Effects aged to zero. They are reaped by the controller, not by this.</summary>
            public readonly int expired;

            /// <summary>
            /// Effects whose remaining time did not come down — a custom effect that ignores
            /// <see cref="IStatusEffect.Refresh"/>, or one parked at a duration too large for a
            /// <see cref="float"/> to subtract from.
            /// </summary>
            public readonly int unaged;

            public DecayReport(double secondsApplied, int shortened, int expired, int unaged)
            {
                this.secondsApplied = secondsApplied;
                this.shortened = shortened;
                this.expired = expired;
                this.unaged = unaged;
            }

            /// <summary>Nothing was aged.</summary>
            public static DecayReport None => new(0d, 0, 0, 0);

            /// <summary>True when any effect was touched.</summary>
            public bool Any => shortened > 0 || expired > 0;
        }

        [Serializable]
        private sealed class Payload
        {
            public long savedAtUtc;
        }

        [Tooltip("Save manager to join. Leave empty to find one in the scene on enable.")]
        [SerializeField] private RevSaveManager saveManager;

        [Tooltip("Controller whose effects age. Leave empty to use the one on this object.")]
        [SerializeField] private MonoBehaviour controllerBehaviour;

        [Tooltip("Section key in the save file. Use your own prefix - revframework.* is taken - and " +
                 "give every actor that ages its own key. Never change one once saves exist.")]
        [SerializeField] private string saveKey = "mygame.buffclock.player";

        [Tooltip("Most shut-down time that will ever be applied, in seconds. 0 means uncapped. A cap " +
                 "is what stops a wrong device clock wiping every buff in the game.")]
        [SerializeField, Min(0f)] private float maxOfflineSeconds = 86400f;

        private IStatusEffectController controller;
        private bool warnedNoController;

        /// <summary>
        /// Clock this component measures shut-down time against. Defaults to the device clock.
        /// </summary>
        /// <remarks>
        /// Assign a server-backed provider before the first load if the player moving their device
        /// clock is a problem for your game. Setting it after a restore has run changes nothing about
        /// that restore.
        /// </remarks>
        public IWallClockProvider Clock { get; set; } = new SystemWallClock();

        /// <summary>What the last restore aged, for a "while you were away" screen.</summary>
        public DecayReport LastRestore { get; private set; } = DecayReport.None;

        /// <inheritdoc />
        public string Key => saveKey;

        /// <inheritdoc />
        public int Version => 1;

        /// <inheritdoc />
        /// <remarks>
        /// Late, because this reads and writes another participant's state rather than its own — the
        /// case <see cref="IRevSaveOrdered"/> exists for. Registered ahead of the status participant it
        /// would age an empty controller and then watch the buffs arrive at full time.
        /// </remarks>
        public int RestoreOrder => RevSaveOrder.Late;

        private void OnEnable()
        {
            if (!saveManager) saveManager = FindAnyObjectByType<RevSaveManager>();

            if (!saveManager)
            {
                Debug.LogWarning(
                    $"[{nameof(OfflineBuffDecay)}] No {nameof(RevSaveManager)} found, so '{name}' will " +
                    "never be asked to save or age anything. This is the failure with no other " +
                    "symptom: buffs simply come back at full time.", this);
                return;
            }

            saveManager.Register(this);
        }

        private void OnDisable()
        {
            if (saveManager) saveManager.Unregister(this);
        }

        /// <summary>
        /// Writes the moment this save was taken. Nothing else is stored.
        /// </summary>
        /// <remarks>
        /// The effects themselves belong to the status participant; duplicating them here would be a
        /// second copy of a shipped feature and would drift from it. Written unconditionally, even with
        /// nothing active, because what is active at capture says nothing about what will be restored
        /// before this component is asked.
        /// </remarks>
        public string Capture()
        {
            return JsonUtility.ToJson(new Payload { savedAtUtc = Clock?.UtcNowSeconds ?? 0L });
        }

        /// <summary>
        /// Ages this actor's active effects by the time the game was shut.
        /// </summary>
        /// <remarks>
        /// <para>Every refusal happens before the first mutation, which is what the participant
        /// contract asks for: a section that throws having changed nothing is preserved and carried,
        /// while one that throws after mutating has to say so or the caller writes stale data over live
        /// state believing it is preserving it.</para>
        /// <para>After that first mutation this method does not throw at all. <c>Refresh</c> is
        /// customer code and may, so each effect is aged independently and a thrower is counted rather
        /// than allowed to abandon the rest — the same reasoning the controller's own tick loop uses.</para>
        /// </remarks>
        /// <param name="payload">This component's section.</param>
        /// <param name="version">Version the section was written with.</param>
        public void Restore(string payload, int version)
        {
            LastRestore = DecayReport.None;

            if (version > Version)
                throw new NotSupportedException(
                    $"Offline buff data is version {version}, but this build understands up to {Version}.");

            if (string.IsNullOrEmpty(payload)) return;

            var data = JsonUtility.FromJson<Payload>(payload);
            if (data == null || data.savedAtUtc <= 0L) return;

            long now = Clock?.UtcNowSeconds ?? 0L;
            double elapsed = now - (double)data.savedAtUtc;

            if (elapsed <= 0d)
            {
                // Not an error worth throwing over: a device clock that moved backwards, or a save
                // written seconds ago. Ageing by a negative number would extend every buff.
                return;
            }

            if (maxOfflineSeconds > 0f && elapsed > maxOfflineSeconds)
                elapsed = maxOfflineSeconds;

            LastRestore = Age(elapsed);
        }

        /// <summary>
        /// Subtracts <paramref name="seconds"/> from every active effect's remaining time.
        /// </summary>
        /// <remarks>
        /// <para>Public because the mechanic is useful outside a load — a rest, a fast-travel, a turn
        /// in a turn-based game are all "time passed without it being played".</para>
        /// <para>Nothing is removed. An effect aged to zero is expired, and the controller reaps it on
        /// its next update with the events that belong to an expiry. That update returns early on a
        /// zero delta, so a paused game keeps them until it resumes.</para>
        /// <para>The active list is snapshotted before anything is touched, for the reason the
        /// controller snapshots its own: <c>Active</c> is the live collection, and <c>Refresh</c> is
        /// customer code that may add or remove effects.</para>
        /// </remarks>
        /// <param name="seconds">Seconds to age by. Zero or less does nothing.</param>
        /// <returns>What was shortened, what expired, and what refused to move.</returns>
        public DecayReport Age(double seconds)
        {
            if (seconds <= 0d) return DecayReport.None;
            if (!TryResolveController(out var target)) return DecayReport.None;

            var active = target.Active;
            if (active == null || active.Count == 0) return DecayReport.None;

            // Allocated per call rather than held as a field. A pooled list would be the obvious
            // saving, and it is wrong here: Refresh is customer code and may call back into this
            // method, which would then clear and refill the very list the outer loop is walking.
            // This runs on a load rather than per frame, so one allocation is not a cost worth that.
            var snapshot = new List<IStatusEffect>(active.Count);
            for (int i = 0; i < active.Count; i++)
            {
                if (active[i] != null)
                    snapshot.Add(active[i]);
            }

            int shortened = 0, expired = 0, unaged = 0;
            float step = (float)seconds;

            for (int i = 0; i < snapshot.Count; i++)
            {
                var effect = snapshot[i];

                float before = effect.TimeRemaining;
                if (before <= 0f)
                    continue;

                float wanted = before - step;
                if (wanted < 0f) wanted = 0f;

                try
                {
                    effect.Refresh(wanted);
                }
                catch (Exception ex)
                {
                    // One effect refusing must not strand the rest half-aged, and this is past the
                    // first mutation, so throwing on is not available either.
                    unaged++;
                    Debug.LogWarning(
                        $"[{nameof(OfflineBuffDecay)}] '{effect.Id}' threw out of Refresh while ageing " +
                        $"'{name}': {ex.GetType().Name}. It keeps its old remaining time.", this);
                    continue;
                }

                float after = effect.TimeRemaining;

                // Compared against where it started, not against the value asked for. Both failures
                // look identical from here and both matter to a caller: an effect that ignores
                // Refresh keeps its old time, and one parked at a duration too large for a float to
                // subtract from lands back on the same number having "accepted" the new value.
                // Checking only "did it take what I asked" reports the second one as shortened.
                if (after >= before)
                {
                    unaged++;
                    continue;
                }

                if (after <= 0f) expired++;
                else shortened++;
            }

            if (unaged > 0)
            {
                Debug.LogWarning(
                    $"[{nameof(OfflineBuffDecay)}] {unaged} effect(s) on '{name}' did not age. A custom " +
                    "IStatusEffect that ignores Refresh, or one parked at an effectively infinite " +
                    "duration, will do that.", this);
            }

            return new DecayReport(seconds, shortened, expired, unaged);
        }

        /// <summary>
        /// Finds the controller to age, complaining once if there is not one.
        /// </summary>
        /// <remarks>
        /// Resolved through <see cref="IStatusEffectController"/> rather than the concrete controller,
        /// because everything this needs — <c>Active</c>, and each effect's own
        /// <c>Refresh</c>/<c>TimeRemaining</c> — is on the published interfaces. Cached, but re-resolved
        /// whenever the cache has gone stale, since a controller destroyed and replaced between loads
        /// would otherwise leave this ageing an object Unity has already thrown away.
        /// </remarks>
        private bool TryResolveController(out IStatusEffectController resolved)
        {
            if (controller is UnityEngine.Object cached && !cached)
                controller = null;

            if (controller == null)
            {
                // The Unity bool first, then the cast. A serialized reference to a destroyed object is
                // fake-null: `is IStatusEffectController` is an ordinary reference test and succeeds,
                // and the next call into it throws MissingReferenceException. ShieldSelector carries
                // the same guard for the same reason. The lookup below cannot hit it — GetComponent
                // never returns a destroyed component.
                if (controllerBehaviour && controllerBehaviour is IStatusEffectController assigned)
                    controller = assigned;
                else if (TryGetComponent<IStatusEffectController>(out var found))
                    controller = found;
            }

            resolved = controller;

            if (resolved == null && !warnedNoController)
            {
                warnedNoController = true;
                Debug.LogWarning(
                    $"[{nameof(OfflineBuffDecay)}] No status effect controller found for '{name}', so " +
                    "nothing ages.", this);
            }

            return resolved != null;
        }
    }
}

Wiring it up

  1. Register the framework's StatusEffectsSaveParticipant with your RevSaveManager. Without it there are no restored effects to age.
  2. Put this component on the actor whose buffs should age — usually the player.
  3. Give it a save key of its own, with your own prefix. revframework.* is the framework's, two actors on one key is a DuplicateKey in the report, and renaming one orphans every existing save.
  4. Set maxOfflineSeconds. A day is a sane default; zero means uncapped and trusts the clock completely.
  5. Assign Clock from your own bootstrap if you have server time. Do it before the first load — setting it afterwards changes nothing about a restore that has already run.
  6. Read LastRestore from RevSaveManager.LoadCompleted for a "while you were away" line: what expired, what is still running, and what refused to age.

What it deliberately does not do

It does not save the effects. That is StatusEffectsSaveParticipant's job and it already does it properly, including instigator attribution and stacking. A second copy would drift.

It does not run the effects. No ticks, no damage, no healing, no offline progress of any kind — see the clock section above for why that is a decision and not an omission.

It does not remove anything. Ageing to zero is enough; the controller owns removal and raises the right events for it.

It does not age effects on other actors. One component, one controller, one save key. A party of four is four components with four keys, which is also what keeps the save file readable.

It does not extend anything. A clock that has gone backwards is ignored rather than trusted, and there is no path here that increases a remaining time.

  • Status Effects — the controller, the effect contract, and what Refresh means.
  • Save — participants, sections, ordering and the restore report.
  • While you were away — the same question answered by the system that does credit shut-down time, and what to report when it goes wrong.
  • A shop that remembers — the other customer-written save participant, and the refusal contract in more detail.
  • A cursed item — where RemoveStatus removing every application of an id is the problem rather than something to route around.