Skip to content

A pickup you have to pay for

A vending plate, a coin-operated door, a toll gate — ordered so the money only moves after the goods have landed, and so a refused purchase leaves the pickup standing.

Recipe

Systems required: Pickups, Currency. 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. It is a ScriptableObject, so you create an asset from it and drop that on a pickup. 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

The obvious route is a CompositeEffect holding a GiveCurrencyEffect with a negative amount followed by the payload. It hands over the goods when the payment is refused.

That is not a bug in the composite. It is documented to run independent effects in authored order, and it deliberately continues past a child that refused so a failed cosmetic cannot cancel a gameplay payload. That is the right call for what a composite is for.

A price and its payload are the opposite of independent, so they need a type that says so.

The whole recipe is an ordering, and it is the reverse of the natural one

Check the balance → deliver → then charge.

Charging first is the worse failure

Charge up front and a refused delivery has to be refunded — and a refund can itself be refused by a balance cap, leaving the player having paid for nothing. Charging last means the money only moves once the goods have actually landed.

A raw Debit that returns Ok is not proof the price was paid

A minimum-balance rule in CapMode.Clamp removes only as much as the floor allows and still reports success — against a wallet already sitting on the floor it removes nothing at all, and emits no wallet-changed event either. So the charge goes through CurrencyPurchase.TrySpend, which measures what actually left the wallet, unwinds a short debit and refuses it, and threads the reason and source id through to the audit layer. CurrencyPurchase.CanAfford does the preflight for the same reason: one helper, one definition of affordable.

The window that leaves, stated rather than hidden

Between the balance check and the debit, the payload runs. So a debit refused at that point leaves the player holding the goods for free.

A payload effect that itself spends money is one cause. It is not the only one, and probably not the most common: an authority-gated stack with no ICurrencyAuthority in the scene refuses every debit with Unauthorized, a policy with requireEscrow and no escrow layer below it refuses with ServiceMissing, and a minimum-balance rule in CapMode.Fail refuses with BelowMinimum. All four deliver the payload and then fail to charge for it. The warning prints the refusal code; read the code rather than assuming the cause.

Closing the window properly needs a currency hold rather than a check — which is a heavier setup requirement than a coin-operated door should carry, and is exactly what the two-currency craft does where the wait is long enough to justify it.

One refusable payload per priced pickup

Delivery stops at the first refusal and charges nothing — so a two-item payload whose second item will not fit hands over the first for free. Nothing in the framework can deliver two refusable payloads atomically and this does not pretend to. Put the one effect that can refuse first, and cosmetics after it.

Ignore that and the free prefix is not a one-off. A refusal deliberately leaves the pickup standing, so the actor walks out, walks back in, and collects the prefix again — and again, for as long as the later effect keeps refusing. Nothing here remembers what already ran.

It reports refusal, so the pickup survives being unaffordable. Without IPickupEffectReportsDelivery an effect is assumed to have applied and the pickup destroys itself — so a player who could not afford the door would watch it open anyway. And IEffectAllowsNullDamageable, because paying for something has nothing to do with being damageable, and without it the whole effect is silently inert for any actor with no health.

The same seam cuts the other way. "Delivered" means the payload did not refuse, not that it did something: a child that does not implement IPickupEffectReportsDelivery is assumed by the framework to have applied — the documented contract, not a gap in this recipe. So a VFX burst with no prefab assigned, or an animator trigger on an actor with no Animator, reports success and is charged for in full, and nothing on this side of the seam can tell. Where the purchase has to be provably worth something, put a reporting effect first: the same rule as "one refusable payload", for the same reason.

A reusable priced pickup charges once per entering collider

With destroyOnUse = false — the toll gate, the coin-operated door — an actor whose colliders cross the trigger boundary on different physics steps is charged once per collider. The pickup's duplicate-consumption latch is scoped to a single physics step and says so; nothing before this recipe put a currency debit behind it, because no shipped effect takes money. Arrange the actor so one collider crosses, or narrow the pickup's allowedLayers until one does.

Two kinds of report, deliberately split

A payload delivered without payment is a live economy problem, so it always logs: wrapping that in UNITY_EDITOR is what makes the class of failure invisible in exactly the build where it costs something.

The authoring mistakes — no payload, no price, no currency service, a payload list of empty entries — go through an editor-and-development-build-only warning instead. They fire on every trigger enter by every actor, which is what you want while you are building the scene and is log spam in a shipped player.

Drop it in

PricedPickup.cs
using System.Collections.Generic;

using RevGaming.RevFramework.Core.Abstractions.Combat;
using RevGaming.RevFramework.Currency;
using RevGaming.RevFramework.Currency.Abstractions;
using RevGaming.RevFramework.Pickups.Abstractions;
using RevGaming.RevFramework.Pickups.Core;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.PricedPickup
{
    /// <summary>
    /// A pickup you have to pay for — a vending plate, a coin-operated door, a toll gate — ordered so
    /// the money only moves after the goods have landed, and so a refused purchase leaves the pickup
    /// standing.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Pickups</b>, <b>Currency</b> — different packages, so <b>Complete only</b>. Public API
    /// only.</para>
    ///
    /// <para><b>Why this is not a composite.</b> Building "charge them, then give them the thing" out
    /// of the shipped <c>CompositeEffect</c> and a <c>GiveCurrencyEffect</c> with a negative amount
    /// hands over the goods when the payment is refused. That is not a bug in the composite: it runs
    /// <i>independent</i> effects in authored order and deliberately continues past a child that
    /// refused, so a failed cosmetic cannot cancel a gameplay payload. A price and its payload are the
    /// opposite of independent, so they need a type that says so.</para>
    ///
    /// <para><b>The whole recipe is an ordering, and it is the reverse of the natural one.</b> Check
    /// the balance, deliver, <em>then</em> charge. Charging first means a refused delivery has to be
    /// refunded, and a refund can itself be refused by a balance cap — leaving the player having paid
    /// for nothing, which is the worst failure available.</para>
    ///
    /// <para><b>The charge goes through <c>CurrencyPurchase.TrySpend</c>, not a raw debit.</b> A raw
    /// <c>Debit</c> can return <c>Ok</c> having removed less than the price — a minimum-balance rule in
    /// <c>CapMode.Clamp</c> does exactly that, down to nothing at all — and the caller cannot tell from
    /// the result. <c>TrySpend</c> measures what actually left the wallet, unwinds a short debit and
    /// refuses it.</para>
    ///
    /// <para><b>The window that leaves, stated rather than hidden.</b> Between the balance check and
    /// the debit the payload runs, so a debit refused at that point leaves the player holding the goods
    /// for free. Causes: a payload that itself spends money, an authority-gated stack with no
    /// <c>ICurrencyAuthority</c> in the scene, a policy with <c>requireEscrow</c> and no escrow layer,
    /// a minimum-balance rule in <c>CapMode.Fail</c>. Every one warns loudly with the refusal code.
    /// Closing it properly needs a currency hold rather than a check — heavier setup than a
    /// coin-operated door should carry.</para>
    ///
    /// <para><b>One refusable payload, and "delivered" is weaker than it sounds.</b> Delivery stops at
    /// the first refusal and charges nothing, so a two-item payload whose second item will not fit
    /// hands over the first free — and a refusal deliberately leaves the pickup standing, so the actor
    /// can walk back in and collect the free prefix again, indefinitely. Nothing here remembers what
    /// already ran. The same seam cuts the other way: "delivered" means the payload did not refuse, not
    /// that it did anything, because a child that does not implement
    /// <see cref="IPickupEffectReportsDelivery"/> is assumed to have applied — so a VFX burst with no
    /// prefab assigned reports success and is charged for in full. Put the refusable, reporting effect
    /// first, with cosmetics after it.</para>
    ///
    /// <para><b>It reports refusal, so the pickup survives being unaffordable.</b> Without
    /// <see cref="IPickupEffectReportsDelivery"/> an effect is assumed to have applied and the pickup
    /// destroys itself, so a player who could not afford the door would watch it open anyway. And
    /// <see cref="IEffectAllowsNullDamageable"/>, because paying has nothing to do with being
    /// damageable — without it the whole effect is silently inert for any actor with no health.</para>
    ///
    /// <para><b>Two kinds of report, deliberately split.</b> A payload delivered without payment is a
    /// live economy problem, so it always logs — wrapping <em>that</em> in <c>UNITY_EDITOR</c> is what
    /// makes the class of failure invisible in exactly the build where it costs something. The
    /// authoring mistakes go through an editor-and-development-only warning instead, because a
    /// misconfigured scene would otherwise log once per actor per trigger enter in a shipped player,
    /// forever.</para>
    /// </remarks>
    [CreateAssetMenu(
        fileName = "PricedPickup",
        menuName = "RevFramework/Cookbook/Priced Pickup")]
    public sealed class PricedPickup : PickupEffect, IEffectAllowsNullDamageable, IPickupEffectReportsDelivery
    {
        [Header("Price")]
        [Tooltip("Currency the actor pays in.")]
        [SerializeField] private string currencyId = "gold";

        [Tooltip("What it costs. Charged only once the payload has been delivered.")]
        [SerializeField, Min(1)] private int price = 25;

        [Header("Payload")]
        [Tooltip("What they get. Put the one effect that can refuse first; cosmetics after it.")]
        [SerializeField] private List<PickupEffect> payload = new();

        /// <inheritdoc />
        /// <remarks>Delegates so the two entry points cannot drift.</remarks>
        protected override void OnApply(IDamageable target, GameObject context) => TryApply(target, context);

        /// <summary>
        /// Checks the balance, delivers the payload, then charges — in that order, and the debit can
        /// still be refused once the payload has landed.
        /// </summary>
        /// <returns>
        /// <c>false</c> when the actor cannot be identified, when the asset has no payload or no valid
        /// currency id and price, when no currency service resolves, when the actor cannot afford it,
        /// and when nothing was delivered — a payload effect that refused, or a payload list whose
        /// entries are all empty. <c>true</c> when the payload was delivered — including the reported
        /// case where the payload landed but the debit was refused.
        /// </returns>
        public bool TryApply(IDamageable target, GameObject context)
        {
            // The actor is the context object. This effect allows a null damageable, so reading the
            // actor out of one would undo the interface it just claimed.
            if (!context)
                return false;

            if (payload == null || payload.Count == 0)
            {
                WarnAuthoring($"'{name}' has no payload, so there is nothing to charge for.");
                return false;
            }

            var id = new CurrencyId(currencyId);
            if (!id.IsValid || price < 1)
            {
                WarnAuthoring($"'{name}' needs a currency id and a price of at least 1 before it can " +
                              "charge for anything.");
                return false;
            }

            var wallet = CurrencyResolve.ServiceFrom(context);
            if (wallet == null)
            {
                WarnAuthoring($"No currency service resolved for '{context.name}', so '{name}' cannot " +
                              "charge for anything.");
                return false;
            }

            var cost = CostBundle.From(new CostLine(id, new Money(price)));

            // Preflight. Reading the balance mutates nothing, so an actor who cannot afford it is
            // charged nothing and the pickup is still standing. The one piece of state a refused
            // attempt does change lives in the base class: TryApplyTo starts this asset's cooldown
            // before dispatching here, so a refusal burns it -- leave the cooldown at 0 on a priced
            // pickup.
            if (!CurrencyPurchase.CanAfford(wallet, context, cost))
                return false;

            if (!DeliverPayload(target, context))
                return false;

            // Charged last. Everything above this line is either free or reversible by doing nothing.
            // TrySpend rather than a raw Debit: a raw debit shortened by a policy floor still reports
            // Ok, and the difference between that and a purchase is the whole subject of this file.
            var paid = CurrencyPurchase.TrySpend(wallet, context, cost, "PricedPickup", name);
            if (!paid.Success)
            {
                // The goods are already delivered and there is no honest way to take them back -- a
                // payload effect has no reverse. Reporting delivery is still correct for the pickup;
                // what is not acceptable is letting this pass unsaid.
                Debug.LogWarning($"[{nameof(PricedPickup)}] '{name}' delivered its payload to " +
                                 $"'{context.name}' and then could not take the {price} '{id}' for it " +
                                 $"({paid}). They have it and the price was not paid.", this);
            }

            return true;
        }

        /// <summary>
        /// Runs the payload in authored order and stops at the first refusal.
        /// </summary>
        /// <returns>
        /// <c>true</c> only when at least one effect ran and none refused. A list of empty entries
        /// reports <c>false</c>: nothing was delivered, so there is nothing to charge for — the same
        /// answer <c>CompositeEffect</c> gives for the same input, and the reason the count is of
        /// deliveries rather than of loop iterations.
        /// </returns>
        /// <remarks>
        /// Through <see cref="PickupEffect.TryApplyTo"/> rather than the void path, so each child keeps
        /// its own gating and cooldown and — the part that matters here — gets to say no.
        /// </remarks>
        private bool DeliverPayload(IDamageable target, GameObject context)
        {
            int delivered = 0;

            for (int i = 0; i < payload.Count; i++)
            {
                var fx = payload[i];
                if (!fx)
                    continue;

                if (fx.TryApplyTo(target, context))
                {
                    delivered++;
                    continue;
                }

                // Stop rather than continue. Continuing is what a composite does, and it is right for
                // effects that do not depend on each other; here the rest of the payload is the reason
                // the player is being charged.
                if (delivered > 0)
                {
                    Debug.LogWarning($"[{nameof(PricedPickup)}] '{name}' delivered {delivered} effect(s) " +
                                     $"to '{context.name}' before '{fx.name}' refused, and charged " +
                                     "nothing. Put the refusable payload first so this cannot happen.", this);
                }

                return false;
            }

            if (delivered == 0)
            {
                WarnAuthoring($"'{name}' has a payload list whose entries are all empty, so it " +
                              "delivered nothing and charged nothing.");
            }

            return delivered > 0;
        }

        /// <summary>
        /// Reports an authoring mistake, in the editor and development builds only.
        /// </summary>
        /// <remarks>
        /// Local rather than a shared helper, so the recipe stays one file. These branches fire on
        /// every trigger enter by every actor for as long as the asset is misconfigured, which is
        /// what you want while you are building the scene and is log spam in a shipped player. The
        /// free-goods report is deliberately <em>not</em> routed through here — see the class remarks.
        /// </remarks>
        [System.Diagnostics.Conditional("UNITY_EDITOR")]
        [System.Diagnostics.Conditional("DEVELOPMENT_BUILD")]
        private void WarnAuthoring(string message)
            => Debug.LogWarning($"[{nameof(PricedPickup)}] {message}", this);
    }
}

Wiring it up

  1. Create a Priced Pickup asset, set the currency and the price.
  2. Put the payload effects in the list — the refusable one first. Leave the inherited cooldown at 0: the base class starts it before this effect runs, so an actor who cannot afford the pickup still burns it.
  3. Assign it as the effect on the pickup. TriggerPickup is the shape this was written against. On UnifiedPickup2D in Mode.Both, note that the "effect already delivered" flag is per component rather than per actor, so if the item half refuses for the actor who paid, the next actor skips the charge and takes the item. And do not nest a priced pickup inside a CompositeEffect — the composite continues past the refused purchase and hands over the siblings, which is the exact behaviour this recipe exists to avoid.
  4. Publish a currency service the actor can resolve. Without one, nothing is charged and nothing is delivered, and it says so.
  5. If you publish through CurrencyServiceBootstrap, the stack it composes includes an authority gate. Without an ICurrencyAuthority in the scene every debit is refused — balances still read fine, so the preflight passes and every pickup pays out for free. The bootstrap logs that once at startup; this recipe logs it on every purchase.

What it deliberately does not do

It does not reverse a delivered payload. A pickup effect has no inverse — there is no honest way to take a heal back — so the one unrecoverable case reports loudly rather than pretending.

It does not hold the funds. A hold would close the window above, and would require every project using a coin-operated door to compose an escrow stack. The check is the right weight for this; the page says what it costs.

It does not decide what happens visually. A refused purchase returns false and the pickup stays standing, and that is the whole of it. TriggerPickup and UnifiedPickup2D have no feedback hooks — IPickupFailFeedback is driven only by InteractablePickupBase, which carries no PickupEffect field, so it cannot be reached from the wiring above. To show a "you cannot afford this" cue, run this effect from your own InteractablePickupBase subclass and return its result from DoPickup, where failFeedbacks and PickupFailed are waiting for it.

  • Pickups — effects, composites, and what refusing actually does.
  • Currency — balances, debits and why a credit can be refused.
  • A craft that costs two currencies — the same ordering problem across time, where a hold is worth the setup.
  • StatusApplyEffect — the shipped status pickup, and the clearest example of the same two interfaces on a payload whose refusals it can only partly see.