Capstone: 2D Platformer Character
What You’ll Learn
Section titled “What You’ll Learn”Nothing. There is no new mathematics on this page.
That is the point of it. A platformer character is made entirely out of the eighteen Sections behind it, and every piece arrives already understood. What is new is the order things happen in — and it turns out that once each individual piece is correct, the order is where all the remaining bugs live.
| Piece | From |
|---|---|
| input turned into a direction, without the diagonal bug | Section 1.3 |
| a fixed timestep, an accumulator, and a cap | Section 4.1 |
| coyote time and jump buffering, as clamped remaps | Section 4.2 |
| an axis-aligned box and its overlap test | Section 5.1 |
| the first solid face in a swept path | Sections 5.2 and 5.4 |
| killing the velocity into a surface, and a contact skin | Section 5.4 |
| semi-implicit velocity, and a jump asked for by height and time | Section 6.1 |
| a camera that follows by exponential decay | Sections 3.3 and 4.1 |
One Step, In Order
Section titled “One Step, In Order”// timers, then velocity, then the move, then the cameratimeSincePressed = pressed ? 0 : timeSincePressed + dt;velocity.x = stick * runSpeed;if (canJump(...)) velocity.y = launch;velocity.y += gravity * dt; // semi-implicit: velocity firstmoveOneAxis("x", velocity.x * dt);moveOneAxis("y", velocity.y * dt);camera = follow(camera, position.x, dt);The camera moves last, after the character has settled. A camera that chases a position which is about to be corrected by a collision produces jitter that looks exactly like a camera problem and is not one.
src/lib/gamedev/demos/2d/platformer.scene.ts /** One scripted run through a level, scrubbable, with the two forgiving windows on switches. */
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 { boxOf } from "../../../gamedev2d/platformer2d.ts";
import {
COYOTE_WINDOW,
JUMP_WINDOW,
LANDMARKS,
RUN_STEPS,
SOLID_CELLS,
TIME_RANGE,
VIEW,
cellRect,
clampCamera,
frameAt,
rectOf,
runScript,
screenOf,
trailTo,
} from "./platformer-shared.ts";
import type { MountFn } from "../runner.ts";
const TILE_FILL = "#2b3138";
const TILE_TOP = "#3b4552";
const TRAIL = "#4a5560";
const GROUNDED = "#7ee787";
const AIRBORNE = "#58a6ff";
const COYOTE = "#f0883e";
const BUFFER = "#d2a8ff";
const JUMPED = "#ff7b72";
const DIM = "#7d8590";
const mount: MountFn = (el) => {
const { ctx, clear } = makeCanvas2D(el, VIEW.height);
const show = addReadout(el);
const note = addReadout(el);
const time = addSlider(
el,
"scrub through the run",
TIME_RANGE.min,
TIME_RANGE.max,
0,
draw,
" s",
TIME_RANGE.step,
);
const coyoteOn = addCheckbox(el, "coyote time", true, draw);
const bufferOn = addCheckbox(el, "jump buffering", true, draw);
function options() {
return {
coyote: coyoteOn() ? COYOTE_WINDOW : 0,
buffer: bufferOn() ? JUMP_WINDOW : 0,
};
}
function draw() {
clear();
const opts = options();
const frame = frameAt(time(), opts);
const camera = clampCamera(frame.state.cameraX);
// The level. One fill for every solid cell, with a brighter top edge so surfaces read as surfaces.
for (const { cx, cy } of SOLID_CELLS) {
const r = cellRect(cx, cy, camera);
if (r.x + r.w < 0 || r.x > VIEW.width) continue;
ctx.fillStyle = TILE_FILL;
ctx.fillRect(r.x, r.y, r.w, r.h);
line(ctx, { x: r.x, y: r.y }, { x: r.x + r.w, y: r.y }, TILE_TOP, {
width: 1,
});
}
// Where it has been, so the whole run is legible from any one moment of it.
const trail = trailTo(frame, opts);
ctx.save();
ctx.strokeStyle = TRAIL;
ctx.lineWidth = 1.5;
ctx.beginPath();
trail.forEach((p, i) => {
const q = screenOf(p, camera);
if (i === 0) ctx.moveTo(q.x, q.y);
else ctx.lineTo(q.x, q.y);
});
ctx.stroke();
ctx.restore();
// Every jump that has already fired, marked where it happened.
for (const f of runScript(opts)) {
if (f.step > frame.step || !f.state.jumped) continue;
const q = screenOf(f.state.character.position, camera);
fillDot(ctx, q.x, q.y, 4, JUMPED);
}
// The character.
const box = rectOf(boxOf(frame.state.character), camera);
const colour = frame.state.character.grounded ? GROUNDED : AIRBORNE;
ctx.save();
ctx.strokeStyle = colour;
ctx.lineWidth = 2;
ctx.strokeRect(box.x, box.y, box.w, box.h);
ctx.restore();
/* The two windows, drawn as bars beside the character rather than in a corner. A label parked in the
corner of the canvas has been the one recurring flaw in these figures: correct, and nowhere near the
thing it describes. */
const barX = box.x + box.w + 8;
const bars: Array<[string, number, string]> = [
["coyote", coyoteOn() ? frame.coyote : 0, COYOTE],
["buffer", bufferOn() ? frame.buffer : 0, BUFFER],
];
bars.forEach(([name, value, tint], i) => {
const y = box.y + 4 + i * 13;
ctx.fillStyle = TILE_FILL;
ctx.fillRect(barX, y, 46, 7);
ctx.fillStyle = tint;
ctx.fillRect(barX, y, 46 * value, 7);
label(ctx, name, barX + 51, y + 7, value > 0 ? tint : DIM);
});
// The camera's own centre, which is what "follows without jitter" is about.
line(
ctx,
{ x: VIEW.width / 2, y: 0 },
{ x: VIEW.width / 2, y: VIEW.height },
DIM,
{ width: 1, dashed: true },
);
const trench = screenOf({ x: LANDMARKS.trench.from, y: 0 }, camera);
label(ctx, "ledge", trench.x - 34, VIEW.height - 8, DIM, "right");
const foot = boxOf(frame.state.character).min.y;
const inTrench = foot < LANDMARKS.floorTop - 1e-9;
show(
`t ${time().toFixed(2)} s \u00b7 step ${frame.step} of ${RUN_STEPS} \u00b7 x ${frame.state.character.position.x.toFixed(2)} \u00b7 ` +
`${frame.state.character.grounded ? "on the ground" : "in the air"}${inTrench ? " \u00b7 down in the trench" : ""}`,
);
note(
!coyoteOn() && !bufferOn()
? "both windows off: the press after the ledge does nothing, and so does the one before landing"
: !coyoteOn()
? "coyote time off: the jump pressed after the ledge is refused, so it drops into the trench"
: !bufferOn()
? "buffering off: the press before landing is thrown away, and the second jump never happens"
: "both windows on: the first jump is pressed after the ledge, the second before landing, and both work",
);
}
draw();
return () => {};
};
export default mount; Both jumps in that run are deliberately mistimed. The first is pressed after the character has already walked off the ledge; the second before it has landed. Switch either window off and watch its own jump stop happening — with coyote time off the character drops into the trench, and with buffering off it finishes units short of where it should.
Coyote Time and Jump Buffering Are Section 4.2
Section titled “Coyote Time and Jump Buffering Are Section 4.2”Both are the same shape: how long ago did the thing happen, as a fraction of a window, clamped. Which is
inverseLerp and clamp01 doing a job that does not look like interpolation at all.
Coyote time lets a jump work for a moment after walking off a ledge. Jump buffering lets a jump pressed slightly before landing fire on touchdown. Six frames of grace at 60 fps, and they are the cheapest thing that makes a platformer feel fair.
They have to be tested together, in one function. A buffered press landing inside the coyote window is exactly the case both features exist to catch, and testing them separately is how that case gets missed.
Why You Resolve One Axis At A Time
Section titled “Why You Resolve One Axis At A Time”This is the technique the whole capstone turns on, and it is not obvious.
Move diagonally and then ask “which way do I push out” and you need Section 5.3’s minimum translation vector. Against a grid of tiles that answer is ambiguous at every corner, because the shallower axis is not always the right one.
Split the move and the ambiguity disappears. Move horizontally: any overlap must be resolved horizontally, because vertically nothing changed. Then move vertically and resolve vertically. The axis to push along is known before the test is run.
src/lib/gamedev/demos/2d/peraxis.scene.ts /** The same charge at the same step, resolved one axis at a time and both at once. */
import { makeCanvas2D, label, line } from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addReadout, addSlider } from "../controls.ts";
import { boxOf } from "../../../gamedev2d/platformer2d.ts";
import {
CHARGE_FACE,
CHARGE_SPEED_RANGE,
CHARGE_TICK_SECONDS,
CHARGE_TICK_STEPS,
CHARGE_UNIT,
CHARGE_VIEW,
COLS,
LANDMARKS,
SOLID_CELLS,
TILE,
chargeAtStep,
chargeRectOf,
chargeScreenOf,
chargeSummary,
tileBoxOf,
} from "./platformer-shared.ts";
import type { MountFn } from "../runner.ts";
const TILE_FILL = "#2b3138";
const TILE_TOP = "#3b4552";
const RIGHT = "#7ee787";
const WRONG = "#ff7b72";
const DIM = "#7d8590";
const mount: MountFn = (el) => {
const { ctx, clear } = makeCanvas2D(el, CHARGE_VIEW.height);
const show = addReadout(el);
const note = addReadout(el);
const speed = addSlider(
el,
"how fast it is running",
CHARGE_SPEED_RANGE.min,
CHARGE_SPEED_RANGE.max,
45,
draw,
" units per second",
CHARGE_SPEED_RANGE.step,
);
const summary = chargeSummary();
/**
* The path, plus a tick every twentieth of a second.
*
* The ticks are the only thing in this figure that changes below the naive version's failure speed, because
* below it both methods finish in exactly the same place - which is the claim. Without them the picture is
* identical at every speed from 4 to 44 and the slider looks broken.
*/
function path(
points: readonly { x: number; y: number }[],
colour: string,
width: number,
tickOffset: number,
) {
ctx.save();
ctx.strokeStyle = colour;
ctx.lineWidth = width;
ctx.beginPath();
points.forEach((p, i) => {
const q = chargeScreenOf(p);
if (i === 0) ctx.moveTo(q.x, q.y);
else ctx.lineTo(q.x, q.y);
});
ctx.stroke();
ctx.restore();
points.forEach((p, i) => {
if (i % CHARGE_TICK_STEPS !== 0) return;
const q = chargeScreenOf(p);
if (q.x < -20 || q.x > CHARGE_VIEW.width + 20) return;
line(
ctx,
{ x: q.x, y: q.y + tickOffset },
{ x: q.x, y: q.y + tickOffset + Math.sign(tickOffset) * 6 },
colour,
{ width: 1 },
);
});
}
function draw() {
clear();
const v = speed();
const good = chargeAtStep(v, true);
const bad = chargeAtStep(v, false);
for (const { cx, cy } of SOLID_CELLS) {
const r = chargeRectOf(tileBoxOf(cx, cy));
if (r.x + r.w < 0 || r.x > CHARGE_VIEW.width) continue;
ctx.fillStyle = TILE_FILL;
ctx.fillRect(r.x, r.y, r.w, r.h);
line(ctx, { x: r.x, y: r.y }, { x: r.x + r.w, y: r.y }, TILE_TOP, {
width: 1,
});
}
// The face it should stop against, drawn so "stopped at the face" is something to look at.
const faceTop = chargeScreenOf({
x: LANDMARKS.step.from,
y: LANDMARKS.step.top,
});
const faceBottom = chargeScreenOf({
x: LANDMARKS.step.from,
y: LANDMARKS.floorTop - 0.6,
});
line(ctx, faceTop, faceBottom, DIM, { width: 1, dashed: true });
/* Left-aligned to the *right* of the face. Right-aligned to its left, the text ran off the canvas and
arrived as "e face it must stop" - the third label-placement flaw in this Module, and the third one no
build assertion could see. */
label(ctx, "must stop here", faceTop.x + 8, faceTop.y - 10, DIM);
// The right edge of the level, so leaving it reads as leaving it.
const edge = chargeScreenOf({ x: COLS * TILE, y: 0 });
line(ctx, { x: edge.x, y: 0 }, { x: edge.x, y: CHARGE_VIEW.height }, DIM, {
width: 1,
});
label(ctx, "end of the level", edge.x - 6, 16, DIM, "right");
/* Red drawn thick and green thin on top, with their time ticks on opposite sides. Below the failure speed
the two paths are the same line to the last bit, so drawing them at equal weight hid one of them
completely and the figure looked like it had only ever had one. A red halo around a green core is the
honest way to show agreement without moving either of them. */
path(bad.points, WRONG, 6, 4);
path(good.points, RIGHT, 2, -4);
for (const [charge, colour, width] of [
[bad, WRONG, 5],
[good, RIGHT, 2],
] as const) {
const r = chargeRectOf(boxOf(charge.end));
ctx.save();
ctx.strokeStyle = colour;
ctx.lineWidth = width;
ctx.strokeRect(r.x, r.y, r.w, r.h);
ctx.restore();
}
const describe = (c: typeof good) =>
c.stoppedAtFace
? `stopped at ${CHARGE_FACE}`
: c.escaped
? `left the level, still falling`
: `ended at x ${c.end.position.x.toFixed(3)}`;
show(
`at ${v} units per second one fixed step moves ${(v / 120).toFixed(4)} units, and one tick to the next is a 60 fps frame \u2014 ${(v * CHARGE_TICK_SECONDS).toFixed(3)} units \u00b7 ` +
`green one axis at a time: ${describe(good)} \u00b7 red both at once: ${describe(bad)}`,
);
note(
bad.stoppedAtFace
? `Both land in the same place, so the green line sits inside the red one \u2014 which is why the naive version ships. ` +
`Only the ticks change down here. Push the slider to ${summary.firstCombinedFailure} and the red one leaves.`
: `Resolving both axes at once fails at ${summary.combinedFailures} of the ${summary.total} speeds on this slider, from ${summary.firstCombinedFailure} up. ` +
`One axis at a time fails at ${summary.perAxisFailures}. Slide back below ${summary.firstCombinedFailure} and they agree again.`,
);
}
draw();
return () => {};
};
export default mount; The naive version is correct up to units per second, which is why it ships. At it loses the character out of the level, and it fails at of the speeds on that slider. Resolving one axis at a time fails at none of them, and stops at exactly — the step’s face less a half width — at every speed from to .
Resolve Against The Swept Path, Not The Destination
Section titled “Resolve Against The Swept Path, Not The Destination”It took three attempts to get the per-axis move right, and the two wrong versions are more instructive than the correct one.
Snapping to each overlapping tile in turn makes the answer depend on the iteration order of the tile scan. A character taller than one tile, moving beside a column of them, was snapped to the top of the highest tile it touched — teleported up the wall, then walked off the end of the level.
Taking the nearest face over the destination box fixes that and still leaves a fast mover placed inside a tile it stepped over: a move of units onto a unit grid lands past the first column, so the nearest face found belongs to the second one. Over 784 configurations of body size, speed and step size, that happened 154 times.
Sweeping the region between the two positions is correct, and it removes tunnelling with it — a move of any length is stopped by the first solid face in its path, because that face lies inside the swept region however long the step was. Which is Section 5.2’s first-blocker over Section 5.4’s swept motion, on one axis where both are exact.
The Least Glamorous Bug In The Module
Section titled “The Least Glamorous Bug In The Module”A character units from centre to foot, standing on a floor whose top is at , has its centre at — and its foot one bit below the floor.
So its next horizontal move finds itself overlapping the floor tile it is standing on, resolves against that tile sideways, and snaps the character back to the tile’s left edge. It walks backwards, off the level, and out of the world. Measured across 504 configurations: 109 of them, and every one had a body half-height whose sum with the floor height was not exactly representable.
The fix is Section 5.4’s skin, moved onto the test instead of the position: resolve exactly to the face, and treat a contact thinner than as no contact at all. Keeping the resolution exact is worth the swap, because it leaves the character on round numbers the build can assert.
The skin has to beat floating-point noise and lose to one step’s fall of units, or a resting character either collides with the ground sideways or never notices it is standing on it. Both bounds are checked.
The Fixed Timestep Needs A Cap
Section titled “The Fixed Timestep Needs A Cap”Section 4.1 made every formula frame-rate independent. An accumulator makes the whole simulation frame-rate identical, which is stronger: the same inputs give the same result on any machine, which is what makes the run above a recording rather than a performance.
The cap is not optional. Without it, one slow frame asks for many steps, those steps take longer than a frame, and that asks for more steps next time. A half-second hitch asks for steps at 120 Hz. That is a hang, not a slowdown.
The Camera Shivers For A Reason You Would Not Guess
Section titled “The Camera Shivers For A Reason You Would Not Guess”Pixel-snapping a camera is standard practice for pixel art. There are four things you could round, and only one of them holds still.
src/lib/gamedev/demos/2d/jitter.scene.ts /** Where the character appears to sit, frame by frame, under each of the four things you could round. */
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 { addReadout, addSlider } from "../controls.ts";
import {
JITTER_FRAMES,
JITTER_SPEED_RANGE,
PIXELS_PER_UNIT,
SNAPS,
advancesWholePixels,
jitterTrace,
perFramePixels,
} from "./platformer-shared.ts";
import type { MountFn } from "../runner.ts";
const VIEW = { width: 620, height: 336 } as const;
const LEFT = 104;
const RIGHT = 604;
const TOP = 30;
const STRIP = 72;
/** One art pixel, in canvas pixels. The whole figure is about sub-pixel motion, so it needs magnifying. */
const MAGNIFY = 22;
const GRID = "#2b3138";
const GRID_LABEL = "#636c76";
const DIM = "#7d8590";
const COLOURS: Record<string, string> = {
neither: "#7d8590",
"camera only": "#ff7b72",
both: "#f0883e",
"the offset": "#7ee787",
};
const mount: MountFn = (el) => {
const { ctx, clear } = makeCanvas2D(el, VIEW.height);
const show = addReadout(el);
const note = addReadout(el);
const speed = addSlider(
el,
"how fast the character is running",
JITTER_SPEED_RANGE.min,
JITTER_SPEED_RANGE.max,
6,
draw,
" units per second",
JITTER_SPEED_RANGE.step,
);
function draw() {
clear();
const v = speed();
const traces = SNAPS.map((snap) => ({
snap,
values: jitterTrace(snap, v, JITTER_FRAMES),
}));
/* A common baseline for all four strips, so they can be compared directly. The steady-state lag grows
with speed, so it has to be recomputed rather than fixed. */
const all = traces.flatMap((t) => t.values);
const baseline = Math.round(all.reduce((a, b) => a + b, 0) / all.length);
const stepX = (RIGHT - LEFT) / (JITTER_FRAMES - 1);
traces.forEach(({ snap, values }, row) => {
const centre = TOP + row * STRIP + STRIP / 2;
const colour = COLOURS[snap];
// Whole art pixels as horizontal rules, which is what "lands on a pixel" means.
for (const offset of [-1, 0, 1]) {
const y = centre - offset * MAGNIFY;
line(ctx, { x: LEFT, y }, { x: RIGHT, y }, GRID, { width: 1 });
if (row === 0)
label(ctx, `${baseline + offset} px`, RIGHT + 2, y + 3, GRID_LABEL);
}
label(ctx, `round ${snap}`, LEFT - 8, centre - 4, colour, "right");
const wobble = Math.max(...values) - Math.min(...values);
const onGrid = values.every((x) => Math.abs(x - Math.round(x)) < 1e-9);
label(
ctx,
wobble < 1e-9
? onGrid
? "still, and on the grid"
: "still, but between pixels"
: `slides ${wobble.toFixed(2)} px`,
LEFT - 8,
centre + 10,
wobble < 1e-9 && onGrid ? colour : DIM,
"right",
);
ctx.save();
ctx.strokeStyle = colour;
ctx.lineWidth = 2;
ctx.beginPath();
values.forEach((value, i) => {
const x = LEFT + i * stepX;
const y = centre - (value - baseline) * MAGNIFY;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.restore();
values.forEach((value, i) => {
const x = LEFT + i * stepX;
const y = centre - (value - baseline) * MAGNIFY;
fillDot(ctx, x, y, 2.4, colour);
});
});
label(ctx, `one frame at 60 fps \u2192`, LEFT, TOP - 12, DIM);
label(
ctx,
`one art pixel is drawn ${MAGNIFY} screen pixels tall`,
RIGHT,
TOP - 12,
DIM,
"right",
);
const advance = perFramePixels(v);
const whole = advancesWholePixels(v);
show(
`at ${PIXELS_PER_UNIT} art pixels to the world unit, ${v} units per second advances ${advance.toFixed(3)} pixels per frame` +
(whole ? " \u2014 a whole number of them" : ""),
);
note(
whole
? "On a whole-pixel advance nothing shimmers, whatever you round \u2014 so the bug is not the camera, it is a fraction being rounded. Nudge the slider off this value."
: `Rounding the camera alone slides the character backwards against a still background. Rounding both positions separately still slides a full pixel, ` +
`because two rounded numbers with a constant difference alternate between the integers either side of it. Rounding the offset holds still and lands on the grid.`,
);
}
draw();
return () => {};
};
export default mount; Each row is the character’s apparent distance from the camera, frame by frame, against a grid of whole art pixels. Rounding both positions separately looks like it should work and does not: two independently rounded numbers whose true difference is constant have a difference that alternates between the two integers either side of it, so the character still slides a full pixel back and forth against a rock-steady background. Rounding the camera alone is worse — it slides backwards while the character walks forwards.
Round the character’s distance from the camera instead and it holds perfectly still, on a whole pixel, at every one of the 89 speeds the slider offers. Rounding nothing is equally steady but sits permanently between two pixels, which is what resamples a sprite — so both halves of the test matter.
The camera itself is Section 4.1’s exponential decay, so a half-life of s leaves of the gap after one frame at 60 fps and the same fraction per second at any frame rate.
src/lib/gamedev/demos/2d/pieces.ts /** The six constants this character is made of, each traced back to the Section it came from. */
import {
CONTACT_SKIN,
FIXED_DT,
TUNING,
coyoteRemaining,
derived,
stepsFor,
} from "../../../gamedev2d/platformer2d.ts";
import {
COYOTE_WINDOW,
JUMP_WINDOW,
chargeSummary,
} from "./platformer-shared.ts";
import type { Demo } from "../runner.ts";
/* Rounded for display only; the checks assert the unrounded values. A gravity solved from 0.34 seconds is
not a binary fraction, and the trailing dust carries no information. */
const tidy = (n: number, places = 4) => Number(n.toFixed(places));
const demo: Demo = (log) => {
// Section 4.1: the fixed step, and the cap that stops one slow frame becoming a hang.
const hitch = stepsFor(0, 0.5);
log(
"stepsFor(0, 0.5) on a 120 Hz fixed step",
`${hitch.steps} steps, ${tidy(hitch.dropped)} s dropped, ${tidy(hitch.leftover, 9)} carried`,
`Section 4.1 - a half-second hitch asks for ${Math.floor(0.5 / FIXED_DT)} steps; the cap runs 8 and throws the rest away rather than spiralling`,
);
// Section 6.1: the jump, solved backwards from what a designer can picture.
const jump = derived(TUNING);
log(
`derived(${TUNING.jumpHeight} units high, ${TUNING.timeToApex} s to the top)`,
`launch ${tidy(jump.launch)}, gravity ${tidy(jump.gravity)}`,
"Section 6.1 - nobody tunes a gravity constant; they tune a height and a rise time",
);
// Section 4.2: two forgiveness windows that are both inverseLerp with a clamp.
log(
"the two windows, in frames at 60 fps",
`coyote ${tidy(COYOTE_WINDOW * 60, 1)}, buffer ${tidy(JUMP_WINDOW * 60, 1)}`,
"Section 4.2 - both are inverseLerp against a window, clamped; six frames of grace is the whole trick",
);
/* And the trap in switching one off, which is worth a row because the failure is silent and inverted:
inverseLerp guards an empty range by returning 0, which reads here as "the window is completely full". */
log(
"coyoteRemaining(0.5 s after leaving, window 0)",
`${coyoteRemaining(0.5, 0)}, where the unguarded form gives 1`,
"Section 4.2 - a zero window would report itself permanently open, so switching the feature off switches it on forever",
);
// Sections 5.1 and 5.4: why the move is split, priced over the speeds a slider offers.
const charge = chargeSummary();
log(
"running at a one-tile step, resolved both ways",
`both axes at once fails at ${charge.combinedFailures} of ${charge.total} speeds, one axis at a time at ${charge.perAxisFailures}`,
`Sections 5.1 and 5.4 - the naive version is correct up to ${(charge.firstCombinedFailure ?? 0) - 1} units per second, then loses the character out of the level`,
);
// The float that forced a contact skin, which is the least glamorous line in the whole Module.
log(
"a foot at 0.9 resting on a floor at 1.0",
`1.0 + 0.9 - 0.9 = ${1.0 + 0.9 - 0.9}`,
`Section 5.4's skin - the foot sits one bit *below* the floor, so a sideways move collides with the ground it stands on; ${CONTACT_SKIN} of tolerance fixes it`,
);
/* Six rows, and two things deliberately absent. What the forgiveness windows are worth on the scripted run
belongs to the run scene, which switches them off and lets the reader watch the jump not happen; and the
camera's pixel wobble belongs to its own figure, because four numbers asking a reader to imagine a
sub-pixel shimmer is exactly the case the visuals-first rule exists for. */
};
export default demo; At Ordinary Speeds, Everything Naive Works
Section titled “At Ordinary Speeds, Everything Naive Works”Worth saying plainly, because it is why these bugs ship. Over the whole scripted run above, at the
character’s own run speed of units per second, per-axis and combined resolution agree exactly —
firstStep: null, zero divergence at every one of the 348 steps. The build asserts that too.
The naive version is not obviously wrong. It is wrong at the speeds you add later: a dash, a knockback, a falling platform, a frame that took 30 ms. That is the shape of most of the bugs on this page.
source The whole character, composed from the eighteen Sections before it
/**
* A platformer character, assembled entirely out of the eighteen Sections before it.
*
* Nothing here is new mathematics. Every piece is imported from the Section that introduced it, and the file
* is mostly about the **order things happen in** - which turns out to be where the bugs live once each
* individual piece is correct.
*
* | Piece | From |
* | :--- | :--- |
* | input to a direction, without the diagonal bug | 1.3 |
* | an AABB and its overlap test | 5.1 |
* | resolving the overlap and killing the velocity into the surface | 5.4 |
* | semi-implicit velocity, and a jump asked for by height and time | 6.1 |
* | coyote time and jump buffering as clamped remaps | 4.2 |
* | a camera that follows by exponential decay | 3.3 and 4.1 |
* | the fixed timestep that makes all of it reproducible | 4.1 |
*/
import { aabbsOverlap, boxAround, type Aabb } from "./collide2d.ts";
import { clamp01, inverseLerp } from "./easing2d.ts";
import { normalize } from "./length2d.ts";
import { jumpFromHeightAndTime } from "./physics2d.ts";
import { smooth } from "./time2d.ts";
import { movedBy, scaled, type Point, type Vector } from "./vectors2d.ts";
// ---- The fixed timestep, from Section 4.1 -----------------------------------------------------
/** The step the simulation always takes, whatever the display is doing. */
export const FIXED_DT = 1 / 120;
/**
* How many fixed steps a variable frame owes, and what is left over.
*
* The accumulator pattern: add the real frame time to a running total, spend it in whole fixed steps, and
* carry the remainder. Section 4.1 made every formula frame-rate independent; this makes the whole
* simulation **frame-rate identical**, which is stronger - the same inputs give bit-for-bit the same result
* on any machine.
*
* **The cap is not optional.** Without `maxSteps`, one slow frame asks for many steps, those steps take
* longer than a frame, which asks for more steps next time. That is the spiral of death, and it is a hang
* rather than a slowdown. Capping drops simulated time on the floor, which is the right trade: the game
* runs slow for a moment instead of stopping for good.
*/
export function stepsFor(
accumulator: number,
frameTime: number,
fixedDt = FIXED_DT,
maxSteps = 8,
): { steps: number; leftover: number; dropped: number } {
const total = accumulator + frameTime;
const wanted = Math.floor(total / fixedDt);
const steps = Math.min(wanted, maxSteps);
// Time the cap threw away, which is worth reporting rather than hiding.
const dropped = (wanted - steps) * fixedDt;
/* The dropped time is subtracted from the carry, not left in it. Reporting time as dropped and then
carrying it anyway is the bug this function was written with: the backlog survives, so the next frame
is capped too, and the game crawls at `maxSteps` per frame until it works the debt off. Throwing it
away is what makes the recovery immediate and what makes `leftover < fixedDt` unconditionally true. */
/* Clamped at zero because `steps * fixedDt + dropped` is not exactly `wanted * fixedDt` in floating point,
so the subtraction can land a few parts in a quintillion below nothing. A negative carry would make the
next frame ask for one step fewer, which is invisible in a game and not invisible in an assertion. */
return {
steps,
leftover: Math.max(0, total - steps * fixedDt - dropped),
dropped,
};
}
/** How far through the current fixed step the display is, for interpolating the drawn position. */
export function renderAlpha(leftover: number, fixedDt = FIXED_DT): number {
return clamp01(leftover / fixedDt);
}
// ---- Input, from Section 1.3 ------------------------------------------------------------------
export type Input = {
/** -1, 0 or +1. */
x: number;
/** True on the frame the button went down. */
jumpPressed: boolean;
/** True while it is held. */
jumpHeld: boolean;
};
/**
* Input turned into a direction of length at most one.
*
* A platformer only moves on one axis, so the diagonal speed bug from Section 1.3 cannot bite here - but the
* same function serves a top-down game where it very much can, and normalizing costs nothing when the input
* is already axis-aligned. Returns the zero vector for no input rather than `null`, because "not moving" is
* a perfectly good movement direction and the caller should not have to branch.
*/
export function moveDirection(input: Vector): Vector {
const unit = normalize(input);
return unit === null ? { x: 0, y: 0 } : unit;
}
// ---- Coyote time and jump buffering, from Section 4.2 -----------------------------------------
/**
* Two forgiving windows, and both of them are `inverseLerp` with a clamp.
*
* **Coyote time** lets a jump work for a moment *after* walking off a ledge. **Jump buffering** lets a jump
* pressed slightly *before* landing fire on touchdown. Together they cover the two ways a player can be a
* few frames out, and they are the single cheapest thing that makes a platformer feel fair.
*
* Both are the same shape: how long ago did the thing happen, as a fraction of a window, clamped. Which is
* Section 4.2's `inverseLerp` and `clamp01`, doing a job that does not look like interpolation at all.
*/
export const COYOTE_TIME = 0.1;
export const JUMP_BUFFER = 0.12;
/**
* How much of the coyote window is left, from 1 at the instant of leaving to 0 when it has expired.
*
* **The zero-window branch is not defensive padding.** Section 4.2's `inverseLerp` returns `0` for an empty
* range rather than dividing by zero, which is the right answer there and exactly the wrong one here: it
* makes `1 - 0 = 1`, so a window of zero would report the coyote time as **permanently full**. Setting the
* window to zero to switch the feature off would switch it on forever. Found by writing the off switch.
*/
export function coyoteRemaining(
timeSinceGrounded: number,
window = COYOTE_TIME,
): number {
if (window <= 0) return timeSinceGrounded <= 0 ? 1 : 0;
return clamp01(1 - inverseLerp(0, window, timeSinceGrounded));
}
/** And the same for a buffered press, with the same guard for the same reason. */
export function bufferRemaining(
timeSincePressed: number,
window = JUMP_BUFFER,
): number {
if (window <= 0) return timeSincePressed <= 0 ? 1 : 0;
return clamp01(1 - inverseLerp(0, window, timeSincePressed));
}
/**
* May this character jump? Grounded, or recently enough grounded, and asking recently enough.
*
* Stated as one function because the two windows have to be considered together: a buffered press landing
* inside the coyote window is exactly the case both features exist to catch, and testing them separately is
* how that case gets missed.
*/
export function canJump(
timeSinceGrounded: number,
timeSincePressed: number,
coyote = COYOTE_TIME,
buffer = JUMP_BUFFER,
): boolean {
return (
coyoteRemaining(timeSinceGrounded, coyote) > 0 &&
bufferRemaining(timeSincePressed, buffer) > 0
);
}
// ---- Move and slide, from Sections 5.1 and 5.4 ------------------------------------------------
/** A tile map as a set of solid cells, which is all a platformer needs. */
export type Tiles = {
/** Cell size in world units. */
size: number;
/** `solid[y][x]`, with y counting upward from the bottom. */
solid: readonly (readonly boolean[])[];
};
/** The box a tile occupies. */
export function tileBox(tiles: Tiles, cx: number, cy: number): Aabb {
return boxAround(
{ x: (cx + 0.5) * tiles.size, y: (cy + 0.5) * tiles.size },
tiles.size,
tiles.size,
);
}
export function isSolid(tiles: Tiles, cx: number, cy: number): boolean {
return tiles.solid[cy]?.[cx] === true;
}
/** Every solid tile a box could be touching. Only the cells it spans need testing. */
export function nearbyTiles(tiles: Tiles, box: Aabb): Aabb[] {
const first = {
x: Math.floor(box.min.x / tiles.size),
y: Math.floor(box.min.y / tiles.size),
};
const last = {
x: Math.floor(box.max.x / tiles.size),
y: Math.floor(box.max.y / tiles.size),
};
const found: Aabb[] = [];
for (let cy = first.y; cy <= last.y; cy += 1) {
for (let cx = first.x; cx <= last.x; cx += 1) {
if (isSolid(tiles, cx, cy)) found.push(tileBox(tiles, cx, cy));
}
}
return found;
}
export type Character = {
/** The centre of the character's box. */
position: Point;
velocity: Vector;
/** Half the width and height of the box. */
half: Point;
grounded: boolean;
};
export function boxOf(c: Character): Aabb {
return {
min: { x: c.position.x - c.half.x, y: c.position.y - c.half.y },
max: { x: c.position.x + c.half.x, y: c.position.y + c.half.y },
};
}
/**
* How much overlap counts as **touching rather than colliding**. Section 5.4's skin, moved onto the test.
*
* Section 5.4 pushed two shapes a definite distance apart so the next frame's test could not be decided by
* the last bit of a float. This is the same idea from the other side: resolve exactly to the face, and treat
* a contact thinner than this as no contact at all. Keeping the resolution exact is worth the swap, because
* it leaves the character on round numbers a build can assert.
*
* **This is not optional, and here is the bug that proves it.** A character `0.9` units from centre to foot,
* standing on a floor whose top is at `1.0`, has its centre at `1.9` - and `1.9 - 0.9` is
* `0.9999999999999999`, not `1`. Its foot is one bit **below** the floor. So the next **horizontal** move
* finds itself overlapping the floor tile it is standing on, resolves against it sideways, and snaps the
* character back to the left edge of that tile. It walks backwards, drops off the level and falls out of the
* world. Measured over 504 combinations of body size, speed and step size: that happened in 109 of them, and
* every one had a body half-height whose sum with the floor height was not exactly representable.
*/
export const CONTACT_SKIN = 1e-6;
/** The overlap test every resolution below uses: a real overlap, not a shared edge. */
function overlapsBeyondSkin(box: Aabb, tile: Aabb): boolean {
return aabbsOverlap(
{
min: { x: box.min.x + CONTACT_SKIN, y: box.min.y + CONTACT_SKIN },
max: { x: box.max.x - CONTACT_SKIN, y: box.max.y - CONTACT_SKIN },
},
tile,
);
}
/**
* Move along **one axis**, then resolve on that axis alone. Called twice per step.
*
* This is the technique the whole capstone turns on, and it is not obvious. Moving diagonally and then asking
* "which way do I push out" needs the minimum translation vector from Section 5.3, and against a grid of
* tiles that answer is ambiguous at every corner - a character running along the floor gets pushed *upward*
* by the tile ahead of it as easily as sideways, so it climbs walls or catches on seams between floor tiles.
*
* Splitting the move removes the ambiguity entirely. Move horizontally: any overlap must be resolved
* horizontally, because vertically nothing changed. Then move vertically and resolve vertically. No minimum
* translation vector, no corner cases, and the axis to push along is known before the test is run.
*
* The build measures the difference against the naive combined version rather than describing it.
*/
export function moveAxis(
c: Character,
tiles: Tiles,
axis: "x" | "y",
distance: number,
): Character {
if (distance === 0) return c;
const moved: Character = {
...c,
position: movedBy(
c.position,
axis === "x" ? { x: distance, y: 0 } : { x: 0, y: distance },
),
};
const start = boxOf(c);
const end = boxOf(moved);
const forward = distance > 0;
/* The region the box **passes through**, not just the one it lands in.
This is the correctness of the whole function, and it took two wrong versions to get here. Testing only
the destination box has two separate failure modes, both measured:
- The first version snapped to each overlapping tile in turn, so with several tiles overlapping at once
the answer was decided by `nearbyTiles`' iteration order. A character taller than one tile was snapped
to the top of the **highest** tile beside it, which teleported it up a wall.
- Taking the nearest face over the destination box fixed that and still left the character **inside** a
tile it had stepped over: a move of 0.875 units onto a 0.5 unit grid lands past the first column, so
the nearest face found belongs to the *second* one, and the box is placed straddling the first. It
then oscillates, climbs, or leaves the level. Over 784 configurations that happened 154 times.
Sweeping removes both, and it removes tunnelling with them: a move of any length is stopped by the first
solid face in its path, because that face is inside the swept region however long the step was. This is
Section 5.2's first-blocker over Section 5.4's swept motion, on one axis where both are exact. */
const swept: Aabb =
axis === "x"
? {
min: { x: Math.min(start.min.x, end.min.x), y: start.min.y },
max: { x: Math.max(start.max.x, end.max.x), y: start.max.y },
}
: {
min: { x: start.min.x, y: Math.min(start.min.y, end.min.y) },
max: { x: start.max.x, y: Math.max(start.max.y, end.max.y) },
};
/** The nearest face ahead of where the box started, over every tile the sweep touches. */
let limit = forward ? Infinity : -Infinity;
let blocked = false;
for (const tile of nearbyTiles(tiles, swept)) {
// `nearbyTiles` returns whole cells, so a box merely touching the edge of one is not yet overlapping it.
if (!overlapsBeyondSkin(swept, tile)) continue;
const face =
axis === "x"
? forward
? tile.min.x
: tile.max.x
: forward
? tile.min.y
: tile.max.y;
/* Faces the box has already passed are not blockers. Without this, a character resting *on* a surface
would be "blocked" by it when moving sideways and snapped back to its edge. */
const leadingEdge = axis === "x" ? start.max.x : start.max.y;
const trailingEdge = axis === "x" ? start.min.x : start.min.y;
if (
forward
? face < leadingEdge - CONTACT_SKIN
: face > trailingEdge + CONTACT_SKIN
)
continue;
blocked = true;
limit = forward ? Math.min(limit, face) : Math.max(limit, face);
}
if (!blocked) return moved;
const back = forward ? -1 : 1;
if (axis === "x") {
return {
...moved,
position: { x: limit + back * c.half.x, y: moved.position.y },
velocity: { x: 0, y: moved.velocity.y },
};
}
return {
...moved,
position: { x: moved.position.x, y: limit + back * c.half.y },
velocity: { x: moved.velocity.x, y: 0 },
// Landing on something is what "grounded" means, and only a downward stop counts.
grounded: !forward ? true : c.grounded,
};
}
/**
* The naive alternative, kept so the build can price it: move both axes, then resolve.
*
* Resolves along whichever axis the overlap is shallower, which is Section 5.3's minimum translation vector
* applied to a box. Reasonable-looking, and it produces the classic platformer bugs.
*/
export function moveCombined(
c: Character,
tiles: Tiles,
delta: Vector,
): Character {
const moved: Character = { ...c, position: movedBy(c.position, delta) };
let position = moved.position;
let velocity = moved.velocity;
let grounded = c.grounded;
for (const tile of nearbyTiles(tiles, boxOf(moved))) {
const current = boxOf({ ...moved, position });
// The same contact skin as the per-axis version, so the comparison is about the axis choice and nothing else.
if (!overlapsBeyondSkin(current, tile)) continue;
const overlapX =
Math.min(current.max.x, tile.max.x) - Math.max(current.min.x, tile.min.x);
const overlapY =
Math.min(current.max.y, tile.max.y) - Math.max(current.min.y, tile.min.y);
if (overlapX < overlapY) {
position = {
x:
current.min.x < tile.min.x
? position.x - overlapX
: position.x + overlapX,
y: position.y,
};
velocity = { x: 0, y: velocity.y };
} else {
const pushedUp = current.min.y < tile.min.y;
position = {
x: position.x,
y: pushedUp ? position.y - overlapY : position.y + overlapY,
};
if (!pushedUp) grounded = true;
velocity = { x: velocity.x, y: 0 };
}
}
return { ...moved, position, velocity, grounded };
}
// ---- The camera, from Sections 3.3 and 4.1 ----------------------------------------------------
/** How quickly the camera closes half the gap to the character. Section 4.1's half-life. */
export const CAMERA_HALF_LIFE = 0.14;
/** One step of a following camera. Exponential decay, so the frame rate cannot change the feel. */
export function followCamera(
cameraX: number,
targetX: number,
dt: number,
halfLife = CAMERA_HALF_LIFE,
): number {
return smooth(cameraX, targetX, halfLife, dt);
}
/**
* The camera held inside the level, so it never shows past either end.
*
* Clamping the camera and not the character is the correct pairing: the character may stand anywhere,
* including in a corner the camera cannot centre on. Section 5.1's clamp again, on one axis, and this is
* the fourth job that one function has done in this Module.
*/
export function clampCamera(x: number, min: number, max: number): number {
return max < min ? (min + max) / 2 : Math.min(Math.max(x, min), max);
}
/** The art scale a pixel-art game draws at: a sixteen pixel tile on a half-unit grid. */
export const ART_PIXELS_PER_UNIT = 32;
/**
* The jitter trap: **snapping the camera to whole pixels while the character moves smoothly.**
*
* Pixel-snapping a camera is standard practice for pixel art, and done alone it is fine. Done while the
* character's own position is *not* snapped, the character's offset from the camera jumps by up to a whole
* pixel each frame - so the character shivers against a rock-steady background. The fix is to snap **both**
* or neither, and the build measures the difference.
*/
export function snapToPixel(worldX: number, pixelsPerUnit: number): number {
return Math.round(worldX * pixelsPerUnit) / pixelsPerUnit;
}
/**
* The four things you can round, and only one of them holds still.
*
* `"the offset"` is the answer, and it is the one nobody reaches for: round the character's **distance from
* the camera**, rather than rounding the two world positions and subtracting. Rounding both separately looks
* like it should work and does not - two independently rounded numbers whose difference is constant have a
* difference that alternates between the two integers either side of it, so the character still slides a
* whole pixel back and forth. Measured at the run speed: `"camera only"` wobbles by 0.8 px, `"both"` by a
* full pixel, and `"the offset"` by nothing at all.
*/
export type PixelSnap = "neither" | "camera only" | "both" | "the offset";
export function apparentOffset(
characterX: number,
cameraX: number,
pixelsPerUnit: number,
snap: PixelSnap,
): number {
if (snap === "neither") return (characterX - cameraX) * pixelsPerUnit;
if (snap === "the offset")
return Math.round((characterX - cameraX) * pixelsPerUnit);
const camera = snapToPixel(cameraX, pixelsPerUnit);
const character =
snap === "both" ? snapToPixel(characterX, pixelsPerUnit) : characterX;
return (character - camera) * pixelsPerUnit;
}
// ---- The step, with everything in its place ---------------------------------------------------
export type Tuning = {
runSpeed: number;
jumpHeight: number;
timeToApex: number;
/** What fraction of the upward velocity survives releasing the button. Section 6.1. */
cutJump: number;
maxFallSpeed: number;
};
export const TUNING: Tuning = {
runSpeed: 6,
jumpHeight: 2.4,
timeToApex: 0.34,
cutJump: 0.45,
maxFallSpeed: 18,
};
/** The gravity and launch speed the tuning above implies. Section 6.1, solved backwards. */
export function derived(tuning: Tuning = TUNING) {
return jumpFromHeightAndTime(tuning.jumpHeight, tuning.timeToApex);
}
export type StepState = {
character: Character;
/** Seconds since the character was last standing on something. */
timeSinceGrounded: number;
/** Seconds since jump was last pressed. */
timeSincePressed: number;
cameraX: number;
/** Whether the step that produced this state fired a jump. An output, for the scenes to mark. */
jumped: boolean;
};
/** What the step is allowed to vary, so the scenes can switch one piece off at a time. */
export type StepOptions = {
dt?: number;
tuning?: Tuning;
/** False to resolve both axes at once, which is the version with the bugs. */
perAxis?: boolean;
/** Zero for no coyote time, which is how the scene shows what it buys. */
coyote?: number;
buffer?: number;
};
/**
* One fixed step: **input, then velocity, then move, then camera.**
*
* The order is the content of this function. Velocity is integrated before the move, because the move needs
* to know where it is going. The move happens one axis at a time. The camera follows *after* the character
* has settled, so it never chases a position that is about to be corrected - chasing an unresolved position
* is a jitter source that looks exactly like a camera problem and is not one.
*/
export function stepCharacter(
state: StepState,
input: Input,
tiles: Tiles,
options: StepOptions = {},
): StepState {
const {
dt = FIXED_DT,
tuning = TUNING,
perAxis = true,
coyote = COYOTE_TIME,
buffer = JUMP_BUFFER,
} = options;
const { launch, gravity } = derived(tuning);
let c = { ...state.character };
// Timers first, so "how long since" means "at the start of this step".
const timeSincePressed = input.jumpPressed ? 0 : state.timeSincePressed + dt;
const timeSinceGrounded = c.grounded ? 0 : state.timeSinceGrounded + dt;
// Horizontal velocity is set rather than accelerated, which is what makes a platformer feel responsive.
const direction = moveDirection({ x: input.x, y: 0 });
c.velocity = { x: direction.x * tuning.runSpeed, y: c.velocity.y };
// The jump, if the two windows allow it. Section 4.2's clamps deciding a Section 6.1 launch.
const jumped = canJump(timeSinceGrounded, timeSincePressed, coyote, buffer);
if (jumped) c.velocity = { x: c.velocity.x, y: launch };
// Releasing the button cuts the rise short. Section 6.1, and the guard matters.
if (!input.jumpHeld && c.velocity.y > 0 && !jumped) {
c.velocity = { x: c.velocity.x, y: c.velocity.y * tuning.cutJump };
}
// Gravity, semi-implicit: velocity first. Section 6.1.
c.velocity = {
x: c.velocity.x,
y: Math.max(c.velocity.y + gravity * dt, -tuning.maxFallSpeed),
};
// The move. Grounded is re-established by the move itself, so it is cleared first.
c = { ...c, grounded: false };
if (perAxis) {
c = moveAxis(c, tiles, "x", c.velocity.x * dt);
c = moveAxis(c, tiles, "y", c.velocity.y * dt);
} else {
c = moveCombined(c, tiles, scaled(c.velocity, dt));
}
/* Both timers are pushed past their windows on a jump, which is what stops one press becoming two jumps.
Clearing only the press would leave the coyote window open for another frame; clearing only the coyote
timer would leave the press live for the next landing. It takes both. */
return {
character: c,
timeSinceGrounded: jumped ? coyote + 1 : timeSinceGrounded,
timeSincePressed: jumped ? buffer + 1 : timeSincePressed,
cameraX: followCamera(state.cameraX, c.position.x, dt),
jumped,
};
} Where This Came From
Section titled “Where This Came From”Every Section, which is the point:
- Section 1.3 — the direction, normalized, so a diagonal is not faster.
- Sections 3.3 and 4.1 — the camera’s decay, and the fixed step that makes the run reproducible.
- Section 4.2 —
clamp01andinverseLerp, doing the two forgiveness windows. - Section 5.1 — the box, the overlap test, and the clamp that has now done four jobs.
- Section 5.2 — the first blocker along a path.
- Section 5.3 — the minimum translation vector, and the reason this page does not use it.
- Section 5.4 — the velocity kill, the skin, and tunnelling.
- Section 6.1 — semi-implicit velocity, and a jump specified as a height and a rise time.