Files
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

321 lines
18 KiB
Markdown

# 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](Crafting.md), a downed teammate to [Combat.md](Combat.md).
This document says how a player acts on a thing; the feature docs say what the thing does.
Read [Architecture.md](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
```cpp
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
```cpp
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
```cpp
/**
* 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.
```
```cpp
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](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](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`](../Ideas.md). Hands only until something needs more.