Skip to content

One switch that refuses everything

A cutscene lock, a safe zone, a spectator mode, a death screen that stops the world without touching timeScale — built from the authority seam every system already has.

Recipe

Systems required: Crafting, Currency, Health, Inventory, Pickups, Status Effects. Package: Complete only — the systems above ship in different packages, so no single-system package can run this. Shape: one class you drop into a project that already exists. No scene to import — but three of the six systems need a box ticked before they will ask it anything, so read the prerequisites. Public API only. 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

Six systems ship an I…Authority interface, and every one of them asks the same question: is this actor allowed to act right now?

Nothing says the answer has to come from six different places. Implement them on one component and "stop everything" becomes a single boolean.

This is a policy, not a mechanic — the first one here

Every other recipe in this cookbook is a thing the game does. This is a rule about when the game accepts input at all.

Worth knowing it exists, because the alternative most projects reach for — disabling components, zeroing input, setting timeScale — fights the framework instead of using it, and comes back as a stream of edge cases six weeks later.

Check these three before you believe anything below

Three of the six systems only consult an authority when the project has said they should, and nothing warns when they do not — an ungated system looks exactly like a permitted one.

System What has to be true
Health Require Authority ticked on every HealthSystem you list on the component
Status Effects Require Authority ticked on every StatusEffectController you want gated
Currency the currency stack was composed with authority — a CurrencyServiceBootstrap in the scene, or CurrencyFactories.WithAuthority. A plain SceneCurrencyService never asks

Inventory, pickups and crafting consult their authority as shipped, with no opt-in.

Uniform in wiring, lumpy in signature

Six interfaces, and three of them take a bare GameObject: inventory, pickups and bench enqueue. Only two of those three are the same method, though — IInventoryAuthority and IPickupAuthority are both HasAuthority(GameObject), differing only in what they call the parameter, while the bench one asks under its own name, CanEnqueueFromBench. Of the rest, status effects asks about a StatusEffectController, currency wants the currency and the operation kind as well, and crafting wants an out reason.

So one implicit HasAuthority(GameObject) would satisfy two of them at once. The recipe writes them out separately anyway — it keeps each answer beside the system it serves, and dropping a system you do not own is then one method plus two asmdef lines (a reference and a define constraint) rather than untangling one answer from another.

A seventh I…Authority interface exists — IHealthAuthority, which asks about an IHealthReadonly. The recipe deliberately does not implement it; see the trap below.

Health cannot see this component

This is the trap worth the whole page.

Inventory, currency, pickups and status effects all resolve their authority by interface, so they find this class on their own with no wiring at all. What they search from is not the actor, though, except for status effects: inventory resolves from its SceneInventoryService, currency from the currency bootstrap, and pickups from the pickup item — each walking its own parents, then the scene.

Which makes this scene policy, not per-actor policy

Hierarchy placement does not scope the answer. SceneInventoryService holds one authority for the whole scene and passes the owner to it, and the status resolver caches a hit it found on one actor for every controller in the scene — so two lockdowns on two players do not give you two policies, they give you whichever one a resolver reached first, answering for everybody.

Put one in the scene and let it take the owner as an argument if you need a per-actor rule.

Health resolves by the concrete binder type

It searches for HealthAuthorityBinder specifically, not for IHealthAuthority. Its own source says so, in as many words, and carries a comment recording that the tooltip used to claim otherwise and was wrong.

So a custom IHealthAuthority sitting in the scene is never consulted. Implementing that interface on this component would be dead code that looks exactly like working code.

And the escape hatch does not take the interface either

HealthSystem.SetAuthorityResolver takes a Func<bool> — a bare delegate. So the public IHealthAuthority interface is, from a customer's side, unreachable: nothing will ever call your implementation of it.

This recipe therefore installs a delegate on each health system you list, and refuses to overwrite a resolver that is already installed. A project that installed its own had a reason, and silently replacing it from a component whose name says nothing about health is an afternoon of debugging for somebody.

The bookkeeping only works in one direction, and that is the public API's shape rather than a choice: HasAuthorityResolver is a bare yes/no. It cannot say whether the resolver was injected or derived by the health system itself, and it cannot say whose it is — so a resolver installed by something else after this component enables is invisible to it, and will be cleared when it disables. If your project injects health resolvers of its own, install them before the lockdown or keep the two off the same HealthSystem.

Crafting is a third story again

The crafting dependency binder reads a serialized component field and casts it to ICraftingAuthority. So this class is consulted when you drag it into that slot — or when something calls CraftingService.SetAuthority, which is public and does the same job at runtime.

Wiring mechanisms for one concept — discovered by interface, injected as a delegate, assigned to a serialized field, set through a public method — and only the first happens by itself.

And the workbenches have a slot of their own

CraftingWorkbench2D/3D read a separate serialized field, Bench Authority, for ICraftingBenchAuthority. The binder's slot does not feed it. Left empty, a workbench falls back to the crafting service's authority — this same component, giving the same answer — so nothing breaks either way. Wire it if you want the bench answer free to differ from the service answer later.

Crafting does hand you an exactly right refusal, which is rare

CraftFailReason is a closed enum, so recipes that invent a new kind of refusal normally have to borrow a wrong one — a health price reports NoCurrency, because there is nothing better.

A lockdown reports Unauthorized, which is precisely what happened. Worth noticing when the closed enum happens to fit.

Do not untick the component to unlock

Set Locked = false instead. Unticking does not mean allowed, it means not found, and the six systems do not agree about what that means:

  • Crafting keeps refusing. Its slot is a plain interface reference, cast once, and nothing re-tests whether the component is still enabled — so unticking does not unlock crafting, it only removes the switch you would have used. Destroy it instead of disabling it and it still answers, because a destroyed component held through an interface reference is not reference-null; CraftingService.SetAuthority is the way out of that one.
  • Status effects stop resolving, and inventory goes permissive. Neither recovers on its own — the controller records that it looked and never looks again, and the service re-resolves to nothing and stays there. Both recover when you tick this component back on, because it re-asks every controller and service on enable. Nothing else will do it for you.
  • Currency fails closed. If this is your scene's only ICurrencyAuthority, every credit, debit and transfer in the game is refused while the component is off — the opposite direction from inventory. It recovers on re-tick by itself.
  • Health recovers correctly.

So an unticked lockdown is half wide open and half locked, and locked in the place you are least likely to look.

Drop it in

Lockdown.cs
using System;
using System.Collections.Generic;

using RevGaming.RevFramework.Crafting.Abstractions;
using RevGaming.RevFramework.Crafting.Core;

using RevGaming.RevFramework.Currency.Abstractions;
using RevGaming.RevFramework.Currency.Authority;

// Imported for the <see cref="IHealthAuthority"/> references in the remarks below. The interface is
// deliberately NOT implemented -- see the remarks for why that would be dead code.
using RevGaming.RevFramework.Health.Abstractions.Authority;
using RevGaming.RevFramework.Health.UnityIntegration;

using RevGaming.RevFramework.Inventory.Authority;
using RevGaming.RevFramework.Inventory.UnityIntegration;

using RevGaming.RevFramework.Pickups.Authority;

using RevGaming.RevFramework.StatusEffects.Authority;
using RevGaming.RevFramework.StatusEffects.Core;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.Lockdown
{
    /// <summary>
    /// One switch that refuses everything — a cutscene lock, a safe zone, a spectator mode, a
    /// death screen that stops the world without touching <c>timeScale</c>.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Crafting</b>, <b>Currency</b>, <b>Health</b>, <b>Inventory</b>, <b>Pickups</b>,
    /// <b>Status Effects</b> — six systems across all three packages, so <b>Complete only</b>. Public
    /// API only.</para>
    ///
    /// <para><b>Every system already ships an authority seam, and one object can answer for all of
    /// them.</b> Six <c>I…Authority</c> interfaces ask the same question — <i>is this actor allowed to
    /// act right now?</i> — and nothing says the answer has to come from six places. Implement them
    /// together and "stop everything" becomes a single boolean.</para>
    ///
    /// <para><b>Three of the six only ask when the project has opted in, and that is the first thing
    /// to check.</b> Inventory, pickups and crafting consult their authority as shipped. The other
    /// three do not: <c>HealthSystem.requireAuthority</c> and
    /// <c>StatusEffectController.requireAuthority</c> are serialized bools defaulting to <i>off</i>,
    /// and a system with the bool off never invokes its resolver at all. Currency reaches
    /// <see cref="ICurrencyAuthority"/> only through an authority-composed currency service — a
    /// <c>CurrencyServiceBootstrap</c> in the scene, or a stack built with
    /// <c>CurrencyFactories.WithAuthority</c>. <b>Nothing warns in any of the three cases, so an
    /// ungated system looks exactly like a permitted one.</b></para>
    ///
    /// <para><b>One per scene, not one per actor.</b> Only <see cref="StatusEffectController"/>
    /// resolves from the actor; the rest resolve from a service, a bootstrap or the pickup item. So
    /// hierarchy placement does not scope the answer, and a second lockdown does not give you a second
    /// policy — whichever one a resolver reaches first answers for everybody.</para>
    ///
    /// <para><b>Health is wired by hand, because it cannot see this component.</b> The other five
    /// resolve their authority by interface. Health resolves by the concrete
    /// <c>HealthAuthorityBinder</c> type, so implementing <see cref="IHealthAuthority"/> here would be
    /// dead code that looks like it works. Its escape hatch is
    /// <c>HealthSystem.SetAuthorityResolver</c>, which takes a <see cref="Func{T}"/> instead — this
    /// component installs one on each system you list, and refuses to overwrite a resolver that is
    /// already there. That bookkeeping is one-directional because the public API is:
    /// <c>HasAuthorityResolver</c> cannot say <i>whose</i> resolver is installed, so one installed
    /// after this component enables is invisible to it and will be cleared when it disables.</para>
    ///
    /// <para><b>Crafting is a third story: it is assigned, not discovered.</b> The dependency binder
    /// reads a serialized field and casts it, so this class is found when you drag it into that slot —
    /// or when something calls the public <c>CraftingService.SetAuthority</c>. The workbenches have a
    /// second, separate slot; see <see cref="ICraftingBenchAuthority.CanEnqueueFromBench"/>.</para>
    ///
    /// <para><b>It refuses; it does not pause — and the two systems that care about time disagree.</b>
    /// A gated <see cref="StatusEffectController"/> stops ticking, so durations freeze and nothing
    /// expires, which is usually what a cutscene wants. Crafting timers run on absolute time and do
    /// not freeze, and a job completing while locked cannot deliver: inputs and currency were spent at
    /// enqueue, the delivery is refused by the inventory answer this same class gives, the refund is
    /// refused for the same reason, and the job is dropped reporting <c>NoSpaceAtDelivery</c>.
    /// <b>Drain or pause the crafting queue before setting <see cref="Locked"/></b> — afterwards is
    /// too late, because <c>PauseJob</c> and <c>CancelJob</c> are gated by this component too.</para>
    ///
    /// <para><b>And two surfaces are outside the seam entirely.</b> <c>CharacterInventory</c>'s own
    /// mutations do not consult <see cref="IInventoryAuthority"/>, and <c>InteractablePickupBase</c>
    /// subclasses — including the shipped <c>SimpleItemPickupInteractable</c> — do not consult
    /// <see cref="IPickupAuthority"/>. Neither is refused by anything here.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    [DefaultExecutionOrder(-500)]
    public sealed class Lockdown : MonoBehaviour,
        IInventoryAuthority,
        IPickupAuthority,
        IStatusAuthority,
        ICurrencyAuthority,
        ICraftingAuthority,
        ICraftingBenchAuthority
    {
        [Header("State")]
        [Tooltip("While set, every system that consults this component refuses.")]
        [SerializeField] private bool locked;

        [Header("Health (wired by hand — see the remarks)")]
        [Tooltip("Health systems to gate. Health does not discover this component by interface the " +
                 "way the other systems do, so each one has to be listed here. Each also needs its " +
                 "own 'Require Authority' ticked, or its resolver is never consulted.")]
        [SerializeField] private HealthSystem[] gatedHealth = Array.Empty<HealthSystem>();

        // The health systems this component actually installed a resolver on. Anything it skipped --
        // because one was already installed -- must not be cleared on the way out, or disabling a
        // lockdown would quietly strip somebody else's authority rule. Held as the systems themselves
        // rather than as flags indexed into gatedHealth, because gatedHealth is a serialized array and
        // resizing it in the Inspector mid-play would otherwise leave installed resolvers behind.
        private readonly List<HealthSystem> _installed = new();

        // Whether this component has been through its first frame. See OnEnable for why a republish
        // before the rest of the scene has enabled is harmful rather than merely early.
        private bool _started;

        /// <summary>
        /// Whether everything is currently refused.
        /// </summary>
        /// <remarks>
        /// Settable at runtime — a cutscene sets it true on the way in and false on the way out. The
        /// health delegate reads this property when asked rather than being reinstalled, so changing it
        /// costs nothing and cannot get out of step. Unlock by setting this false, never by unticking
        /// the component: the systems do not agree on what a missing authority means.
        /// </remarks>
        public bool Locked
        {
            get => locked;
            set => locked = value;
        }

        private void OnEnable()
        {
            _installed.Clear();

            foreach (HealthSystem health in gatedHealth)
            {
                // The duplicate test is not tidiness. The guard below reads state this loop's own
                // earlier iterations write, so the same health system listed twice warns that somebody
                // else owns a resolver this component installed one iteration ago.
                if (!health || _installed.Contains(health))
                    continue;

                // Not overwritten. A project that installed its own resolver did so deliberately, and
                // clobbering it from a component whose name says nothing about health is the kind of
                // action that gets debugged for an afternoon. The test cannot tell an injected
                // resolver from one the health system derived for itself, so the wording claims only
                // what it knows.
                if (health.HasAuthorityResolver)
                {
                    Debug.LogWarning(
                        $"[Cookbook] '{name}' left '{health.name}' alone: it already has an authority " +
                        "resolver. That health system will not be gated by this lockdown.", this);
                    continue;
                }

                // Reads the property when asked, rather than capturing the current value, so toggling
                // Locked takes effect without touching the health system again.
                health.SetAuthorityResolver(() => !locked);
                _installed.Add(health);
            }

            // Not at scene load. Nothing has resolved yet then, so the ordinary lazy resolution finds
            // this component anyway -- and republishing early is worse than useless, because
            // [DefaultExecutionOrder(-500)] puts this ahead of StatusEffectController.OnEnable, which
            // overwrites the authority field from its own serialized slot (null unless assigned) while
            // leaving the "I already looked" epoch this republish just pinned. The controller would
            // then never look again and refuse everything for the rest of the session. Start runs
            // after every OnEnable in the batch, so the first republish happens there instead.
            if (_started)
                Republish();
        }

        private void Start()
        {
            _started = true;
            Republish();
        }

        private void OnDisable()
        {
            foreach (HealthSystem health in _installed)
            {
                if (!health)
                    continue;

                // ClearAuthorityResolver, not SetAuthorityResolver(null) -- the setter throws on null.
                // Clearing returns the health system to whatever it did before this component existed,
                // which is scene resolution, rather than pinning it permissive.
                health.ClearAuthorityResolver();
            }

            _installed.Clear();
        }

        // Inventory and status effects resolve their authority once and then keep the answer -- and a
        // miss is kept too. A lockdown that appears after they looked (instantiated for the cutscene,
        // or sitting on a GameObject that starts inactive) is invisible to them, silently, for the rest
        // of the session. The shipped binders publish themselves by invalidating the scene caches,
        // which is internal; these two calls are the public per-consumer equivalent. A scene scan is a
        // real cost, but it is paid once per enable, and the alternative -- lists to fill in -- is one
        // more thing to forget in exactly the way this is fixing. Never call this before the rest of
        // the scene has enabled -- see OnEnable.
        private void Republish()
        {
            // CS0618: the FindObjectsSortMode overloads are obsolete on Unity 6000.5+, but the
            // replacements do not exist before it -- one code path across 6.0-6.5+, as the framework
            // does in the same situation.
#pragma warning disable CS0618
            foreach (SceneInventoryService inventory in
                     FindObjectsByType<SceneInventoryService>(FindObjectsSortMode.None))
                inventory.RefreshAuthority();

            foreach (StatusEffectController controller in
                     FindObjectsByType<StatusEffectController>(FindObjectsSortMode.None))
                controller.RefreshAuthority();
#pragma warning restore CS0618
        }

        /// <inheritdoc />
        /// <remarks>Inventory finds this component by interface. Nothing to wire.</remarks>
        bool IInventoryAuthority.HasAuthority(GameObject owner) => !locked;

        /// <inheritdoc />
        /// <remarks>
        /// Identical in signature to the inventory one, so a single implicit method would have served
        /// both. Kept separate so that the two can diverge — a safe zone that stops pickups but still
        /// lets you rearrange your bag is an ordinary thing to want.
        /// </remarks>
        bool IPickupAuthority.HasAuthority(GameObject actor) => !locked;

        /// <inheritdoc />
        /// <remarks>
        /// Asks about the controller rather than the actor. <see cref="StatusEffectController"/> is a
        /// <see cref="MonoBehaviour"/>, so <c>controller.gameObject</c> gets you back to who it is if a
        /// per-actor rule is wanted here. Only consulted when that controller's
        /// <c>requireAuthority</c> is ticked.
        /// </remarks>
        bool IStatusAuthority.HasAuthority(StatusEffectController controller) => !locked;

        /// <inheritdoc />
        /// <remarks>
        /// The richest of the six: it knows which currency and which kind of operation. A lockdown
        /// ignores both, but this is where "you may still earn, you may not spend" would live. Only
        /// consulted when the currency stack was composed with authority.
        /// </remarks>
        bool ICurrencyAuthority.HasAuthority(GameObject owner, CurrencyId currency, CurOpKind kind)
            => !locked;

        /// <inheritdoc />
        bool ICurrencyAuthority.HasAuthorityTransfer(GameObject from, GameObject to, CurrencyId currency)
            => !locked;

        /// <inheritdoc />
        /// <remarks>
        /// Assigned rather than discovered — drag this component into the crafting dependency binder's
        /// authority slot, or it is never consulted.
        /// </remarks>
        bool ICraftingAuthority.CanMutate(GameObject owner, out CraftFailReason denyReason)
        {
            denyReason = locked ? CraftFailReason.Unauthorized : CraftFailReason.None;
            return !locked;
        }

        /// <inheritdoc />
        /// <remarks>
        /// A <i>second</i> slot, and a separate one: the workbenches read their own
        /// <c>benchAuthorityComponent</c> field, not the binder's. Leave it empty and a workbench falls
        /// back to the crafting service's authority — this same component, giving the same answer — so
        /// wiring it changes nothing today. Wire it anyway if you want the bench answer to be able to
        /// differ from the service answer later; that is the only reason this method exists.
        /// </remarks>
        bool ICraftingBenchAuthority.CanEnqueueFromBench(GameObject owner) => !locked;
    }
}

Wiring it up

  1. Put one of these in the scene — on the player, on a manager, anywhere. It is scene-wide policy either way, so a second one is not a second policy.
  2. Inventory and pickups need nothing further — they find it.
  3. Tick Require Authority on every StatusEffectController you want gated, and confirm your currency stack was composed with authority. Without those, those two legs are silently inert.
  4. Drag it into the crafting dependency binder's authority slot — and, if you use them, into each CraftingWorkbench2D/3D's separate Bench Authority slot.
  5. List the health systems it should gate (they cannot find it on their own) and tick Require Authority on each of them.
  6. Toggle Locked from your cutscene, safe zone, or death screen — and drain or pause any timed crafting jobs first, see below.

What it deliberately does not do

It does not gate per system. One switch, by design. Per-system flags are a two-line change — each answer is already its own method — but a lockdown that is half on is a different feature with a different name.

It does not overwrite a health resolver that is already there. It warns and leaves that health system ungated, which is visible, rather than taking over silently, which is not. It errs towards leaving well alone: a HealthSystem that derived a resolver for itself from a HealthAuthorityBinder reads the same as one that was injected, and this component cannot tell them apart.

It does not pin health permissive on the way out. OnDisable calls ClearAuthorityResolver, so the health system returns to scene resolution — the state it was in before this component existed. Note that SetAuthorityResolver(null) is not the way to do that: it throws.

It does not implement IHealthAuthority. It could, and the code would compile, and nothing would ever call it. An interface you cannot be asked through is worse than no interface, because it reads as wired.

It does not stop time, and the two systems that keep time disagree about what that means. A gated StatusEffectController stops ticking outright while locked: durations freeze, poisons deal no damage and nothing expires — usually exactly what a cutscene wants. Crafting timers run on absolute time and keep advancing.

So drain or pause the crafting queue before you lock, not after

A timed job that completes while locked cannot deliver. Its inputs and currency were spent when it was enqueued, delivery is refused by the inventory answer this same component is giving, the refund is refused for the same reason, and the job is dropped — reported as NoSpaceAtDelivery, which is not what happened.

Afterwards is too late: PauseJob and CancelJob are themselves gated by this component. If your game has long crafts and long cutscenes, leave the inventory leg permissive instead — each answer is already its own method.

It does not reach two surfaces at all. CharacterInventory's own mutations do not consult IInventoryAuthority, and InteractablePickupBase subclasses — including the shipped SimpleItemPickupInteractable — do not consult IPickupAuthority. TriggerPickup, UnifiedPickup2D and InventoryPickupInteractable do.

It does not assume it was there at scene load, but it cannot fix that for free. Inventory and status effects keep a miss as well as a hit, so a lockdown instantiated mid-session, or living on a GameObject that starts inactive, would otherwise be invisible to anything that already looked. It therefore re-asks every SceneInventoryService and StatusEffectController in the loaded scenes — from Start on the first pass and from OnEnable after that, never before the rest of the scene has enabled, and one scan each time. That is the price of not having a list to keep up to date.

  • Health — the authority section covers the binder and the injected resolver.
  • Crafting — the dependency binder and its authority slot.
  • Recipes that cost blood — the same closed CraftFailReason enum, from the other side: a refusal with no right reason to give.
  • A cursed item — the other recipe that had to take a concrete type because the interface would not express what it needed.