NOTICE - Equipment Orientation Module The following training materials describe standard-issue Associate equipment and approved interaction protocols. Associates who interact with non-approved objects do so at their own risk. Associates who fail to interact with approved objects also do so at their own risk. Salvage Solutions Inc. appreciates your compliance either way.
- S.A.L.L.I.
When players talk about extraction games, they talk about loot. But behind every item pickup is a chain of systems that need to answer hard questions: How does the player target something in a dark room? How does the server validate that the client is not cheating? How does a crowbar know it can pry open a sealed door? How does a 0.5kg capacitor know it goes in the backpack and not a quick slot?
This post breaks down Deep Haul's interaction and item architecture from the trace that starts everything to the data asset pipeline that lets me add new items in under ten minutes.
Two Components, One Trace Channel
The interaction system is built on a custom trace channel called ECC_Interaction. Only actors with an enabled UInteractableComponent respond to this channel. Everything else is invisible to the interaction trace.
The player side is UInteractionComponent, attached to ADeepHaulCharacter. Every tick (local player only), it fires a ray from the camera:
// InteractionComponent.cpp - PerformInteractionTrace()
FVector TraceStart = Camera->GetComponentLocation();
FVector TraceEnd = TraceStart + (Camera->GetForwardVector() * InteractionRange);
// InteractionRange = 250cm (2.5 meters)
If the ray hits an actor with an enabled UInteractableComponent, the component caches the target and updates the HUD prompt. If it hits nothing, the prompt clears.
The target side is UInteractableComponent, attached to any interactable actor. In BeginPlay, it finds its owner's collision primitive (StaticMesh, SkeletalMesh, or any UPrimitiveComponent) and sets ECC_Interaction to ECR_Block. When disabled, it flips to ECR_Ignore, making the actor completely invisible to interaction traces without touching any other collision channels.
This two-component split means I can make anything interactable by adding one component. Doors, items, terminals, corpses, buttons. The interaction logic does not care what the actor is. It cares whether it has the component and whether the component is enabled.
Simple vs Complex: Two Interaction Patterns
Not every interactable needs custom behavior. A salvage item just needs to be picked up. An O2 terminal needs tick-by-tick refill logic. I handle this with two patterns:
Simple (delegate pattern): The actor has UInteractableComponent and binds to its OnInteractionComplete delegate. When the interaction finishes, the delegate fires and the actor responds. AWorldItem uses this pattern for pickup. No interface, no tick logic, no virtual functions.
Complex (interface pattern): The actor has UInteractableComponent AND implements the IInteractable interface. This gives it five hooks:
bool CanInteract(AActor* Interactor); // Gate: can this player use this?
FInteractionOption GetInteractionOption(AActor* Interactor); // Dynamic prompt + duration
void OnInteractStart(AActor* Interactor); // Begin session
EInteractionTickResult OnInteractTick(AActor* Interactor, float DeltaTime); // Continue/Complete/Cancel
void OnInteractEnd(AActor* Interactor, bool bCompleted); // Cleanup
The O2 terminal is the canonical example. It implements IInteractable with InteractionDuration = -1 (indefinite hold). Each tick, it transfers oxygen from the ship reserve to the player's personal tank. It returns Complete when the tank is full, Cancel when the ship reserve is empty.
GA_Interact (the GAS ability that drives all interactions) checks whether the target implements the interface. If it does, it calls the interface methods. If it does not, it falls back to the simple delegate path. One ability handles every interaction in the game.
The Server Trust Problem
In multiplayer, the server cannot trust the client's interaction target. A modded client could claim to be interacting with an item across the map. But the server also cannot re-derive the target by re-tracing from the client's replicated control rotation, because replication lag means the server's trace might miss by a few degrees.
The solution is a pending target handoff:
- Client ticks
InteractionComponent, caches the current target - Client presses E, immediately fires
Server_SetPendingInteractionTarget(CurrentTarget) - Client activates
GA_Interact(LocalPredicted, starts instantly on client) - Server receives the pending target RPC, caches it
- Server's
GA_Interact::ActivateAbilitycallsConsumePendingInteractionTarget()to get the target - Server validates: target exists, has
InteractableComponent, component is enabled, distance <= 300cm (slightly more than the client's 250cm to account for latency)
If validation fails, the server cancels the ability. The client's prediction gets rolled back. If it passes, the interaction proceeds server-authoritatively.
The 300cm vs 250cm range difference is deliberate. A player moving at sprint speed covers about 50cm during a typical network round trip. Without the buffer, legitimate interactions at max range would fail intermittently.
Channeled Interactions: 60Hz Tick With Real Time
Interactions with a duration (door hacking, O2 refill, item pickup with a hold timer) run through a repeating timer at approximately 60Hz. The elapsed time uses real world time, not accumulated deltas:
// GA_Interact.cpp - TickInteraction()
ElapsedTime = (float)(GetWorld()->GetTimeSeconds() - InteractionStartTime);
float Progress = FMath::Clamp(ElapsedTime / InteractionDuration, 0.0f, 1.0f);
Character->SetInteractionProgress(Progress);
I originally accumulated fixed deltas (ElapsedTime += 0.016f per tick), but discovered it ran about 4% slower than real time. The progress bar would hit 100% visually before the server called completion. Switching to GetTimeSeconds() fixed the desync.
The tick also checks three cancel conditions every frame:
- Movement: velocity > 10 cm/s (any WASD input)
- Damage: current health < health at interaction start
- Input release: player let go of E
If any fires, the interaction cancels. The montage stops, exclusive access releases, and the progress bar resets.
Exclusive Access: One Player Per Terminal
Some interactables (O2 terminals, hack panels, loot containers) should only allow one player at a time. UInteractableComponent handles this with a replicated CurrentUser:
// InteractableComponent.h
UPROPERTY(ReplicatedUsing = OnRep_CurrentUser)
TObjectPtr<AActor> CurrentUser;
bool TryClaimAccess(AActor* Requester); // Server only
void ReleaseAccess(AActor* User); // Server only
When GA_Interact activates on the server, it calls TryClaimAccess. If another player already has it, the ability cancels. Access is released in EndAbility BEFORE broadcasting the completion delegate, which matters because the delegate might destroy the actor (item pickup destroys the WorldItem).
The Item Architecture: Definition vs Instance
Every item in Deep Haul is split into two parts:
UItemDataAsset is the static definition. One per item type, created in the editor. It defines everything about an item that never changes at runtime: name, icon, weight, salvage value, what ability it grants, what animation layer to use, what sound to play on pickup.
FItemInstance is the runtime state. One per item that exists in the world or in an inventory. It holds a pointer to its ItemDataAsset plus mutable state: current charges remaining and whether the item is activated (lit flare vs unlit flare).
// InventoryTypes.h
struct FItemInstance
{
TObjectPtr<UItemDataAsset> ItemDef; // Points to static definition
int32 CurrentCharges; // -1=unlimited, 0=depleted, 1+=uses left
bool bIsActivated; // Toggle state (flare lit, scanner active)
};
When a player picks up a WorldItem, the FItemInstance transfers intact from the WorldItem actor into the inventory slot. When they drop it, it transfers back to a newly spawned WorldItem. Charges, activation state, everything persists through the cycle.
This split is important because multiple WorldItems can reference the same DataAsset. Ten Burnt Capacitors in a level all point to DA_BurntCapacitor, but each has its own FItemInstance.
Three Item Types, Two Slot Types
Items are classified into three types that determine where they live in the inventory:
| Type | Slot | Used How | Charges |
|---|---|---|---|
| Tool | Quick Slot | Equipped, unlimited use | -1 (unlimited) |
| Consumable | Quick Slot | Equipped, limited charges | 1-5 uses |
| Salvage | Backpack | Cannot be "used," has credit value | N/A |
UInventoryComponent manages both slot arrays. Quick slots (default 4) hold tools and consumables. Backpack slots (default 12) hold salvage. The component enforces this split: you cannot put salvage in a quick slot, and you cannot put a tool in the backpack.
When you select a quick slot, the item's ActiveItemEffect GE is applied to your ASC. This grants a tag like Item.Tool.Crowbar or Item.Tool.Scanner. Other systems query these tags for context-sensitive behavior. A sealed door checks whether you have the crowbar tag and changes its prompt from "Locked" to "Pry Open (Hold E)."
Weight and Encumbrance: GAS All the Way Down
Every item has a weight in kilograms. The inventory component tracks total carry weight and applies encumbrance through three tiers of Gameplay Effects:
| Weight Ratio | Effect | Gameplay Impact |
|---|---|---|
| < 50% | None | Full speed |
| 50-80% | LightEncumbered | Slight speed reduction |
| 80-100% | HeavyEncumbered | Noticeable speed reduction, increased O2 drain |
| > 100% | Overencumbered (stacking) | Severe penalties per 20% over cap |
The stacking at >100% is the interesting part. Each 20% over the weight cap applies another stack of the overencumbered GE. A player at 160% carry weight has three stacks. These stack with sprint, breached areas, and panic effects through the same additive GAS composition described in my previous post about the GAS system.
The weight cap itself (MaxCarryWeight) is a GAS attribute, which means future items or mission modifiers could increase it with a Gameplay Effect. No special code needed.
Context-Sensitive Interactions: The Tool Tag Pattern
The crowbar-door interaction is my favorite example of how these systems compose without direct coupling.
The door (ADeepHaulDoor) implements IInteractable. Its GetInteractionOption checks the interactor's ASC for the Item.Tool.Crowbar tag:
- No crowbar equipped: Returns "Locked" with
bCanInteract = false(greyed-out prompt) - Crowbar equipped: Returns "Pry Open (Hold E)" with
HoldDuration = 3.0fandbCanInteract = true
The crowbar does not know about doors. The door does not know about the crowbar. The door knows about a tag, and the crowbar's DataAsset has an ActiveItemEffect that grants that tag. If I add a plasma cutter tool that also grants the crowbar tag, it automatically works on sealed doors with zero door code changes.
The crowbar's own use ability (GA_UseItem_Crowbar) is a melee swing for stunning enemies. Door forcing goes through the interaction system, not the item ability. This keeps the crowbar's code focused on one thing (hitting stuff) while the interaction system handles the channeled hold-to-pry behavior.
WorldItem: The Physical Representation
AWorldItem is the actor that exists in the world when an item is not in someone's inventory. It has three components:
UStaticMeshComponent(root, collision target)UInteractableComponent(interaction detection)UProjectileMovementComponent(drop physics)
When spawned (placed in editor or dropped by a player), BeginPlay reads the FItemInstance's DataAsset to configure the interactable: prompt text ("E - Pick up Burnt Capacitor"), pickup duration, pickup montage.
The pickup handler is clean:
// WorldItem.cpp - OnPickupComplete (server only)
void AWorldItem::OnPickupComplete(AActor* Interactor, bool bCompleted)
{
if (!HasAuthority() || !bCompleted) return;
ADeepHaulCharacter* Character = Cast<ADeepHaulCharacter>(Interactor);
UInventoryComponent* Inventory = Character->GetInventoryComponent();
if (Inventory->CanAddItem(ItemInstance.ItemDef))
{
Inventory->AddItem(ItemInstance); // Full FItemInstance preserved
Destroy();
}
}
When dropped, the WorldItem does a little front-flip arc (ProjectileMovement with a spin on tick), then snaps to the ground. It raycasts 500cm downward to find the floor and positions the mesh bottom flush with the surface. The interactable component disables during flight (so you cannot pick it up mid-air) and re-enables once it lands.
The item also implements IYeetable, a marker interface that means hull breach vacuum volumes can suck it out into space. If your salvage is near a breach when it blows, the items go flying. This is a real gameplay scenario that players have to think about.
The Solo Dev Item Pipeline
Adding a new salvage item to Deep Haul takes me about ten minutes. Here is the actual workflow from my docs:
Step 1: DataAsset (2 minutes). Right-click in Content Browser, create DA_ItemName. Fill in name, description, item type (Salvage), tag, value, weight. Save.
Step 2: WorldItem Blueprint (3 minutes). Create WI_ItemName from parent class WorldItem. Set the static mesh, adjust scale. Point ItemInstance.ItemDef at the DataAsset. Point the DataAsset's WorldItemClass back at this Blueprint. Compile, save.
Step 3: Icon (4 minutes). Screenshot the item in the Blueprint viewport. Open Figma, paste, remove background, crop to 300x300, export at 2x. Import to Unreal, set max texture size to 128. Set on the DataAsset's Icon field.
Step 4: Test (1 minute). Place in test level, PIE. Check: visible, prompt works, pickup works, icon displays, drop works. Done.
No C++ for salvage items. No new Blueprint classes. No ability setup. The entire item inherits its behavior from the base WorldItem class and the InteractableComponent delegate binding. Tools and consumables need a bit more setup (ability classes, activation types, cooldowns), but salvage is pure data.
Here is the current salvage roster with the value-to-weight ratios that make players think:
| Item | Value | Weight | cr/kg |
|---|---|---|---|
| Stripped Bolt Assembly | 12 cr | 0.8 kg | 15.0 |
| Burnt Capacitor | 15 cr | 0.5 kg | 30.0 |
| Copper Wire | 20 cr | 0.3 kg | 66.7 |
| Cables | 25 cr | 1.5 kg | 16.7 |
| Cracked Display Panel | 35 cr | 2.0 kg | 17.5 |
| Corroded Wire Bundle | 75 cr | 1.8 kg | 41.7 |
| Radiant Cube | 100 cr | 2.5 kg | 40.0 |
Copper Wire is the most efficient by weight (66.7 cr/kg) but low total value. Cables are heavy for what they are worth. The Radiant Cube is the jackpot at 100 credits but 2.5kg. These ratios matter because of the encumbrance system. Grabbing that Radiant Cube might push you from 70% to 80% carry weight, triggering the heavy encumbrance tier. Is 100 credits worth being slower when the Sentinel is between you and the airlock?
That is the extraction loop in a single decision.
How AI Keeps the Pipeline Organized
Building a game solo means wearing every hat. I use Claude to help maintain documentation, track asset status, and keep the pipeline moving. The salvage workflow doc I maintain is a checklist that any Claude session can follow to help me create new items without missing steps.
When I model a new mesh in Blender and import it, I can describe what the item is and Claude helps me fill out the DataAsset values (reasonable weight for the object type, credit value that fits the existing tier structure, flavor text that fits S.A.L.L.I.'s corporate tone). The gameplay tag gets registered, the workflow checklist gets followed, and the item is testable in minutes.
For tools and consumables, Claude reads the existing ability code to understand the patterns and helps me scaffold new item abilities that follow the same architecture. The "dumb applicator" philosophy from my GAS post means these abilities are usually short. Apply an effect, consume a charge, end. The complexity lives in the Blueprint GEs, not the C++ ability.
This is how a solo dev ships a game with dozens of items. Not by writing less code, but by building systems where new content is data, not code.
"Equipment orientation complete. Associates are reminded that all Company-issued tools remain Company property. Salvage recovered during operations is also Company property. Your labor is... well, you signed the agreement. Please return to your duties."
- S.A.L.L.I.