Skip to content

Velocity, Acceleration and Forces

The three quantities everything moves by — position, velocity, acceleration — and how each one gets to the next. Gravity and drag, where drag turns out to be Section 4.1’s exponential decay pointed at a velocity. Impulses against continuous forces, which differ only in how long they last and yet feel completely different. And the technique this Section exists for: solving a jump backwards, from the height and the timing you want to the gravity that produces them.

acceleration  ×dt  velocity  ×dt  position\text{acceleration} \xrightarrow{\;\times\, dt\;} \text{velocity} \xrightarrow{\;\times\, dt\;} \text{position}

Acceleration changes velocity. Velocity changes position. Both by multiplying by dtdt, which Section 4.1 established is correct for a rate, and which is why velocity * dt was the one thing that page said you should write.

Everything a game does to a moving object is a rule for the acceleration. Gravity is a constant one. Drag is one that depends on the current velocity. A thruster is one that switches on and off. The two multiplications never change.

If you have met calculus, velocity is the derivative of position and acceleration the derivative of velocity, and none of the rest of this page needs that.

Earth’s gravity is 9.81 m/s29.81\ \text{m/s}^2, and putting that number in a platformer produces a jump that feels like the moon. The reason is worth seeing rather than being told.

A designer does not have an opinion about gravity. They have two other opinions, both concrete:

  • How high? High enough to reach that ledge.
  • How long? Snappy, not floaty.

Those are a height and a duration. So take them as the inputs and let gravity be the output. At the apex the rise has been cancelled, and the two standard formulas invert in one line each:

g=2ht2v0=2htg = \frac{2h}{t^2} \qquad\qquad v_0 = \frac{2h}{t}
A jump asked for as a height and a duration, with the gravity that gives it
The code that draws it src/lib/gamedev/demos/jump.scene.ts
/** A jump described as a height and a duration, with the gravity that produces it. */
import * as THREE from "three";
import {
  FORWARD,
  FPS,
  analyticArc,
  derived,
  steppedApex,
  steppedArc,
} from "./jump-shared.ts";
import { makeCanvas, addSlider, addReadout, addPolyline } from "./ui.ts";
import type { MountFn } from "./runner.ts";

const EXACT = 0x39d3c3;
const STEPPED = 0xf0883e;
const DIM = 0x484f58;

const mount: MountFn = (el) => {
  const {
    renderer,
    width,
    height: canvasHeight,
    background,
  } = makeCanvas(el, 300);

  const scene = new THREE.Scene();
  scene.background = background;
  const aspect = width / canvasHeight;
  const halfHeight = 2.3;
  const camera = new THREE.OrthographicCamera(
    -halfHeight * aspect,
    halfHeight * aspect,
    halfHeight,
    -halfHeight,
    0.1,
    100,
  );
  camera.position.set(halfHeight * aspect - 0.6, 1.5, 10);

  const ground = addPolyline(scene, 0x8b949e);
  ground([new THREE.Vector3(-1, 0, 0), new THREE.Vector3(20, 0, 0)]);
  const target = addPolyline(scene, DIM, {
    dashed: true,
    dashSize: 0.14,
    gapSize: 0.12,
  });
  const exactArc = addPolyline(scene, EXACT);
  const steppedLine = addPolyline(scene, STEPPED);

  const apexDot = new THREE.Mesh(
    new THREE.SphereGeometry(0.07, 12, 9),
    new THREE.MeshBasicMaterial({ color: EXACT }),
  );
  scene.add(apexDot);

  const derivedOut = addReadout(el);
  const steppedOut = addReadout(el);
  const wantHeight = addSlider(el, "how high", 0.4, 3, 1.2, draw, " m", 0.1);
  const wantTime = addSlider(
    el,
    "how long to get there",
    0.15,
    0.9,
    0.4,
    draw,
    " s",
    0.05,
  );
  const fall = addSlider(el, "fall this much faster", 1, 3, 1, draw, "x", 0.1);

  function draw() {
    const h = wantHeight();
    const t = wantTime();
    const m = fall();
    const d = derived(h, t, m);

    target([new THREE.Vector3(-1, h, 0), new THREE.Vector3(20, h, 0)]);
    exactArc(analyticArc(h, t, m).map((p) => new THREE.Vector3(p.x, p.y, 0)));
    steppedLine(steppedArc(h, t, m).map((p) => new THREE.Vector3(p.x, p.y, 0)));
    apexDot.position.set(t * FORWARD, h, 0);

    const reached = steppedApex(h, t, m);
    const short = ((h - reached) / h) * 100;

    derivedOut(
      `gravity ${d.gravity.toFixed(1)} m/s\u00B2 up` +
        (m > 1.001 ? `, ${d.fallGravity.toFixed(1)} down` : "") +
        ` \u00B7 launch at ${d.launchSpeed.toFixed(2)} m/s \u00B7 in the air ${d.total.toFixed(2)} s`,
    );
    steppedOut(
      `stepped at ${FPS} fps it only reaches ${reached.toFixed(2)} m, ${short.toFixed(1)}% short of the ${h.toFixed(2)} m asked for`,
    );
    renderer.render(scene, camera);
  }

  draw();

  return () => renderer.dispose();
};

export default mount;

The dashed line is the height asked for, teal is the exact arc, and the readout gives the gravity and launch speed that produce it. Drag the sliders and watch the derived numbers move.

Notice the square. Halving the time to apex quadruples the gravity while only doubling the launch speed. A 1.2 m jump reaching its peak in 0.4 s needs g=15g = 15; ask for 0.2 s and it needs 60. That is the whole reason 9.81 feels wrong — under Earth gravity, a 1.2 m jump takes 0.49 s just to get to the top, and half a second of rise reads as floating.

The build check round-trips every combination it tries: derive gg and v0v_0 from a height and a time, then run the ordinary forwards formulas and require the same height and time back. The two directions share no code, so agreeing to 101210^{-12} means the algebra is right rather than merely consistent with itself.

The third slider is not physics. Multiply gravity once the velocity turns over and the character falls faster than it rose.

Almost every platformer does this. The rise is what the player asked for and wants to watch; the fall is dead time. A multiplier of 2 cuts a 0.80 s jump to 0.68 s without losing any height, and it reads as responsiveness rather than as a cheat. Four times the gravity halves the fall exactly, which the check pins.

Both change velocity. They differ in how long they take, and that turns out to matter enormously.

  • An impulse changes the velocity now. A jump, a bullet hit, an explosion.
  • A force changes it while it is applied. A thruster, a conveyor, a wind zone.
Δv=JmΔv=Fmdt\Delta v = \frac{J}{m} \qquad\qquad \Delta v = \frac{F}{m}\,dt

Divide by mass in both, because the same shove moves a heavy thing less.

The same push, given all at once or spread over a moment
The code src/lib/gamedev/demos/impulse.ts
/** The same change in velocity, delivered instantly or spread over a tenth of a second. */
import {
  jumpFromHeightAndTime,
  riseFromImpulse,
  riseFromSteadyForce,
} from "../dynamics.ts";
import type { Demo } from "./runner.ts";

const HEIGHT = 1.2;
const TIME_UP = 0.4;
const PUSH = 0.1;

const demo: Demo = (log) => {
  const { gravity, launchSpeed } = jumpFromHeightAndTime(HEIGHT, TIME_UP);
  log(
    `a ${HEIGHT} m jump in ${TIME_UP} s needs`,
    `${launchSpeed.toFixed(2)} m/s, against gravity ${gravity.toFixed(1)}`,
    "the impulse is applied all at once",
  );

  for (const t of [0.025, 0.05, PUSH]) {
    const instant = riseFromImpulse(t, launchSpeed, gravity);
    const spread = riseFromSteadyForce(t, launchSpeed, PUSH);
    log(
      `height after ${t} s`,
      `${instant.toFixed(3)} m as an impulse, ${spread.toFixed(3)} m as a force`,
      t === PUSH ? "both are now doing 6 m/s, but one is far lower" : undefined,
    );
  }

  const behind =
    riseFromImpulse(PUSH, launchSpeed, gravity) -
    riseFromSteadyForce(PUSH, launchSpeed, PUSH);
  log(
    "so a force spread over 0.1 s ends up",
    `${behind.toFixed(3)} m behind`,
    "which is why a jump is an impulse and a thruster is not",
  );
};

export default demo;
a 1.2 m jump in 0.4 s needs 6.00 m/s, against gravity 15.0 // the impulse is applied all at once
height after 0.025 s 0.145 m as an impulse, 0.019 m as a force
height after 0.05 s 0.281 m as an impulse, 0.075 m as a force
height after 0.1 s 0.525 m as an impulse, 0.300 m as a force // both are now doing 6 m/s, but one is far lower
so a force spread over 0.1 s ends up 0.225 m behind // which is why a jump is an impulse and a thruster is not

Here is the part that is easy to miss. The force is chosen to deliver exactly the same total change in velocity, over a tenth of a second. At the moment it finishes, both are travelling at the same 6 m/s — and the force-driven one is 0.225 m lower, because it spent that tenth of a second getting up to speed from nothing.

Same final velocity, less distance covered. Which is why a jump has to be an impulse: spread it out even slightly and the jump loses its snap, because the character is still accelerating when it should already be rising. The check confirms the two converge as the push gets shorter, which is the sense in which an impulse is a force over no time at all.

Drag is a force that depends on how fast you are already going, which makes it a decay:

v=ekdtv \mathrel{{*}{=}} e^{-k\,dt}

That is Section 4.1’s exponential decay, applied to velocity instead of position, and for the same reason: multiplying by a constant each frame would make drag depend on the frame rate. The check runs one second of drag at 1, 30, 60, 144 and 1000 fps and requires the results to agree to 101210^{-12}.

Fall long enough and drag exactly cancels gravity. The speed stops changing:

gkv=0    vterminal=gk-g - k\,v = 0 \;\Longrightarrow\; v_{\text{terminal}} = \frac{g}{k}
The same shot through vacuum and through air
The code that draws it src/lib/gamedev/demos/drag.scene.ts
/** The same shot with and without drag, and the lopsided arc drag produces. */
import * as THREE from "three";
import { EARTH_GRAVITY, terminalSpeed } from "../dynamics.ts";
import {
  arc,
  peakDistanceFraction,
  peakTimeFraction,
  rangeOf,
} from "./drag-shared.ts";
import { makeCanvas, addSlider, addReadout, addPolyline } from "./ui.ts";
import type { MountFn } from "./runner.ts";

const WITH_DRAG = 0x39d3c3;
const VACUUM = 0x8b949e;

const mount: MountFn = (el) => {
  const { renderer, width, height, background } = makeCanvas(el, 270);

  const scene = new THREE.Scene();
  scene.background = background;
  const aspect = width / height;
  const halfWidth = 8.4;
  const camera = new THREE.OrthographicCamera(
    -halfWidth,
    halfWidth,
    halfWidth / aspect,
    -halfWidth / aspect,
    0.1,
    100,
  );
  camera.position.set(halfWidth - 0.8, halfWidth / aspect - 0.5, 10);

  const ground = addPolyline(scene, VACUUM);
  ground([new THREE.Vector3(-1, 0, 0), new THREE.Vector3(30, 0, 0)]);
  const vacuumArc = addPolyline(scene, VACUUM, {
    dashed: true,
    dashSize: 0.3,
    gapSize: 0.25,
  });
  const draggedArc = addPolyline(scene, WITH_DRAG);

  const landed = new THREE.Mesh(
    new THREE.SphereGeometry(0.16, 12, 9),
    new THREE.MeshBasicMaterial({ color: WITH_DRAG }),
  );
  scene.add(landed);

  const show = addReadout(el);
  const shape = addReadout(el);
  const angle = addSlider(el, "launch angle", 15, 75, 45, draw);
  const drag = addSlider(
    el,
    "how thick the air is",
    0,
    1.5,
    0.4,
    draw,
    "",
    0.05,
  );

  function draw() {
    const a = angle();
    const k = drag();

    vacuumArc(arc(a, 0).map((p) => new THREE.Vector3(p.x, p.y, 0)));
    draggedArc(arc(a, k).map((p) => new THREE.Vector3(p.x, p.y, 0)));

    const clean = rangeOf(a, 0);
    const dirty = rangeOf(a, k);
    landed.position.set(dirty, 0, 0);

    show(
      `range ${clean.toFixed(1)} m through vacuum, ${dirty.toFixed(1)} m through air \u00B7 ` +
        `${(((clean - dirty) / clean) * 100).toFixed(0)}% shorter`,
    );
    shape(
      k < 0.001
        ? `no drag: the peak sits halfway through the flight and halfway along it, and the arc is symmetric`
        : `the peak comes at ${(peakTimeFraction(a, k) * 100).toFixed(0)}% of the flight time ` +
            `but ${(peakDistanceFraction(a, k) * 100).toFixed(0)}% of the distance, so it drops almost straight down \u00B7 ` +
            `drag pulls this shot down to ${terminalSpeed(EARTH_GRAVITY, k).toFixed(1)} m/s if it falls long enough`,
    );
    renderer.render(scene, camera);
  }

  draw();

  return () => renderer.dispose();
};

export default mount;

Dashed grey is the shot through vacuum, teal is the same shot through air.

Drag shortens the range, obviously. It also makes the arc lopsided, and the way it does that is worth reading carefully, because the two obvious measurements disagree.

In vacuum the peak sits at halfway by both clocks: halfway through the flight time, and halfway along the ground. With drag they come apart, and in opposite directions:

Where is the peak?VacuumWith drag
through the flight time50%early
along the distance50%late

At 66° with heavy air, the peak arrives at 40% of the flight time but 69% of the distance. The climb is fast and covers ground; then drag has taken the horizontal speed away and the thing drops almost straight down, slowly. Most of the flight is spent falling, and almost none of the forward travel happens during it.

That contradiction is the “heavy” feeling. And the readout gives both numbers on purpose: quoting only the time fraction next to a picture looks like a bug, because your eye measures distance and the clock does not.

There is also a hard ceiling. Horizontal motion under linear drag is pure decay, so the shot can never travel further than vx0/kv_{x0}/k however you aim it — 3.25 m for the numbers above, which is why it lands at 2.96 m and looks like it ran out of road. The check pins all of it: both fractions at 50% in vacuum, opposite sides of 50% with any drag, both moving further out as drag rises, and the range under the ceiling.

It also breaks a rule everyone learns at school. 45° is the farthest angle only in vacuum. With drag it is not, which is why artillery aims lower than you would expect. The check confirms 45° wins with no drag and that range falls monotonically as drag rises.

source Motion, forces, drag, and the backwards jump solve src/lib/gamedev/dynamics.ts 231 lines
/**
 * Position, velocity, acceleration - and the trick of solving them backwards.
 *
 * The forwards direction is the one every physics course teaches: pick a gravity, pick a launch
 * speed, see how high the jump goes. It is the wrong direction for a game. A designer does not
 * have an opinion about gravity; they have an opinion about **reaching that ledge** and about the
 * jump feeling snappy rather than floaty. Those are a height and a duration.
 *
 * So invert it. Take the height and the time as the inputs and let gravity fall out. Two lines of
 * algebra, and it turns tuning from guesswork into typing in the answer.
 *
 * Drag here is Section 4.1's exponential decay pointed at a velocity instead of a position. Same
 * function, same reason: a per-frame multiplier is frame-rate dependent, and `exp(-k·dt)` is not.
 */
import type { Vec3 } from "./matrices.ts";
import { decayFactor } from "./interpolation.ts";

/** Earth, for reference. Games rarely use it: it feels floaty at human scale. */
export const EARTH_GRAVITY = 9.81;

/** Where something is and how fast it is going. Everything else is a rule for changing these. */
export type Body = { position: Vec3; velocity: Vec3 };

const add = (a: Vec3, b: Vec3): Vec3 => ({
  x: a.x + b.x,
  y: a.y + b.y,
  z: a.z + b.z,
});
const mul = (a: Vec3, k: number): Vec3 => ({
  x: a.x * k,
  y: a.y * k,
  z: a.z * k,
});

// ---- The forwards direction ---------------------------------------------------------------

/** Height above the launch point at time `t`, ignoring drag. The parabola. */
export function heightAt(
  t: number,
  launchSpeed: number,
  gravity: number,
): number {
  return launchSpeed * t - 0.5 * gravity * t * t;
}

/** How long until the rise stops. Velocity reaches zero when `g·t` has cancelled `v₀`. */
export function timeToApex(launchSpeed: number, gravity: number): number {
  return gravity <= 0 ? Infinity : launchSpeed / gravity;
}

/** The top of the arc: substitute the apex time back into the parabola. */
export function apexHeight(launchSpeed: number, gravity: number): number {
  return gravity <= 0 ? Infinity : (launchSpeed * launchSpeed) / (2 * gravity);
}

// ---- The backwards direction, which is the one worth having --------------------------------

/**
 * The gravity that makes a jump of `height` take `timeToApex` seconds to get there.
 *
 * From $h = \tfrac{1}{2} g t^2$ at the apex:
 *
 * $$g = \frac{2h}{t^2}$$
 *
 * Note the **square**. Halving the time to apex quadruples the gravity, which is why a snappy
 * jump needs a gravity nothing like Earth's - and why copying 9.81 into a platformer always feels
 * like the moon.
 */
export function gravityFor(height: number, timeToApex: number): number {
  return (2 * height) / (timeToApex * timeToApex);
}

/**
 * The launch speed for that same pair.
 *
 * $$v_0 = \frac{2h}{t}$$
 *
 * Which is just "twice the average speed on the way up", because the rise decelerates linearly
 * from $v_0$ to zero.
 */
export function launchSpeedFor(height: number, timeToApex: number): number {
  return (2 * height) / timeToApex;
}

/** Both numbers a jump needs, from the two a designer actually has an opinion about. */
export function jumpFromHeightAndTime(
  height: number,
  time: number,
): { gravity: number; launchSpeed: number } {
  return {
    gravity: gravityFor(height, time),
    launchSpeed: launchSpeedFor(height, time),
  };
}

/** The other pairing: gravity is fixed by the rest of the game, so solve for the rest. */
export function jumpFromHeightAndGravity(
  height: number,
  gravity: number,
): { launchSpeed: number; timeToApex: number } {
  const launchSpeed = Math.sqrt(2 * gravity * height);
  return { launchSpeed, timeToApex: launchSpeed / gravity };
}

/**
 * How long a jump lasts, with a heavier gravity on the way down.
 *
 * Falling faster than you rose is not physics, it is a **feel** trick, and almost every platformer
 * uses it. The rise is what the player asked for and wants to watch; the fall is dead time. So
 * multiply gravity once the velocity turns over and the jump reads as responsive without losing
 * any height.
 */
export function airTime(
  height: number,
  timeUp: number,
  fallMultiplier = 1,
): { up: number; down: number; total: number } {
  const down = timeUp / Math.sqrt(fallMultiplier);
  return { up: timeUp, down, total: timeUp + down };
}

/** Height above the launch point at `t`, with the fall accelerated after the apex. */
export function heightWithFallMultiplier(
  t: number,
  height: number,
  timeUp: number,
  fallMultiplier: number,
): number {
  const gravity = gravityFor(height, timeUp);
  if (t <= timeUp) return heightAt(t, launchSpeedFor(height, timeUp), gravity);
  const falling = t - timeUp;
  return height - 0.5 * gravity * fallMultiplier * falling * falling;
}

// ---- Forces and impulses ------------------------------------------------------------------

/**
 * An **impulse**: change the velocity right now.
 *
 * This is what a jump is, what a bullet hit is, what an explosion is. Divide by mass because the
 * impulse is a change in momentum, and heavier things move less for the same shove.
 */
export function applyImpulse(body: Body, impulse: Vec3, mass = 1): Body {
  return {
    position: body.position,
    velocity: add(body.velocity, mul(impulse, 1 / mass)),
  };
}

/**
 * A **continuous force**: change the velocity a bit, for as long as it is applied.
 *
 * The difference from an impulse is entirely about time. A force of 75 N for a tenth of a second
 * ends at the same velocity as the equivalent impulse - but it spent that tenth of a second
 * getting there, so it has travelled less far. Rockets and thrusters are forces; jumps are not.
 */
export function applyForce(
  body: Body,
  force: Vec3,
  mass: number,
  dt: number,
): Body {
  const acceleration = mul(force, dt / mass);
  return {
    position: body.position,
    velocity: add(body.velocity, acceleration),
  };
}

/** How high an impulse-driven jump is after `t`, against a force spread over `duration`. */
export function riseFromImpulse(
  t: number,
  launchSpeed: number,
  gravity: number,
): number {
  return heightAt(t, launchSpeed, gravity);
}

/**
 * The same total velocity change, delivered as a steady force over `duration` instead.
 *
 * Only valid up to `duration`, which is the interesting part: at that moment both are travelling
 * at the same speed and the force-driven one is lower, because it started from nothing.
 */
export function riseFromSteadyForce(
  t: number,
  launchSpeed: number,
  duration: number,
): number {
  const capped = Math.min(t, duration);
  return 0.5 * (launchSpeed / duration) * capped * capped;
}

// ---- Drag ---------------------------------------------------------------------------------

/**
 * Linear drag: shave a fraction of the velocity per second, frame-rate independently.
 *
 * Section 4.1's `decayFactor(k, dt)` is the fraction of the gap a decay *removes* in `dt`, so what
 * survives is one minus it - which is `exp(-k·dt)`. Reaching for the same function is the point:
 * multiplying velocity by a constant each frame would make drag depend on the frame rate, exactly
 * as Section 4.1's headline bug did for position.
 */
export function dragStep(velocity: Vec3, k: number, dt: number): Vec3 {
  return mul(velocity, 1 - decayFactor(k, dt));
}

/**
 * The speed a falling object settles at, where drag exactly cancels gravity.
 *
 * $$-g - k\,v = 0 \;\Longrightarrow\; v_{\text{terminal}} = \frac{g}{k}$$
 *
 * A real falling body has drag going as the *square* of speed, so its terminal speed is
 * $\sqrt{g/k}$ instead. Linear is what games mostly use, because it is stable at any timestep and
 * nobody can tell the difference.
 */
export function terminalSpeed(gravity: number, k: number): number {
  return k <= 0 ? Infinity : gravity / k;
}

/** One step of a projectile under gravity and linear drag. Semi-implicit: velocity first. */
export function stepProjectile(
  body: Body,
  gravity: number,
  k: number,
  dt: number,
): Body {
  const dragged = dragStep(body.velocity, k, dt);
  const velocity = { x: dragged.x, y: dragged.y - gravity * dt, z: dragged.z };
  return { position: add(body.position, mul(velocity, dt)), velocity };
}
  • Every jump in every platformer, tuned as a height and a duration rather than a gravity.
  • Double jumps and variable-height jumps, which are the same solve with a second impulse or a shortened rise.
  • Coyote time and jump buffering, which are forgiveness windows around the impulse, not physics.
  • Thrown grenades and lobbed projectiles, where the arc has to clear something, so you solve for the launch velocity that does.
  • Explosion knockback, an impulse scaled by distance and divided by mass.
  • Anything with a top speed — a car, a falling player, a sprint — which is drag against a constant force.
  • Section 7.2, which is where the shortfall above gets fixed properly.