Skip to content

Crafting System

Summary: Crafting is a server-authoritative, cube-based deterministic-influence system. Players place a target item plus crafting materials into the crafting cube (UCraftingContainerComponent, a spatial-grid container). Cube contents are matched against authored recipes; on execute, the server aggregates the materials' affix influence into an FRecipeAffixContext (a guaranteed-specific modifier, guaranteed/biased modifier families, and a bias multiplier), captures that context before consuming the ingredients, then threads it into the recipe outcome and each stateless UCraftingOperation. Material tooltips resolve a {MODIFIER_EFFECT_RANGE} token against the referenced modifier's tier ranges so a shard's authored description shows the real value range it would grant.

Table of Contents


Architecture Overview

┌──────────────────────────────────────────────────────────────────┐
│                   CRAFTING CUBE (player-facing)                   │
│   UCraftingContainerComponent : UItemContainerComponent          │
│     • Spatial grid holds target item + materials                 │
│     • OnCubeContentsChanged → CheckCurrentRecipe                 │
└──────────────────────────┬───────────────────────────────────────┘
                           │ contents change
┌──────────────────────────────────────────────────────────────────┐
│                    RECIPE MATCHING (UCraftingSubsystem)          │
│     • Match cube contents → FCraftingMatchResult                 │
│     • Validate (ECraftingValidationResult)                       │
└──────────────────────────┬───────────────────────────────────────┘
                           │ Server_ExecuteCraft (server RPC)
┌──────────────────────────────────────────────────────────────────┐
│              CRAFT EXECUTION (server-authoritative)              │
│                                                                  │
│   1. GetMaterialInfluence()  ── builds FRecipeAffixContext       │
│        from materials STILL IN the cube                          │
│            │                                                     │
│   2. ConsumeIngredients()  ── materials now gone                 │
│            │                                                     │
│   3. ApplyOutcome(Outcome, Recipe, Context)                     │
│            │                                                     │
│            ▼  per outcome type                                   │
│        CreateNew / ModifyExisting / TransformItem               │
│            │                                                     │
│            ▼  per operation                                      │
│        UCraftingOperation::Execute(Item, Context, PoolManager)  │
│            │                                                     │
│   4. Client_OnCraftingComplete (result modifiers via RPC)       │
└──────────────────────────────────────────────────────────────────┘

Key Design Principles

Principle Implementation
Server authority Server_ExecuteCraft is a Server, Reliable RPC; all consume/apply logic runs on the server. Results pushed to the owning client via Client_OnCraftingComplete.
Reuse, don't fork The cube is a UItemContainerComponent subclass — same grid, replication, and item-object plumbing as inventory/stash.
Capture-before-consume Material influence is read before ingredients are removed; consuming first would leave operations with an empty context.
Stateless operations UCraftingOperation instances carry no state — recipes reference TSubclassOf<> and the container calls the CDO. The FRecipeAffixContext is the only craft-time data passed in.
Broken references surface, not hide Missing/empty modifier resolution logs LogCrafting errors and leaves tokens literal rather than silently degrading.

Core Concepts

Why Crafting Works This Way

Crafting follows the GDD's deterministic-influence model: materials are not random-reroll buttons but intent expressed through three escalating tiers of certainty. The cube is the staging area; the recipe is the rule; the affix context is the resolved intent.

The system is designed so new material kinds (Ichor, Lure variants) and new operations slot in without touching the execution core — operations read a single context struct, and the tooltip formatter dispatches by material category so future resolvers register without ViewModel churn.

Material Categories → Influence Tiers

ECraftingMaterialCategory maps each material to how it shapes the outcome. Priority is Specific > Family > Bias — a guaranteed specific modifier overrides everything else.

Category Influence Determinism
Currency None (drives major operations only) n/a
Shard Guarantees one specific modifier ID Full
Ichor Forces a modifier from guaranteed families (weighted roll within) Family-bounded
Lure Biases family weights (no guarantee) Soft
RawMaterial Basic component, no affix influence n/a

The Affix Context

FRecipeAffixContext is the single carrier of material intent through a craft:

Field Source category Meaning
GuaranteedSpecificModifierID Shard Exact modifier; if set, all other influence ignored
GuaranteedFamilies Ichor Modifier must come from one of these families
BiasedFamilies Lure These families get weighted up
AggregatedBiasMultiplier Lure Multiplier applied to biased family weights

Authoring a Reagent (FCraftingMaterialFragment)

An influence reagent is just an item carrying a FCraftingMaterialFragment. Which fields you populate decides the reagent's tier — the MaterialCategory enum is a label (runtime logic keys off field presence, not the enum value), so authoring is "fill in exactly one influence channel."

Field Sets context field Category
GuaranteedSpecificModifierID GuaranteedSpecificModifierID Shard
GuaranteedModifierFamilies GuaranteedFamilies (AddUnique) Ichor
BiasedModifierFamilies BiasedFamilies (AddUnique) Lure
BiasMultiplier AggregatedBiasMultiplier (*=) Lure

ApplyToAffixContext folds one fragment's fields into the shared FRecipeAffixContext with strict priority:

  • Specific returns early. If GuaranteedSpecificModifierID is set it writes that ID and returns — family/bias fields on the same fragment are ignored. Exact intent overrides everything.
  • Families are additive. Each guaranteed family is AddUnique-merged, so multiple Ichors in the cube union their families rather than overwrite.
  • Bias multiplies. Biased families are AddUnique-merged and AggregatedBiasMultiplier *= BiasMultiplier, so two bias reagents on a shared family stack multiplicatively.

Affix Influence & Context Capture

GetMaterialInfluence() walks every item currently in the cube, inspects each FCraftingMaterialFragment, and aggregates the result into one FRecipeAffixContext.

Ordering Is Load-Bearing

Server_ExecuteCraft (server):
    1. Match + validate recipe
    2. Context = GetMaterialInfluence()   ◄── materials still present
    3. ConsumeIngredients(Recipe)          ◄── materials removed
    4. ApplyOutcome(Outcome, Recipe, Context)

The context is captured at step 2 and passed as a parameter through ApplyOutcome and into each UCraftingOperation::Execute. Earlier code consumed ingredients first and then re-derived the context from the (now empty) cube inside ApplyOutcome, producing a default-constructed context — no specific ID, no families, no bias — which made every shard/ichor/lure recipe silently fail (operations returned false). Capturing once, up front, and threading the value down is the fix and the contract: ApplyOutcome and Execute never call GetMaterialInfluence() themselves.


Recipe Outcomes & Operations

ERecipeOutcomeType selects what a successful craft does. Each outcome runs an ordered list of TSubclassOf<UCraftingOperation>, each receiving the captured context.

Outcome Behaviour Operations run against
CreateNew Spawn a brand-new item — (item produced directly)
ModifyExisting Apply operations to the target item in place The existing target item
TransformItem Replace the input with a new output item The newly created item

UCraftingOperation is abstract, blueprintable, and stateless:

Method Parameters Purpose
CanExecute (Item, PoolManager) Pre-flight check run before resources are consumed
Execute (Item, Context, PoolManager) Perform the operation using the affix context
GetFailureReason ECraftingValidationResult for UI feedback
GetDisplayName Debug/UI label

Because operations are stateless CDOs, the only craft-specific data they see is the FRecipeAffixContext — which is exactly why its correct, pre-consume value matters.

Fail-Safe Execution (the no-consume contract)

A craft that cannot do anything must not consume reagents. Two rules enforce it:

Outcome selection rolls only among outcomes whose operations can actually execute. SelectRandomOutcomeFromIndices filters to executable outcomes and renormalizes the weights. This restores the recipe contract — RNG decides which outcome, never if — and closes the free re-click outcome-fishing loop a blockable roll allowed. The Crafting Manager's Monte Carlo simulation mirrors the same selection so its odds match the game's. When every executable outcome has zero weight, the pick is uniform among the allowed** indices rather than falling back to the unrestricted (blockable) roll.

An operation that mutates and then returns false breaks the contract. Execute()'s mutation contract is documented on the base class: returning false asserts nothing was changed. Concretely — RerollModifiers snapshots the lane and restores it when the filtered pool turns out empty, and RerollAllAffixes aggregates with |= so any mutated lane consumes. TransformItem re-adds the original item when the output cannot fit the freed cube space, rather than destroying it.

Pre-Click Failure Affordances

Failure is surfaced before the click, not as an error afterwards. CraftingController::ShouldDimCubeItem verdicts each cube shard against the target item's lanes; shards that cannot contribute dim to 0.35 opacity (the same treatment two-handers get). Verdicts are re-run after in-place crafts (RefreshCubeTiles).

Footer affordance labels reserve their space from window open and only animate opacity (0.25s ease-out), so the footer never reflows as verdicts change.

Layout gotcha worth keeping: a SizeBox reserve is voided by a Collapsed child. Use opacity, not visibility, to hide something whose space must stay reserved.

Reagent & Recipe Catalog

The three shipped influence reagents, one per determinism tier. The recipe matches on ingredients (target equipment + the reagent); the operation reads the captured context.

Reagent Category Influence Recipe Operation
Shard of Hemorrhage Shard Guarantees a specific Bleed modifier Recipe_Shard_Of_Hemorrhage AddGuaranteedModifierOperation
Ichor of Blight Ichor Rolls within the Corruption family Recipe_Ichor_Of_Blight AddRandomAffixOperation
Lure of Cinders Lure Biases the Fire theme Recipe_Lure_Of_Cinders AddRandomAffixOperation

Naming convention: RecipeName mirrors its reagent — there is no separate craft verb (no Imbue/Anoint/Coax). The cosmetic recipe name only surfaces beside the reagent at confirm-time; the player-facing mechanics live in RecipeDescription.

Asset layout:

Asset kind Path
Reagents /Game/DataAssets/Items/ItemManifests/Crafting/
Recipes /Game/DataAssets/Crafting/<Family>/

Cost vs Certainty

ResourceCost scales with determinism — the more precisely you control the outcome, the dearer the craft:

   Lure (soft bias)  <  Ichor (family-bounded)  <  Shard (exact mod)
   cheapest                  mid                     dearest

The exact numbers are tuned in the recipe assets; the ordering is the contract.

Bias Is a Nudge, Not a Guarantee

BiasMultiplier up-weights the spawn weight of every modifier whose family is in BiasedFamilies, inside CreateWeightedListWithBias. In a large pool a single biased family stays a soft increase (the rest of the pool still competes), which is exactly why Lure is the lowest-determinism tier: the shipped Lure of Cinders leans the roll toward the Fire theme, so Fire ends up over-represented — not guaranteed. The strength of the nudge is tuned in the reagent asset.


Material Tooltip Range Resolution

Crafting materials author a description containing a {MODIFIER_EFFECT_RANGE} token. FCraftingDescriptionFormatter resolves that token to the referenced modifier's effect line spanning its tier value ranges, so a shard tooltip shows the actual range it would grant.

Material description template (authored)
   "Imbues {MODIFIER_EFFECT_RANGE} bleed damage"
            ▼  FCraftingDescriptionFormatter::FormatCraftingMaterialDescription
   Pool->FindModifierByID(GuaranteedSpecificModifierID)
            ▼  ComputeTierUnion over Tier1..Tier5 of TiersValueRanges
   { Min, Max }
            ▼  FModifierDefinition::FormatRangeDescription(Min, Max)
   "(8-24)"   (collapses to single value when Min == Max)
            ▼  FText::Format with FFormatNamedArguments (localization preserved)
   "Imbues (8-24) bleed damage"

Resolution Rules

  • Tier union: the displayed range is the union of Tier1..Tier5 entries in FModifierDefinition::TiersValueRanges (lowest min, highest max across populated tiers).
  • Min == Max collapse: FormatRangeDescription shows a single value instead of (N-N).
  • Failure is loud: a null pool, an unknown modifier ID, or a modifier with no populated tier ranges logs a LogCrafting error and leaves the {MODIFIER_EFFECT_RANGE} token literal in the tooltip. A broken modifier reference is a content bug for QA to catch, not something to paper over.
  • Category dispatch: the formatter is stateless and dispatches by material category (specific / family / bias), so future Ichor/Lure tooltip resolvers register without ViewModel changes.

Pool Resolution

Both craft execution and tooltip formatting need the active UModifierPoolManager. UModifierSubsystem::GetPoolManagerFromWorld(WorldContext) consolidates the World → GameInstance → subsystem → pool manager lookup chain (with a current-play-world fallback) into one static accessor, so callers don't re-walk that chain. FRolledModifier::GetDefinition resolves its definition through the same path (its cached definition pointer is mutable for lazy resolution).


Public Contracts

UCraftingContainerComponent

Method Parameters Purpose
CheckCurrentRecipe Match cube contents → FCraftingMatchResult
GetCurrentMatchedRecipe The currently matched recipe, if any
HasValidRecipe Whether current contents form a valid recipe
Server_ExecuteCraft Server RPC: capture context, consume, apply outcome
CanExecuteCraft Validation result for the current craft
GetMaterialInfluence Aggregate FRecipeAffixContext from cube materials
Delegate Payload When Fired
OnRecipeStateChanged FCraftingMatchResult Recipe match validity changes
OnCraftingComplete FRecipeOutcome, FEquipmentModifiers Craft succeeds (owning client)
OnCraftingFailed ECraftingValidationResult Craft fails

FCraftingDescriptionFormatter

Method Parameters Purpose
FormatCraftingMaterialDescription (Template, Material, Pool) Resolve {MODIFIER_EFFECT_RANGE} against the material's modifier

Data Types

Struct / Enum Purpose
ECraftingMaterialCategory Currency / Shard / Ichor / RawMaterial / Lure
ERecipeOutcomeType CreateNew / ModifyExisting / TransformItem
ECraftingValidationResult Why a craft can/can't proceed
FRecipeAffixContext Aggregated material intent (specific / families / bias)
FCraftingMatchResult Matched recipe + validation state
FCraftingModifierEntry / FCraftingResultContext Result-panel display data

Source References

Component Location
UCraftingContainerComponent Public/Crafting/Components/CraftingContainerComponent.h
UCraftingOperation Public/Crafting/Operations/CraftingOperation.h
Crafting types / affix context Public/Crafting/Types/CraftingTypes.h
UCraftingSubsystem Public/Crafting/Subsystems/CraftingSubsystem.h
Recipe data asset Public/Crafting/Data/CraftingRecipeDataAsset.h
FCraftingDescriptionFormatter Public/UI/Tooltip/CraftingDescriptionFormatter.h
FCraftingMaterialFragment Public/Inventory/Items/Fragments/ItemFragment.h
UModifierLibrary::FilterByAffixContext Public/Inventory/Modifiers/ModifierLibrary.h
UModifierLibrary::CreateWeightedListWithBias Public/Inventory/Modifiers/ModifierLibrary.h
FModifierDefinition::FormatRangeDescription Public/Inventory/Modifiers/ModifierDefinitions.h
UModifierSubsystem::GetPoolManagerFromWorld Public/Inventory/Subsystems/ModifierSubsystem.h
Log category Public/Crafting/CraftingLog.h


Recent Changes

Date Change Impact
2026-07-23 Fail-safe craft execution + pre-click failure affordances (FSH-409/410, 57b6f705f + 279d17d27): outcome selection rolls only among executable outcomes with renormalized weights (mirrored in the Monte Carlo sim); the Execute() mutation contract — return false ⇒ nothing changed — documented on the base class and enforced (RerollModifiers lane snapshot/restore, RerollAllAffixes |= aggregation, TransformItem re-adds the original when the output cannot fit); shard grey-out verdicts + reserve-stable fading footer labels; W_Crafting adopted into WidgetForge Removes free re-click outcome fishing and a class of silent reagent loss; failure is legible before the click rather than after it
2026-06-17 Documented the author-side FCraftingMaterialFragment (priority/additive/multiplicative ApplyToAffixContext rules) and the shipped reagent/recipe catalog (Shard of Hemorrhage / Ichor of Blight / Lure of Cinders), with cost-vs-certainty ordering and the "bias is a nudge" note Authors and designers can see how to define a reagent and how the three tiers map to operations, costs, and asset paths
2026-04-19 Material tooltips resolve {MODIFIER_EFFECT_RANGE} via FCraftingDescriptionFormatter + FModifierDefinition::FormatRangeDescription; GetPoolManagerFromWorld consolidates pool lookup Shard tooltips show the real tier-union value range; broken modifier IDs surface as LogCrafting errors instead of hiding
2026-04-18 Capture FRecipeAffixContext before consuming ingredients; thread it through ApplyOutcome and UCraftingOperation::Execute Fixes every shard/ichor/lure recipe silently failing on a default-constructed (empty) context
2026-04-13 Initial crafting runtime: cube container, recipe matching, affix-context aggregation, outcome/operation execution Server-authoritative crafting loop with deterministic material influence