Unreal 5 C++ – Climbing System


This project implements a full third-person climbing system in UE5 C++. The scope covers climbing up and down walls, hopping between holds, vaulting over obstacles, automatic ledge detection, and surface snap — all integrated with root motion animations, Motion Warping, and Enhanced Input. The implementation is built on a custom UCharacterMovementComponent subclass, which is where the interesting decisions happen.

📺 Demo Video  |  🔗 GitHub Repository  |  📖 Learning Source


How to replace the movement component correctly

The first architectural decision is a prerequisite for everything else: how to inject UCustomMovementComponent as the character’s movement component. The only correct way is through the constructor’s FObjectInitializer:

cpp

AClimbingSystemCharacter::AClimbingSystemCharacter(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer.SetDefaultSubobjectClass<UCustomMovementComponent>(
ACharacter::CharacterMovementComponentName))

SetDefaultSubobjectClass<> tells the object initializer to create a UCustomMovementComponent instance instead of the default UCharacterMovementComponent when it constructs the subobject named CharacterMovementComponentName. This happens before the constructor body runs, so by the time we reach GetCharacterMovement(), the component is already the right type and can be safely cast and cached. Attempting to swap it later — in BeginPlay for example — would leave the character’s internal movement subsystem pointing at the wrong component.


The custom physics mode

UCharacterMovementComponent has a built-in extension point called PhysCustom(), which is called every physics tick when MovementMode == MOVE_Custom. Setting a custom movement mode is done via:

cpp

SetMovementMode(MOVE_Custom, ECustomMovementMode::MOVE_Climb);

PhysCustom() receives the custom mode as a parameter and dispatches accordingly. This slots into UE’s existing physics tick without needing a manual timer, a separate tick component, or any other workaround. GetMaxSpeed() and GetMaxAcceleration() are overridden to return climb-specific values, so CalcVelocity() — called inside PhysClimb() — automatically uses the correct limits without additional handling.

Each tick of PhysClimb() does the following in order: resamples surface data, checks exit conditions (surface lost or floor reached), computes velocity via CalcVelocity() when no root motion is active, applies root motion if present, moves the component with a surface-aligned rotation, handles wall impacts and sliding, snaps the character to the surface, and checks if the ledge has been reached.


Surface detection: two trace types

Climbable surfaces are detected with two complementary traces, each serving a different purpose.

A capsule trace runs forward from slightly in front of the character. It covers the full character silhouette and detects whether there is geometry close enough to grab. This is the primary check for whether climbing is possible and is re-run every frame during PhysClimb() to update the surface data.

A line trace from eye height runs forward from a configurable height offset relative to BaseEyeHeight. This is used for ledge detection (is there open air above?), hop target finding, and the CanStartClimbing() check — which requires both a capsule hit (surface nearby) AND an eye-height hit (surface at face level, not a floor). The eye-height trace prevents the character from trying to climb low obstacles, floor edges, or anything that would look wrong.


Surface averaging and rotation alignment

The capsule trace can hit multiple faces simultaneously — wall corners, concave geometry, multi-polygon surfaces. Using a single hit result for rotation would cause the character to snap between face normals as it moves. Instead, ProcessClimbableSurfaceInfo() accumulates all impact points and normals from the trace results and averages them:

cpp

for (const FHitResult& HitResult : ClimbableSurfacesTracedResults)
{
CurrentClimbableSurfaceLocation += HitResult.ImpactPoint;
CurrentClimbableSurfaceNormal += HitResult.ImpactNormal;
}
CurrentClimbableSurfaceLocation /= static_cast<float>(ClimbableSurfacesTracedResults.Num());
CurrentClimbableSurfaceNormal = CurrentClimbableSurfaceNormal.GetSafeNormal();

This produces a blended normal that transitions smoothly across geometry changes.

The character needs to face into the wall — its forward vector (X axis) should point against the surface normal. GetClimbRotation() builds this rotation using FRotationMatrix::MakeFromX(InverseSurfaceNormal).ToQuat(), where InverseSurfaceNormal = -CurrentClimbableSurfaceNormal. QInterpTo at speed 5 smooths the transition:

cpp

return FMath::QInterpTo(ComponentQuat, TargetQuat, DeltaTime, 5.0f);

During root motion (when a montage is playing), the rotation is held fixed — returning ComponentQuat directly — to avoid fighting the animation warping.


Surface snap

Without active correction, the character would drift away from the wall over time as the physics resolution and collision responses add up. SnapMovementToClimbableSurfaces() prevents this by computing a correction vector each tick.

The logic: take the vector from the character’s location to the averaged surface contact point, project it onto the character’s forward vector to extract only the depth component (how far the character is from the wall along the forward axis), then move the component by that depth in the inverse normal direction:

cpp

const FVector ToSurface = CurrentClimbableSurfaceLocation - ComponentLocation;
const FVector ProjectedOnForward = ToSurface.ProjectOnTo(ComponentForward);
const float ProjectedLength = ProjectedOnForward.Length();
const FVector SnapDelta = -CurrentClimbableSurfaceNormal * ProjectedLength * MaxClimbSpeed * DeltaTime;
UpdatedComponent->MoveComponent(SnapDelta, UpdatedComponent->GetComponentQuat(), true);

Projecting onto the forward vector isolates the depth component — lateral drift is intentionally left uncorrected because the character’s collision handles it. Scaling by MaxClimbSpeed * DeltaTime keeps the snap frame-rate-independent and prevents overcorrection.


Ledge detection

Detecting when the character has reached the top of a wall requires two sequential traces. First, a line trace from 50 units above eye height: if it hits anything, there is still wall above — the character is not at the top yet. Second, if the above trace returns no hit (open air), a downward trace from the end of that open-air segment looks for a walkable surface. If it finds one, the character is at the top and the climb-to-top montage is triggered.

cpp

const FHitResult AboveLedgeHit = TraceFromEyeHeight(50.0f);
if (AboveLedgeHit.bBlockingHit) { return false; }
const FVector TraceEnd = AboveLedgeHit.TraceEnd + (-UpdatedComponent->GetUpVector() * 50.0f);
const FHitResult WalkableSurfaceHit = ExecuteLineTrace(AboveLedgeHit.TraceEnd, TraceEnd);
return WalkableSurfaceHit.bBlockingHit;

The two-step check prevents false positives on overhangs or geometry that has open air at one height but no landing surface above.


Vaulting and hopping with Motion Warping

Vault, hop up, and hop down all use Motion Warping to land the character precisely on pre-computed positions regardless of where on the wall the action is triggered. The pattern is the same in all three cases: compute the target position with a line trace, register it with UMotionWarpingComponent::AddOrUpdateWarpTargetFromLocation() under the warp target name that matches the montage’s warp node, then play the montage.

For vaulting, two positions are computed: the vault start (top of the obstacle, 90 units forward and 100 up, then down) and the vault land (further forward at 360 units, same height, deeper down trace). Both must succeed for the vault to trigger. The vault itself enters MOVE_Climb so the root motion is processed by PhysClimb() rather than the standard walking physics.

Hopping uses eye-height traces at negative offsets: -30 units for hop up (grab point slightly below eye level), -300 units for hop down (a ledge well below). Hop up additionally requires a safety trace at +150 units to confirm there is enough wall above the grab point for the character to actually hold onto.


Input architecture: two contexts with priority

The ground and climbing movement inputs are split across two UInputMappingContext assets. The default context is added at priority 0 on BeginPlay. When the character enters the climb state, the climb context is added at priority 1:

cpp

void AClimbingSystemCharacter::OnPlayerEnterClimbState()
{
AddInputMappingContext(ClimbInputMappingContext, 1);
}

Priority 1 means climb bindings win over any shared keys in the default context without needing conditional checks in the handlers. On exit, the climb context is removed. This is driven by delegates — UCustomMovementComponent declares FOnEnterClimbState and FOnExitClimbState, which AClimbingSystemCharacter binds to in BeginPlay. The movement component fires the delegate and has no knowledge of input contexts — decoupling is clean.

Climb movement itself uses a surface-relative coordinate system. The “forward” direction on the wall (up/down) is the cross product of the inverse surface normal and the actor’s right vector. The “right” direction (lateral) is the cross product of the inverse surface normal and the actor’s down vector. Both are computed fresh each frame from the current GetClimbableSurfaceNormal(), so they always match the actual wall geometry as the character moves across it.


Root motion during the climb-to-top pull-up

When the character reaches the ledge and the climb-to-top montage plays, the movement mode transitions to MOVE_Falling while the animation drives the arc upward. The base class implementation of ConstrainAnimRootMotionVelocity() would constrain or discard root motion velocity during falling, breaking the pull-up. The override lets it through:

cpp

const bool bIsPlayingRootMotionMontage = IsFalling() && PlayerAnimInstance->IsAnyMontagePlaying();
return bIsPlayingRootMotionMontage
? RootMotionVelocity
: Super::ConstrainAnimRootMotionVelocity(RootMotionVelocity, CurrentVelocity);

This is a narrow override — it only applies when falling AND a montage is playing, which is precisely the pull-up window. All other falling root motion (jumps, standard air movement) still goes through the base class logic.


Code quality notes

The CharacterAnimInstance.h was missing #pragma once entirely — a straightforward bug that would cause redefinition errors on any translation unit that included the header more than once through different include paths. LogTemp was replaced throughout with a custom LogClimbingSystem category. Raw UPROPERTY pointers were upgraded to TObjectPtr<T>. check() calls on recoverable conditions (missing assets in Blueprint defaults, subsystem resolution failures) were replaced with ensureMsgf() so a misconfigured Blueprint produces a descriptive log message rather than a hard crash.


Engine version: Unreal Engine 5.0

Leave a comment

Create a website or blog at WordPress.com

Up ↑