Files
UnrealPrototyping/Docs/Spec/Movement.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

26 KiB

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, built on the root-motion hooks here) or picking things up (Interaction.md, which supplies the carry-weight penalty this document consumes).

Read 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 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

// 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
};
// 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.
};
// 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.
// 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

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

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) 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.
  • 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.