This week, I added physics-based platforming support - so now you can control a little box who can move around, jump and push some balls around.
Making movement feel good is really tricky! For example, real life physics would lead you to think that a jump makes you travel in a parabola, so that the time taken to reach the peak of your jump is the same as the time to fall from the peak to the ground... but in a game, that feels terrible!
Physics support
To jump from platform to platform, it helps to be able to know when your character is standing on a platform - which means detecting collisions and responding to them.
I did already have "pixel physics", where pixels can collide and come to a stop, but I wanted the player to be a bit bigger than 1 by 1 pixel.
The traditional platformer approach is to implement collision handling directly: for each box-shaped platform, use its origin and its dimensions to determine whether it intersects with your player box. If all your boxes are axis-aligned then this is some straightforward additions and subtractions, but usually it gets more complicated if you want to have support for sloped objects.
(In my case, support for slopes would have come for free because they're represented as individual pixels already!)
However, I want to have rigid body physics too, so players can push larger objects around, so I cut to the chase and integrated the Rapier physics library.
Worlds, scales, and cameras
An annoying wrinkle is that the physics library is tuned so that "1 unit" is 1 metre or 1 kg, and you can't customize that. So when I naively implemented a 20 pixel ball as being 20m wide, well, turns out that if you drop a 20m ball from a height of 200m, it's going to look like it moves veeerrrry slowly initially.
To fix this I needed to manually convert between the two coordinate systems: the screen (bottom right pixel = screen resolution) and the physics game world, where everything is about 10x smaller (10 pixels = 1m).
No, scratch that; I needed to convert between 3 coordinate systems: the two above, plus the game world. Otherwise the window would act as a fixed frame (the camera can't "pan"), rather than since as an actual window through which you can see the game world.
So I implemented that, made the camera movable, and added debug drawing of the physics objects:
I'm sure there is a clever way to do the world -> screen transform using the graphics card (something something opengl transform), but all my attempts were "pixel imperfect", meaning that a single logical pixel would be rendered as more than one physical pixel. So I'll punt that to later.
Movement, jumping, and making it feel decent
With the addition of physics and a camera that could hypothetically follow a player character around the world, I just needed to implement the character. Rapier provides a sample controller that lets the character push objects and stops them from falling through walls, and all I had to do was provide a velocity vector of "where the character would like to move to". So how hard could it be, right?
// first cut of movement logic
game.player.vel.x = player_input_x * max_speed;
if player_input_jump_started {
game.player.vel.y = -12.0;
}
game.player.vel += GRAVITY * dt;
Well, it worked, but it felt terrible:
- Jumps feel super floaty because they have you travel in a parabola
- Jumps are always to maximum height
- You hit top speed instantly, and halted instantly when you stopped moving
- Being able to change direction basically instantly in mid-air is even weirder
This led me down a rabbit hole of tricks that platformers use to make themselves feel not-terrible.
The first fix was non-linear gravity: firstly, when the player has reached the peak of the jump, increase the player's gravity dramatically so they hit the ground again sooner. Secondly, if the player is still jumping upwards but has released the jump button already, also boost the gravity; this lets players jump lower.
// Non-linear gravity, based on whether player is jumping or not
let gravity_mult = if player.vel.y > 0.0 {
// player has reached peak of jump -> time to fall faster
fall_multiplier
} else if player.vel.y < 0.0 && !jump_key_down {
// player is falling and not holding the jump key -> also fall faster
low_jump_multiplier
} else {
1.0
};
player.vel.y += 9.81 * gravity_mult * dt;
You might notice that I also implemented some acceleration-over-time there.
The next tricks were more subtle. Based on a few breakdowns of Celeste and an "Ultimate" controller for Unity, I implemented:
- Jump buffering: if you press jump just before your character landed on a platform, automatically jump as soon as they actually land.
- Coyote time: if you press jump just after leaving a platform, jump anyway, even though there's nothing to jump off.
// jump buffering and coyote timer implementation
if player.jump_buffer_timer.is_running()
&& (player.on_ground || player.coyote_available_timer.is_running()) {
player.jump_buffer_timer = Countdown::stopped();
player.coyote_available_timer = Countdown::stopped();
player.vel.y = -jump_force;
}
// (assuming the timers are started on jump press & leaving ground)
Here's what it looks like:
There are a few more tricks, such as clamping the maximum fall speed to avoid losing control when falling, but they're less interesting. I'll need to keep tweaking all this stuff of course - but at least now it feels pretty nice.
Camera, meet player
Obviously the camera now needed to follow the player, but making the camera directly centre on the player 100% of the time felt a bit too jarring.
First I tried moving the camera a small distance to its destination each time which was a little better, but still not great. I wanted something like Unity's Cinemachine, which implements (among other things) smooth camera movement.
I got lost in maths a little bit, but eventually I found the Getting There In Style GDC talk (recording), which clued me into PD controllers; a PD controller lets me move the camera as if it were on a spring attached to the player. Which I probably would already known if I'd studied a real engineering degree, but let's not get into that.
Playable web build
If you manage to make it onto all 7 floating platforms without cheating, you're a hero!
Shiny new controls for you:
- A and D to move left/right
- W to jump (NB: Space pauses instead! See below.)
- T, G, B to spawn a fixed platform, circle or box at mouse position
- L to toggle locking camera to player (arrow keys to move when unlocked)
- F1 to toggle physics debug UI (enabled by default)
If you fall off the edge, just refresh the page! And if you see lots of gold blobs appearing while holding jump, that's just your OS's key-repeat kicking in - on Web, we don't (can't?) distinguish between that and physical new key presses.
Also, there's a totally intentional thing right now where if you hold jump and hit your head on a platform, you can keep moving upwards and (if you're careful) eventually make it onto that same platform. Sweet, right?
Also, here are your existing controls from last week (which still work):
- 1 to 5 select between drawing empty, solid, bricks, sand and water
- 6 to 0 change drawing cursor size
- P to toggle pause; Space to step frame by frame
- C to reset pixel (doesn't affect physics objects or players yet)
- = and - to zoom in and out
- Alt+Enter to fullscreen (bonus - forgot to document this last week!)
The pixel physics are not yet interacting with the rigid body physics, so e.g. sand will happily overlap with a circle you spawn.. maybe I'll tackle that next week!