Movement on stairs has been the bane of my existence for a long time. I first added it to our old prototype in late 2017 and the code stayed more or less the same up until recently. It was barely good enough for our prototype, and it was never meant to stick around until release.
However, precisely because it has produced so many bugs and highlighted so many pitfalls, I can now tell you what to look out for when designing your own system. I say “designing” because I will talk about game design a lot and address programming only conceptually rather than providing actual code snippets. Otherwise, this already lengthy write-up would definitely grow out of proportion.
First, I want to quickly describe what the key features of our movement system are. If yours has completely different requirements, the solutions I am about to lay out my not work for you.
With all that out of the way, let’s iterate and solve problems as they pop up! We’ll start with side (i.e. diagonal) stairs; as you will see, most of the rules we’re about to set up apply to frontal (i.e. vertical) stairs as well.
The first thing we’re trying to do is get the player on the stairs. Let’s create the very basic iteration 1 of our stairs: When the player is about to pass by the stairs, they hit a collider (we’ll name “bypass check”) that switches them to stairs controls. Place another bypass check at the other end of the stairs and switch back to normal controls.
The player collider is shown in green, the bypass check is blue.
The first problem we're going to solve might appear exotic at first, but you will run into it, so let's get it out of the way right now: The player might not hit the collider at the right moment, especially at a low frame rate if your player movement speed is FPS-independent (which it definitely should be). With fewer frames per second, the walking distance between frames increases, and the higher it gets, the more the player will overshoot.
At high FPS, the player will hit the collider just as they get in range:
However, at low FPS, they they may hit the bypass check with just the back of their collider:
This will cause the walk animation not to fit the stairs sprite. That is why, in iteration 2, we simply teleport the player to the right position when starting (“entry point”) or ending (“exit point”) movement on stairs. Since the teleport distance is only bigger at low FPS, when the game is already choppy, the teleport won't be noticeable.
The arrow illustrates a movement from one frame to the next.
So far, so good. But what if the player wants to be able to walk past the stairs? So far, the player will always enter them once they touch one of its colliders.
To solve this in iteration 3, let’s start by defining a different collider (called “entry range”) around the start of the stairs. Only when pressing up (at the bottom) or down (at the top) will the player start walking up or down the stairs. Moving left and right makes them walk past the entry points, and they continue with their regular horizontal movement.
The problem now is that once the player starts pressing up or down inside the entry range, teleporting to the starting position looks way too obvious because the collider is so wide:
To solve this in iteration 4, we make the character walk to the actual entry point while the player holds up or down inside the entry range. E.g. if the player is right of the entry point at the bottom of the stairs, holding up will cause the character to walk to the entry point first before they enter the stairs. For this to work, we need to add some conditional extra controls to our regular movement controls that only apply within entry range.
Arrows in squares show which button the player is currently pressing.
Now of course we got rid of the teleporting and, as a result, we’ve again lost control over where exactly the player enters the stairs, so the animation looks off. That is why, in iteration 5, we combine entry range and bypass check! However, the latter needs to be upgraded now that the player can approach the stairs from both sides. It needs to dynamically switch to the opposite side of the stairs’ entry point, so the player is just in the right position when they hit it.
The combination ultimately works like this: If the player is within entry range and holds up/down, they move towards the entry point, and then when they hit the bypass check, they start moving on the stairs.
Note: The bypass check always switches to the opposite side of the player.
Now the player can walk past the entry point of all our stairs. But what if the stairs are the only way to progress to the left or right? What if we sometimes want them the way they were in iteration 1, forcing the player to enter if they hold left or right towards the stairs?
In iteration 6, we distinguish between two types of stairs entry points (“bypassable” and “non-bypassable”). To that end, let’s add a boolean called “bypassable” that we can set individually for each end of each stairs object. If an entry point is not bypassable, the bypass check collider no longer dynamically switches sides because the entry point can only be approached from one side. Instead, the collider is always fixed to the opposite, unreachable side of the approaching player.
We have to make sure to account for every possible button or combination of buttons the player may use to express their intent to enter the different types of stairs entry points. Up (at the bottom) and down (at the top) already work on all stairs. However, when approaching non-bypassable stairs from the left, pressing right also expresses the intent to enter the stairs (and vice versa) because moving past them is not allowed. Now when hitting a bypass check, the player will be put on the stairs only if they are currently pressing one of the buttons specific to the current case.
If the stairs in this example are bypassable, the player must hold up to enter them, or left/right to walk past them.
The same goes on top of bypassable stairs.
If they are not bypassable, holding right or up will both be interpreted as the player intending to enter the stairs.
The same goes on top of non-bypassable stairs.
Depending on the exact implementation of our controls, we may have just created a different set of problems by giving the player the ability of pressing so many different buttons near the stairs to walk towards them.
To fix them in iteration 7, we need to define which buttons cancel each other out at the entry points of the different types of stairs and which ones must not add their movement speeds up with one another. We also need to set the animations accordingly, e.g. if two buttons cancel each other out (if the player is holding left and right, for example), the player model should not walk in place. To account for all the possible problems, it is important to distinguish between ascending stairs (bottom left to top right) and descending stairs (top left to bottom right). For instance, a player standing in the range of descending stairs might cause a “walk in place” animation by holding left (away from the stairs) and down (which, remember, counts as towards the stairs in this case). Or they might hold down (towards the stairs) and right (also towards the stairs) for twice the movement speed. We have to account for all different cases of all different button combinations on all types of stairs to make sure that doesn’t cause any problems. Maybe you have a clever system that does this for you already or you're like me and you manually define a bunch of boolean combinations outside the actual movement logic.
Holding left, holding down, and holding left + down should all yield the same result in this situation.
Down + right and left + right both contradict each other. Holding these combinations causes the player to stop.
I urge you not to be lazy about this step and to cover all your bases, both to prevent bugs and make the movement as intuitive as possible.
Alright. Getting on side stairs works fine now. Are there special rules for frontal stairs?
We actually only need to make a few small adjustments to add frontal stairs in iteration 8, as the basic logic works the same. We made it so only a pre-defined line on frontal stairs can actually be used rather than their whole surface. As a result, they have two clearly defined entry points, just like side stairs. This also means that the player can always walk past frontal stairs, i.e. both their top and bottom are bypassable, because the width of the stairs sprite is always wider than the entry range. In some cases, of course, the player may walk past frontal stairs and bump right into a wall which then stops them anyway. The rest (about getting on the stairs) works exactly the same. Even better, we don't ever need to account for the player trying to get up frontal stairs by pressing left or right.
Holding up within entry range causes the player to walk to the entry point and then start walking up the stairs.
Holding down within entry range causes the player to walk to the entry point and then start walking down the stairs.
If you've made it this far, you seriously deserve a pat on the back. Entering stairs has been the most complicated part of this system, I promise. Now let's define movement on the different types of stairs.
Depending on how your game is set up (e.g. if you usel actual physics and gravity), going up diagonally or vertically might cause some problems, e.g. your player slides right down on their own once they enter stairs from the top. That is why, in iteration 9, we railroad the player along the stairs. Instead of physically resting on a collider, they move along an angled line with no physics involved. In fact, let’s cancel all forces on the player object when entering stairs and ignore any forces while on the stairs. In the case of our game, we luckily don't need to account for combat or other factors that might apply forces to the player while they're moving on stairs.
But how can we ensure that the player always ends up at the top or bottom when moving up and down that angle? If they ever miss an exit point, they may forever be trapped on the stairs, infinitely walking up or down. We could calculate the angle between top and bottom, but since our steps are always the same size to fit the animation, the angle of all our side stairs is automatically always the same regardless of length, and we can simply hard-code it.
The angle is shown in blue. It's not a collider, but exists merely in code.
Alright, so how do our movement controls work on stairs? Let's design them in iteration 10. The player expects both up, right, and up + right to walk them up ascending stairs (and the opposite for descending stairs). Make sure not to add up speeds.
We also need to account for button combinations that cancel each other out. For example, on ascending stairs, left and down are the same, so both should cancel out up and right, which are also the same.
What about controls on frontal stairs? Time for iteration 11!
You might think that pressing up or down is all there is to it. However, forcing the player to hold up or down until they're at the very end before they can go left or right feels counterintuitive and unpolished. It may also happen that the player is still 1 pixel beneath the top or above the bottom, wondering why pressing left or right doesn't do anything. That is why we need to define the part of the stairs within 1 meter of each end as “exit range” of the top or bottom respectively. While the player is within exit range, pressing left or right will move them towards the exit point of the stairs. Then, once they reach the exit point, they leave the stairs and continue walking in the direction they're holding down. Maybe you've already noticed that this works exactly like the entry range, which allows players to use additional buttons to walk towards the entry point (which we added all the way back in iterations 3 and 4). Again, we have to account for button combinations that should cancel each other out or would add up speeds.
Exit range at the top and bottom of stairs shown in blue. You may use a collider for this, but we calculate it from the distance between player and nearest exit point.
While the addition of an exit range adds a lot of polish, it also brings about its own set of problems that we’ll have to solve in iteration 12. For example, what if our frontal stairs are only 2m high or even smaller, putting the player within exit range of both top and bottom at the same time? Let's set the exit range at, say, 20% of the stairs’ total length instead of hard-coding it to 1m each.
Plus, if the stairs fall below a certain threshold, they have no exit range at all. We’ve determined in testing that this latter case is not a big problem because the player will tend to hold up or down longer than they need to on a very short set of stairs, causing them to run all the way up or down anyway 99% of the time.
This fixes our problem for short stairs, but it causes a new problem for long stairs, for which 20% is too big of an exit range. So in addition to the rules we set up in iteration 12, let's also set a maximum exit range of 1m for each exit range in iteration 13.
We're almost done! Now that getting on and moving along stairs works, getting off the stairs is actually fairly easy.
Right now, our player just walks right past the top and bottom into infinity:
In iteration 14, we set up colliders that allow the player to exit the stairs (i.e. go back to regular walking controls with physics re-applied) upon being triggered. We actually re-used the range colliders of top and bottom for this:
The only problem with that is that the colliders are triggered too early, especially at the top, because of the player collider's height. Let’s place the colliders at the top and bottom high and low enough respectively, so the player is inside them at the very end of their walk up or down the stairs.
Alright, but: The player is still inside that collider when entering, so they can turn back around and walk right through it without triggering it again, allowing them to walk past the exit point. In iteration 15, we solve this the same way we did for entering the stairs: We define a number of buttons that expresses the intent to leave the stairs for each case (descending/ascending/frontal), same as in iteration 6. When the player presses one of those while inside the exit range, they leave the stairs again.
In iteration 16, we again account for low FPS which may cause the player to overshoot the exit point. We teleport the player to the exact exit point, the same way we teleported them to the entry point:
While this protects against most bugs such as walking past the exit point or falling through the floor, it has two problems: 1. It is noticeable at low FPS because the player can be seen beneath the floor for at least one (long) frame. 2. It does not account for very low FPS, as the player might still overshoot the exit collider completely if their moving distance between frames gets large enough.
That is why, finally, in iteration 17, we adjust the height of the exit collider to the current FPS: The lower the FPS, the higher it gets. This causes the player to trigger it earlier and never miss it, solving both problems described in iteration 16.
And that's it! In only 17 simple iterations, we've reached the goal of creating intuitive, bug-resistant, and good-looking stairs movement.
Since this video was recorded, we've further polished the system at little. We have smoothed exit and entry by adjusting the teleport positions, and we've added custom idle animations for standing still on frontal stairs depending on the previous movement direction.
Of course we realize that this type of movement system isn't at all complicated or sophisticated compared to some AI-based 3D multi-terrain movement system some programming super brains are able to cook up nowadays. However, it works well for us and should be reasonably easy to implement into any similar 2D side-scroller. In case you're thinking that any aspects of it could be simplified, solidified, or anything-else-ified, feel free to let us know. We'll gladly refine both our code and this guide to better help other developers in the future.
We hope it helped or entertained you or, at the very least, showed that a seemingly innocuous mechanic can be pretty complicated to implement sometimes.