Collision Response and Sliding
What You’ll Learn
Section titled “What You’ll Learn”Sections 5.1 to 5.3 answered “did they touch”. This one answers “so what”, and there are two wrong things to fix, which is worth separating before touching either:
- The position is wrong. The shapes are inside each other and have to be moved apart.
- The velocity is wrong. It is still carrying the object into the wall, so fixing only the position means overlapping again next frame.
Both come down to splitting a vector into the part along the wall’s normal and the part along the wall — Section 1.4’s projection doing the most useful work it will ever do. Keep the along-the-wall part and you slide. Reverse the into-the-wall part and you bounce. One split, two behaviours.
The Split
Section titled “The Split”Perpendicular to each other, and they add back to — the build checks both across a few thousand angles. Then:
The only difference is the 2, and it is worth seeing why: subtracting the normal part once removes it,
and subtracting it twice sends it back the way it came. Both as one function, with restitution choosing:
src/lib/gamedev/demos/2d/deflect.scene.ts /** A velocity split against a wall into the part along it and the part into it, with restitution blending. */
import {
makeCanvas2D,
arrow,
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 {
CONTACT,
RESTITUTION_RANGE,
VELOCITY_RANGE,
VIEW,
WALL_DRAW_LENGTH,
WALL_RANGE,
deflectReport,
screenOf,
tipOf,
} from "./deflect-shared.ts";
import type { MountFn } from "../runner.ts";
const WALL = "#7d8590";
const INCOMING = "#58a6ff";
const NORMAL_PART = "#ff7b72";
const TANGENT_PART = "#7ee787";
const RESULT = "#ffd866";
const NORMAL = "#d2a8ff";
const DIM = "#636c76";
const mount: MountFn = (el) => {
const { ctx, clear } = makeCanvas2D(el, VIEW.height);
const show = addReadout(el);
const note = addReadout(el);
const wallAngle = addSlider(
el,
"the wall's angle",
WALL_RANGE.min,
WALL_RANGE.max,
-18,
draw,
"\u00B0",
1,
);
const velocityAngle = addSlider(
el,
"the velocity's angle",
VELOCITY_RANGE.min,
VELOCITY_RANGE.max,
-125,
draw,
"\u00B0",
1,
);
const restitution = addSlider(
el,
"restitution: 0 slides, 1 bounces",
RESTITUTION_RANGE.min,
RESTITUTION_RANGE.max,
0,
draw,
"",
RESTITUTION_RANGE.step,
);
const showParts = addCheckbox(el, "show the two components", true, draw);
function draw() {
clear();
const r = deflectReport(wallAngle(), velocityAngle(), restitution());
const at = screenOf(CONTACT);
// The wall, drawn long enough to read as a surface rather than a segment.
line(
ctx,
screenOf({
x: CONTACT.x - r.along.x * WALL_DRAW_LENGTH,
y: CONTACT.y - r.along.y * WALL_DRAW_LENGTH,
}),
screenOf({
x: CONTACT.x + r.along.x * WALL_DRAW_LENGTH,
y: CONTACT.y + r.along.y * WALL_DRAW_LENGTH,
}),
WALL,
{ width: 3.5 },
);
// Its outward normal, short and unit length, so the reader can see what everything is measured against.
arrow(ctx, at, screenOf(tipOf(r.normal)), NORMAL, 1.6);
label(
ctx,
"n",
screenOf(tipOf(r.normal)).x + 8,
screenOf(tipOf(r.normal)).y - 4,
NORMAL,
);
/* The incoming velocity, drawn arriving **at** the contact rather than leaving it, because that is what
it is doing. Everything else leaves the contact. */
arrow(
ctx,
screenOf({ x: CONTACT.x - r.velocity.x, y: CONTACT.y - r.velocity.y }),
at,
INCOMING,
2.6,
);
label(
ctx,
"v",
screenOf({ x: CONTACT.x - r.velocity.x, y: CONTACT.y - r.velocity.y }).x -
10,
screenOf({ x: CONTACT.x - r.velocity.x, y: CONTACT.y - r.velocity.y }).y -
6,
INCOMING,
);
/* The split, drawn from the contact so the two parts visibly add to the incoming arrow. Dashed, since
they are a decomposition rather than a motion anything performs. */
if (showParts()) {
for (const [part, colour, name] of [
[r.normalComponent, NORMAL_PART, "into the wall"],
[r.tangentComponent, TANGENT_PART, "along the wall"],
] as const) {
const tip = screenOf(tipOf(part));
line(ctx, at, tip, colour, { width: 2, dashed: true });
fillDot(ctx, tip.x, tip.y, 3, colour);
label(
ctx,
name,
tip.x + 8,
tip.y + (colour === NORMAL_PART ? -8 : 14),
colour,
);
}
}
// The result, which is the only arrow that describes what actually happens next.
arrow(ctx, at, screenOf(tipOf(r.responded)), RESULT, 3);
fillDot(ctx, at.x, at.y, 4.5, RESULT);
label(ctx, "the wall", 12, 18, WALL);
label(
ctx,
r.intoWall
? "moving into the wall, so the response applies"
: "moving away from the wall, so nothing is changed",
12,
32,
r.intoWall ? DIM : NORMAL_PART,
);
const speed = Math.hypot(r.velocity.x, r.velocity.y);
const kept = Math.hypot(r.responded.x, r.responded.y);
show(
`${r.angleFromWall.toFixed(0)}\u00B0 from the wall \u00b7 ` +
`a slide keeps ${(r.kept * 100).toFixed(1)}% of the speed \u00b7 ` +
`this response keeps ${((kept / speed) * 100).toFixed(1)}%`,
);
note(
!r.intoWall
? "the guard matters here: applied anyway, a slide would cancel the outward part and glue it to the surface"
: restitution() < 0.01
? "restitution 0: the into-the-wall part is dropped, and what is left runs along the surface"
: restitution() > 0.99
? "restitution 1: the same part is reversed instead of dropped, so the speed is unchanged"
: `restitution ${restitution().toFixed(2)}: the part is reversed and shrunk, which is most real surfaces`,
);
}
draw();
return () => {};
};
export default mount; Note what a slide does to speed. What survives is where is the angle between the velocity and the wall:
| Angle from the wall | Speed kept |
|---|---|
Hit a wall square on and all of the speed goes. That is correct rather than unfortunate — it is why a character pressed into a wall stops — and it is why running along a wall at a shallow angle feels fast while a steep approach feels like stopping dead.
The Guard That Stops You Sticking
Section titled “The Guard That Stops You Sticking”One dot product and a comparison, and it is the most commonly omitted line in this Section. Apply a slide unconditionally and an object already moving away from the wall has its outward velocity cancelled too — so it clings to the surface for as long as contact lasts. That reads as glue, not as a bug, which is why it survives.
The build sweeps a full turn of velocities: for every inbound one the guard changes nothing, and for every outbound one it changes everything.
Fixing The Position, And Why Exactly Is Not Enough
Section titled “Fixing The Position, And Why Exactly Is Not Enough”Push out along the normal by the depth — Section 5.3’s minimum translation vector, finally used:
Correct, and on its own not enough. Pushing out to exactly zero overlap leaves the two shapes touching, and whether touching counts as a collision then depends on the last bit of a float. So the next frame may detect the same contact, push by nothing, detect it again — a character that shivers against every wall it leans on.
The fix is a sliver: push out by depth plus a skin of about a thousandth of a unit. Not a fudge — it puts the shapes a definite, known distance apart so the next test has an unambiguous answer instead of a coin flip.
src/lib/gamedev/demos/2d/pushout.ts /** Fixing the position, the guard that stops a character sticking, and what corners cost. */
import {
SKIN,
convergenceRate,
pushOut,
pushOutExactly,
resolveVelocity,
settleVelocity,
slide,
substepsNeeded,
tunnellingChance,
tunnellingSpeed,
} from "../../../gamedev2d/response2d.ts";
import { FRAME, WALL_THICKNESS } from "./deflect-shared.ts";
import type { Demo } from "../runner.ts";
const UP = { x: 0, y: 1 };
const RIGHT = { x: 1, y: 0 };
/* Rounded, because the skin is 0.001 and that is not a binary fraction - so 0.25 + 0.001 - 0.25 prints as
0.0010000000000000009 in output that gets committed to the repository. Four places is plenty here and the
dust carries no information. */
const V = (v: { x: number; y: number }) =>
`(${Number(v.x.toFixed(4))}, ${Number(v.y.toFixed(4))})`;
const demo: Demo = (log) => {
// The position fix, and why exact is not good enough.
log(
"pushOutExactly, then pushOut, from 0.25 deep",
`${V(pushOutExactly({ x: 0, y: -0.25 }, { normal: UP, depth: 0.25 }))} then ${V(pushOut({ x: 0, y: -0.25 }, { normal: UP, depth: 0.25 }))}`,
`exact leaves them touching, where the next frame's answer rests on the last bit of a float; the skin of ${SKIN} does not`,
);
// The guard. Without it, an outward velocity loses its outward part.
log(
"slide vs resolveVelocity on a velocity already leaving the wall",
`${V(slide({ x: 1, y: 1 }, UP))} vs ${V(resolveVelocity({ x: 1, y: 1 }, UP))}`,
"unguarded it is glued to the surface; the guard is one dot product and a comparison",
);
// A right-angled corner settles at once, and to exactly zero.
const corner = settleVelocity({ x: -1, y: -1 }, [RIGHT, UP], 0);
log(
"settleVelocity((-1, -1)) into a right-angled corner",
`${V(corner.velocity)} in ${corner.passes} pass, residual ${corner.residual}`,
"perpendicular normals never fight, so one pass is exact - which is the corner games actually hit",
);
/* A wider wedge does fight, and only approaches the answer. The rate is cos squared of the angle between
the normals, which is why 120 degrees sheds exactly a quarter of the residual per pass. */
const wedge = {
x: Math.cos((120 * Math.PI) / 180),
y: Math.sin((120 * Math.PI) / 180),
};
const slow = settleVelocity({ x: -0.5, y: -0.866 }, [RIGHT, wedge], 0, 4, 0);
log(
"the same into normals 120\u00B0 apart, capped at 4 passes",
`residual ${slow.residual.toFixed(6)}, settled ${slow.settled}`,
`it falls by cos\u00B2 120\u00B0 = ${convergenceRate(RIGHT, wedge).toFixed(3)} each pass, so it approaches zero without reaching it`,
);
// Tunnelling: the speed, and how often it actually bites above that speed.
log(
`escape speed for a wall ${WALL_THICKNESS} thick at 60 fps`,
tunnellingSpeed(WALL_THICKNESS, FRAME).toFixed(1),
"thickness over the frame time, and a wall 0.1 thick gives only 6.0",
);
log(
"at twice that speed, the fraction of start offsets that tunnel",
`${(tunnellingChance(2 * tunnellingSpeed(WALL_THICKNESS, FRAME), FRAME, WALL_THICKNESS) * 100).toFixed(1)}%`,
"so above the threshold it is intermittent rather than certain, which is what makes it hard to find",
);
log(
"substeps needed at four times the escape speed",
substepsNeeded(
4 * tunnellingSpeed(WALL_THICKNESS, FRAME),
FRAME,
WALL_THICKNESS,
),
"and the thinnest wall in the level sets that budget for everything in it",
);
};
export default demo; Corners
Section titled “Corners”Sliding along one wall can leave you heading into another, so a corner needs more than one pass. Repeat until nothing is being driven into any wall.
That loop is alternating projection, and how it behaves is exactly predictable:
| Angle between the normals | Residual shed per pass | Passes to settle |
|---|---|---|
| or less | — | 1, exactly |
| about 21 | ||
| about 97 | ||
| about 866 |
The good news is the top row. A right-angled corner — the case a tile-based game hits constantly — has perpendicular normals and settles in a single pass, to exactly zero. So does anything narrower. The build sweeps a full turn of velocities against normals from to apart and confirms every one settles first time.
Beyond a right angle the two projections fight, and where the correct answer is “you cannot move at all” the loop approaches zero without reaching it. That is why real solvers cap their iterations and live with a residual, and why “it did not settle” almost always means not yet rather than never.
Tunnelling
Section titled “Tunnelling”A discrete step moves an object in one go. If that is longer than a wall is thick, the object can begin one side and end the other with no frame in between where the two overlap. The test never fires. Nothing is wrong with the collision code; it was never asked.
Which is unpleasantly small. A wall a tenth of a unit thick at 60 fps is defeated by 6 units per second — and by 3 at 30 fps.
src/lib/gamedev/demos/2d/substep.scene.ts /** A fast mover, a thin wall, and the frame that steps straight over it without noticing. */
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 {
FRAME,
MOVER_SPEED,
PHASE_RANGE,
SUBSTEP_RANGE,
THIN_WALL,
VIEW,
WALL_THICKNESS,
screenOf,
substepReport,
} from "./deflect-shared.ts";
import type { MountFn } from "../runner.ts";
const WALL = "#7d8590";
const FRAME_DOT = "#3b4552";
const SUBSTEP_DOT = "#58a6ff";
const CAUGHT = "#7ee787";
const MISSED = "#ff7b72";
const DIM = "#636c76";
const mount: MountFn = (el) => {
const { ctx, clear } = makeCanvas2D(el, VIEW.height);
const show = addReadout(el);
const note = addReadout(el);
const speed = addSlider(
el,
"speed",
MOVER_SPEED.min,
MOVER_SPEED.max,
24,
draw,
" per second",
MOVER_SPEED.step,
);
const substeps = addSlider(
el,
"substeps per frame",
SUBSTEP_RANGE.min,
SUBSTEP_RANGE.max,
1,
draw,
"",
1,
);
/* The control that makes the point: above the escape speed, whether the wall is noticed depends on where
the frame boundaries happen to fall, and this slides them. */
const phase = addSlider(
el,
"where in a frame it starts",
PHASE_RANGE.min,
PHASE_RANGE.max,
0,
draw,
"",
PHASE_RANGE.step,
);
function draw() {
clear();
const r = substepReport(speed(), substeps(), phase());
const midY = VIEW.height / 2;
// The wall, and the path the mover travels.
const wallA = screenOf(THIN_WALL.min);
const wallB = screenOf(THIN_WALL.max);
ctx.save();
ctx.fillStyle = r.tunnelled
? "rgba(255, 123, 114, 0.18)"
: "rgba(126, 231, 135, 0.15)";
ctx.fillRect(
Math.min(wallA.x, wallB.x),
Math.min(wallA.y, wallB.y),
Math.abs(wallB.x - wallA.x),
Math.abs(wallB.y - wallA.y),
);
ctx.strokeStyle = WALL;
ctx.lineWidth = 2;
ctx.strokeRect(
Math.min(wallA.x, wallB.x),
Math.min(wallA.y, wallB.y),
Math.abs(wallB.x - wallA.x),
Math.abs(wallB.y - wallA.y),
);
ctx.restore();
line(ctx, { x: 0, y: midY }, { x: VIEW.width, y: midY }, FRAME_DOT, {
width: 1,
});
// Where the mover is at each whole frame. Sparse at speed, which is the whole problem.
for (const p of r.frames) {
const q = screenOf(p);
if (q.x < 0 || q.x > VIEW.width) continue;
fillDot(ctx, q.x, q.y, 3, FRAME_DOT);
}
// The frame that decides it: where it began, and every substep inside it.
const beganAt = screenOf(r.before);
fillDot(ctx, beganAt.x, beganAt.y, 5, SUBSTEP_DOT);
label(
ctx,
"this frame starts here",
beganAt.x - 6,
beganAt.y - 14,
SUBSTEP_DOT,
"right",
);
for (const p of r.substepsAt) {
const q = screenOf(p);
fillDot(ctx, q.x, q.y, 3.5, SUBSTEP_DOT);
}
if (r.hit) {
const q = screenOf(r.hit);
fillDot(ctx, q.x, q.y, 6.5, CAUGHT);
label(ctx, "caught here", q.x + 10, q.y - 10, CAUGHT);
} else {
const past = r.substepsAt[r.substepsAt.length - 1];
const q = screenOf(past);
fillDot(ctx, q.x, q.y, 6.5, MISSED);
label(ctx, "straight through", q.x + 10, q.y - 10, MISSED);
}
label(ctx, `wall ${WALL_THICKNESS} thick`, wallA.x - 8, 22, WALL, "right");
label(
ctx,
"grey dots are whole frames \u00b7 blue are the substeps inside the frame that matters",
12,
VIEW.height - 10,
DIM,
);
show(
`${r.perFrame.toFixed(3)} per frame against a wall ${WALL_THICKNESS} thick \u00b7 ` +
`escape speed ${r.threshold.toFixed(1)} \u00b7 ` +
`${(r.chance * 100).toFixed(1)}% of start offsets tunnel at this speed`,
);
note(
r.perFrame <= WALL_THICKNESS
? "one step is shorter than the wall is thick, so no alignment can slip past it"
: r.tunnelled
? `this alignment steps over it \u2014 ${r.needed} substeps would be enough to stop that`
: `this alignment happens to land inside, but slide the start offset and it will not \u2014 ${r.needed} substeps fixes it for good`,
);
}
draw();
return () => {};
};
export default mount; But the threshold is only half the story, and the other half is what makes this bug so hard to find. Above the escape speed, tunnelling is possible but not certain: whether the wall is noticed depends on where the frame boundaries happen to fall. Samples spaced apart miss an interval of width for a fraction
of the possible offsets. At twice the escape speed it happens half the time; at four times, three quarters. That closed form is checked against a sweep of two thousand start offsets at five speeds.
So slide the start-offset control in the scene above and watch the collision blink in and out with nothing else changing. I found this out the way everyone does: my first measurement reported “detected” at every speed I happened to try, because the speeds I picked all landed a frame inside the wall.
The fix is substeps — split the frame into pieces each shorter than the thinnest wall:
The build confirms that count is tight: at each speed, substeps drives the measured tunnel chance to exactly zero and does not. Note the parameter is the thinnest wall in the level, not the average — your flimsiest piece of geometry sets the budget for the whole simulation.
Capping the speed also works, and it is worth naming as the different thing it is: a velocity of 40 capped for this wall becomes 15, which is of what was asked for. That is a design decision about how fast projectiles are allowed to be, not a bug fix.
source The split, slide and bounce, the guard, the skin, and tunnelling with its closed form
/**
* Detection was half the job. This is the other half: **what to do once two things have overlapped.**
*
* Two problems, and they are genuinely separate. The **position** is wrong - the shapes are inside each
* other and have to be pushed apart. And the **velocity** is wrong - it is still carrying the object into
* the wall, so leaving it alone means overlapping again on the next frame.
*
* Both come down to splitting a vector into the part along the wall's normal and the part along the wall,
* which is Section 1.4's projection doing the most useful work it will ever do. Keep the along-the-wall
* part and you slide. Reverse the into-the-wall part and you bounce. One split, two behaviours.
*/
import { dot } from "./dot2d.ts";
import { length, normalize } from "./length2d.ts";
import {
combine,
displacement,
movedBy,
reversed,
scaled,
type Point,
type Vector,
} from "./vectors2d.ts";
/**
* A contact: which way to push, and how far. Exactly what Section 5.3's `smallestOverlap` produced.
*
* The normal is **expected to be unit length**, and every function here says so, because the cost of it
* not being is not a slightly-wrong answer but a wildly wrong one - see `slide`.
*/
export type Contact = {
/** Unit length, pointing out of the wall toward the moving object. */
normal: Vector;
/** How deep the overlap is, along that normal. Never negative for a real contact. */
depth: number;
};
/**
* The part of a vector pointing along the normal. Section 1.4's projection, unchanged.
*
* $$v_n = (v \cdot \hat{n})\,\hat{n}$$
*
* **This assumes $\hat{n}$ is unit length**, and the assumption is doing real work. The honest projection
* divides by $|n|^2$; dropping that division is only correct when the length is 1, and when it is not the
* correction is scaled by $|n|^2$ - so a normal of length 2 removes four times too much.
*/
export function normalPart(v: Vector, normal: Vector): Vector {
return scaled(normal, dot(v, normal));
}
/** And the part along the wall: everything the normal part is not. */
export function tangentPart(v: Vector, normal: Vector): Vector {
return displacement(normalPart(v, normal), v);
}
/**
* Slide: **drop the into-the-wall part and keep the rest.**
*
* $$v' = v - (v \cdot \hat{n})\,\hat{n}$$
*
* One line, and it is the whole reason a character walks along a wall instead of stopping dead against it.
* The name in Godot is `Vector2.slide`; in Unity it is `Vector3.ProjectOnPlane`, which describes the same
* operation from the other direction.
*
* Note what it does to speed: the surviving speed is $|v|\cos\theta$ where $\theta$ is the angle between
* the velocity and the wall. Hit a wall square on and $\theta = 0$, so **all** of the speed goes. That is
* correct rather than unfortunate, and it is why a character pressed into a wall stops.
*/
export function slide(v: Vector, normal: Vector): Vector {
return displacement(normalPart(v, normal), v);
}
/**
* Bounce: **reverse the into-the-wall part instead of dropping it.**
*
* $$v' = v - 2(v \cdot \hat{n})\,\hat{n}$$
*
* The 2 is the only difference from a slide, and it is worth seeing why: subtracting the normal part once
* removes it, and subtracting it twice sends it back the way it came.
*/
export function reflect(v: Vector, normal: Vector): Vector {
return displacement(scaled(normalPart(v, normal), 2), v);
}
/**
* Slide and bounce as one function, with `restitution` choosing between them.
*
* $$v' = v_t - e\,v_n$$
*
* At $e = 0$ this is a slide, at $e = 1$ a perfect bounce, and in between the bounce loses energy. Worth
* having as one function rather than two, because a game almost always wants something between the two and
* writing it as a blend makes that a parameter rather than a rewrite.
*/
export function respond(
v: Vector,
normal: Vector,
restitution: number,
): Vector {
return combine(
tangentPart(v, normal),
scaled(normalPart(v, normal), -restitution),
);
}
/**
* Is this velocity actually heading **into** the wall?
*
* $$v \cdot \hat{n} < 0$$
*
* The guard that has to be there, and the one most often left out. Apply a slide unconditionally and a
* character already moving *away* from a wall has its outward velocity cancelled too - so it sticks to
* the surface for as long as it stays in contact, which reads as glue rather than as a bug.
*/
export function movingInto(v: Vector, normal: Vector): boolean {
return dot(v, normal) < 0;
}
/** The response, applied only when it should be. This is the function a game actually calls. */
export function resolveVelocity(
v: Vector,
normal: Vector,
restitution = 0,
): Vector {
return movingInto(v, normal) ? respond(v, normal, restitution) : v;
}
/**
* How much speed a slide keeps, as a fraction, given the angle between velocity and wall.
*
* $$\frac{|v'|}{|v|} = \cos\theta$$
*
* Here so the page can tabulate it rather than assert it. It is also the number that explains why running
* at a wall at a shallow angle feels fast and at a steep angle feels like stopping.
*/
export function slideSpeedFraction(radiansFromWall: number): number {
return Math.abs(Math.cos(radiansFromWall));
}
// ---- Fixing the position ----------------------------------------------------------------------
/**
* The overlap removed exactly: push out along the normal by the depth.
*
* $$p' = p + d\,\hat{n}$$
*
* Which is correct and, on its own, **not enough**. Pushing out to exactly zero overlap leaves the two
* shapes touching, and whether "touching" counts as a collision then depends on the last bit of a float.
* So the next frame may detect the same contact, push again by nothing, and detect it again - a character
* that shivers against every wall it leans on.
*/
export function pushOutExactly(p: Point, contact: Contact): Point {
return movedBy(p, scaled(contact.normal, contact.depth));
}
/**
* The overlap removed **plus a sliver**, which is what stops the shivering.
*
* The extra is variously called skin, slop, or a contact offset. It is not a fudge: it puts the shapes a
* definite, known distance apart, so the next frame's test has an unambiguous answer instead of a
* coin-flip on the last bit. Small enough to be invisible, large enough to beat floating-point noise -
* a thousandth of a world unit is generous for a character a unit tall.
*/
export const SKIN = 1e-3;
export function pushOut(p: Point, contact: Contact, skin = SKIN): Point {
return movedBy(p, scaled(contact.normal, contact.depth + skin));
}
/** Every contact's push applied once. Positions only, so the order cannot matter. */
export function pushOutAll(
p: Point,
contacts: readonly Contact[],
skin = SKIN,
): Point {
return contacts.reduce(
(at, contact) => (contact.depth > 0 ? pushOut(at, contact, skin) : at),
p,
);
}
/**
* A velocity settled against **several** walls at once, which is what a corner is.
*
* One pass is not always enough, and the reason is worth seeing: sliding along wall A can leave a velocity
* that is heading into wall B, and fixing B can put it back into A. So the loop repeats until nothing is
* being driven into any wall, or until the budget runs out.
*
* Real solvers do exactly this and cap the passes, because a sharp enough wedge can take many. The cap is a
* budget rather than a correctness fix, and `settled` says honestly whether it was enough - the build
* measures how many passes different corners actually need rather than assuming one.
*/
export function settleVelocity(
v: Vector,
normals: readonly Vector[],
restitution = 0,
maxPasses = 8,
tolerance = 1e-9,
): {
velocity: Vector;
passes: number;
settled: boolean;
/** How much velocity is still heading into the worst wall. Zero when genuinely settled. */
residual: number;
} {
const worstInward = (w: Vector) =>
normals.reduce((worst, n) => Math.max(worst, -dot(w, n)), 0);
let velocity = v;
for (let pass = 1; pass <= maxPasses; pass += 1) {
if (worstInward(velocity) <= tolerance) {
return {
velocity,
passes: pass - 1,
settled: true,
residual: worstInward(velocity),
};
}
for (const n of normals) {
velocity = resolveVelocity(velocity, n, restitution);
}
}
return {
velocity,
passes: maxPasses,
settled: worstInward(velocity) <= tolerance,
residual: worstInward(velocity),
};
}
/**
* How fast the residual shrinks per pass, which is what "converges" actually means here.
*
* $$\text{rate} = \cos^2\phi$$
*
* Repeatedly projecting onto two half-spaces is alternating projection, and where the correct answer is
* "you cannot move at all" it approaches that answer **geometrically rather than exactly**. The residual
* falls by $\cos^2\phi$ per pass, where $\phi$ is the angle between the two normals.
*
* **Squared, and it took measuring to get that right** - the first version of this function returned
* $|\cos\phi|$, and the measured ratios were its square every time: normals $120°$ apart have
* $|\cos| = 0.5$ and shed exactly a **quarter** of the residual per pass, not a half. One pass performs
* both projections, which is where the second factor comes from.
*
* Two consequences worth having. Normals **$90°$ or less apart settle exactly, in a single pass** - the
* feasible cone is at least a right angle wide and the two projections never fight. Beyond that they do:
* at $150°$ the residual only falls by three quarters per pass, and at $170°$ by $0.97$, which is a crawl.
* A right-angled corner - the case games actually hit - has $\cos^2 90° = 0$ and is exact immediately.
*/
export function convergenceRate(a: Vector, b: Vector): number {
return dot(a, b) * dot(a, b);
}
// ---- Tunnelling ------------------------------------------------------------------------------
/**
* The speed at which an object starts passing straight through a wall of a given thickness.
*
* $$v_{\text{escape}} = \frac{\text{thickness}}{\Delta t}$$
*
* Because a discrete step moves the object $v\,\Delta t$ in one go, and if that is longer than the wall is
* thick it can begin one side and end the other with **no frame in between where the two overlap**. The
* test never fires. Nothing is wrong with the collision code; it was simply never asked.
*
* This is a hard number rather than a rule of thumb, and it is unpleasantly small: a wall a tenth of a
* unit thick at 60 fps is defeated by 6 units per second.
*/
export function tunnellingSpeed(thickness: number, dt: number): number {
return thickness / dt;
}
/** How far an object travels in one step. The quantity that has to stay under the wall's thickness. */
export function stepDistance(speed: number, dt: number): number {
return speed * dt;
}
/**
* How **often** a given speed tunnels, which is the part that makes this bug so unpleasant.
*
* $$P = \max\!\left(0,\ 1 - \frac{\text{thickness}}{v\,\Delta t}\right) = \max\!\left(0,\ 1 - \frac{v_{\text{escape}}}{v}\right)$$
*
* Above the escape speed, tunnelling is **possible but not certain** - it depends where the frame
* boundaries happen to fall relative to the wall. Samples spaced $v\,\Delta t$ apart miss an interval of
* width $w$ for a fraction $1 - w/(v\,\Delta t)$ of the possible offsets.
*
* So at twice the escape speed it happens half the time, and at four times, three quarters of the time.
* A bug that fires on some frames and not others, depending on sub-pixel timing, is far harder to track
* down than one that always fires - and finding that my first attempt at measuring this reported "detected"
* at every speed I happened to try is exactly how that plays out in practice.
*/
export function tunnellingChance(
speed: number,
dt: number,
thickness: number,
): number {
const step = stepDistance(speed, dt);
return step <= thickness ? 0 : 1 - thickness / step;
}
/**
* How many substeps keep each one shorter than the thinnest wall in the level.
*
* The cheap fix, and the honest one: split the frame into pieces small enough that the object cannot skip
* anything. Costs more collision tests per frame, in exchange for not needing swept shapes.
*
* Note the parameter it depends on is the **thinnest** wall, not the average - the level's flimsiest piece
* of geometry sets the budget for the whole simulation.
*/
export function substepsNeeded(
speed: number,
dt: number,
thinnest: number,
): number {
return Math.max(1, Math.ceil(stepDistance(speed, dt) / thinnest));
}
/**
* Walk a step in pieces, reporting whether any piece landed inside the wall.
*
* Used by the build to show that one step misses and several do not. The `inside` predicate keeps the
* geometry out of here, so this stays about the stepping rather than about the shape.
*/
export function sweepHits(
from: Point,
velocity: Vector,
dt: number,
substeps: number,
inside: (p: Point) => boolean,
): { hit: Point | null; tested: number } {
const piece = dt / substeps;
for (let i = 1; i <= substeps; i += 1) {
const at = movedBy(from, scaled(velocity, piece * i));
if (inside(at)) return { hit: at, tested: i };
}
return { hit: null, tested: substeps };
}
/**
* The wrong fix, kept so the build can price it: just cap the speed.
*
* It does prevent tunnelling, and it also silently changes the game - a projectile that was meant to be
* fast is now not. Worth knowing as a deliberate design choice and not as a bug fix.
*/
export function cappedSpeed(v: Vector, dt: number, thinnest: number): Vector {
const maximum = thinnest / dt;
const speed = length(v);
if (speed <= maximum) return v;
const unit = normalize(v);
return unit === null ? { x: 0, y: 0 } : scaled(unit, maximum);
}
/** The reverse of a velocity, for drawing where something came from. */
export function incoming(v: Vector): Vector {
return reversed(v);
} Where This Shows Up
Section titled “Where This Shows Up”- Section 6.1, where a fixed timestep makes constant, so the escape speed becomes a number you can design against rather than one that varies with the frame rate.
- The capstone, where move-and-slide against tiles is this Section applied per axis.
- Section 5.3’s minimum translation vector, which is the input to the push-out.
- Section 1.4’s projection, which is the whole of the split.
- Section 4.1’s delta time, which is what makes tunnelling a function of frame rate at all.