The SAT namespace provides narrow-phase collision detection using the Separating Axis
Theorem. It supports three shape combinations: polygon–polygon, circle–polygon, and
circle–circle. It is designed to pair with RigidBody for physics resolution, and with
QuadTree for broad-phase filtering.
All shapes are in the SAT namespace:
import { SAT } from "craters";
const { Vector, Box, Polygon, Circle, Response,
testPolygonPolygon, testCirclePolygon, testCircleCircle } = SAT;
An axis-aligned bounding box. Convert to Polygon before testing.
const box = new Box(new Vector(100, 50), 40, 30); // pos, width, height
const poly = box.toPolygon(); // Polygon centred at (100, 50)
A convex polygon defined by a position and a list of local-space vertices.
const triangle = new Polygon(
new Vector(200, 200), // world position
[new Vector(0, -20), new Vector(20, 20), new Vector(-20, 20)]
);
Key methods:
polygon.setAngle(Math.PI / 4); // rotate (recalcs normals/edges)
polygon.setOffset(new Vector(0, -8)); // local offset from pos
polygon.rotate(Math.PI / 6); // rotate points in place
polygon.translate(5, 0); // translate points in place
polygon.getAABBAsBox(); // Box — cheap AABB for broad-phase
polygon.getCentroid(); // Vector — centre of mass
const circle = new Circle(new Vector(150, 150), 20); // pos, radius
circle.setOffset(new Vector(0, 0)); // optional local offset
circle.getAABBAsBox(); // Box AABB
Pass a Response object to get overlap information. Reuse a single Response instance
and call response.clear() between tests to avoid allocations.
const response = new Response();
// Polygon vs Polygon
if (testPolygonPolygon(shapeA, shapeB, response)) {
console.log("Overlap:", response.overlap);
console.log("Normal:", response.overlapN);
console.log("Vector:", response.overlapV);
}
// Circle vs Polygon
if (testCirclePolygon(circle, polygon, response)) { ... }
// Circle vs Circle
if (testCircleCircle(circleA, circleB, response)) { ... }
Pass null instead of a Response for a boolean-only existence test (faster):
if (testPolygonPolygon(shapeA, shapeB, null)) {
// collision occurred
}
| Field | Type | Description |
|---|---|---|
overlap |
number |
Penetration depth |
overlapN |
Vector |
Collision normal (unit vector, points from B toward A) |
overlapV |
Vector |
overlapN × overlap — minimum separation vector |
aInB |
boolean |
Whether shape A is fully inside B |
bInA |
boolean |
Whether shape B is fully inside A |
contactPoint |
Vector |
Approximate world-space contact point |
RigidBody owns position and velocity; shapes track geometry. After integrating physics,
copy the body position to the shape's pos:
body.integrate(dt);
shape.pos.copy(body.position);
shape.setAngle(body.angle);
import { SAT, RenderLoop, Canvas2DRenderer } from "craters";
const { Vector, Circle, Box, testCirclePolygon, Response } = SAT;
const renderer = new Canvas2DRenderer(800, 600);
document.body.appendChild(renderer.canvasElement);
const ballPos = new Vector(400, 100);
const ballVel = new Vector(3, 0);
const ball = new Circle(ballPos, 16);
const floor = new Box(new Vector(0, 560), 800, 40).toPolygon();
const response = new Response();
new RenderLoop((loop) => {
// Physics
ballVel.y += 0.3; // gravity
ballPos.x += ballVel.x;
ballPos.y += ballVel.y;
// Collision
response.clear();
if (testCirclePolygon(ball, floor, response)) {
ballPos.sub(response.overlapV);
ballVel.y *= -0.7; // bounce
}
// Render
renderer.clear("rgba(10,10,20,1)");
renderer.drawCircle(ballPos.x, ballPos.y, 16, "cyan", true);
renderer.drawRect(0, 560, 800, 40, "grey", true);
});
Use QuadTree to avoid testing every pair. Only shapes returned by retrieve() need a SAT test.
import { QuadTree, SAT } from "craters";
const { Box, Vector, testPolygonPolygon, Response } = SAT;
const qt = new QuadTree.QuadTree(new Box(new Vector(0, 0), 800, 600));
const response = new Response();
// Each frame
qt.clear();
entities.forEach(e => qt.insert(e));
entities.forEach(a => {
const candidates = qt.retrieve([], a);
candidates.forEach(b => {
if (a === b) return;
response.clear();
if (testPolygonPolygon(a.shape, b.shape, response)) {
a.body.resolveCollision(b.body, response);
}
});
});
new Box(pos, w, h) → toPolygon()new Polygon(pos, points[]) → setAngle(), setOffset(), rotate(), translate(), getAABBAsBox(), getCentroid()new Circle(pos, r) → setOffset(), getAABBAsBox()testPolygonPolygon(a, b, response?): booleantestCirclePolygon(circle, polygon, response?): booleantestCircleCircle(a, b, response?): booleanResponseclear() — reset for reuseoverlap, overlapN, overlapV, aInB, bInA, contactPoint