Files
UnrealPrototyping/Docs/Spec/Interaction.md
T
Moto 0e61a77346 Add comprehensive design and specification documentation (#1)
* Write the Unreal documentation set: design, steps, ideas, decisions and specs

Rewrites the design and engineering docs of the two earlier Unity projects
(Adventurer Guild, Project Malleable) for an Unreal Engine 5 prototype that
builds and proves three things in order: a movement controller, fighting and
crafting. Neither earlier core loop is carried; their ideas are catalogued as
features with what each needs.

- Docs/Design.md: the human summary (pillars, fixed decisions, glossary)
- Docs/Steps.md: a ladder of fifteen proofs, the first six in full
- Docs/Ideas.md: salvaged ideas from both projects, none scheduled
- Docs/Decisions.md: D-01..D-36, open decisions OD-01..OD-08, deferrals
- Docs/Spec/: notation, Architecture, Movement, Interaction, Combat,
  Crafting, Networking, Telemetry, UI, written as Unreal C++ skeletons
- CLAUDE.md and README.md for the repository


* Add the stat block: one attribute set on every body, every influence an effect

Every body (player, enemy, NPC) carries the same UStatBlockAttributeSet, and
every outside influence on it is a gameplay effect: base values, buffs,
debuffs, ground surfaces, carried weight, gear, being downed. Counters are
tags and attributes on the receiver: immunity blocks by tag, resistance
scales by attribute, cleanse removes by tag. Same status from several
sources: strongest wins; different statuses multiply.

- Docs/Spec/Stats.md: the block, effects and their five sources (abilities,
  areas, surfaces, items, the world), stacking, counters, readers, tests
- Movement: the movement component's tagged speed-multiplier map is removed;
  speed is tuning times the MoveSpeed attribute; the ground surface trace
  turns mud into an effect; the gym gets mud and ice patches
- Combat: the attribute set moves to Stats; enemies and kits carry
  FStatBlockDefaults; the funnel reads MagicResist and Immune.Damage tags;
  taunt and mark are statuses
- Interaction: carried weight is GE_Encumbered against CarryCapacity
- Crafting: class bonuses are the WorkSpeed and WorkQuality attributes;
  enchantments may grant a holder effect
- Steps: step 5 builds the block with mud, a volume and an immunity
- Decisions D-37 and D-38; design summary, CLAUDE.md, indexes, worklog
2026-09-15 17:54:00 +03:00

18 KiB

Interaction, carrying and throwing

Owns the one system through which a player acts on the world: what can be touched, how it is targeted, what the prompt says, how the request reaches the server, and what happens to things a player picks up. It does not own any prop's behaviour: a bench belongs to Crafting.md, a downed teammate to Combat.md. This document says how a player acts on a thing; the feature docs say what the thing does.

Read Architecture.md first. Built in step 6, before combat, because reviving a teammate and inserting a piece into a bench are the same act on different props and neither should invent its own mechanism.

One system

Before the earlier projects had this, their design docs described four implicit interaction models: a raycast "use", a proximity push, a trigger volume you stood on, and an undefined "confirm". In a game where the world is the interface that becomes a dozen mechanisms that each feel slightly different. One system instead, and one interface, which is justified by the count: the two source projects between them named a bench, a station, a container, a ticket, a contract card, a counter, a locker, a plinth, a lever, a cart's handles, a cart being righted, and a downed player. Twelve implementations is not speculative abstraction.

The boundary that keeps it one system: an interaction is a discrete act on a fixed prop. Carrying is continuous possession of a moving object. Pushing is continuous force and is neither. Picking something up therefore goes through the carry component, not the interaction service, even though both are on the same key; the interaction component routes the press.

Layout

Source/<Project>/Interaction/
├── Interactable.h              // IInteractable, the UInterface every prop implements (on an actor or a component)
├── InteractionTypes.h          // FInteractionPrompt, FInteractionQuery, the reason tags
├── InteractionSubsystem.h      // UWorldSubsystem: registry, server-side validation, the one dispatch path
├── InteractionComponent.h      // on the player: targeting, prompt, the request RPC, hold-to-confirm
├── InteractionHighlightComponent.h // on a prop: custom depth outline, in-range and targeted states
├── Carryable.h                 // ICarryable and UCarryableComponent: what can be held, and by whom
└── CarryComponent.h            // on the player: hands, pick up, drop, throw, hand over

Content/Interaction/
├── M_Outline_PP                // the post-process outline material reading custom stencil
└── WBP_InteractionPrompt       // one prompt widget, see UI.md

Trace channel Interactable is ECC_GameTraceChannel1, declared in DefaultEngine.ini; every interactable's collider responds to it and nothing else does. Carryables additionally sit on Carryable (ECC_GameTraceChannel2).

Types at a glance

Type Lifetime Notes
IInteractable interface Implemented by a prop actor, or by a component when one actor holds several props
FInteractionPrompt, FInteractionQuery value The prompt is the output of one function, on both peers
UInteractionSubsystem world Registry and the server-side funnel
UInteractionComponent per player body Owner only; the only thing that sends the request
UInteractionHighlightComponent per prop Presentation
ICarryable, UCarryableComponent per carryable actor Replicated CarriedBy
UCarryComponent per player body Two hands

The interactable contract

USTRUCT(BlueprintType)
struct FInteractionQuery
{
    GENERATED_BODY()
    TObjectPtr<AController> Instigator;    // who is asking
    TObjectPtr<APawn> Body;                // their body, for range and carried state
    FGameplayTagContainer BodyTags;        // State.Carrying, State.Downed, ... from the ability system
};

USTRUCT(BlueprintType)
struct FInteractionPrompt
{
    GENERATED_BODY()
    FGameplayTag Verb;             // Interact.Verb.Open, .Take, .Insert, .Revive, .Pull ...; localised by the widget
    FText TargetName;              // "Assembly bench", "Rainer" (a downed player's name is a name, not a key)
    bool bEnabled = true;
    FGameplayTag Reason;           // Interact.Reason.HandsFull, .Occupied, .NotYours, .OutOfReach ... shown when disabled
    float HoldSeconds = 0.f;       // > 0 for irreversible verbs: scrap, cancel, leave
};

UINTERFACE(BlueprintType)
class UInteractable : public UInterface { GENERATED_BODY() };

/**
 * Anything a player can touch. The prompt and the permission come from ONE function so what the prompt says is
 * possible and what the server allows can never disagree. Interact() runs on the authority only.
 */
class IInteractable
{
    GENERATED_BODY()
public:
    virtual FInteractionPrompt GetPrompt(const FInteractionQuery& Query) const = 0;   // pure, called every frame locally
    virtual void Interact(const FInteractionQuery& Query) = 0;                         // SERVER ONLY, after re-validation
    virtual FVector GetInteractionLocation() const = 0;                                // what the server measures reach against
    virtual bool IsLocalOnly() const { return false; }   // true: acts on this machine's own state, never forwarded
    virtual float GetPriorityBias() const { return 1.f; } // a person outranks the furniture they stand at
    virtual FGameplayTag GetPropTag() const = 0;         // stable identity for telemetry: Prop.Bench, Prop.ReviveTarget
};

IsLocalOnly exists for props like a personal locker or a settings surface: things that only change this player's own local state. They run their Interact on the client without a round trip and are never sent to the server. Everything touching shared state stays forwarded. Without the flag, an earlier project had a guest's locker setting the host's class.

The subsystem: one funnel

UCLASS()
class UInteractionSubsystem : public UWorldSubsystem
{
    GENERATED_BODY()
public:
    // Reach. Tokens, not literals, so they are tuned in one place and the prompt and the server agree.
    static constexpr float ReachCm = 180.f;
    static constexpr float HighlightCm = 350.f;
    static constexpr float ReachToleranceCm = 50.f;   // the client's frame and the server's are a tick apart

    bool IsInReach(const FVector& BodyLocation, const IInteractable& Target, float Tolerance = 0.f) const;

    /**
     * SERVER ONLY. The only path an interaction takes. Never trusts the client's claim that it was close enough
     * or that the prompt was enabled: re-checks reach with tolerance, re-runs GetPrompt for permission, then calls
     * Interact. Emits prop_interacted. One distance comparison is the difference between a convenience and an
     * exploit.
     */
    bool ServerTryInteract(const FInteractionQuery& Query, TScriptInterface<IInteractable> Target);

    // Registry, so the highlight sweep and later systems can ask "what is near" without a world search.
    void Register(TScriptInterface<IInteractable> Prop);  void Unregister(TScriptInterface<IInteractable> Prop);
    void QueryNear(const FVector& Point, float Radius, TArray<TScriptInterface<IInteractable>>& Out) const;
};

Props register in BeginPlay on every peer and unregister in EndPlay. The RPC carries the prop as an object reference: map-placed actors and replicated actors both resolve on the server through their network identity, which is why there is no hand-rolled handle table. A prop that is neither placed nor replicated cannot be interacted with over the network, by construction, which is the correct failure.

The player's component

/**
 * The targeting, the prompt, the press. Owner only. Finds what you are looking at, offers it, and on the press
 * either routes to the carry component (a carryable with free hands) or sends the request to the server.
 */
UCLASS()
class UInteractionComponent : public UActorComponent
{
    GENERATED_BODY()
public:
    TScriptInterface<IInteractable> GetCurrentTarget() const;
    FInteractionPrompt GetCurrentPrompt() const;   // the HUD reads this; it never computes its own
    void OnInteractPressed();  void OnInteractReleased();   // released matters for hold-to-confirm

protected:
    // TickComponent, owner only:
    //   1. Sweep a 15 cm sphere from the camera along its forward for ReachCm on the Interactable channel.
    //      What you LOOK at wins; the earlier overlap-and-angle-score model is used only for the highlight set.
    //   2. If the hit resolves to an IInteractable (on the hit component first, then its owner) and it is in
    //      reach per the subsystem, it is the target; GetPrompt(query) is the prompt. Otherwise no target, no prompt.
    //   3. Every 0.25 s, QueryNear(HighlightCm) drives the in-range outline set; the target gets the strong outline.
    // Press:
    //   target is ICarryable and hands are free      -> Carry->RequestPickUp(target)      (not an interaction)
    //   carrying and target accepts an insert        -> ServerRequestInteract(target)     (Insert is an interaction)
    //   target.IsLocalOnly()                         -> target->Interact(query) locally
    //   prompt.HoldSeconds > 0                       -> start the hold; send on completion; release cancels silently
    //   otherwise                                    -> ServerRequestInteract(target)

    UFUNCTION(Server, Reliable) void ServerRequestInteract(UObject* Target);
    //   builds the query from the owning controller and calls the subsystem's ServerTryInteract.
    //   The client sends the target and nothing else; the server reads the body's position itself.
};

Two players interacting with one prop in the same frame resolve host-side by arrival order: the second fails the re-run GetPrompt if the first changed the prop's state. Contested props that need a soft lock ("someone is using this", with a name) get it in their own GetPrompt; the system does not know what contention means.

Carrying

[SALVAGED] Two hands, and things you carry are real. Most objects take one hand; big ones take both and, in
first person, sit in the middle of your view. Carrying something two-handed blocks Interact, Insert and attacking
until you put it down. Drop is always available and instant. Nothing you carry is ever destroyed by dropping,
throwing or bumping into a wall with it.
UINTERFACE() class UCarryable : public UInterface { GENERATED_BODY() };
class ICarryable
{
    GENERATED_BODY()
public:
    virtual UCarryableComponent* GetCarryable() = 0;
};

/** On any actor that can be held. The actor is the object; this is its carry state. */
UCLASS(meta = (BlueprintSpawnableComponent))
class UCarryableComponent : public UActorComponent
{
    GENERATED_BODY()
public:
    UPROPERTY(EditDefaultsOnly) bool bTwoHanded = false;
    UPROPERTY(EditDefaultsOnly) float CarryWeight = 1.f;         // against the carrier's CarryCapacity, see Stats.md
    UPROPERTY(EditDefaultsOnly) bool bBlocksAttack = false;       // one-handed loot does not; a crate does
    UPROPERTY(EditDefaultsOnly) FName AttachSocket = TEXT("carry_r");   // or carry_both

    UPROPERTY(ReplicatedUsing = OnRep_CarriedBy) TObjectPtr<APlayerState> CarriedBy;   // null on the ground
    bool IsCarried() const { return CarriedBy != nullptr; }

    // Server: attach to the carrier's mesh socket, physics off, collision to Ignore, dormancy off.
    // Release: detach, physics on, collision back, an impulse if thrown, dormancy back on when at rest.
    // OnRep_CarriedBy does the same attach/detach on clients so the carried object rides the body without
    // per-frame movement replication.
};

/** On the player. Two hands, the verbs, and the encumbrance effect that is the only way weight slows a body. */
UCLASS()
class UCarryComponent : public UActorComponent
{
    GENERATED_BODY()
public:
    UPROPERTY(Replicated) TArray<TObjectPtr<UCarryableComponent>> Hands;   // 0, 1 or 2 entries; two-handed fills both
    bool HasFreeHandsFor(const UCarryableComponent& Object) const;
    UCarryableComponent* GetHeld() const;   // the one-handed object, or the two-handed one

    void RequestPickUp(UCarryableComponent* Object);   // owner -> ServerPickUp
    void RequestDrop();                                // G tap
    void RequestThrow(float ChargeSeconds);            // G held then released; the interaction component tracks the hold

    UFUNCTION(Server, Reliable) void ServerPickUp(UCarryableComponent* Object);
    //   Validate: in reach (subsystem, with tolerance), not carried by anyone, not State.Downed, hands free.
    //   Object->AttachTo(this). ApplyStatus(GE_Encumbered, CarryMath::SpeedFactor(weight, CarryCapacity), infinite):
    //   weight is an effect on MoveSpeed like mud or a slow (Stats.md). Grant State.Carrying(.TwoHanded).
    //   Emit object_picked_up.
    UFUNCTION(Server, Reliable) void ServerDrop();
    //   Always legal while carrying. Release at the hands with no velocity. Remove GE_Encumbered and the tags. Emit object_dropped.
    UFUNCTION(Server, Reliable) void ServerThrow(FVector_NetQuantizeNormal Aim, float ChargeSeconds);
    //   Two-handed objects cannot be thrown (reason: TooHeavy). power = clamp(charge / MaxCharge) * ThrowForce(mass).
    //   Release with impulse along Aim. It lands where it lands. Fragile things take a Mark (Crafting.md), never damage.
    //   Emit object_thrown.
    UFUNCTION(Server, Reliable) void ServerHandOver(UCarryComponent* Other);
    //   Both in reach, other has free hands. The co-op verb. Emit object_handed_over.

    // A down drops what you hold (Combat.md calls RequestDrop on the server when State.Downed is granted).
};

The attach-not-simulate approach is the whole reason carrying feels instant: while held, the object is a child of the carrier's mesh and rides its predicted movement; there is no physics to predict and nothing to reconcile. The moment it leaves the hands it is a server-simulated physics body again, replicated with the engine's predictive interpolation mode, and a thrown object arriving a tick late on a remote screen reads as weight, not lag.

Throwing is in, and it is allowed to be scrappy. It is the cheapest physical comedy available and it serves coordination: tossing an ingot across a room beats walking it, at the cost of accuracy. Rules that keep it fun: objects never break from a throw; a thrown object that comes to rest is an ordinary floor object; thrown objects collide with the world but never knock something out of another player's hands; catching is not a mechanic, aim at the feet.

Blocking is deliberate. A two-handed object commits your hands and, in first person, your eyes. The deadzone widening while carrying (Movement.md) is the safety valve: you can peek round your load without turning your body. If playtests read the block as friction rather than weight, the valve is per-object (bTwoHanded is data) and never a global rule change.

Highlighting

Custom depth with stencil. UInteractionHighlightComponent sets bRenderCustomDepth and a stencil value on the prop's primitives: 1 for in-range, 2 for targeted, 0 otherwise. One post-process material in the player camera's volume draws the outline from the stencil, reading its two colours from the theme (UI.md). The prompt says what; the outline says which, so two ingots lying on each other show which one the press takes. No prop draws its own outline and no prop chooses a colour.

Networking

State Authority Mechanism
Targeting and the prompt Client, owner Local every frame; cosmetic; never replicated
The request Client asks, server decides ServerRequestInteract on the player's own component
Reach and permission Server Re-checked with tolerance; the client's claim is never trusted
The prop's effect Server Whatever the owning feature does; replicated by that feature
Local-only props Client Never sent
Held objects Server CarriedBy replicated; attachment mirrored in OnRep
Thrown and dropped objects Server Physics simulated on the server, replicated movement

Telemetry

Event When Payload
prop_interacted A successful interaction prop, verb, held_seconds, player_class
interaction_refused The server refused a request prop, reason
object_picked_up Carry begins object, two_handed, weight
object_dropped Drop or forced drop object, forced
object_thrown Throw object, charge, distance_landed
object_handed_over Hand over object

prop_interacted earns its place because a world-as-interface game has a failure mode a menu game does not: a player who cannot find the bench has nothing to fall back on. If a prop exists and nobody in a session ever touches it, it is not discoverable, and this event is the only way to learn that before someone says so. interaction_refused by reason is the cheapest possible measure of whether prompts explain themselves.

Tests

  • Automation: IsInReach with and without tolerance; the prompt of a test prop is disabled with HandsFull when the query carries State.Carrying.TwoHanded.
  • Functional: two clients and a server; client A picks up a crate, client B sees it on A's body; A throws it, both see it land within tolerance of the server's resting place; a request from beyond reach plus tolerance is refused and interaction_refused carries OutOfReach.

Open questions

  • Q1. Contested props. Arrival order is the rule. Whether a bench or a station wants a visible soft lock with a name is each prop's call in its own GetPrompt; the first prop that needs one sets the pattern.
  • Q2. A greyed prompt or no prompt for an unusable prop. Greyed, with the reason: more discoverable and more honest. Revisit if prompts clutter a crowded room.
  • Q3. Hold-to-interact as an accessibility option. The hold mechanism exists for irreversible verbs; making it optional for every verb is a settings toggle over the same code. With the settings pass, not before.
  • Q4. Which objects are two-handed. Data per object. The first crate, the first log and the first assembled greatsword decide the initial list; the earlier projects chose crates, logs, ore chunks and packed stations.
  • Q5. A satchel. The earlier smithing design added purchasable pocket slots later in progression. Not now; parked in ../Ideas.md. Hands only until something needs more.