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

446 lines
26 KiB
Markdown

# Movement
Owns the character, the movement component, input, the look model, the two camera modes, the gym level and the
hooks other systems use to change how a body moves. It does **not** own abilities that move a body (dodge, blink,
charge: [Combat.md](Combat.md), built on the root-motion hooks here) or picking things up
([Interaction.md](Interaction.md), which supplies the carry-weight penalty this document consumes).
Read [Architecture.md](Architecture.md) first. This is the first system built and the one everything else stands
on, which is why it gets three steps of its own in [`../Steps.md`](../Steps.md) before a single enemy exists.
## Why the controller comes first
A crafting game with bad movement is a menu with a walk between screens. A combat game with bad movement is unfair
before the first swing. Both earlier projects put movement in a corner of a combat step and tuned it by feel once,
against one enemy; neither ever wrote down what "good" meant. This time the controller is the first thing built,
it is built against a level made of nothing but movement problems, and it is not called done until a written
checklist passes with a person at the keyboard.
## Decisions
```
[DECIDED] UCharacterMovementComponent, extended. Not a custom controller, not the Mover plugin.
The engine's character movement is server-authoritative with client prediction and server correction built in,
which is exactly the posture Architecture.md demands and the thing the earlier Unity project never had (its
movement was client-authoritative, which does not survive contact with a real server). It integrates with root
motion sources, which is how abilities move a body without a second movement system. It is mature, documented and
what the engine's own animation tooling assumes.
The Mover plugin is the engine's future answer and is still marked experimental. Q2 keeps it in view; the
extension points used here (a saved-move flag, a tuning asset, root motion sources) are the ones Mover also
exposes, so a later move is a port, not a rewrite.
```
```
[DECIDED] One rig, two camera modes. The camera is presentation and never enters the authority path.
First person is the engine's mannequin seen from a socket on its own head; third person is the same mannequin
seen over its shoulder. There is no first-person arms rig, no second animation set. Every activity, prop and
fight must be completable in both modes, and nothing about the camera is ever replicated, saved or used by the
server to decide anything. Which mode is the default is Q1; both exist from step 3 so the answer can be played
rather than argued.
```
```
[DECIDED] Units are the engine's: centimetres, Z up, 1 uu = 1 cm. Every figure in this doc is in those.
```
## Layout
```
Source/<Project>Core/Movement/
├── LookModel.h / .cpp // pure: the deadzone-then-body-follows look model
└── MovementTuning.h // the tuning asset class and its validation
Source/<Project>/Movement/
├── BaseCharacter.h // ACharacter subclass shared by players and enemies: mesh, team, ASC access
├── PlayerCharacter.h // adds camera, input, interaction and carry components
├── ExtendedCharacterMovement.h // the CMC subclass: sprint flag, coyote time, jump buffer, speed multipliers
├── CameraModeDefinition.h // UPrimaryDataAsset per mode
└── CameraModeComponent.h // owns the camera and spring arm, blends between modes, applies the look model
Content/Movement/
├── Definitions/DA_Tuning_Player, DA_CameraMode_FirstPerson, DA_CameraMode_ThirdPerson
├── Input/IMC_Gameplay, IA_* // one mapping context for play; a second (IMC_Menu) arrives with the menu
├── BP_PlayerCharacter // sets the mesh, animation blueprint, tuning and camera assets. No logic.
├── Surfaces/PM_Mud, PM_Ice // UPhysicalMaterialWithTags for the gym's surfaces (Stats.md), step 5
└── Maps/L_Gym // the movement test level
```
## Types at a glance
| Type | Module | Lifetime | Notes |
| --- | --- | --- | --- |
| `FLookModelParams`, `FLookModelState`, `LookModel::Tick` | Core | value | pure, tested without a world |
| `UMovementTuning` | Core | asset | numbers, with `IsDataValid` checks |
| `ABaseCharacter` | Gameplay | per body | `IAbilitySystemInterface`, `IGenericTeamAgentInterface` |
| `APlayerCharacter` | Gameplay | per body | camera, input binding, interaction, carry |
| `UExtendedCharacterMovement` | Gameplay | per body | the CMC subclass, predicted |
| `UCameraModeDefinition` | Gameplay | asset | one per mode |
| `UCameraModeComponent` | Gameplay | per player body | local only, never replicated |
## Input
Enhanced Input. One mapping context, `IMC_Gameplay`, added at priority 0 in `APlayerCharacter::SetupPlayerInputComponent`
through the local player's `UEnhancedInputLocalPlayerSubsystem`. Actions are assets under `Content/Movement/Input/`
and are bound by the action asset, never by key, so a rebind is a settings change and not a code change. Rebinding
itself uses the engine's `UEnhancedInputUserSettings` (player-mappable key settings on each action), which persists
to the player's save folder; no custom rebinding code is written.
The default layout. Keyboard bindings are the ones the earlier project settled on, with the one conflict resolved
in Q4's favour: sprint takes Shift, the fourth ability slot moves.
| Action | Keyboard and mouse | Gamepad | Value | Notes |
| --- | --- | --- | --- | --- |
| `IA_Move` | W A S D | Left stick | Axis2D | Relative to the body in first person, to the camera in third |
| `IA_Look` | Mouse | Right stick | Axis2D | Mouse deltas are **not** scaled by delta time; stick rates are |
| `IA_Jump` | Space | A | Bool | Buffered and coyote-timed, see below |
| `IA_Sprint` | Left Shift (hold) | Left stick click | Bool | A predicted movement flag, not an ability |
| `IA_Crouch` | Left Ctrl (toggle) | B (hold) | Bool | Engine crouch, capsule shrinks |
| `IA_Dodge` | Left Alt | B (tap) | Bool | Activates the dodge ability once it exists, step 5 |
| `IA_Attack` | Left mouse | Right trigger | Bool | Owned by Combat; bound here so the map is in one place |
| `IA_Interact` | F | X | Bool | Owned by Interaction |
| `IA_Drop` | G (tap drops, hold throws) | Y | Bool | Owned by Interaction |
| `IA_Ability1..4` | Q, E, R, C | LB, RB, Y, LT | Bool | Owned by Combat, routed through input tags |
| `IA_CameraToggle` | V | D-pad down | Bool | Swaps camera mode |
| `IA_Menu` | Escape | Start | Bool | Owned by UI |
Every action carries an `Input.*` gameplay tag in its player-mappable key settings so the ability system, the prompt
and the action bar can ask "which key is `Input.Interact` on the device this player touched last" and get the
live binding back. No view ever prints a literal key.
## The character and the movement component
```cpp
// Source/<Project>Core/Movement/MovementTuning.h
UCLASS(BlueprintType)
class UMovementTuning : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
// Speeds, cm/s. All guesses until the gym says otherwise. Walk is the old project's 4.5 m/s.
UPROPERTY(EditDefaultsOnly, Category = "Speed") float WalkSpeed = 450.f;
UPROPERTY(EditDefaultsOnly, Category = "Speed") float SprintSpeed = 650.f;
UPROPERTY(EditDefaultsOnly, Category = "Speed") float CrouchSpeed = 250.f;
UPROPERTY(EditDefaultsOnly, Category = "Speed") float MaxAcceleration = 2048.f;
UPROPERTY(EditDefaultsOnly, Category = "Speed") float BrakingDeceleration = 2048.f;
UPROPERTY(EditDefaultsOnly, Category = "Speed") float AirControl = 0.35f;
// Jump. Apex ≈ JumpZ² / (2 · 980 · GravityScale): 560 at 1.5 gravity is about 106 cm, the old 1.1 m jump.
UPROPERTY(EditDefaultsOnly, Category = "Jump") float JumpZVelocity = 560.f;
UPROPERTY(EditDefaultsOnly, Category = "Jump") float GravityScale = 1.5f;
UPROPERTY(EditDefaultsOnly, Category = "Jump") float CoyoteTime = 0.10f; // seconds after leaving a ledge a jump still counts
UPROPERTY(EditDefaultsOnly, Category = "Jump") float JumpBufferTime = 0.12f; // seconds before landing a press is remembered
// Ground. The engine defaults are right for stairs up to 45 cm and slopes to 45 degrees; listed so they are tuned here.
UPROPERTY(EditDefaultsOnly, Category = "Ground") float MaxStepHeight = 45.f;
UPROPERTY(EditDefaultsOnly, Category = "Ground") float WalkableFloorAngle = 45.f;
// Landing. A drop taller than this costs a brief recovery; taller than the second, fall damage (Combat.md).
UPROPERTY(EditDefaultsOnly, Category = "Landing") float HardLandingHeight = 300.f;
UPROPERTY(EditDefaultsOnly, Category = "Landing") float HardLandingRecovery = 0.2f;
#if WITH_EDITOR
virtual EDataValidationResult IsDataValid(FDataValidationContext& Context) const override;
// SprintSpeed > WalkSpeed > CrouchSpeed > 0; CoyoteTime and JumpBufferTime under 0.3 s; angles in (0, 90).
#endif
};
```
```cpp
// Source/<Project>/Movement/ExtendedCharacterMovement.h
/**
* The engine's character movement plus the four things every feel pass ends up adding: a sprint flag that
* predicts correctly, coyote time, a jump buffer, and the ground surface trace that turns mud into an effect.
*
* Sprint is a compressed flag in the saved move, which is the engine's mechanism for predicted input state.
* It is deliberately NOT a gameplay ability: an ability round-trips through the ability system for something the
* movement component already replicates for free. Dodge and blink ARE abilities, because they apply root motion.
*/
UCLASS()
class UExtendedCharacterMovement : public UCharacterMovementComponent
{
GENERATED_BODY()
public:
void ApplyTuning(const UMovementTuning& Tuning); // called by the character on BeginPlay and on tuning change
// Input state, set by the owning character, carried in the saved move
void SetWantsToSprint(bool bWants);
void PressJumpBuffered(); // remembers a press for JumpBufferTime
// Speed is the state's tuning value times the body's MoveSpeed attribute (Stats.md), and nothing else. Carrying,
// being downed, mud, a haste and gear all change that one attribute through effects; this component never
// holds a multiplier of its own. Before the stat block exists (steps 3 and 4) the attribute reads as one.
float GetMoveSpeedAttribute() const; // 1.0 when the owner has no ability system component yet
void UpdateGroundSurface(); // server, ~5 Hz: the floor's UPhysicalMaterialWithTags -> its surface effect
// UCharacterMovementComponent
virtual float GetMaxSpeed() const override; // walk/sprint/crouch by state, times MoveSpeed
virtual bool CanAttemptJump() const override; // grounded, OR within CoyoteTime of leaving the ground
virtual void UpdateFromCompressedFlags(uint8 Flags) override;
virtual FNetworkPredictionData_Client* GetPredictionData_Client() const override;
virtual void OnMovementModeChanged(EMovementMode PrevMode, uint8 PrevCustomMode) override; // starts the coyote clock
protected:
bool bWantsToSprint = false;
float TimeLeftGround = -1.f;
float JumpBufferedAt = -1.f;
TWeakObjectPtr<UPhysicalMaterial> CurrentSurface;
FActiveGameplayEffectHandle SurfaceEffect;
// FSavedMove_Character subclass carrying bWantsToSprint in FLAG_Custom_0; FNetworkPredictionData_Client_Character
// subclass allocating it. Standard engine pattern; see the engine's own ACharacter crouch flag for the shape.
};
```
```cpp
// Source/<Project>/Movement/PlayerCharacter.h
/**
* The player's body. Owner-only concerns (camera, input, interaction, carrying) live here as components;
* everything shared with enemies is on ABaseCharacter. The character decides nothing: it reads input, hands it to
* the movement component and the ability system, and lets the camera component look.
*/
UCLASS()
class APlayerCharacter : public ABaseCharacter
{
GENERATED_BODY()
public:
APlayerCharacter(const FObjectInitializer& OI);
protected:
UPROPERTY(EditDefaultsOnly, Category = "Movement") TObjectPtr<UMovementTuning> Tuning;
UPROPERTY(EditDefaultsOnly, Category = "Input") TObjectPtr<UInputMappingContext> GameplayContext;
UPROPERTY(VisibleAnywhere) TObjectPtr<UCameraModeComponent> CameraMode;
UPROPERTY(VisibleAnywhere) TObjectPtr<UInteractionComponent> Interaction; // Interaction.md
UPROPERTY(VisibleAnywhere) TObjectPtr<UCarryComponent> Carry; // Interaction.md
// Replicated so remote players can see where this one is looking. RemoteViewPitch is the engine's; yaw is ours.
UPROPERTY(Replicated) uint8 RemoteHeadYaw; // HeadYaw compressed to a byte, written by the owner every tick
virtual void SetupPlayerInputComponent(UInputComponent* Input) override; // binds IA_* to the handlers below
void OnMove(const FInputActionValue& V); // AddMovementInput relative to body (FP) or camera (TP)
void OnLook(const FInputActionValue& V); // feeds CameraMode->AddLookInput
void OnJumpPressed(); void OnJumpReleased(); // Jump() plus the buffer
void OnSprint(const FInputActionValue& V); // Movement->SetWantsToSprint
void OnCameraToggle(); // CameraMode->CycleMode()
// Attack, Interact, Drop and Ability1..4 forward to Combat and Interaction; they decide nothing here.
virtual void Landed(const FHitResult& Hit) override; // hard-landing recovery, fall damage event, telemetry
};
```
`ABaseCharacter` sets `UExtendedCharacterMovement` as the movement class (so an enemy is slowed by the same mud a
player is), sets the capsule to the mannequin's 42 cm radius and 96 cm half-height, implements
`IAbilitySystemInterface` (returning the player state's component for players, its own for enemies) and
`IGenericTeamAgentInterface`, and carries the `USkeletalMeshComponent` and animation blueprint reference. It has no
input.
## The look model
```
[SALVAGED] The head turns freely inside a yaw deadzone; past it, the body eases around to follow. Inside the
deadzone the body does not move at all, and that "nothing" is the whole feel: you can glance at a teammate or the
thing on the bench beside you without stepping out of position. The deadzone widens while carrying something big,
so you can peek round your own load.
```
```cpp
// Source/<Project>Core/Movement/LookModel.h // pure, no UObject, tested
struct FLookModelParams
{
float YawDeadzone = 45.f; // degrees either side before the body turns
float YawDeadzoneCarry = 70.f; // while carrying a two-handed object
float MaxTurnRate = 540.f; // degrees per second the body may turn to catch up
float MovingDeadzoneScale = 0.35f; // smaller while walking: you face where you go
float ReCenterRate = 90.f; // degrees per second the head drifts forward while moving
float PitchMin = -80.f, PitchMax = 70.f;
float SpinePitchShare = 0.5f; // how much of the pitch bends the spine, for the visible body
};
struct FLookModelState { float HeadYaw = 0.f; float BodyYaw = 0.f; float Pitch = 0.f; };
struct FLookModelInput { FVector2D LookDelta; bool bMoving; bool bStrafing; bool bCarryingTwoHanded; float DeltaSeconds; };
namespace LookModel
{
// pure. Returns the new state; the caller writes BodyYaw to the actor and BodyYaw + HeadYaw to the camera.
FLookModelState Tick(const FLookModelState& State, const FLookModelParams& Params, const FLookModelInput& In);
// HeadYaw += LookDelta.X
// deadzone = (carrying ? YawDeadzoneCarry : YawDeadzone) * (moving ? MovingDeadzoneScale : 1)
// if |HeadYaw| > deadzone: turn = min(|HeadYaw| - deadzone, MaxTurnRate * dt) * sign; BodyYaw += turn; HeadYaw -= turn
// if moving && !strafing: HeadYaw = MoveTowards(HeadYaw, 0, ReCenterRate * dt)
// Pitch = clamp(Pitch + LookDelta.Y, PitchMin, PitchMax)
}
```
How it meets the engine: `bUseControllerRotationYaw` is off. In **first person** the character's actor yaw is
`BodyYaw`, the camera yaw is `BodyYaw + HeadYaw`, and movement input is relative to the body, so you walk where you
face and look elsewhere. In **third person** the camera is free (spring arm on control rotation),
`bOrientRotationToMovement` is on so the body faces where it walks, and the model drives only the head and spine aim
in the animation blueprint so teammates can see where you are looking. One implementation, both modes; `Pitch`
reaches the rig through the engine's replicated `RemoteViewPitch`, `HeadYaw` through `RemoteHeadYaw`.
## Camera modes
```cpp
UCLASS(BlueprintType)
class UCameraModeDefinition : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly) FGameplayTag ModeTag; // Camera.Mode.FirstPerson / .ThirdPerson
UPROPERTY(EditDefaultsOnly) float FieldOfView = 90.f;
UPROPERTY(EditDefaultsOnly) bool bFirstPerson = false;
// third person
UPROPERTY(EditDefaultsOnly) float ArmLength = 300.f;
UPROPERTY(EditDefaultsOnly) FVector SocketOffset = FVector(0, 60, 40); // over the right shoulder
// first person
UPROPERTY(EditDefaultsOnly) FName HeadSocket = TEXT("head");
UPROPERTY(EditDefaultsOnly) FVector EyeOffset = FVector(10, 0, 0);
UPROPERTY(EditDefaultsOnly) float HeadStabilization = 20.f; // damping stiffness; 0 = rigidly on the bone (do not ship that)
UPROPERTY(EditDefaultsOnly) bool bHideLocalHead = true;
UPROPERTY(EditDefaultsOnly) FLookModelParams Look;
UPROPERTY(EditDefaultsOnly) float BlendSeconds = 0.25f;
};
/** Owns the camera and spring arm, blends between definitions, runs the look model. Local player only. */
UCLASS()
class UCameraModeComponent : public UActorComponent
{
GENERATED_BODY()
public:
void SetMode(FGameplayTag ModeTag); void CycleMode();
void AddLookInput(FVector2D Delta);
FRotator GetCameraRotation() const; // BodyYaw + HeadYaw, Pitch
// TickComponent: run LookModel::Tick, write BodyYaw to the owner, position the camera:
// FP: camera location = damped follow of Mesh->GetSocketLocation(HeadSocket) + EyeOffset, in LateUpdate order
// (tick group PostUpdateWork) so it runs after animation; rotation from the look model, NEVER from the bone.
// Mesh->HideBoneByName(head) on the locally controlled pawn only; other clients see the whole body.
// TP: spring arm with collision test on, length and socket offset from the definition, camera on control rotation.
protected:
UPROPERTY(EditDefaultsOnly) TArray<TObjectPtr<UCameraModeDefinition>> Modes;
UPROPERTY(VisibleAnywhere) TObjectPtr<USpringArmComponent> SpringArm;
UPROPERTY(VisibleAnywhere) TObjectPtr<UCameraComponent> Camera;
FLookModelState Look;
};
```
The stabilised head socket is the make-or-break piece. Rigid parenting to the bone turns every walk-cycle bob into
camera shake, which the earlier project found out the hard way and fixed twice. The camera follows the socket's
position through a damped spring and takes its rotation from the look model only. An optional procedural bob (off
by default, scaled by the motion accessibility setting) is the only bob there is.
Comfort settings are launch requirements for a first-person mode, not options: stabilisation strength, FOV per
mode, bob toggle, and the motion scale that also governs camera kicks in combat.
## Networking
| State | Authority | Mechanism |
| --- | --- | --- |
| Position, velocity, movement mode | Server, client predicts | Character movement's own prediction and correction |
| Sprint, crouch | Server, client predicts | Compressed flags in the saved move |
| Jump | Server, client predicts | Engine jump plus the buffered press, resolved in the predicted move |
| Look pitch | Owner writes | `RemoteViewPitch`, engine built-in, for the rig only |
| Head yaw | Owner writes | `RemoteHeadYaw` byte, for the rig only |
| Camera mode, FOV, stabilisation | Local only | Never replicated |
| `MoveSpeed`, `JumpPower` | Server | Attributes on the stat block, replicated; a server-applied change costs one small correction on the owner, see [Stats.md](Stats.md) |
Remote bodies interpolate through the movement component's network smoothing (exponential). Test every step of this
document with the editor's network emulation profile set to 100 ms and 5 % loss, and `p.NetShowCorrections 1` on;
a correction you can see at those settings is a bug in the saved move.
## The gym
`L_Gym` is a level made only of movement problems, greyboxed from engine primitives with a material per problem
kind. It is the level every movement step is proved in, and it stays in the project forever as the regression
test for the controller.
| Section | What it holds |
| --- | --- |
| Stairs | Risers of 15, 20, 30 and 45 cm, straight and spiral |
| Slopes | 15, 30, 45 and 60 degrees, up and down; the last must not be walkable |
| Gaps | 150, 200, 250 and 300 cm, flat; 250 makeable at sprint only |
| Ledges | Drops of 100, 200, 300 and 500 cm onto flat ground |
| Doorways | 110 by 220 cm, and a 90 by 200 cm one that a crouch fits |
| Beams | 30 cm wide walkways over a drop |
| Corridor | A 40 m straight for speed and stop-distance measurement, marked every 5 m |
| Surfaces | Patches of mud and ice on the corridor's second half, on tagged physical materials; inert until step 5 |
| Arena | An open 30 by 30 m circle for the combat steps later |
## The feel checklist
Step 4 closes when a person at the keyboard ticks every line, in both camera modes, and the numbers that made it
pass are committed in `DA_Tuning_Player`.
- Stairs of every riser at walk and sprint: no camera stutter, no snag, no bounce at the top.
- Slopes: walkable to 45 degrees, slides off 60; speed on a 30 degree climb reads as effort, not a wall.
- Gaps: 150 and 200 at walk, 250 at sprint, 300 never. The 250 is the one that teaches sprint.
- Coyote time: stepping off a ledge and pressing jump within a tenth of a second still jumps. Buffer: pressing jump
just before landing jumps on landing.
- Landing from 100 and 200 cm: nothing. From 300: a visible knee-bend and a fifth of a second of no input. From 500:
fall damage (once Combat exists) and the same recovery.
- Stop distance from sprint under 150 cm; from walk under 60 cm. Turning at sprint has a radius, not a pivot.
- Air control is enough to correct a jump onto the beam, not enough to reverse mid-air.
- Doorways: the wide one at sprint without touching, the narrow one only crouched.
- First person: a five-minute walk of the whole gym without discomfort with stabilisation at its default. Looking
down shows your own feet. The deadzone lets you glance at a wall sign without turning.
- Third person: the spring arm never clips through a wall; the body faces where it walks; the head turns to follow
the look.
- Two clients and a dedicated server in editor, emulation at 100 ms and 5 %: the remote body is smooth on stairs,
slopes and jumps, and no correction snap is visible on the local one.
## Hooks for other systems
- **`MoveSpeed` and `JumpPower`** on the stat block ([Stats.md](Stats.md)) are the only way anything slows or
speeds a body: carrying, being downed, mud, gear and every buff or debuff are effects on those two attributes,
and the movement component multiplies. It never holds a multiplier of its own.
- **Root motion sources** are how abilities move a body. Dodge is a `FRootMotionSource_ConstantForce` over its
duration with i-frames granted as a `State.Invulnerable` tag; blink is a `MoveToForce` along the aim, flattened;
the shoulder charge is a constant force with a hit window. All three predict through the movement component's
existing root-motion prediction and are specified in [Combat.md](Combat.md).
- **Downed** sets the crouch capsule, overrides `MoveSpeed` to zero through `GE_Downed` and disables jump; the
camera drops to the downed eye height through the camera component, which reads the `State.Downed` tag.
- **Impulses** (a shove, a heal-launch) call `LaunchCharacter`, which the movement component already replicates.
## Telemetry
| Event | When | Payload |
| --- | --- | --- |
| `movement_sample` | Every 5 s while moving | `speed`, `mode`, `camera_mode`, `sprinting` |
| `jump` | A jump begins | `coyote` (bool), `buffered` (bool) |
| `land` | `Landed` | `fall_height`, `hard` (bool) |
| `camera_mode_changed` | Mode swap | `from`, `to` |
| `settings_changed` | A comfort or binding setting is committed | `setting_id`, `value` |
`movement_sample` is what later says which camera mode people actually live in and how much of the gym's speed
range is used, which is what decides Q1 and the sprint tuning with data instead of taste.
## Tests
- `LookModel` automation: inside the deadzone the body yaw does not change; an overshoot turns the body by the
overshoot and never faster than `MaxTurnRate`; moving without strafing recenters the head; carrying widens the
deadzone; pitch clamps. Five tests, no world.
- `UMovementTuning::IsDataValid` refuses sprint slower than walk, negative times, and angles outside (0, 90).
- Functional: `FT_Gym_Stairs` drives an `APlayerCharacter` up each riser with `AddMovementInput` and asserts it
reaches the top; `FT_Gym_Slopes` asserts the 60 degree slope is not climbable; `FT_Gym_Gaps` asserts the 300 cm
gap is not crossable at sprint.
## Open questions
- **Q1. Which camera mode is the default?** Both earlier projects chose first person: it is where close manual
work reads best and it is cheapest to make feel good with one rig. The case for third person is seeing your own
crafted gear and a better read of a crowded fight. Build both in step 3, play the gym and the first fight in each,
and let `camera_mode_changed` and `movement_sample` settle it by step 8.
- **Q2. The Mover plugin.** Revisit when it leaves experimental. The port cost is bounded by keeping the custom
surface to the saved-move flag, the tuning asset and root-motion sources.
- **Q3. Does sprint cost stamina?** Not in step 3. If combat wants a stamina attribute, sprint may draw from it
through the ability system's attribute, but the flag itself stays in the movement component.
- **Q4. The Shift conflict.** The earlier layout put the third ability slot on Shift because that game had no sprint.
This one does. Decided for now: Shift sprints, the slots are Q, E, R, C. Revisit if the fourth slot is unreachable
in a fight.
- **Q5. Head bob.** Off by default, one amplitude setting, scaled by the motion setting. Whether it earns a place
in first person is a playtest question after step 4.
- **Q6. Mantling and vaulting.** Not now. The gym has no ledge you are meant to climb. If the world later wants it,
it is a movement ability on root motion, not a change to the component.