any actually good solution for preventing the player from falling off ledges?

Im triying to create player movement pretty much like the one seen in the game The Witness, where the player cant jump or fall off ledges, theres also moving platforms and the player can still move freely when on top of them. however, when trying to implement this kind of movement in unreal i came across some issues.

making it so that the player can’t jump is easy enough. the true problem is making it so that the player cant fall off ledges in a way that doesnt feel buggy or unpolished.

heres what i have tried so far:

  1. Unchecking “Can fall of ledges” checkbox in the movement component. this does prevent the player from falling off ledges, but it introduces undesired and buggy behaiviour:
  • first off, the player is still able to hang very far out on the ledge, and i can only minimize this to the point where the player can still hang with its center right on the ledge, whereas what would be desirable is for the capsule to be always necesarily fully on ground, as seen in The Witness and many other games.

  • it also introduces some weird and buggy bouncing, it seems like the player looses friction when perching and this leads to some buggy behaivour, but i havent found a way to fix this.

  1. Limiting the player to always be on the navmesh by only moving it with the “Simple Move To” function. this does prevent the player from falling off ledges and from perching too far out on them. however:
  • The “Simple Move To” function uses pathfinding, so in some cases the player moves in weird ways when triying to move it with WASD or a thumbstick. for example, when aproaching some stairs from the side, instead of just bumping into them, the player goes around and tries to go up the stairs.

  • this also means that the player would not be able to move if on a moving platform, as navmeshes cant really be used for that.

  1. manually placing collision on ledges. this would seem like the perfect solution. altough a lot more tedious. i could choose exactly where the player can and cannot go. however:
  • in my game the player can move some parts of the level to be able to traverse it. for example, the player can reorder some pieces so that they now form a bridge. this means that what a “Ledge” is gets redifined. where before there was a ledge with a long fall, now there can be a bridge, and this is unpredictable, the player has total control over how he reorders the pieces of the environment. and making a system that spawns the collisions dynamically seems like too much of a headache for me.

At this point i am out of options. it seems like even the most simple feature is impossible to implement without learning Cpp and digging through the unreal source code. if anyone knows of a solution to this problem. with no major tradeoffs in terms of polish like the ones i listed, i would much apreciate they told me about it.

here are some things that would solve the problem entirely if i could do them:

  • limiting player movement to navmesh while keeping the normal movement. (basically just having the navmesh be the new ground and making the player ignore most other collision). i could still work around moving platforms if i could do this.

  • any way of forcing the capsule to always be fully on the ground without introducing rubberbanding-like effects or stopping the player completely, (just as if there was a wall everywhere theres a ledge i think is the best way to describe it) but again keep in mind that the level is dynamic and the player can reorder its pieces like chunks of legos. any “Baked” approach will fail. this has to be calculated on the fly, preferably by the character itself.

again any help is apreciated, as i am stuck and cant find any solutions that arent heaviliy overcooked or just straight up make the probmlem worse.

1 Like

you could just ignore the player input if there is a ledge in that direction,
so on addmovementinput trace in the direction the player has pressed, if there is a ledge dont process the input

2 Likes

Thanks for taking the time to answer!

I have considered something similar to this, but there is a couple of problems with it:

  • How can i know where the player is gonna end up in the next frame? If i cant, then that means i need to wait for them to actually be on the ledge and then rubber-band them back to the last valid location, which will feel laggy.

  • If i ingore input when it would move the player to a ledge, this means the player would get “Stuck” or not move at all when moving towards a ledge even in the lightest, which would feel very wrong. If there was a way to make the player just continue to move along the ledge instead of into the ledge (just like it happens when you try to walk into a wall) this wouldn’t happen, but for that i would have to know the angle of the ledge, and solve weird cases in corners

  • doing only one line trace in the center of where the player would be would still let the player get its center right on the ledge by moving with a very small angle of attack towards the ledge, so i would have to do arbatrarily many line traces, but this is not as bad as the other two points.

well it has to be before input otherwise you get the rubberbanding when you set their position back

we’re doing it on input so we do know exactly where the player will be, ie if the play inputs forward we trace forward, if he moves left we trace left, you can multiply this by its velocity to check how far to trace

you can use a capsule trace to test the ‘ledge hole’ size

if you want to move along the ledge you can just alter the input vector. so if the player presses forward and its a ledge we can rotate that vector to left and he’ll move along the ledge BUT this would require another trace in case there is another ledge. id just block the input myself

This can be adjusted by tweaking the Perch Radius Threshold var in the character movement component.

1 Like

i have tried tweaking this variable multiple times, maybe im using it wrong or something, but when i increase this variable, no matter how much i increase it the player can still place its center right on the ledge, which is not what im after, also the player seems to get stuck on the ledges instead of moving along them as if colliding with a wall at a non perpendicular angle (which is what im actually after)

how would you go about determining the angle of the ledge though? if i want to alter the input vector i would have to know how, based on the ledge angle, but i have no idea how to find this accurately, it doesnt seem plausible. i could try a rough aproximation of the anlge based on multiple line traces but this would cause the player to vibrate as it moves along the ledge due to the inacuracy, and is also really overcooked of a solution

how big are your levels?

might only require half an hour to place some blockers even in a pretty large level

well if you want a simple but possibly tedious solution, just put an invisible wall on all your ledges.

otherwise, any input will be on the x/y axis, you can do 2 traces, if any hits (or no hits since we’re looking for ledges) just null that value.

so for example the input is 0.8x, 0.3y. y ‘hits’ so we change it to 0.8x,0y

its not gonna be that big, but even if it was i would be ok with it being a tedious process. the problem is (as i listed in the original post) that ledges can become walkable paths and walkable paths can become ledges when the player uses the main mechanic of the game which is to be able to swap the positions of certain chunks of the world and also rotate them, making new paths and breaking others. this means that manually placed collision will not work. and as i stated, dinamically generating collision doesnt seem easy either. maybe if i could use some sort of boolean logic and mark where i want “air” instead of where i want collision i could follow that aproach

my hunch would be to include the collision parameters with each of the modular pieces.
e.g. if bridge rotates this way, then deactivate collision box A.
this can just be defined with each modular piece. Simple code, almost no room for any hard to solve bugs.

just mentioning the simplest solution because sometimes it gets overlooked. can’t know if it fits in your project or not without going through every detail carefully

lets just say its not gonna be easy to predict how the level changes, the player has a lot of agency here. and its not necesarely gonna be laid out in a grid either. its more like if two “chunks” have the same shape (when looked from above) you can swap them. and you can also rotate them based on their shape, for example a square could have 4 possible rotations, a circle would be free, a stretched square two, etc.

Odd. Maybe it’s bugged? I’ve used it in the past to keep ai chars from getting stuck in the nav deadzone around the edge of platforms. It used to be just a matter of increasing perch radius to match capsule. May need to edit something in c++ class.

There are three ways to do this:

  1. Prevent movement in the direction of edges as part of controls/simulation.
  2. Auto-generate invisible walls outside ledges.
  3. Let the player fall off, let them die when doing it, this is now part of your gameplay.

Auto-generating invisible walls can be done in the editor, using some utility you write. If the player can also change the level, then build invisible walls into the geometry pieces the player can move around. If pieces will move around like a tile puzzle, then you need the ability to disable the invisible wall geometry along the edges where the pieces match up, which presumably can be built-into the logic of the piece arranging (whatever that is.)

Preventing movement can be done with one or two raycasts in player pawn Tick().
Cast a ray 45 degrees down from 60 units up from the floor contact, in the main direction of actual player movement. If this doesn’t find a floor within 40 units of the current contact, then remove all part of the player movement/velocity that is moving towards the ledge. You might at that point cast a ray in from the place where the floor “isn’t” to find the edge, which should give you some idea of where the ledge is. (you could even cast a couple of rays in different directions)

this would work easily too, just change the collision response on the pawn rather than all the ledges.

so you create invisible walls, give them a custom collision channel. have the pawn block that channel and then as your game state needs change the collision to ignore.

if its overlapping on change can add some knockback to reset the pawn

This problem has plagued me. This is the only post on all of the internet that directly asked how to do this in unreal lol.
After many months, I have made my own solution to this problem. I imagine the OP has moved on, but to anyone else that need this, Here you go.

I originally did this in blueprints but moved it to C++ for efficiency. It essentially overrides the AddMovementInput function and does traces to check for walkable surface, taking into account MaxStepHeight and whether you are jumping. It sets a LastGoodLocation along the way. When it doesnt see a walkable surface, it searches left and right then left and right further around etc, until it finds one. If not, it takes you back to LastGoodLocation. If it does, it will adjust the movement vector, scaled to SearchAngle, to a walkable direction.

This seems to work in multiplayer with average lag emulation but time will tell.

I added options that can be set in Character Bluprint.
KeepPlayerOnNavMesh was a neat one that I just added today. It just checks if walkable surface is on nav mesh. Works pretty good so far. In Edit->Project Settings->Navigation System, you’ll need to enable the option for Allow Client Side Navigation for Multiplayer

That said, this thing has so many holes in it. It traces on visibility so what happens if something is in the way like an enemy? I haven’t even attempted it with AI. And so many more lol.

StoryCharacter.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "StoryCharacter.generated.h"

UCLASS()
class HEROIC_API AStoryCharacter : public ACharacter
{
	GENERATED_BODY()

public:
	virtual void Tick(float DeltaTime) override;
	virtual void GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const override;
	
protected:
	virtual void Jump() override;
	virtual void AddMovementInput(FVector WorldDirection, float ScaleValue, bool bForce /*=false*/) override;
	FHitResult TraceForLedge(FVector TraceVector) const;
	FVector GetNewLedgeMove(FVector Delta, float& OutScalar) const;
	bool IsStepHeightWalkable(FVector Location, bool CheckTouchingGround = false) const;

	UPROPERTY(Replicated, VisibleAnywhere, BlueprintReadOnly, Category="Input")
	bool bIsJumping;
	float JumpHangTime = 0.f;
	float EdgeDetectTime = 0.f;

	UPROPERTY(EditDefaultsOnly, Category="Movement|Edge Detection")
	bool StopWalkOffEdge;
	UPROPERTY(EditDefaultsOnly, Category="Movement|Edge Detection")
	bool KeepPlayerOnNavMesh;
	UPROPERTY(EditDefaultsOnly, Category="Movement|Edge Detection", meta=(ClampMin="0", UIMin="0", ForceUnits=cm))
	float SearchAngleSteps = 15.f;
	UPROPERTY(EditDefaultsOnly, Category="Movement|Edge Detection", meta=(ClampMin="0", UIMin="0", ForceUnits=cm))
	float SearchAngleEndAddition = 45.f;
	UPROPERTY(EditDefaultsOnly, Category="Movement|Edge Detection", meta=(ClampMin="0", UIMin="0", ForceUnits=cm))
	float SearchDistance = 15.f;
	UPROPERTY(EditDefaultsOnly, Category="Movement|Edge Detection")
	bool DebugLines = false;
	UPROPERTY(Replicated)
	FVector LastGoodLocation;
};


StoryCharacter.cpp

#include "Character/StoryCharacter.h"

#include "NavigationSystem.h"
#include "AI/NavigationSystemBase.h"
#include "Components/CapsuleComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/PawnMovementComponent.h"
#include "Kismet/KismetMathLibrary.h"
#include "Net/UnrealNetwork.h"

void AStoryCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	DOREPLIFETIME(AStoryCharacter, LastGoodLocation);
}


// Called every frame
void AStoryCharacter::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	if(StopWalkOffEdge)
	{
		FHitResult HitResult;
		FVector TraceStart = GetActorLocation();
		FVector TraceEnd = TraceStart + FVector(0, 0, -200);
		FCollisionQueryParams QueryParams;
		QueryParams.AddIgnoredActor(GetOwner());
		GetWorld()->LineTraceSingleByChannel(HitResult, TraceStart, TraceEnd, ECC_Visibility, QueryParams);
		if(!IsStepHeightWalkable(HitResult.Location) && !LastGoodLocation.IsZero())
		{
			
			if(!bIsJumping)
			{
				if(EdgeDetectTime > .00001f)
				{
					//GEngine->AddOnScreenDebugMessage(-1, 5, FColor::Blue, FString::Printf(TEXT("Teleported!")));
					SetActorLocation(LastGoodLocation);
					
					EdgeDetectTime = 0.f;
				}
				else { EdgeDetectTime += DeltaTime; }
				
			}
			else
			{
				if(JumpHangTime > 2.f)
				{
					SetActorLocation(LastGoodLocation);
					JumpHangTime = 0;
					bIsJumping = false;
				}
				else
				{
					JumpHangTime += DeltaTime;
					//GEngine->AddOnScreenDebugMessage(2, 5, FColor::Green, FString::Printf(TEXT("Jumping: %f"), JumpHangTime));
				}
			}
		}
		else
		{
			if(IsStepHeightWalkable(HitResult.Location, true))
			{
				LastGoodLocation = GetActorLocation();
				bIsJumping = false;
				JumpHangTime = 0;
				if(DebugLines) DrawDebugSphere(GetWorld(), LastGoodLocation, 15,12,FColor::Yellow);
			}
		}
	}
}

void AStoryCharacter::Jump()
{
	Super::Jump();
	bIsJumping = true;
}

void AStoryCharacter::AddMovementInput(FVector WorldDirection, float ScaleValue, bool bForce /*=false*/)
{
	FVector NewWorldDirection = WorldDirection;
	if(StopWalkOffEdge)
	{
		if(DebugLines) DrawDebugLine(GetWorld(), GetActorLocation(), GetActorLocation() + (WorldDirection * 100.f),FColor::Yellow);
		FHitResult WorldDirectionHitResult = TraceForLedge(WorldDirection);
		if(!IsStepHeightWalkable(WorldDirectionHitResult.Location) && !bIsJumping)
		{
			NewWorldDirection = GetNewLedgeMove(WorldDirection, ScaleValue);
			NewWorldDirection += (LastGoodLocation - GetActorLocation()).GetSafeNormal()*WorldDirection.Length();
		}
	}
	if(NewWorldDirection.IsZero()){ return; }
	
	UPawnMovementComponent* MovementComponent = GetMovementComponent();
	if (MovementComponent)
	{
		MovementComponent->AddInputVector(NewWorldDirection * ScaleValue, bForce);
	}
	else
	{
		Internal_AddMovementInput(WorldDirection * ScaleValue, bForce);
	}
}

FHitResult AStoryCharacter::TraceForLedge(FVector TraceVector) const
{
	TraceVector = TraceVector.GetSafeNormal();
	FHitResult HitResult;
	FVector TraceStart = GetActorLocation() + TraceVector * FVector(SearchDistance, SearchDistance, 0);
	FVector TraceEnd = TraceStart + FVector(0, 0, -200);
	
	FCollisionQueryParams QueryParams;
	QueryParams.AddIgnoredActor(GetOwner());
	
	GetWorld()->LineTraceSingleByChannel(HitResult, TraceStart, TraceEnd, ECC_Visibility, QueryParams);

	if(DebugLines) DrawDebugLine(GetWorld(), TraceStart, TraceEnd, HitResult.bBlockingHit ? FColor::Blue : FColor::Green, false, -1, 0, 1.f);
	return HitResult;
}

FVector AStoryCharacter::GetNewLedgeMove(FVector Delta, float& OutScalar) const
{
	if (Delta.IsZero())
	{
		return FVector::ZeroVector;
	}

	float SearchAngle = 1.f;
	
	FVector SearchVector = Delta.GetSafeNormal();

	while(SearchAngle < 360.f && SearchAngle > -360.f)
	{
		FHitResult SearchHitResult = TraceForLedge(SearchVector);
		if(IsStepHeightWalkable(SearchHitResult.Location))
		{
			SearchAngle += SearchAngle >= 0 ? SearchAngleEndAddition : -SearchAngleEndAddition;
			FVector NewDelta = Delta.RotateAngleAxis(SearchAngle, FVector(0,0,1));
			

			float DeltaScaler = UKismetMathLibrary::MapRangeClamped(FMath::Abs(FMath::Abs(SearchAngle)-90), 0.f, 45.f, 0.f, 1.f);
			OutScalar *= DeltaScaler;
			if(DebugLines) DrawDebugDirectionalArrow(GetWorld(), GetActorLocation(), GetActorLocation() + (NewDelta * 100.f), 1.f, FColor::Red, false, -1);
			
			return NewDelta;
		}
		SearchAngle = SearchAngle > 0 ? (SearchAngle + SearchAngleSteps) * -1.f : (SearchAngle - SearchAngleSteps) * -1.f;
		SearchVector = Delta.RotateAngleAxis(SearchAngle, FVector(0,0,1));
	}
	return FVector::ZeroVector;
}

bool AStoryCharacter::IsStepHeightWalkable(FVector Location, bool CheckTouchingGround) const
{
	if(Location.IsZero()){ return false; }
	float CapsuleHalfHeight = GetCapsuleComponent()->GetScaledCapsuleHalfHeight();
	float CharacterFeetZ = GetActorLocation().Z - CapsuleHalfHeight;
	if(CheckTouchingGround)
	{
		//GEngine->AddOnScreenDebugMessage(1, 5, FColor::Blue, FString::Printf(TEXT("Jumping: %f"), FMath::Abs(Location.Z - CharacterFeetZ)));
		if(GetCharacterMovement()->Velocity.Z > 0) { return false; }
		if(FMath::Abs(Location.Z - CharacterFeetZ) > 15.f){ return false; }
	}
	if(FMath::Abs(Location.Z - CharacterFeetZ) > GetCharacterMovement()->MaxStepHeight){ return false; }
	if(KeepPlayerOnNavMesh)
	{
		UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld());
		FNavLocation LocationOnNavMesh;
		bool IsOnNavMesh = NavSys->ProjectPointToNavigation(Location, LocationOnNavMesh, FVector(1.f, 1.f, GetCharacterMovement()->MaxStepHeight));
		if(IsOnNavMesh){
			//DrawDebugSphere(GetWorld(), LocationOnNavMesh, 5.f, 6, FColor::Cyan, false, 1);
		}
		else { return false; }
	}
	return true;
}


1 Like

Probably the easiest way is to just add a cube or cube(s) and make them invisible and set them accordingly or as needed.

sadly enough i am yet to find a solution to this problem, i have indeed moved on to other areas of my game but this one is still in need of attention. This might just be a solution, will have to try it.
Recently though i had an idea to drive character movement fully with procedural animation, esentially simulating each step the pawn takes, i am yet to attempt implementing this, but its also promosing, It would add a bit of a weird feel to how the camera movement responds to input, but that might just be a nice quirk to have in a game.
At any rate, i look forward to trying this solution as well.
Thanks for taking the time to reply, it is indeed very hard to find other people with this same issue out there