Lesson 5

Running and jumping

A platformer character from the numbers designers think in: seconds to top speed, jump height and time to the top. Deriving gravity and take-off speed from them, integrating so the jump really reaches that height, and the asymmetries that make a jump feel good: heavier falls, apex hang and cutting the jump short.

Steve Swink’s book Game Feel defines its subject as “real-time control of virtual objects in a simulated space, with interactions emphasized by polish”. The control part is mostly a character controller, and a character controller that feels good is rarely a physics simulation. It is a small state machine with a handful of numbers, each tuned so the character does what the player meant. This part of the section builds one: running and jumping here, the forgiveness that hides the player’s timing errors in the next lesson, and the camera and feedback in the one after.

Every widget runs the feel lab’s controller: a box 0.7 tiles wide and 1.3 tall on a tile map, stepped at a fixed 60 Hz, in tiles and seconds. Its Tight preset is the starting point throughout.

Running: tune in seconds

The simplest run sets the velocity to top speed while a direction is held and to zero when it isn’t. It’s precise and lifeless: the character has no mass. The usual alternative keeps a velocity and moves it towards the speed the stick asks for, by at most an acceleration times the tick:

float target = input_x * max_speed;
float time   = input_x != 0 ? accel_time : decel_time;        // seconds to reach top speed, or to stop
float accel  = max_speed / time;
if (input_x != 0 && sign(vx) == -sign(input_x)) accel *= turn_boost;   // reversing: brake harder
if (!grounded) accel *= air_control;
vx = move_towards(vx, target, accel * dt);

The designer tunes time to top speed and time to stop in seconds, not acceleration in tiles per second squared. Seconds are what a player feels, and they stay meaningful when the top speed changes.

Loading…

The script holds right for 0.8 s, then left for 1.2 s, then lets go. Watch the speed line under the box.

Tight reaches its 9 tiles/s top speed in 100 ms and stops in 67 ms (60 ms, rounded up to whole ticks). Stopping is faster than starting because overshooting a ledge feels worse than a slow start. Two refinements matter more than they sound:

  • A turnaround boost multiplies the acceleration while you push against your current direction. Tight’s ×2 turns it round at full speed in 150 ms instead of 200 ms, without making the start twitchy. At half a second to top speed it’s the difference between 733 ms and 1000 ms to turn round.
  • Air control scales the acceleration in the air. At 1 the jump is something you steer; near 0 it’s a commitment. Tight keeps 65%.

Jumping: from height and time

The obvious parameters for a jump are gravity and take-off speed, and they’re the wrong ones to give a designer: change either and both the height and the time in the air change. Designers think in how high and how long. Kyle Pittman’s GDC 2016 talk, Building a Better Jump, derives the physics from those instead. For a jump of height h that takes time t to reach the top, with constant gravity g:

  • at the top the speed is zero, so the take-off speed is v = g·t;
  • the height gained is h = v·t − ½·g·t², which is ½·g·t².

So g = 2h / t² and v = 2h / t. Tight’s jump is 3.2 tiles high and takes 360 ms to the top, which gives gravity 49.4 tiles/s² and a take-off speed of 17.8 tiles/s. Pittman then swaps the time for the horizontal distance to the top at a given run speed (t = x / vₓ), which suits level designers laying out gaps.

struct Jump { float gravity, take_off; };
Jump jump_from(float height, float time_to_apex) {
    return { 2 * height / (time_to_apex * time_to_apex), 2 * height / time_to_apex };
}

Loading…

It starts with the same gravity up and down: a parabola. Change the height and the jump still takes the same time to the top; change the time and it still reaches the same height. The dashed line is Tight’s full jump.

Integrating it so it lands where you said

The derivation assumes continuous time. A game steps it, and the simplest update (add gravity to the velocity, then move by the velocity) doesn’t reach the height: with Tight’s numbers it peaks at 3.05 tiles instead of 3.20, a twentieth short, and the error grows with the tick. Tick Also step it the simplest way to see it.

The fix is to move by the average of the velocity before and after the tick. For constant acceleration that’s exact (Pittman’s talk presents it as velocity Verlet with constant acceleration, a small change from Euler), so the jump reaches the height you set at any fixed tick rate:

float v0 = vy;
vy += g * dt;
y  += 0.5f * (v0 + vy) * dt;     // exact for constant gravity: the jump peaks at exactly `height`

The lab’s controller does this, and its jump peaks at 3.199 tiles: the top falls between two ticks.

Asymmetry: heavier falls and apex hang

Real platformer jumps are rarely parabolas. Pick Fall gravity ×1.8: gravity is multiplied once the character starts falling. The rise is unchanged, the drop is quicker, and the whole jump takes 633 ms instead of 733 ms. Players read that as snappy and decisive; a symmetrical jump reads as floaty. Pittman’s talk gives the fall its own gravity: a second parabola from the top, carrying over position and speed. 1.5 to 3 times the rising gravity is a common range. Cap the fall speed too, so long drops stay readable: Tight caps it at 22 tiles/s.

Pick + apex hang ×0.55: while jump is held and the vertical speed is near zero (here, under a quarter of the take-off speed), gravity is lighter. The jump lingers at the top for a few frames, which gives the player a moment to line up the landing. It peaks a little higher, 3.32 tiles, reached at 417 ms. Celeste does the same: gravity halves while jump is held and the vertical speed, up or down, is under 40 px/s, against a take-off speed of 105 px/s.

float g = gravity;
if (vy < 0)                                   g *= fall_multiplier;   // falling (y up)
else if (jump_held && fabs(vy) < hang_speed)  g *= apex_multiplier;   // near the top

Variable jump height

Players expect a tap to make a hop and a held button a full jump. Without that, every jump is the same height, and small gaps and low ceilings become awkward: you either bonk your head or overshoot.

The common way is a jump cut: the moment jump is released while still rising, multiply the upward speed by a fraction. A held jump is unchanged.

Loading…

Each line is the same jump held for a different number of frames; the dots mark the release. With Tight’s cut to 45%, a one-frame tap rises 0.88 tiles, and holds of 4, 8, 12 and 16 frames reach 1.51, 2.19, 2.70 and 3.03; held all the way it’s 3.32. Turn the cut off and every line reaches the same height.

Two things to get right. Cut once per jump, on the first tick jump isn’t held, not on the release event: a jump that fires from the buffer after the button is already up must be cut at once, or every early tap becomes a full jump. And there are other shapes: extra gravity while the button isn’t held gives a softer cut, and Celeste holds the take-off speed for as long as jump is held, up to 0.2 s, before gravity takes over, so the height depends on how long you held rather than when you let go.

Takeaways

  • Tune in seconds: time to top speed and to stop, not acceleration. Stop faster than you start; add a turnaround boost.
  • Derive the jump from height h and time to the top t: g = 2h / t², v = 2h / t.
  • Integrate with the average velocity over the tick, and the jump reaches the height you set.
  • Make it asymmetric: heavier fall gravity, a fall-speed cap, lighter gravity at the apex while jump is held.
  • Cut the upward speed when jump is no longer held, once per jump, so taps are hops.

In the lab

The lab has every one of these as a slider, presets from floaty to heavy, and a strip that shows the last five seconds of input, speed and state.

Open the Tight preset with a symmetrical jump in the feel lab →

Further reading