0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI CONSUMABLES_SYSTEM
UTC 00:00:00
◀ RETURN
CONSUMABLES_SYSTEM.md 3194 words ~15 min read Updated 2026-07-03

Consumables

Status: Live. v1 (food + stat-boost potions) shipped. Decided: 2026-05-13 Shipped: 2026-05-13 Companion to: COMBAT_SYSTEM.md, STAT_PIPELINE.md, ARCHITECTURE.md

Scope

How single-use inventory items deliver effects — healing items (foods) and stat-boost items (potions) at v1, with the contract pre-shaped for future leaves (combo foods, throwables, flasks). Covers the consume action, the spec asset, the GE delivery path, and the cooldown gate. Does not cover stat balance, the per-effect GE catalog, or non-item buff sources (mob debuffs, environmental status) — those belong to their own contracts. Stat-boost composition itself is governed by STAT_PIPELINE.md; this doc only specifies how consumables emit into that pipeline.

Quick Reference

Topic Answer Section
Action surface Action.Trigger.Item.ConsumeUConsumeItemAbility The Consume Action
Spec asset UConsumableDefinition DataAsset, referenced from FItemRow.Consumable Consumable Definition
Heal channel Negative-magnitude GE on the Damage meta attribute Healing Channel
Stat-boost channel Duration GE on a Bucket-1 attribute (per STAT_PIPELINE.md) Stat Boost Channel
Cooldown granularity Per-category (Food, Potion); CheckCooldown-gated Eat Cooldown
Mid-combat behavior Consume runs free; auto-attack loop is untouched Mid-Combat Behavior
Overheal Clamped to MaxHealth at v1; pure-heal items rejected at full HP (anti-waste); additive migration path documented Overheal Policy
Net policy LocalPredicted; stack decrement and GE application are authority-only Replication
XP Consuming grants no XP. Healing path is the existing DamageDelta <= 0 short-circuit Healing Channel
Tag deltas Action.Item.Consume, Cooldown.Item.Consume.{Food,Potion}, GameplayCue.Item.Consume.{Food,Potion} Tag Taxonomy

The Consume Action

Rule: Consume is a UConsumeItemAbility whose Trigger.TriggerTag = Action.Trigger.Item.Consume and whose NetExecutionPolicy = LocalPredicted — same dispatch shape as UEquipItemAbility and every other item-action ability. The right-click "Consume" entry built in UInventoryComponent::GatherSlotActions already fires this trigger; the ability is the missing consumer.

Why: Item actions are a closed contract — one tag, one ability, one trigger. Adding UConsumeItemAbility closes the loop the inventory UI already opened. Reusing the dispatch path means hotkeys, drag-and-drop verbs, and right-click menus all reach the same authority code with no per-source branching.

Implementation surface: - Files: Source/CRADL/Abilities/ConsumeItemAbility.{h,cpp} (new). - Trigger: Action.Trigger.Item.Consume (already declared). - Activation tag: Action.Item.Consume (new under Action.*). - Payload: FGameplayEventData::OptionalObject = the source IItemContainer; EventMagnitude = slot index. Mirror of UEquipItemAbility.

Activation gates: - Caller pawn alive (no Status.Dead). - Source slot non-empty and resolves to a row tagged Item.Consumable. - Row's UConsumableDefinition reference is non-null (validator enforces this matches the tag). - CheckCooldown passes for the consumable's CooldownTag (see Eat Cooldown). - Full-HP gate for heal-only consumables. A consumable whose Effects are entirely heal entries (every entry keyed by SetByCaller.Item.Consume.Heal) is rejected when the caller is at MaxHealth. Posts "You're already at full health." to the player; no stack decrement, no cooldown. Hybrid items (heal + buff) and pure-boost potions pass through — boosts still apply at full HP. See Overheal Policy for the future overheal path that lifts this gate by raising the ceiling. - Not modal-blocked. Inventory action gating already excludes Consume while banking — Consume inherits this from the menu builder, not from per-ability logic.

Authority sequence (server side, single tick): 1. Re-validate everything in the gate list — UI state may have drifted by the time the activation lands. The full-HP-on-heal-only gate is part of this list, so an at-max-HP click on a pure-heal food fails before any state mutates. 2. Decrement the source stack by 1 via the existing inventory mutation path. Stack decrement is unconditional once gates pass — once we're past the gate list, the item commits. 3. Apply each entry in ConsumableDefinition.Effects to the caller's ASC as a self-target GE with the entry's magnitude set via SetByCaller. 4. Apply the cooldown GE. 5. Fire the per-category cosmetic cue (GameplayCue.Item.Consume.{Food,Potion}). 6. EndAbility.

Footguns: - Do not include Action.Combat.AutoAttack in CancelOnTagsAdded, and do not include Action.Item.Consume in UAutoAttackAbility::CancelOnTagsAdded. The two run free of each other by contract (see Mid-Combat Behavior); coupling them re-introduces the OSRS "eating eats your swing" rule we deliberately deferred. - Stack decrement happens before GE application, not after. If a GE class fails to apply (CDO null, etc.), the stack must still have decremented — same authority-order pattern UEquipItemAbility uses for slot transitions.

Related: Consumable Definition, Replication, ARCH #18 (widgets dispatch via ASC events).


Consumable Definition

Rule: Each consumable's spec lives on a UConsumableDefinition DataAsset, referenced from FItemRow.Consumable (new TObjectPtr<UConsumableDefinition> field). Presence of the reference is the consumability marker; the existing Item.Consumable tag stays as a fast-path hint for the menu builder, with the validator enforcing tag ⇔ reference agreement.

Why: Items DataTable rows are getting crowded as it is — pushing heal amounts, effect lists, cooldown tags, and cosmetic anims into FItemRow would balloon every non-consumable row with sparse columns. A separate DataAsset matches the codebase's established pattern (USkillDefinition, ULoadoutDefinition, URecipeDefinition, USpellDefinition, USpellbookDefinition) and lets the editor present consumable authoring as a focused per-asset UI. Attaching by reference from the row (rather than via a parallel registry keyed by ItemId) keeps a single source of truth per item — there's no second lookup that can drift.

Asset shape:

UCLASS(BlueprintType)
class CRADL_API UConsumableDefinition : public UDataAsset
{
    GENERATED_BODY()
public:
    // Authoring category. Drives the default CooldownTag, the cosmetic cue, and
    // the AnimMontage selection. Adding a category is additive — new enum entry,
    // new cooldown leaf, new cue leaf; no contract change.
    UPROPERTY(EditAnywhere) EConsumableCategory Category = EConsumableCategory::Food;

    // GE classes applied to the caller on consume, with per-entry SetByCaller
    // magnitudes. Heal-only items have one entry (the heal GE); boost items have
    // one or more boost GEs; combo items have both.
    UPROPERTY(EditAnywhere) TArray<FConsumableEffect> Effects;

    // Cooldown tag granted on consume. Empty = derive from Category
    // (Cooldown.Item.Consume.Food / .Potion / ...). Explicit override lets an
    // edge-case item opt into a tighter or looser cooldown bracket.
    UPROPERTY(EditAnywhere, meta=(Categories="Cooldown.Item.Consume"))
    FGameplayTag CooldownTagOverride;

    // Cosmetic: AnimMontage played on the caller, GameplayCue fired on the ASC.
    // Both are client-side decoration; gameplay state does not depend on them.
    UPROPERTY(EditAnywhere) TSoftObjectPtr<UAnimMontage> ConsumeMontage;
    UPROPERTY(EditAnywhere, meta=(Categories="GameplayCue.Item.Consume"))
    FGameplayTag ConsumeCueOverride;
};

USTRUCT(BlueprintType)
struct FConsumableEffect
{
    GENERATED_BODY()
    UPROPERTY(EditAnywhere) TSubclassOf<UGameplayEffect> Effect;
    UPROPERTY(EditAnywhere) float Magnitude = 0.f;
    UPROPERTY(EditAnywhere) FGameplayTag SetByCallerTag;
};

EConsumableCategory at v1: Food, Potion. Future leaves (Brew, Throwable, Flask) are additive — new enum entry plus the matching cooldown/cue tag.

Authoring rule: A row tagged Item.Consumable must have a non-null Consumable reference, and vice versa. The items DataTable validator enforces both directions, plus: every FConsumableEffect.Effect is non-null and every SetByCallerTag is non-empty.

Why a row reference rather than a parallel registry: The trade-off considered was a sidecar table keyed by ItemId. That approach keeps FItemRow untouched but introduces two facts that must stay in sync (the Item.Consumable tag and the registry membership) and a second authoring surface. A single row field collapses both to one truth. The cost is one extra column on FItemRow — accepted.

Related: Healing Channel, Stat Boost Channel, CLAUDE.md "Editor-time validators shadow the runtime structs."


Healing Channel

Rule: A healing consumable applies a UGE_Item_Heal GE that writes a negative magnitude to the Damage meta attribute on UCradlAttributeSet. The existing PostGameplayEffectExecute drains the meta into Health, clamps [0, MaxHealth], and short-circuits before the cue/InCombat/XP block because DamageDelta <= 0. No new attribute. No new code path on the attribute set.

Why: Healing-via-negative-Damage is already the contract documented on UCradlAttributeSet::Damage in CradlAttributeSet.h. Inventing a parallel Healing meta would duplicate the clamp logic and force the death/event/XP gates to know about both paths. Reusing the existing meta keeps the attribute set's invariant — "one execute, one clamp, one cue choke point" — intact.

Implementation surface: - New GE class: UGE_Item_Heal (instant duration, Damage modifier with SetByCallerMagnitude keyed by the entry's SetByCallerTag). Single class is reused across every food because the magnitude is per-asset on the UConsumableDefinition. - Magnitude is negative (heal = Damage -= N). The food's Effects[0].Magnitude is authored as a positive number representing HP restored; the ability negates it when filling SetByCaller, or the GE class's ModifierMagnitude uses a FloatCurve with -1 slope. Either is fine — pick at implementation time, but the authoring number is always positive.

XP: Consuming food grants no XP. The existing DamageDelta <= 0.f early-return in PostGameplayEffectExecute already implements this — codifying it here so a future "heal grants Hitpoints XP" idea doesn't sneak in. OSRS does not award HP XP for eating; this contract preserves that.

Footguns: - Heal-clamp happens at the meta drain (FMath::Clamp(OldHealth - DamageDelta, 0.f, GetMaxHealth())). Authoring a +99 HP food on a MaxHealth=10 pawn silently clamps to 10 on partial-HP eats. The full-HP gate (see The Consume Action) prevents the full-HP overshoot case from consuming the stack, but mid-HP overshoots still clamp without a warning. - Do not route healing through a Healing meta or write SetHealth directly. Both bypass the existing cue/event funnel. - The full-HP gate checks SetByCaller.Item.Consume.Heal keys on every entry. A future heal-like effect using a different SetByCaller key would silently bypass the gate. If a new heal channel ships, extend IsHealOnly to recognize it (or restructure the check to inspect modifier attributes).

Related: Overheal Policy, STAT_PIPELINE.md "Bucket 1 — Buffable scalars", COMBAT_SYSTEM.md "Health & Damage Application".


Stat Boost Channel

Rule: A stat-boost consumable (potion) applies one or more duration GEs to a Bucket-1 attribute on UCradlAttributeSetStrength, RangedStrength, MagicDamage, Defense, or AttackSpeed. Composition is standard GAS aggregation per STAT_PIPELINE.md; this contract adds no new attribute and no new aggregator.

Why: STAT_PIPELINE.md already names "buffs / potions / status (future)" as the fourth contributor surface, with GE on player ASC as the pipeline. Consumables are the delivery vehicle for that surface — the math is already settled. Boosts compose with gear and loadout contributions through one aggregation; the damage formula in UCradlCombatMath reads the same attribute regardless of source.

Implementation surface: Per-stat parameterized GE classes (UGE_Buff_Strength, UGE_Buff_Defense, …) take a SetByCaller magnitude and a duration. A potion's UConsumableDefinition.Effects holds the GE class plus the magnitude; the duration is on the GE class itself.

Boost decay (OSRS-style "+5 then drops 1 per minute"): deferred at v1. The contract permits either model when authored — a single duration GE for stepwise hold-then-drop, or a series of short-duration GEs for linear decay. Pick the shape per-potion family when the design lands; no contract change needed in either case.

No new attribute for "potion-boosted level": OSRS distinguishes "visible level" vs "boosted level" for skill checks. CRADL's combat formula already reads the effective scalar (Strength, etc.) post-aggregation — equipment, ship, pilot, and potion contributions all land in the same attribute. No "boosted vs base" split is needed.

Footguns: - Don't author a potion's stat-boost as an instant-duration GE on a non-meta attribute. Instant duration writes to BaseValue; that's permanent. Boosts must be HasDuration or Infinite (with explicit removal). - Potions don't author into the per-DamageType AttackBonus/DefenseBonus matrices (Bucket 2). Per STAT_PIPELINE.md, the matrix is gear-only. A "potion of slash defense" expresses itself via the universal Defense scalar instead, until per-type defense multipliers ship.

Related: STAT_PIPELINE.md "Bucket 1 — Buffable scalars", "Defense Granularity".


Eat Cooldown

Rule: Each consumable names a CooldownTag (defaulted from its category). Standard GAS CheckCooldown gates re-activation: a duration GE granting the tag is applied on consume; CheckCooldown blocks until it expires. At v1, two leaves exist:

  • Cooldown.Item.Consume.Food — applies to EConsumableCategory::Food consumables.
  • Cooldown.Item.Consume.Potion — applies to EConsumableCategory::Potion consumables.

Why: OSRS-accurate. Food shares one delay across all foods (you can't spam shark → karambwan → shark mid-tick to bypass the gate); potions have a separate, shorter delay so drink-and-eat in the same engagement remains viable. Per-category cooldowns capture that without per-item cooldown sprawl. Single-global would lose the food/potion distinction; per-item would let alternating items bypass the rule.

Implementation surface: - New GE classes: UGE_Cooldown_Item_Consume_Food, UGE_Cooldown_Item_Consume_Potion. Duration is a Cooldown.* namespace tag per the convention in COMBAT_SYSTEM.md "Tag Taxonomy" (Cooldown.* keeps gate tags out of Status.*). - Default duration values live on UCradlConsumableSettings (a UDeveloperSettings) — FoodCooldownSeconds (default 1.8), PotionCooldownSeconds (default 0.6). Same fallback-from-settings pattern as UCradlCombatSettings.

Authoring escape hatch: UConsumableDefinition.CooldownTagOverride lets an edge-case item opt into a different cooldown bracket without inventing a new category. Use sparingly — the validator emits a warning when an override is set so reviewers see it.

Footguns: - CheckCooldown returns false while any matching tag is active. If a future combo-food wants "ignore food cooldown for the first bite," it must opt into a different CooldownTag for that bite — don't try to fork CheckCooldown behavior per-ability. - Cooldown tag matching is parent-OK (Cooldown.Item.Consume would block both food and potion). Do not author parent-level cooldowns by mistake; the validator pins the override to Cooldown.Item.Consume.* leaves.

Related: COMBAT_SYSTEM.md "Tag Taxonomy" (Cooldown.* namespace convention).


Mid-Combat Behavior

Rule: Consuming an item during an active auto-attack engagement does not cancel the engagement and does not delay the next swing. The consume ability and the auto-attack ability run as independent activations on the same ASC; neither lists the other's activation tag in CancelOnTagsAdded.

Why: The combat MVP's swing rhythm is the load-bearing player-facing surface — coupling consumes into it risks subtle regressions (skipped swings, drift in cadence) for a v1-tier feature. Keeping the channels independent ships the heal-during-combat affordance immediately, with zero coupling to combat internals. The OSRS-faithful "eating delays your next swing by ~1.8s" rule is recognized as future work — adding it is purely additive (Status.Eating tag pushed for the cooldown window; UAutoAttackAbility::HandleSwingFinished observes it and reschedules its WaitDelay) and does not require contract changes here, only an addendum.

Footguns: - Reviewing PRs that touch UAutoAttackAbility or UConsumeItemAbility: confirm neither ability's CancelOnTagsAdded includes the other's activation tag. Adding the coupling later is a deliberate design decision, not a refactor opportunity. - Consume's own cooldown does not propagate to combat. A player who ate food 0.3s ago can still swing; the Cooldown.Item.Consume.Food tag does not appear in any combat CheckCooldown or CancelOnTagsAdded list.

Related: COMBAT_SYSTEM.md "Engagement Loop", "Cancellation Channels".


Overheal Policy

Rule: Healing is clamped to MaxHealth at v1 — the existing PostGameplayEffectExecute clamp is the choke point. Pure-heal items are additionally rejected at full HP (see The Consume Action) so the stack isn't wasted on a no-op. Boost-over-max foods (anglers / Saradomin brews / etc.) are deferred.

Why: Overheal needs HUD work (current vs effective max), formula consideration (does effective max ever decay back into actual?), and design choices on whether boosted HP decays. None of that is on the v1 path. The clamp behavior already exists, so the v1 contract is "do nothing extra" for the clamp itself; the full-HP gate is a v1 addition that prevents accidental waste while overheal remains future work. Migration path stays small and additive: add a Status.OverHealed tag plus a duration GE that bumps MaxHealth additively (Bucket-1 pattern — see STAT_PIPELINE.md). Healing flows into the lifted ceiling; on tag expire, MaxHealth returns to base and the existing clamp pulls current Health down. When overheal foods ship, the full-HP gate naturally adapts — IsHealOnly items can still consume because the heal target is the lifted ceiling, not current MaxHealth. Implementation can either special-case "overheal foods bypass the gate" or compare Health against the lifted ceiling; the latter falls out for free if MaxHealth is the read point.

Related: Healing Channel, STAT_PIPELINE.md "Bucket 1 — Buffable scalars".


Replication

Rule: Standard GAS — UConsumeItemAbility is LocalPredicted. Authority decrements the inventory stack and applies the effect GEs; the resulting Health change and any duration buff GEs replicate through the standard ASC channels. Cosmetic montage and GameplayCue.Item.Consume.* cues fire on all clients via the standard cue replication.

Why: Matches every other inventory action ability and every combat ability per COMBAT_SYSTEM.md "Net Execution Policy". Prediction keeps the eat feel immediate; server authority on the stack decrement and the GE application keeps the effect tamper-proof.

Footguns: - Inventory stack decrement is server-authoritative. The client-predicted ability should not optimistically reduce the local stack — UInventoryComponent already replicates slot state, and double-counting would briefly show stack=N-2 before correcting to N-1. Match the pattern in UEquipItemAbility: predict only the cosmetic side; let the state replicate.

Related: COMBAT_SYSTEM.md "Net Execution Policy", UEquipItemAbility precedent.


Tag Taxonomy

Delta vs current Config/DefaultGameplayTags.ini:

Action.Item.* addition (activation owned tag for UConsumeItemAbility): - Action.Item.Consume

Cooldown.Item.Consume.* (new leaves under existing Cooldown.*): - Cooldown.Item.Consume.Food - Cooldown.Item.Consume.Potion

GameplayCue.Item.Consume.* (new under GameplayCue.*): - GameplayCue.Item.Consume.Food - GameplayCue.Item.Consume.Potion

SetByCaller.* (magnitude tag for the heal GE; new leaf if no existing convention): - SetByCaller.Item.Consume.Heal — magnitude passed to UGE_Item_Heal. Boost GEs each name their own SetByCaller tag on the consumable definition entry, per FConsumableEffect.SetByCallerTag.

Already-declared tags this doc reuses, no new declaration needed: - Action.Trigger.Item.Consume (declared in CradlGameplayTags.cpp) - Item.Consumable (declared in CradlGameplayTags.cpp)

Per CLAUDE.md's tag-declaration rule: only tags referenced by name in C++ get a UE_DECLARE_GAMEPLAY_TAG_EXTERN in CradlGameplayTags.h. Cooldown and cue tags that the ability looks up via the consumable asset (not by symbolic reference) live only in the .ini.


Migration Stages

Each stage is independently shippable.

Stage 1 — Consume action + healing

  • Add UConsumableDefinition (DataAsset) and FConsumableEffect (USTRUCT).
  • Add FItemRow.Consumable field (TObjectPtr<UConsumableDefinition>).
  • Add UGE_Item_Heal (instant, Damage meta modifier via SetByCaller).
  • Add UConsumeItemAbility triggered by Action.Trigger.Item.Consume; authority path per The Consume Action.
  • Add UGE_Cooldown_Item_Consume_Food and UCradlConsumableSettings for default durations.
  • Update items DataTable validator: Item.Consumable tag ⇔ non-null Consumable reference; effects array entries are well-formed.
  • Author one canonical food row end-to-end as a smoke test.

Stage 2 — Stat-boost potions

  • Add per-Bucket-1-attribute boost GE classes (UGE_Buff_Strength, UGE_Buff_Defense, …) parameterized by SetByCaller + duration.
  • Add UGE_Cooldown_Item_Consume_Potion.
  • Author one canonical potion row end-to-end.
  • Update validator to recognize EConsumableCategory::Potion.

Stage 3 — Cross-references

Future (only if needed)

  • OSRS-style eat-during-combat swing delay. Additive: Status.Eating tag pushed by UConsumeItemAbility for the cooldown window; UAutoAttackAbility::HandleSwingFinished observes it and reschedules its WaitDelay. No contract change to this doc.
  • Overheal foods. Per Overheal Policy — additive Status.OverHealed + MaxHealth duration GE.
  • Boost decay shape. Per-potion-family choice between stepwise-hold-then-drop and linear-decay GE chains. No contract change.
  • Throwables, flasks, combo foods. New EConsumableCategory leaves; new cooldown/cue tags; no contract change.

Out of Scope

  • Specific food/potion balance numbers (heal amounts, boost magnitudes, durations).
  • Non-item buff sources (mob debuffs, environmental status, ability-cast buffs that aren't consumed from inventory).
  • Reusable/multi-use containers (flasks). The v1 contract is single-use, stack-decrement-on-consume.
  • Throwable / targeted consumables. Consume's payload is the source slot only; no target picking.
  • Crafting consumables (the recipe side). Recipes that produce a food row live under the existing URecipeDefinition contract.
  • UI surfacing of cooldown progress, boost timers, or overheal indicators.
  • AI / NPC consumption of items. Not part of v1 combat.

Decision Owners

A, R, J — decided 2026-05-13.