Implementing a Ledge Grab Ability for a 2.5D Platformer in Unity 2021

In this article, we’ll create a ledge grab system including climb up and drop from hanging!

As shown in the previous article, download a hanging idle animation from Mixamo.com.

Set the Rig as “Humanoid” and the Animation Motion as “<Root Transform>”.

Then duplicate the animation and move it into the PlayerCharacter subfolder of the Animations folder in your project.

Set it to “Loop Time” and “Loop Pose”.

I’ve also downloaded some additional animations related to hanging such as climbing up from a hanging position and dropping from a hanging position.

I’m not sure if or when I’ll use these just yet, but I’ve got them ready to go when I do!

I suspect I’ll end up using “Jump To Hang” rather than “Jump to Freehang” since the latter starts from a ground level position while the former is already in the air when the animation begins.

Since I have jumping implemented and I’m not wanting to predict jumping to a ledge while still on the ground, transitioning from my Running Jump or Double Jump animation makes more sense with a ledge grab animation that begins in the air.

Now, let’s drag in our new animations and setup their transitions.
Notice that there really isn’t a lot of bidirectionality to these.

This will flow pretty much in one direction until we reach a back to idle state.

I’ve spaced them out a little bit to try and make these transition lines a bit more visible or at least a little less cluttered.

For each of my new animation states I want to enable Foot IK which may or may not be necessary but until I see a problem, I’m going to stick with that approach.

We’ll need to add a new Boolean parameter for each of our new animation states.

For each transition we’ll need to add the appropriate Boolean checker.

Simply put, the Boolean Parameter that represents the animation state being transitioned to will be set as the condition with a value of true.

PlayerAnimations Class/Script — PlayerCharAnimState Enum
Next, we’ll update our PlayerCharAnimState enum with values to represent each new animation state.

We’ll also add them to our UpdatePlayerCharAnimState() method.

And of course, we’ll need to add them to the ResetAnimatorParameters() method as well.

Add a Ledge Trigger Zone
We’ll create a cube primitive game object, resize and position it in front of a ledge, and disable the mesh renderer for it.

We’ll then create a new tag called “LedgeTrigger” and assign it to this game object.

Make sure to enable “Is Trigger” on the box collider.

When the player is in the jumping state and collides with a game object tagged with “LedgeTrigger”, we’ll move into our ledge grab animations.

We may need to extend our collider out further from the platform or reposition it, but this will do for now.

We can use the Any State “animation” in our Animator window to force a transition from idle directly to Hanging idle.

This will be useful now that we are creating a “Ledge Checker” collider as a child game object of the Player game object.

Create the LedgeChecker Game Object
We need the character to be in the hanging pose for this part, so use the Any State to Hangine Idle trick in the Animator Window.

Once the character takes the pose, add a primitive cube as a child game object of the Player game object.

Resize and reposition it until it looks appropriate for the character to be hanging from.

This doesn’t need to be absolutely perfect due to the game’s 2.5D camera style, but do your best.

When you feel like its size and position are correct, drag it into your prefabs folder in the Project window.

You can exit play mode now.

Add the LedgeChecker game object to the Player game object’s prefab.

Disable the Mesh Renderer, set Is Trigger, add a Rigidbody and and Disable Use Gravity on the LedgeChecker prefab and make sure it is applied to the version in the scene.

We could also create and assign a “LedgeChecker” tag if we desired but I don’t believe it will be necessary.

On the Player game object, if you can apply any Overrides, go ahead and do so just to be safe.

If you select the player in the Hierarchy, it should look similar to the above in the Scene view.

Create the LedgeChecker Class/Script and Assign It
Create a new c# script called “LedgeChecker” and assign it to the LedgeChecker prefab.

To get the ball rolling I’ve wrote out some of the basic framework for the LedgeChecker class/script.

I’m not sure if I’ll need all of these class variables, but it will be easy enough to remove the ones I don’t.

This way, I don’t have to leave the flow of coding to add a variable reference to a script elsewhere for instance.

If we run the game with our basic script, we can see that the functionality so far is working.

On the ledge collision our player’s character controller is disabled, which cancels gravity, and thus hangs in the air.

The console is throwing a ton of errors though.

The PlayerController class/script is still trying to access a now disabled character controller component.

Our console points out the lines to look at and we can see the _controller.Move call on line 158 of our MovePlayer() method.

Note though that the previously called out line in the console is 155 and it has an if/then condition that _movementDisables = true before calling MovePlayer().

I already have 2 public methods for enabling and disabling _movementDisabled.

LedgeChecker — OnTriggerEnter()
With the functionality already baked into PlayerController, I need only add one line of code to the OnTriggerEnter() method in the LedgeChecker class/script.

If we run the game, we no longer get any error codes.
However, our Running animation is stuck.

Running it a second time, it got stuck on Double Jump animation.

Since we haven’t implemented our animation state changes to the new ledge grab animations, I’m not overly concerned at this time.

First, we’re going to add the above GetPlayerCharAnimState() method to both my PlayerController and PlayerAnimations classes/scripts.

We’ll only add the SetPlayerCharAnimState() method shown above to the PlayerController class/script.

It will call the public method UpdatePlayerCharAnimState() from the PlayerAnimations class/script and update it as well when used.

I realized there was no need for the transition from JumpToHang to HangingIdle to be reliant on a Boolean value.

I’ll dial in the transition time here later and leave the condition list empty.

I haven’t removed the associated parameter or value of PlayerCharAnimState enum just yet, but it may no be unnecessary.

I had issues with my Jump to Hange animation as shown above.

I ended up having to delete the xbot@Jump To Hang and associated duplicate animation.
Then I redownloaded it and only set the Rig to “Humanoid”

I did not change anything on the Animation tab, leaving the Motion to “<None>”.

Now it works better, with the animation not moving the character forward but sticking with the player game object.

Still, we need to implement a change to get our hand placement correct.

This is because the player game object freezes as soon as the LedgeChecker collides with a LedgeTrigger collider.

We need to snap to the actual visual ledge somehow.

Creating a Snap-To Location
Let’s duplicate the player game object, and then move this duplicate to what looks to be the correct snap-to positon for ledge hanging.

Once you are happy with the positioning, break the prefab of the duplicated Player game object.

Remove all components from the duplicated Player game object except the Transform.
Delete all child game objects.

Rename the duplicate Player game object as “LedgeSnapTo” and tag it as “LedgeSnapTo”.

Child the newly created LedgeSnapTo game object to the LedgeTrigger game object.

Drag the LedgeTrigger game object into the Prefabs folder of the Project window.

Now we have a position to snap our Player game object to, but we’ll still need to code this out.

LedgeChecker Class/Script — Class Variables
We’ll need to add some variables to the LedgeChecker class/script.

We need to know the SnapTo game object’s Transform, whether we are moving towards it or not, and how fast to move towards it.

Let’s also null check _snapToMoveSpeed and assign it a default value if the Game Designer fails to assign it.

We’ll need to a FixedUpdate() method for our movement logic.

In the OnTriggerEnter() method we’ll get the child transform of the LedgeSnapTo game object.

If that isn’t null, we’ll set our Boolean flag _movePlayerTowardsSnapTo as true.

This will trigger the FixedUpdate if/then check which calls the MovePlayerTowardsSnapTo() method.

In the MovePlayerTowardsSnapTo() method we check if the _playerTransform has already reached the _ledgeSnapToTransform’s position.

If we have, reset the Boolean flag.

If we haven’t, use Vector3.MoveTowards to take us there at the _snapToMoveSpeed the Game Designer has entered in the Unity Editor (or the default value we assign if they forgot).

For the time being, I’ve set this to a really high value.

I’m guessing I’ll need to modify the Animator state transitions as well, but we’ll get to that in a bit.

Maybe this isn’t the case on your end, but in my project running the game gave the result shown above.

Changing the Root Transform Position (Y) ‘s Bake Into Pose setting to enabled helped.

I also ended up adding a small offset as well.

This gave me a better result, but I still think there is room for improvement.

Disabling the Has Exit Time setting on the Running Jump/Double Jump transitions to JumpToHang seemed to help as well.

A small improvement, but I think I know what to try next.

By modifying the Speed value for the Jump To Hang animation state in the Animator, we can make this look a bit more fluid rather than slow and drawn out.

I ended up leaving the Speed value on the Jump To Hang animation state as 1.75.

Let’s go ahead and add a tag to our LedgeChecker prefab called “LedgeChecker” for future use in our code.

PlayerController — UpdatePlayerAnimationState() Refactored
I’ve overloaded the UpdatePlayerAnimationState() method so that it can take in a delay period before setting a new animation state.

This required also creating an IEnumerator() method for the coroutine.

Dropping Down from the Ledge
We’ll reuse the jump action keybinding to drop from the ledge since obviously the player cannot jump in this situation anyways.

But first we need to do some coding.

PlayerController Class/Script
We’ll add a new Boolean class variable to the PlayerController class/script called _hangingInputEnabled;

When this value is true, we’ll know that the player is hanging and ready to accept new player input to either drop down or climb up.

We can also add the _ledgeChecker class variable in case we need access to that.

In the Jump input event methods, I’ve made sure the player doesn’t try to execute a jump while hanging.

I’ve also added the logic for when the player is hanging to update the animation state to dropping.
We also need to remember to reenable the _controller here.

In Start() we assign _ledgeChecker after finding the appropriate transform using CompareTag().

We also add a debug.log mess in DoNullChecks() if this is null.

LedgeChecker Class/Script
In the LedgeChecker class/script we’ll call the HangDelay() coroutine after transitioning into the jumpToHanging animation state.

Since I know the transition time from Jump/Double Jump is .25 seconds, that the Jump To Hang animation is 2 seconds long but playing at 1.9 speed (so takes about half the time), I’ll delay for about 1.5 seconds just to be safe.

If we run the game, we can see that this is sort of working.

There seems to be a long delay from when we hit the spacebar and when the character drops.

And when the character does drop, it stays floating in the air without gravity being applied despite its Y velocity going infinite.

The delay from spacebar press is probably because the transitions from Hanging Idle to Freehang Drop and Freehang Climb have Exit Time enabled.

Let’s disable those.

Now when I hit the spacebar key, the drop is much quicker.

LedgeChecker — OnTriggerEnter()

PlayerController — JumpOnCancelled
Our physics is not being applied because we disabled movement we started our ledge grab.

We simply reenable it in JumpOncancelled.

We’ve got our physics running again, but the PlayerVelocity.Y value is incrementing while the player is hanging on the ledge.

The longer the player hangs, the more absurd the value becomes.

There is an easy fix for this issue.

Just reset the _playerVelocity value right before reenabling physics movement!

If we run our game, we can see that dropping now works as expected!

Climbing Up the Ledge
We’ll add a new action to our InputActions asset.

The use key is always a nice input to have because it can be used in so many different ways.

Our player hanging on a ledge will probably not be able to open a door or a chest, so why not use it to climb up?

With our _hangingInputEnabled value implemented for the dropping sequence, we’ve already laid the ground work for climbing up.

Before we jump into the code, let’s tackle some of the Animation settings in the Editor.

I can see that the length of my Freehang Climb animation is 3.867 seconds.

On the Freehang Climb to Idle transition, I’ve set my Exit Time to be 3.87 seconds.

I have noticed that the transition timeline looks incorrect though.

Not only does this transition to idle take longer than 4 seconds due to Transition Duration being in % as .25, but once the animation hits the idle animation the character’s position resets in 3D space to where they climbed up from.

Setting this to Fixed Duration of 3.878 and moving the transition area helps.
When finished, set Transition Offset to 0.

I’ve left a little gap between climbing and idling to give me some time to reposition the Player game object in that window before giving the player movement control.

PlayerAnimations — GetAnimator()
Let’s add a “getter” for the _animator variable in the PlayerAnimations class/script.

PlayerController — OnEnable/OnDisable
We’ll need to add a new subscriber method called “UseOnPerformed” to the OnEnable() and OnDisable() methods of our PlayerController class/script.

Next, we’ll add the UseOnPerformed() method and the ClimbDelaySetPlayerGOPosition coroutine as shown above.

I got the delay value by looking at the Freehang Climb animations nearly last frame time marker of 3.51 seconds.

When the E key (assigned to Use Action) is pressed, we’ll check that _hangingInputEnabled is true before we update to the hangingClimbing animation state and starting our new coroutine.

The coroutine will wait for the animation to complete.

Then it will set the Player game object’s position to that of the _animator.BodyPosition.
This is the position of the character in the animation, but not the position of the CharModel or Player game objects’ transforms.

This way, we can almost automatically detect the correct “landed” position after climbing up.

Now if we hit play, we’ll see that we are getting somewhat closer to our goal here.

We’ll copy the transform value at this point.

The animation clearly goes a bit weird, jumping above and floating back down.
We’ll fix that in a bit.

First, let’s correct the height of the player.

Using the transform position copied a moment ago and this new transform position value of the Player game object, we can do simple math to add the correct amount of height.

For me, the difference was +0.39254 on the Y axis.

Update the Player position with the difference in Y position as shown above in the ClimbDelaySetPlayerGoPosition() coroutine of PlayerController class/script.

Now run the program, and we can see that after our float glitch completes, the player character is at the correct height.

Now, we need to do something about this Mary Poppins effect we’ve got going on.

I ended up with the settings above for the Animator transition from Freehang Climb to Idle.

Disable Has Exit Time, disable Fixed Duration, Transition Duration > 0 & Offset > 0, Interruption Source > None.
IsIdle = true is the only condition.

And you can see the layout and positioning of the transition and idle animation in the timeline above.

PlayerController — ClimbDelay Coroutine
We’ll update our ClimbDelaySetPlayerGoPosition coroutine in the PlayerController class/script.

I’m not sure that resetting the animator’s body position to Vector3.zero was necessary but I’m leaving it in for now.
Unity is throwing warnings but not crashing over it.

We also need to reset some variables, including _hangingInputEnabled, _playerVelocity, _controller.enabled, and _movmentDisabled.

If you run your project, you should see something similar to the above.
Congrats, that took some time and effort!

ADDENDUM: See above an updated version of the LedgeChecker class/script.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store