Better slopes in AABB collision systems
Earlier this year I published a guide on how to implement slopes in AABB collision resolution using bump.lua. The resulting system worked, but was a bit hard to use in-game and had a few issues, so I wouldn’t consider it a viable solution.
The main issue with the old approach was the use of the cross collision response.
It allowed the object to move through the slope as if it wasn’t there at all, and then the update loop corrected the y position after the fact.
This alone makes writing code the game a lot more complicated than it needs to be:
(fn player-collision-filter [_ obj]
(if (not= nil obj.slope)
:cross
:slide))
(fn grounded? [obj]
(let [(_ y collisions l)
(world:check obj obj.x (+ obj.y 1) player-collision-filter)]
(and (or (faccumulate [res nil i 1 l :until res]
(let [col (. collisions i)]
(when col.other.slope ;; <- eeeeeh...
(= obj.y (col.other.slope obj)))))
(= obj.y y))
collisions)))
(fn update [player main]
;; ---8<---
(let [(x y collisions l)
(world:move
player
(+ player.x player.x-vel)
(+ player.y player.y-vel)
player-collision-filter)]
(doto player
(tset :x x)
(tset :y y))
(for [i 1 l]
(let [collision (. collisions i)
other collision.other]
(when other.slope
(case (other.slope player)
y* (when (>= player.y y*)
(world:update player player.x y* player.w player.h) ;; <- yikes!
(doto player
(tset :y y*)))))))
;; ---8<---
))
I had to resort to calling the world:update method, which, as described in the bump’s docs, is used to update object’s position in the world without checking collisions.
This made slopes really awkward to use and work with.
And while I suspected that this was a wrong approach, I decided to go with it anyway, because at the time I didn’t knew better.
Then, while working on the game for the Spring Lisp Game Jam I again needed a custom collision handling that is not supported by bump.lua directly, but a bit simpler this time around.
It’s a common thing in games, where you can pass through the block from one side, but not from the other.
Like clouds usually behave in platformers - you can cross them from below, but still land on them:
A cloud platform in the game Rayman (1995)
However, bump.lua by default only supports the following type of collisions: slide, cross, touch, and bounce:

Figure 1: Response types from bump.lua documentation
Solving this using the cross handler seemed wrong, and after a bit of searching I learned that in bump.lua you can define your own collision response functions.
So I defined my own collision response called slide-cross (because the main intention was to be able to go through from the sides with the slide response but cross over it from below):
(fn slide-cross [world col x y w h goal-x goal-y filter]
(let [goal-x (or goal-x x)]
(var goal-y (or goal-y y))
(if (<= (+ y h) col.otherRect.y)
(let [tch col.touch
move col.move]
(when (or (not= move.x 0) (not= move.y 0))
(set goal-y tch.y))
(set col.slide {:x goal-x :y goal-y})
(let [(cols len) (world:project col.item tch.x tch.y w h goal-x goal-y filter)]
(values goal-x goal-y cols len)))
(let [(cols len) (world:project col.item x y w h goal-x goal-y filter)]
(values goal-x goal-y cols len)))))
(world:addResponse :slide-cross slide-cross)
With such colliders in place, I could replicate cloud platform behavior:
I didn’t use clouds themselves in the actual game - instead, this collider was assigned to specific blocks I wanted to be able to run past, acting more as background structures you can still jump on, i.e. pedestals, but the idea of making a custom collider got me thinking. If I can make a custom collision response function, why not try to do it for slopes too?
So that’s what I did. But before that, I had to redo slopes a bit.
Slopes in bump.lua
First, let’s start with how slopes are defined:
(fn make-slope [x y x0 y0 x1 y1 slope-type]
(let [(nx ny) (slope-normal x0 y0 x1 y1 slope-type)]
{:slope slope-type
:surface #(+ y0 (/ (* (- y1 y0) (- (clamp x0 $ x1) x0)) (- x1 x0)))
: x : y : nx : ny
:h (m/max y0 y1) :w (m/max x0 x1)}))
For example, to define a slope at coordinates x,y that is 16px long and 8px tall (
), we can call make-slope like this:
(make-slope x y 0 8 16 0 :floor)
A 45° 8x8 slope (
) can be defined as follows:
(make-slope x y 0 8 8 0 :floor)
These slopes get taller from left to right, if we want a slope that goes lower from left to right (
), we swap the coordinates:
(make-slope x y 0 0 16 8 :floor)
This function returns an object representing the slope.
Of course, we’re not limited to defining 16x8 and 8x8 slopes - these are just the most logical choices for a platformer in TIC-80.
We can define steeper slopes like 8x16, or use an arbitrary size altogether.
As long as it can be clearly represented by the underlying spritework given resolution constraints you work in.
The make-slope constructor computes the slope’s normal vector:
(fn slope-normal [x0 y0 x1 y1 slope-type]
(let [dx (- x1 x0)
dy (- y1 y0)
len (m/sqrt (+ (* dx dx) (* dy dy)))
nx (/ (- dy) len)
ny (/ dx len)]
(if (or (and (= slope-type :floor) (> ny 0))
(and (= slope-type :ceiling) (< ny 0)))
(values (- nx) (- ny))
(values nx ny))))
It will be used in our response function to implement bouncing off the slope surface correctly - one of the things I fell short while making the previous version.
Slope response function for bump.lua
Now we can define the response function itself:
(fn slope-response [world col x y w h goal-x goal-y filter]
(let [goal-x (or goal-x x)
{: surface :slope slope-type : nx : ny} col.other
{:x ox :y oy} col.otherRect
{:y touch-y} col.touch]
(var goal-y (or goal-y y))
(case slope-type
:floor (let [surf-y (m/min (+ oy (surface (- goal-x ox)))
(+ oy (surface (- (+ goal-x w) ox))))]
(if (> (+ goal-y h) surf-y)
(if (> col.normal.y 0)
(set goal-y touch-y)
(set [goal-y col.normal.x col.normal.y] [(- surf-y h) nx ny]))
(set [col.normal.x col.normal.y] [0 0])))
:ceiling (let [surf-y (m/max (+ oy (surface (- goal-x ox)))
(+ oy (surface (- (+ goal-x w) ox))))]
(if (< goal-y surf-y)
(if (< col.normal.y 0)
(set goal-y touch-y)
(set [goal-y col.normal.x col.normal.y] [surf-y nx ny]))
(set [col.normal.x col.normal.y] [0 0])))
_ (error (.. "unknown slope type: " (tostring _))))
(set col.slide {:x goal-x :y goal-y})
(local (cols len) (world:project col.item x y w h goal-x goal-y filter))
(values goal-x goal-y cols len)))
It has two case branches - one that I’ve omitted in the above explanation of make-slope.
The slope-type argument, set to floor in those examples, determines if a slope is a ground one or a one on the ceiling.
Another thing that wasn’t supported in the old method.
This response function works as follows:
- It gets
goal-xandgoal-y, as well as other usual parameters, and the collision object. - The slope is always in
col.other, because slope itself never collides with anything (they’re never passed toworld:movecalls). - We get the slope’s rectangle to adjust our object’s position across the slope’s width.
- Then we check
slope-type- For floor slopes we get the
minof theycoordinate of the slopey. - Then we check if the
goal-yreceived by the collision response function is below the slope’s surface. - If so, and the normal vector points up, meaning the collision comes from above the slope, we set the
goal-yand normal vector.
- For floor slopes we get the
- For ceiling slopes, the logic is the same, only some signs and checks are inverted.
- If approached from the other side of the slope, i.e. from below - the collision acts like a regular AABB, so the slope forms a right triangle.
Handling goal-y and normal vector inside the response function provides much cleaner slopes:
Note: it may seem that balls float over the slopes without touching, but that's only a visual quirk because ball's collider is still a 8x8 square - it touches the slope wiht a corner, but the sprite makes it look that it's floating. A more precise collider for the ball is needed, but it's out of the scope of this post.
The old implementation couldn’t achieve such smooth gliding over the slope’s surface. Balls get stuck on adjacent slopes, and incorrect normals prevent balls from rolling off smoothly:
Note: in this clip the ball seems to touch the slope properly, but again, it's just a visual thing - in reality the slope is a little below the sprite's visual representation of it.
Now, when slopes are defined better, we can integrate them into a mock-up platformer to test them in a game environment:
I don’t use normal vectors here, since in a platformer the jump usually goes up, regardless of the terrain, but depending on the player’s state one could use normals as well, given that we have them in the collision info.
The old implementation of slopes had problems, that can be seen here:
Notice that when transitioning from slope to solid ground, the player transitions to the falling state for one frame. This was because we had to handle slope collisions outside of the collider itself, in the update loop - we couldn’t cross slopes unless they’re a tiny bit higher than the ground next to it. This problem is now eliminated by handling slopes during bump’s collision resolution step.
But we don’t have to stop there - what we have now is basically an extension to normal AABB with custom height functions that are bound by bounding boxes.
So we can create any kind of slopes, like ramps - we just need a proper height function.
However, we did a small optimisation that prevents us from doing it right now - we pre-computed slope normals.
If we use a height function whose normal vector changes along the x coordinate, we need to handle that in the slope-response function:
(fn slope-response [world col x y w h goal-x goal-y filter]
(let [goal-x (or goal-x x)
- {: surface :slope slope-type : nx : ny} col.other
+ {: surface :slope slope-type} col.other
{:x ox :y oy} col.otherRect
- {:y touch-y} col.touch]
+ cx (- (+ goal-x (/ w 2)) ox)
+ (nx ny) (slope-normal (- cx 1) (surface (- cx 1))
+ (+ cx 1) (surface (+ cx 1)) slope-type)]
(var goal-y (or goal-y y))
(case slope-type
:floor (let [surf-y (m/min (+ oy (surface (- goal-x ox)))
(+ oy (surface (- (+ goal-x w) ox))))]
(if (> (+ goal-y h) surf-y)
- (if (> col.normal.y 0)
- (set goal-y touch-y)
- (set [goal-y col.normal.x col.normal.y] [(- surf-y h) nx ny]))
+ (set [goal-y col.normal.x col.normal.y] [(- surf-y h) nx ny])
(set [col.normal.x col.normal.y] [0 0])))
:ceiling (let [surf-y (m/max (+ oy (surface (- goal-x ox)))
(+ oy (surface (- (+ goal-x w) ox))))]
(if (< goal-y surf-y)
- (if (< col.normal.y 0)
- (set goal-y touch-y)
- (set [goal-y col.normal.x col.normal.y] [surf-y nx ny]))
+ (set [goal-y col.normal.x col.normal.y] [surf-y nx ny])
(set [col.normal.x col.normal.y] [0 0])))
_ (error (.. "unknown slope type: " (tostring _))))
(set col.slide {:x goal-x :y goal-y})
With that in place, we can even make dynamic slopes, like a moving sine wave, and objects will correctly respond:
For my purposes, I don’t need this kind of flexibility yet, but it’s nice to be able to do it still.
I could see this being used in a game like Sonic, where you have loops, which are circles.
Defining four slopes for each circle quadrant (two bottom ones as floor slopes and upper ones as ceiling slopes), and handling how to enter and exit them by marking some of them inactive via the cross collision response.
Handling normals in this case would prove handy, as Sonic jumps perpendicular to the normal at the current position on the slope.
However, we lost one additional feature our original slope-response had: these slopes don’t have solid opposite side.
Triangular floor slopes prevented objects from entering the slope from beneath, only from the sides and from above the slope.
Sine-wave slope will teleport objects to the slope side if an object entered inside - it’s a correctness measure, because of a more complicated geometry.
That’s not a bad thing on its own, since slopes are usually surrounded by other types of ground anyway, but still a thing to consider.
With that, I have almost perfect slopes for bump.lua, and I can use it in systems where I can’t access more sophisticated collision detection systems like Box2D.
There are still things to be aware of, though.
First, because bump’s collision resolution does a single pass, we can’t handle cases well where a ball touches the slope, another wall, and another ball.
Second, the order of collisions can give different outcomes - if a ball lands exactly in the middle of two slopes, the outcome is determined by which slope bump.lua will check first.
So it’s good for simple cases, but not a proper slope collision simulation, like one found in non-AABB systems.
On that note, I think I’m satisfied with what I have, and I’ll try to use this in my next game projects! And, as always, the you can try this online here, and a downloadable version of a cart is provided below: