a simple, precise platformer physics system in pico-8
I picked up pico-8 recently, and started out by seeing if I could make a platformer collision system on my
own. I ended up getting further than I expected, implementing many notoriously difficult features like rigid
box pushing, carrying objects, slopes, conveyors, etc.
A couple people have asked for an explanation of the system, so here you go!
Demo:
Z: enable debug stepping
X: change levels
Z+X on same frame: toggle 3d mode
Code
If you would just like to poke around in the project yourself, you can view the code above or download the cart here:
Download torc_platformer.p8
(CC0, do whatever you want with it! Build off it for your own games, whatever. You can credit me or not, I dont mind either way :)
Preamble
This project was entirely made out of self interest, and is also my first time working with pico-8. I didn't do much in the way of optimizations or following best practices. I also didn't follow any preexisting work, it's possible I'm directly recreating an existing approach, I have no idea. This is just what I naturally ended up with as i solved things. I welcome anyone with more experience to build off this work!
That said, here's an explanation of my platformer physics system :)
Summary
This does not use the conventional collision/response solver system. There's no global solver of any kind, nothing tries to shift itself out of collision with anything else. I don't have a good name for the approach but I've been calling it something like “push-forwarding” in my head.
With this approach, any time an object wants to move, it checks if it will overlap anything. If it overlaps a static object, it cancels the move. If it overlaps a movable object, it then checks if that object can move (and so on). If all checks in the chain pass, all objects move forward by the original amount, otherwise none of them move. This outright prevents any object from overlapping anything.
Of course, if an object tries to move more than one pixel in a frame, it would look like it stops before reaching the edge.
So the last piece of the puzzle is chunked delta movement. Where the conventional system increases accuracy by increasing iteration count in the global solver, this approach's equivalent is reducing the movement delta. When an object wants to move 4px in a frame, the system chunks that into four 1px movements, and at the end it rounds the final position to the nearest pixel
Movement is separated into each axis. This allows you to e.g. run along the ground even though gravity would otherwise cause you to collide with it.
An object landing on another object stores that as its “ground object”. When an object moves, if it is carrying an object it will also try to push that object in the direction it is moving. This method is also used for conveyors, etc.
This by default produces the effect where e.g. running right on top of a left-moving platform makes you move “slower”
Ground objects are seen as “static” to the object above, to prevent pushing back down against the ground object. This is less realistic but feels better, letting you e.g. jump at full height even with a box on your head.
Slopes take horizontal movement and apply an equivalent vertical movement, which allows pushing boxes up slopes. This also happens from vertical to horizontal, but only under certain conditions (e.g. player ducking to push downward)
This inherently creates the (sometimes desired) effect of keeping horizontal speed when running up a slope.
Water simply adds upwards velocity to objects inside it.
More detail
Movement
- For each actor update, calculate its total dx/dy for this frame
- Set a “delta queue” for each axis equal to the total dx/dy.
- Loop until both queues run out:
- Decrement dx queue by max 1. If it drops below 0, set dx to the remainder. (so e.g. if moving 1.5 pixels this frame, first move is 1px, next move is the remaining 0.5px)
- Check movement along dx (see push check below. Summarized:)
- If hitting static object, cancel movement
- If hitting movable object, run push check on that object
- If all checks pass or do not collide, move all objects in chain
- (niche:) If the actor's impulse is in the same direction as its movement and movement is being stopped, prevent the dx queue from decrementing. This ensures consistency when moving along gaps (e.g. if pushing and sliding down a wall with a 1 tile gap, you will always move into the gap no matter the starting y position, because every pixel it will try to move into the gap. But a box hitting and sliding down the wall will not move into the gap because it has no impulse velocity)
- Repeat the above for dy
Push check
(check if an actor can move in a given direction, and if so push objects in the way)
- First check actor collision:
- Iterate over every actor
- If aabb overlaps, run a push check on that actor
- If check hits static actor (or if moving down into an actor's ground actor), return early and cancel movement
- Next check tile collision, if not stopped already:
- Get the tile at each corner of the actor's collision box
- If tile is solid, return early and cancel movement
- If tile is a slope/one-way, see below
- Otherwise, continue
Note: actors larger than 8x8 may overlap a tile without triggering a collision on a corner. To counteract this, we actually generate collision points for every 8x8 region the actor overlaps. So in the demo, the long blue platform has 8 collision points (i.e. "corners").
- If all checks pass, apply the movement to the actor
Checking actor collision before tile collision is less performant but necessary due to slopes. Since the slope check adds another push check, it may see a valid move as invalid because it did not move actors out of the way yet.
Alternatively, the additional push check could be delegated after the other checks, but I opted for simplicity.
Slopes
- If an incoming push check crosses the diagonal of a slope, the slope will add another push check the same distance mirrored across the diagonal (e.g. from horizontal to vertical). If the check passes, the object moves both times.
- If the incoming push is coming downwards from above, it also needs to have the “force” flag.
- The “force” flag is true for a push check if the actor has an impulse in the same direction as the delta movement (e.g. a player or conveyor causing the push)
- The flag is carried along the chain, so pushing multiple boxes still allows pushing the last box down a slope
- This prevents e.g. gravity pulling objects down slopes
One-way platforms
Only return a solid collision under the following circumstances:
- Collision check is coming from a corner only at the bottom of the actor
- Movement is downwards
- The incoming y position is near the bottom of its tile (i.e. old_y > 6px )
- The new y position will end up near the top of its tile (i.e. new_y < 2px)
And that's it!
Much of the code in the demo can be trimmed out if you don't need e.g. water or slopes or whatever.
Special thanks to Werxzy for additional optimizations and help!
Feel free to shoot me any questions on my bluesky or my email, I'll happily update this page with any further info people may want.
<3