Skip to content

Velocity, Gravity and Jump Arcs

A physics step is two lines:

v += a * dt;
p += v * dt;

No calculus, and no need for any. But the order of those two lines changes the answer, both orders are wrong, and they are wrong by exactly the same amount in opposite directions — so their average is exact. That last fact is free, and it is the difference between a jump that lands on the height you asked for and one that lands near it.

The second half of the Section is the parameterisation. Nobody wants to choose a gravity constant. They want a jump two units high that takes four tenths of a second to reach the top. Those are the same thing, and inverting the relationship is the move Section 4.1 made when it exposed a half-life instead of a decay rate.

Explicit Euler moves by the old velocity, then updates it. It crosses the whole step at the velocity from the start, so under gravity it travels too far upward.

Semi-implicit Euler updates the velocity first, then moves by the new one. It crosses the whole step at the velocity from the end, so it comes out short. This is what nearly every engine does, chosen for its stability with springs and damping rather than its accuracy here.

Each is off by

12 a Δt2 n  =  12 a Δt t\tfrac{1}{2}\,a\,\Delta t^2\,n \;=\; \tfrac{1}{2}\,a\,\Delta t\,t

in opposite directions. Two things worth reading off that: the error grows linearly in time, not quadratically, and it is proportional to the step size — halve the timestep and you halve the drift.

Three ways to step one throw, and the exact parabola between two of them
The crosses are the average of the orange and blue traces; they sit on the grey curve.
The code that draws it src/lib/gamedev/demos/2d/stepper.scene.ts
/** Three ways to step one throw, plotted against the truth: two bracket it and their average is it. */
import { makeCanvas2D, dot as fillDot, label, line } from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addCheckbox, addReadout, addSlider } from "../controls.ts";
import {
  FPS_RANGE,
  THROW_HEIGHT,
  THROW_TIME,
  VIEW,
  allTraces,
  bracketAt,
} from "./leap-shared.ts";
import type { MountFn } from "../runner.ts";

const EXACT = "#7d8590";
const EXPLICIT = "#f0883e";
const SEMI = "#58a6ff";
const MIDPOINT = "#7ee787";
const AVERAGE = "#d2a8ff";
const GRID = "#252b33";
const TEXT = "#9198a1";
const DIM = "#636c76";

const COLOURS = [EXPLICIT, SEMI, MIDPOINT] as const;

const mount: MountFn = (el) => {
  const { ctx, clear } = makeCanvas2D(el, VIEW.height);

  const show = addReadout(el);
  const note = addReadout(el);
  const fps = addSlider(
    el,
    "frames per second",
    FPS_RANGE.min,
    FPS_RANGE.max,
    12,
    draw,
    "",
    FPS_RANGE.step,
  );
  const showAverage = addCheckbox(
    el,
    "plot the average of the two Euler steps",
    true,
    draw,
  );

  function draw() {
    clear();
    const traces = allTraces(fps());
    const steps = traces[0].heights.length - 1;

    // A plot: step number across, height up. Margins leave room for the labels.
    const left = 44;
    const right = VIEW.width - 16;
    const top = 26;
    const bottom = VIEW.height - 34;
    // Enough headroom that the explicit trace, which is the highest, always fits.
    const tallest = Math.max(...traces.flatMap((t) => t.heights), THROW_HEIGHT);
    const at = (stepIndex: number, h: number) => ({
      x: left + (stepIndex / steps) * (right - left),
      y: bottom - (h / (tallest * 1.08)) * (bottom - top),
    });

    line(ctx, { x: left, y: bottom }, { x: right, y: bottom }, GRID, {
      width: 1,
    });
    line(ctx, { x: left, y: top }, { x: left, y: bottom }, GRID, { width: 1 });
    label(ctx, "0", left - 8, bottom + 4, TEXT, "right");
    label(ctx, tallest.toFixed(1), left - 8, top + 10, TEXT, "right");
    label(ctx, "steps \u2192", right - 44, bottom + 20, TEXT);

    // The truth, drawn first and thickest, as the thing the other three are approximating.
    ctx.save();
    ctx.strokeStyle = EXACT;
    ctx.lineWidth = 2.6;
    ctx.beginPath();
    traces[0].exact.forEach((h, i) => {
      const q = at(i, h);
      if (i === 0) ctx.moveTo(q.x, q.y);
      else ctx.lineTo(q.x, q.y);
    });
    ctx.stroke();
    ctx.restore();

    // The three stepped versions, with a dot per step so the timestep stays visible.
    traces.forEach((trace, index) => {
      ctx.save();
      ctx.strokeStyle = COLOURS[index];
      ctx.lineWidth = 1.8;
      ctx.beginPath();
      trace.heights.forEach((h, i) => {
        const q = at(i, h);
        if (i === 0) ctx.moveTo(q.x, q.y);
        else ctx.lineTo(q.x, q.y);
      });
      ctx.stroke();
      ctx.restore();
      for (let i = 0; i < trace.heights.length; i += 1) {
        const q = at(i, trace.heights[i]);
        fillDot(ctx, q.x, q.y, 2.4, COLOURS[index]);
      }
      const last = at(steps, trace.heights[steps]);
      label(
        ctx,
        trace.which,
        last.x - 6,
        last.y + (index === 0 ? -8 : 12),
        COLOURS[index],
        "right",
      );
    });

    /* The average of the two Euler traces, which lands on the exact curve. Drawn as crosses rather than a
       line so it reads as a claim about the grey curve rather than as a fourth method. */
    if (showAverage()) {
      for (let i = 0; i <= steps; i += 1) {
        const mean = (traces[0].heights[i] + traces[1].heights[i]) / 2;
        const q = at(i, mean);
        line(
          ctx,
          { x: q.x - 4, y: q.y - 4 },
          { x: q.x + 4, y: q.y + 4 },
          AVERAGE,
          { width: 1.4 },
        );
        line(
          ctx,
          { x: q.x - 4, y: q.y + 4 },
          { x: q.x + 4, y: q.y - 4 },
          AVERAGE,
          { width: 1.4 },
        );
      }
      label(ctx, "their average", left + 8, top + 14, AVERAGE);
    }

    label(ctx, "grey is the exact parabola", left + 8, top, DIM);

    const apexStep = Math.max(1, Math.round(THROW_TIME * fps()));
    const b = bracketAt(fps(), Math.min(apexStep, steps));
    show(
      `at the apex step: explicit ${b.explicit.toFixed(4)}, exact ${b.exact.toFixed(4)}, semi-implicit ${b.semi.toFixed(4)} \u00b7 ` +
        `midpoint ${b.midpoint.toFixed(4)}`,
    );
    note(
      `the two Euler forms are wrong by the same amount in opposite directions, so their average is exact to ` +
        `${Math.abs(b.averageError) < 1e-12 ? "floating-point dust" : b.averageError.toExponential(1)} \u2014 and the midpoint step is that average, computed directly`,
    );
  }

  draw();

  return () => {};
};

export default mount;

The purple crosses are the average of the orange and blue traces, and they land on the grey curve. That is not approximately true; the build asserts three separate forms of it to floating-point dust across several accelerations and step sizes:

  • the two errors sum to zero
  • their average equals the closed form p0+v0t+12at2p_0 + v_0t + \tfrac{1}{2}at^2
  • the midpoint step is that average, computed directly
p+=(v+12a Δt)Δtp \mathrel{+}= \left(v + \tfrac{1}{2}a\,\Delta t\right)\Delta t

Use the average of the start and end velocities to cross the step, which is what the velocity actually averages when acceleration is constant. For constant acceleration this is not an approximation — it reproduces the closed form exactly at every step, at every step size. Also called velocity Verlet.

One multiply and one add. There is no accuracy-versus-speed trade here to agonise over.

Solve the closed form twice — the apex is where v0+at=0v_0 + at = 0, and the height there is −v02/2a-v_0^2/2a — and you get the two numbers a loop needs from the two a person can picture:

v0=2htupa=−2htup2v_0 = \frac{2h}{t_{\text{up}}} \qquad a = -\frac{2h}{t_{\text{up}}^2}

So “two units high, four tenths of a second to the top” is a launch speed of 1010 and a gravity of −25-25. The round trip is exact, and the build checks it both ways on five different jumps.

A jump you can describe, turned into the numbers a loop needs
The code src/lib/gamedev/demos/2d/airtime.ts
/** Turning a jump you can describe into the two numbers a loop needs, and back again. */
import {
  apexOf,
  clampFallSpeed,
  cutJump,
  dragExactAt,
  jumpFromHeightAndAirtime,
  jumpFromHeightAndTime,
  remainingHeight,
  terminalVelocity,
} from "../../../gamedev2d/physics2d.ts";
import { ASYMMETRIC, asymmetricFor } from "./leap-shared.ts";
import type { Demo } from "../runner.ts";

/* Rounded for display. The arithmetic is exact to within a part in a trillion - the checks assert that - but
   $0.4^2$ is not a binary fraction, so a gravity of exactly -25 prints as -24.999999999999996 in output that
   gets committed to the repository. The dust carries no information. */
const tidy = (n: number, places = 4) => Number(n.toFixed(places));

const demo: Demo = (log) => {
  // The whole point: a description a person can hold, turned into the two constants a loop wants.
  const jump = jumpFromHeightAndTime(2, 0.4);
  log(
    "jumpFromHeightAndTime(2, 0.4)",
    `launch ${tidy(jump.launch)}, gravity ${tidy(jump.gravity)}`,
    "two units high, four tenths of a second to the top - nobody has to pick a gravity",
  );
  const back = apexOf(jump.launch, jump.gravity);
  log(
    "apexOf on those two numbers",
    `height ${tidy(back.height, 9)}, time ${tidy(back.timeToApex, 9)}`,
    "the round trip is exact, so the two descriptions really are the same jump",
  );
  log(
    "the same from total airtime instead",
    `${jumpFromHeightAndAirtime(2, 0.8).launch} equals ${jump.launch}`,
    "half the airtime is the rise, for a symmetric jump",
  );

  // Most platformers are not symmetric, and falling faster is why they feel controllable.
  const a = asymmetricFor();
  log(
    `asymmetricJump(${ASYMMETRIC.height}, up ${ASYMMETRIC.up}, down ${ASYMMETRIC.down})`,
    `rise ${a.riseGravity.toFixed(2)}, fall ${a.fallGravity.toFixed(2)}`,
    `the fall is ${(a.fallGravity / a.riseGravity).toFixed(4)} times stronger, which is (${ASYMMETRIC.up}/${ASYMMETRIC.down})\u00B2`,
  );

  /* Cutting the jump short, and the thing worth noticing: height goes as the square of the velocity, so
     keeping half the speed keeps a quarter of the height. */
  const full = jumpFromHeightAndTime(2.5, 0.5);
  log(
    "cutJump at half the launch speed, then the height left",
    `${tidy(cutJump(full.launch, 0.5))} gives ${tidy(remainingHeight(cutJump(full.launch, 0.5), full.gravity))}`,
    `a quarter of the full ${tidy(remainingHeight(full.launch, full.gravity))}, because height goes as velocity squared`,
  );

  // Terminal velocity, which is Section 4.1's decay wearing different labels.
  log(
    "terminalVelocity(-25, 4)",
    terminalVelocity(-25, 4),
    "gravity over drag: the speed at which they cancel and nothing changes",
  );
  log(
    "how much of it a fall has reached after 0.5 s and after 1 s",
    `${((dragExactAt(0, -25, 4, 0.5) / terminalVelocity(-25, 4)) * 100).toFixed(2)}% then ${((dragExactAt(0, -25, 4, 1) / terminalVelocity(-25, 4)) * 100).toFixed(2)}%`,
    `exponential approach, never arrival - and clampFallSpeed(-40, 12) = ${clampFallSpeed(-40, 12)} is the blunt alternative that never slows a rise`,
  );
};

export default demo;
jumpFromHeightAndTime(2, 0.4) → launch 10, gravity -25 // two units high, four tenths of a second to the top - nobody has to pick a gravity
apexOf on those two numbers → height 2, time 0.4 // the round trip is exact, so the two descriptions really are the same jump
the same from total airtime instead → 10 equals 10 // half the airtime is the rise, for a symmetric jump
asymmetricJump(2.5, up 0.4, down 0.28) → rise -31.25, fall -63.78 // the fall is 2.0408 times stronger, which is (0.4/0.28)²
cutJump at half the launch speed, then the height left → 5 gives 0.625 // a quarter of the full 2.5, because height goes as velocity squared
terminalVelocity(-25, 4) → -6.25 // gravity over drag: the speed at which they cancel and nothing changes
how much of it a fall has reached after 0.5 s and after 1 s → 86.47% then 98.17% // exponential approach, never arrival - and clampFallSpeed(-40, 12) = -12 is the blunt alternative that never slows a rise

This is the whole reason to do the algebra. gravity = -25 is a number nobody can picture; a jump height and a rise time can be tuned by someone who has never opened the physics code.

A jump asked for by height and time, and the arc a loop actually produces
Drop the frame rate to see the gap open up, then switch to the midpoint step.
The code that draws it src/lib/gamedev/demos/2d/leap.scene.ts
/** A jump described by the height and time you want, and the arc a stepped loop actually produces. */
import { makeCanvas2D, dot as fillDot, label, line } from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addButtonRow, addReadout, addSlider } from "../controls.ts";
import {
  APEX_TIME_RANGE,
  FPS_RANGE,
  GROUND,
  HEIGHT_RANGE,
  INTEGRATORS,
  LAUNCH_X,
  VIEW,
  arcFor,
  exactArc,
  predictedDrift,
  requested,
  screenOf,
  type Integrator,
} from "./leap-shared.ts";
import type { MountFn } from "../runner.ts";

const GROUND_COLOUR = "#3b4552";
const EXACT = "#7d8590";
const ASKED = "#7ee787";
const STEPPED = "#58a6ff";
const SHORT = "#ff7b72";
const OVER = "#f0883e";
const DIM = "#636c76";

const mount: MountFn = (el) => {
  const { ctx, clear } = makeCanvas2D(el, VIEW.height);

  let which: Integrator = "semi-implicit";

  const show = addReadout(el);
  const note = addReadout(el);
  const height = addSlider(
    el,
    "how high the jump should be",
    HEIGHT_RANGE.min,
    HEIGHT_RANGE.max,
    2,
    draw,
    " units",
    HEIGHT_RANGE.step,
  );
  const apexTime = addSlider(
    el,
    "how long to reach the top",
    APEX_TIME_RANGE.min,
    APEX_TIME_RANGE.max,
    0.4,
    draw,
    " s",
    APEX_TIME_RANGE.step,
  );
  const fps = addSlider(
    el,
    "frames per second",
    FPS_RANGE.min,
    FPS_RANGE.max,
    60,
    draw,
    "",
    FPS_RANGE.step,
  );
  const setActive = addButtonRow(
    el,
    INTEGRATORS.map((name, index) => ({
      label: name,
      apply: () => {
        which = name;
        setActive(index);
        draw();
      },
    })),
  );
  setActive(1);

  function draw() {
    clear();
    const asked = height();
    const arc = arcFor(asked, apexTime(), fps(), which);
    const reached = arc.peak - GROUND;
    const off = reached - asked;
    const colour = Math.abs(off) < 1e-6 ? ASKED : off < 0 ? SHORT : OVER;

    // The ground, and the height that was asked for, so the gap between them is the subject.
    const groundY = screenOf({ x: 0, y: GROUND }).y;
    line(
      ctx,
      { x: 0, y: groundY },
      { x: VIEW.width, y: groundY },
      GROUND_COLOUR,
      {
        width: 2,
      },
    );
    const askedY = screenOf({ x: 0, y: GROUND + asked }).y;
    line(ctx, { x: 0, y: askedY }, { x: VIEW.width, y: askedY }, ASKED, {
      width: 1,
      dashed: true,
    });
    label(ctx, `asked for ${asked.toFixed(1)}`, 12, askedY - 6, ASKED);

    // The true parabola, behind everything, as the thing the arithmetic promised.
    ctx.save();
    ctx.strokeStyle = EXACT;
    ctx.lineWidth = 1.6;
    ctx.beginPath();
    exactArc(asked, apexTime()).forEach((p, i) => {
      const q = screenOf(p);
      if (i === 0) ctx.moveTo(q.x, q.y);
      else ctx.lineTo(q.x, q.y);
    });
    ctx.stroke();
    ctx.restore();

    // The stepped arc, one dot per frame, so the timestep is visible rather than implied.
    ctx.save();
    ctx.strokeStyle = STEPPED;
    ctx.lineWidth = 2;
    ctx.beginPath();
    arc.points.forEach((p, i) => {
      const q = screenOf(p);
      if (i === 0) ctx.moveTo(q.x, q.y);
      else ctx.lineTo(q.x, q.y);
    });
    ctx.stroke();
    ctx.restore();
    for (const p of arc.points) {
      const q = screenOf(p);
      fillDot(ctx, q.x, q.y, 2.6, STEPPED);
    }

    // Where it actually peaked, and the gap to where it should have.
    const peakPoint = arc.points.reduce(
      (best, p) => (p.y > best.y ? p : best),
      arc.points[0],
    );
    const peakScreen = screenOf(peakPoint);
    fillDot(ctx, peakScreen.x, peakScreen.y, 5, colour);
    if (Math.abs(off) > 1e-6) {
      line(
        ctx,
        { x: peakScreen.x, y: peakScreen.y },
        { x: peakScreen.x, y: askedY },
        colour,
        { width: 1.5 },
      );
      label(
        ctx,
        `${off < 0 ? "short" : "over"} by ${Math.abs(off).toFixed(3)}`,
        peakScreen.x + 10,
        (peakScreen.y + askedY) / 2,
        colour,
      );
    }

    const launchScreen = screenOf({ x: LAUNCH_X, y: GROUND });
    fillDot(ctx, launchScreen.x, launchScreen.y, 4, DIM);
    label(
      ctx,
      "grey is the true parabola, blue is the stepped loop",
      12,
      18,
      DIM,
    );

    const r = requested(asked, apexTime());
    show(
      `${asked.toFixed(1)} units in ${apexTime().toFixed(2)} s means launch ${r.launch.toFixed(2)} and gravity ${r.gravity.toFixed(2)} \u00b7 ` +
        `${which} at ${fps()} fps reached ${reached.toFixed(3)}`,
    );
    note(
      which === "midpoint"
        ? "exact at every frame rate, for one extra multiply and one add \u2014 the arc and the parabola are the same line"
        : `predicted drift over the rise is ${predictedDrift(asked, apexTime(), fps(), which).toFixed(3)}, which is what the gap measures \u2014 ` +
            `halve the timestep and it halves`,
    );
  }

  draw();

  return () => {};
};

export default mount;

Ask for two units at 60 fps with semi-implicit Euler and you get 1.9167 — short by 0.08330.0833, which is 4.17% of the jump. The drift formula predicts that number before the arc is stepped. Explicit Euler overshoots by exactly the same amount. The midpoint step lands on 22.

Drop the frame rate in the scene and watch the gap widen: at 10 fps that same jump reaches 1.51.5, a quarter short.

Gravity with linear drag:

dvdt=a−kvv∞=ak\frac{dv}{dt} = a - kv \qquad v_\infty = \frac{a}{k}

The speed at which drag exactly cancels gravity. With a gravity of −25-25 and a drag of 44, terminal velocity is −6.25-6.25, and the approach to it is

v(t)=v∞+(v0−v∞)e−ktv(t) = v_\infty + (v_0 - v_\infty)e^{-kt}

which is decay from Section 4.1 with different labels on the variables. Same equation, same frame-rate independence, same exponential approach that never quite arrives. Noticing that the two are the same thing is worth more than either alone.

Two Things Platformers Do That Physics Does Not

Section titled “Two Things Platformers Do That Physics Does Not”

Different gravity going up and coming down. Each half is its own parabola through the same apex, so each half gets the same formula with its own time. Rising in 0.40.4 s and falling in 0.280.28 s makes the fall 2.04082.0408 times stronger — which is (0.4/0.28)2(0.4/0.28)^2, and knowing that the ratio is a square is what makes tuning it by feel less mysterious. Completely unphysical, and it is what makes a jump feel controllable.

Cutting the jump short on release. Scale the upward velocity down. The thing to know is that height goes as the square of the velocity, so keeping half the speed keeps a quarter of the height — which is why a tapped jump feels so much shorter than expected. And guard it: applied while falling, it would slow the fall, which reads as the character snagging on something.

source Three integrators, the drift formula, and a jump solved backwards src/lib/gamedev2d/physics2d.ts 284 lines
/**
 * Position, velocity and acceleration - **without calculus**, because a game loop does not use any.
 *
 * A physics step is two lines of arithmetic. The interesting part is that the *order* of those two lines
 * changes the answer, that both orders are wrong, and that they are wrong by exactly the same amount in
 * opposite directions - so their average is exact. That last fact is free, and it is the reason a jump can
 * land on the height you asked for instead of near it.
 *
 * The second half is the parameterisation. Nobody wants to choose a gravity constant; they want a jump two
 * units high that takes four tenths of a second to reach the top. Those are the same thing, and inverting
 * the relationship is the same move Section 4.1 made when it exposed a half-life instead of a decay rate.
 */
import { length, normalize } from "./length2d.ts";
import {
  combine,
  movedBy,
  scaled,
  type Point,
  type Vector,
} from "./vectors2d.ts";

export type Body = {
  position: Point;
  velocity: Vector;
};

/**
 * **Explicit Euler**: move by the old velocity, then update it.
 *
 * ```js
 * p += v * dt;
 * v += a * dt;
 * ```
 *
 * The order most people write first, because it reads in the order the words come. It uses the velocity
 * from the *start* of the step to cross the whole step, so under gravity it consistently travels too far
 * upward - a jump that goes higher than the arithmetic says it should.
 */
export function stepExplicit(body: Body, a: Vector, dt: number): Body {
  return {
    position: movedBy(body.position, scaled(body.velocity, dt)),
    velocity: combine(body.velocity, scaled(a, dt)),
  };
}

/**
 * **Semi-implicit Euler**: update the velocity first, then move by the new one.
 *
 * ```js
 * v += a * dt;
 * p += v * dt;
 * ```
 *
 * Swapping two lines, and it is what nearly every game engine actually does. It is more stable than the
 * explicit form for springs and damping, which is the usual reason given. For constant acceleration it is
 * wrong by exactly as much as the explicit form and in the opposite direction: it uses the *end* velocity
 * for the whole step, so a jump comes out **short**.
 */
export function stepSemiImplicit(body: Body, a: Vector, dt: number): Body {
  const velocity = combine(body.velocity, scaled(a, dt));
  return { position: movedBy(body.position, scaled(velocity, dt)), velocity };
}

/**
 * **The midpoint step**, which for constant acceleration is not an approximation at all.
 *
 * ```js
 * p += (v + 0.5 * a * dt) * dt;
 * v += a * dt;
 * ```
 *
 * Use the *average* of the start and end velocities to cross the step, which is what the velocity actually
 * averages when acceleration is constant. The extra term is one multiply and one add.
 *
 * The build shows this reproduces $p_0 + v_0t + \tfrac{1}{2}at^2$ **exactly**, to floating-point dust, at
 * every step - and that it is precisely the average of the two Euler forms above, because their errors are
 * equal and opposite. Also called velocity Verlet, and for constant acceleration the two are identical.
 */
export function stepMidpoint(body: Body, a: Vector, dt: number): Body {
  return {
    position: movedBy(
      body.position,
      scaled(combine(body.velocity, scaled(a, dt / 2)), dt),
    ),
    velocity: combine(body.velocity, scaled(a, dt)),
  };
}

export type Integrator = "explicit" | "semi-implicit" | "midpoint";

export const INTEGRATORS: readonly Integrator[] = [
  "explicit",
  "semi-implicit",
  "midpoint",
];

export function step(
  body: Body,
  a: Vector,
  dt: number,
  which: Integrator,
): Body {
  if (which === "explicit") return stepExplicit(body, a, dt);
  if (which === "semi-implicit") return stepSemiImplicit(body, a, dt);
  return stepMidpoint(body, a, dt);
}

/**
 * Where a body with constant acceleration really is, in closed form.
 *
 * $$p(t) = p_0 + v_0 t + \tfrac{1}{2}at^2$$
 *
 * Not what a game uses - forces change, and a closed form cannot cope with a wall appearing halfway
 * through - but it is the thing to measure the stepped versions against.
 */
export function exactAt(body: Body, a: Vector, t: number): Point {
  return movedBy(
    body.position,
    combine(scaled(body.velocity, t), scaled(a, 0.5 * t * t)),
  );
}

/**
 * How far a stepped integrator drifts from the truth after `n` steps, in closed form.
 *
 * $$\text{error} = \tfrac{1}{2}\,a\,\Delta t^2\,n = \tfrac{1}{2}\,a\,\Delta t\,t$$
 *
 * Positive for semi-implicit, negative for explicit, and zero for the midpoint form. Worth stating as a
 * formula rather than a warning, because it says two useful things at once: the error grows **linearly in
 * time**, not quadratically, and it shrinks in proportion to the step size. Halve the timestep and you
 * halve the drift.
 */
export function driftAfter(
  a: number,
  dt: number,
  steps: number,
  which: Integrator,
): number {
  if (which === "midpoint") return 0;
  const size = 0.5 * a * dt * dt * steps;
  return which === "semi-implicit" ? size : -size;
}

// ---- Gravity as something a designer can ask for ---------------------------------------------

/**
 * The gravity and launch speed that give a jump of a chosen height in a chosen time to the top.
 *
 * $$v_0 = \frac{2h}{t_{\text{up}}} \qquad a = -\frac{2h}{t_{\text{up}}^2}$$
 *
 * This is the whole reason to do the algebra. "Gravity is -25 and the jump velocity is 10" is a pair of
 * numbers nobody can picture; "two units high, four tenths of a second to the top" is a description of a
 * jump. Same jump, and the second one can be tuned by someone who has never opened the physics code.
 *
 * Derived by solving the closed form twice: the apex is where $v_0 + at = 0$, so $t_{\text{up}} = -v_0/a$,
 * and the height there is $-v_0^2/(2a)$. Two equations, two unknowns.
 */
export function jumpFromHeightAndTime(
  height: number,
  timeToApex: number,
): { launch: number; gravity: number } {
  return {
    launch: (2 * height) / timeToApex,
    gravity: (-2 * height) / (timeToApex * timeToApex),
  };
}

/** The same, given the **total** airtime of a symmetric jump. Half of it is the rise. */
export function jumpFromHeightAndAirtime(
  height: number,
  airtime: number,
): { launch: number; gravity: number } {
  return jumpFromHeightAndTime(height, airtime / 2);
}

/** And forward again, so the round trip can be checked rather than trusted. */
export function apexOf(
  launch: number,
  gravity: number,
): { height: number; timeToApex: number } {
  const timeToApex = -launch / gravity;
  return {
    height: (-launch * launch) / (2 * gravity),
    timeToApex,
  };
}

/**
 * A jump that rises and falls at **different** rates, which is what most platformers actually use.
 *
 * Falling faster than you rose makes a jump feel snappy and controllable, and it is not physical in the
 * slightest. Each half is its own parabola, sharing the apex, so each half gets its own gravity from the
 * same formula.
 */
export function asymmetricJump(
  height: number,
  timeUp: number,
  timeDown: number,
): { launch: number; riseGravity: number; fallGravity: number } {
  return {
    launch: (2 * height) / timeUp,
    riseGravity: (-2 * height) / (timeUp * timeUp),
    fallGravity: (-2 * height) / (timeDown * timeDown),
  };
}

/**
 * Cutting a jump short when the button is released: **scale the upward velocity down.**
 *
 * The cheap, standard variable-height jump. Multiplying by a fraction rather than zeroing it keeps a little
 * of the arc, which reads better than stopping dead. The guard matters: applied while falling it would
 * *slow the fall*, which feels like the character catching on something.
 */
export function cutJump(velocityY: number, keep: number): number {
  return velocityY > 0 ? velocityY * keep : velocityY;
}

/** How high a jump cut at the moment of release still reaches, from that instant. */
export function remainingHeight(velocityY: number, gravity: number): number {
  return velocityY <= 0 ? 0 : (velocityY * velocityY) / (-2 * gravity);
}

// ---- Terminal velocity -----------------------------------------------------------------------

/**
 * Gravity with linear drag, which is where terminal velocity comes from.
 *
 * $$\frac{dv}{dt} = a - k v \quad\Longrightarrow\quad v_\infty = \frac{a}{k}$$
 *
 * The speed at which drag exactly cancels gravity, so the acceleration is zero and nothing changes. Note
 * what shape the approach has: the gap between the current speed and the terminal speed decays
 * exponentially, which is **Section 4.1's decay** turning up in a completely different setting - and it is
 * frame-rate independent for the same reason.
 */
export function terminalVelocity(gravity: number, drag: number): number {
  return drag <= 0 ? Infinity : gravity / drag;
}

/** One step of gravity with drag. Semi-implicit, because that is what the stability argument is for. */
export function stepWithDrag(
  velocityY: number,
  gravity: number,
  drag: number,
  dt: number,
): number {
  return velocityY + (gravity - drag * velocityY) * dt;
}

/**
 * The exact speed after falling for a while with drag, for the stepped version to be checked against.
 *
 * $$v(t) = v_\infty + (v_0 - v_\infty)e^{-kt}$$
 *
 * Which is `decay` from Section 4.1 with a different name on the variables. Seeing that the two are the
 * same equation is worth more than either of them alone.
 */
export function dragExactAt(
  v0: number,
  gravity: number,
  drag: number,
  t: number,
): number {
  const terminal = terminalVelocity(gravity, drag);
  return terminal + (v0 - terminal) * Math.exp(-drag * t);
}

/**
 * The simpler alternative: no drag, just refuse to fall faster than a limit.
 *
 * Not physical, and often the right choice anyway - it is one comparison, it gives an exact maximum fall
 * speed you can design a level around, and it never quietly changes how far a jump goes. Drag does: it
 * slows the rise as well, so a jump tuned without it comes out short once it is added.
 */
export function clampFallSpeed(velocityY: number, limit: number): number {
  return Math.max(velocityY, -Math.abs(limit));
}

/** A whole velocity clamped in speed, for anything that should have a maximum regardless of direction. */
export function clampSpeed(v: Vector, limit: number): Vector {
  const speed = length(v);
  if (speed <= limit) return v;
  const unit = normalize(v);
  return unit === null ? { x: 0, y: 0 } : scaled(unit, limit);
}
  • The capstone, where the character’s jump is tuned by height and airtime and integrated on a fixed step.
  • Section 5.4’s tunnelling, which is this Section’s velocity meeting that Section’s thin wall.
  • Section 4.1’s delta time and decay, which terminal velocity turns out to be.
  • Section 4.3’s arc, which was a Bezier where this one is a parabola — and a parabola is a quadratic Bezier, which is worth noticing.
  • Anything thrown, dropped, or launched.