Collision detection
Validate collision flow and response with interactive shapes.
Collision detection
ExoJS provides a geometry-based collision system built on shape primitives and the Separating Axis Theorem (SAT). Every shape implements the Collidable interface, giving you three levels of collision query: boolean overlap (intersectsWith), penetration response (collidesWith), and point containment (contains). A Quadtree provides spatial partitioning for broad-phase culling when you have many objects.
This is not a physics engine — there are no rigid bodies, no friction, no constraint solvers. It answers “are these two things touching?” and “how far do I push them apart?” You build physics, overlap triggers, and hit detection on top.
The shape primitives
Five concrete shapes implement Collidable:
| Shape | Constructor | Collision type |
|---|---|---|
Rectangle | new Rectangle(x, y, width, height) | AABB |
Circle | new Circle(x, y, radius) | Center + radius |
Polygon | new Polygon(points, x?, y?) | Convex polygon (SAT) |
Ellipse | new Ellipse(x, y, rx, ry?) | Radius pair |
Line | new Line(x1, y1, x2, y2) | Segment |
All shapes carry a position (Vector) and expose collisionType for dispatch. intersectsWith is broadly available across shape pairs; collidesWith (penetration response) is implemented for a subset of pairs.
Boolean overlap: intersectsWith
shapeA.intersectsWith(shapeB) returns true if the shapes overlap. This is the cheapest query — no penetration depth, no contact normal, just a boolean:
import { Circle, Rectangle } from '@codexo/exojs';
const player = new Circle(400, 300, 20);
const wall = new Rectangle(100, 200, 80, 160);
if (player.intersectsWith(wall)) {
// prevent movement into the wall
}
All shape-pair combinations are supported for boolean overlap — rectangle-rectangle, circle-circle, polygon-circle, ellipse-rectangle, etc. (ellipse/poly intersections use internal polygon approximations).
Penetration response: collidesWith
shapeA.collidesWith(shapeB) returns a CollisionResponse or null. The CollisionResponse contains:
| Field | Type | Meaning |
|---|---|---|
overlap | number | Penetration depth (positive) |
projectionN | Vector | Unit normal of the minimum-translation axis |
projectionV | Vector | MTV = projectionN × overlap — the push-out vector |
shapeAinB | boolean | true if A is fully inside B |
shapeBinA | boolean | true if B is fully inside A |
shapeA | Collidable | Reference to the first shape |
shapeB | Collidable | Reference to the second shape |
collidesWith returns null in two distinct cases:
- The shapes do not overlap — the usual no-contact case.
- The pair is not implemented — e.g.
Lineagainst any shape, orEllipseagainstEllipse/Polygon. UseintersectsWithfor boolean overlap when the pair may not support a response.
Supported response pairs in the current implementation:
Rectangle.collidesWith— SceneNode, Rectangle, Polygon, Circle, Ellipse.Circle.collidesWith— SceneNode, Rectangle, Polygon, Circle, Ellipse.Polygon.collidesWith— SceneNode, Rectangle, Polygon, Circle.Ellipse.collidesWith— SceneNode, Rectangle, Circle.Line.collidesWith— alwaysnull; useintersectsWithinstead.
Resolving collisions: using projectionV
projectionV is the minimum-translation vector (MTV): the smallest displacement that separates the two shapes. Move the caller by projectionV and the shapes no longer overlap.
// Top-down wall blocking — push the player out of the wall
const response = player.collidesWith(wall);
if (response) {
player.move(response.projectionV.x, response.projectionV.y);
}
player.move(dx, dy) is equivalent to player.setPosition(player.x + dx, player.y + dy) — both work.
For two-way separation (two dynamic objects), split the MTV:
const response = a.collidesWith(b);
if (response) {
const halfX = response.projectionV.x * 0.5;
const halfY = response.projectionV.y * 0.5;
a.move(halfX, halfY);
b.move(-halfX, -halfY);
}
Using projectionN for directional logic
projectionN is the unit normal of the contact surface. Use it to classify which side was hit — without it you cannot distinguish “landed on the floor” from “hit a wall”:
const response = player.collidesWith(surface);
if (response) {
// Separate first
player.move(response.projectionV.x, response.projectionV.y);
// Then classify direction
if (response.projectionN.y < -0.7) {
// Normal points upward — player is on a floor
player.isGrounded = true;
} else if (Math.abs(response.projectionN.x) > 0.7) {
// Normal is mostly horizontal — player hit a wall
}
}
The sign convention: projectionN points from shapeB toward shapeA (away from the surface the caller is being pushed out of).
Point containment: contains
shape.contains(x, y) returns true if the world-space point is inside the shape. Every shape implements it:
if (enemyHitbox.contains(pointer.x, pointer.y)) {
selectEnemy();
}
A Sprite’s contains() works on the rotated quad — fast AABB check when rotation is a multiple of 90°, exact dot-product test for arbitrary angles.
Scene-node collision
Every SceneNode (Sprites, Containers, etc.) implements Collidable with CollisionType.SceneNode. This means you can collision-test drawables directly without wrapping them in shape primitives:
const hero = new Sprite(texture);
hero.setPosition(400, 300);
const box = new Sprite(texture);
box.setPosition(200, 200);
if (hero.intersectsWith(box)) {
const response = hero.collidesWith(box);
if (response) {
box.position.add(response.projectionV);
}
}
SceneNode.intersectsWith dispatches based on whether the node’s rotation is axis-aligned (fast AABB path) or rotated (SAT path). SceneNode.collidesWith returns a response for AABB pairs and, on the rotated path, currently returns responses for SceneNode/Rectangle/Polygon/Circle targets.
SceneNode.getBounds() returns the axis-aligned bounding box — a Rectangle. Use this for broad-phase checks before expensive SAT tests.
Spatial partitioning with Quadtree
For scenes with many objects, pair-wise collision checks against every other object are O(n²). A Quadtree reduces the search space:
import { Quadtree } from '@codexo/exojs';
const tree = new Quadtree(new Rectangle(0, 0, 800, 600), 8, 5);
// Insert objects each frame
for (const enemy of this.enemies) {
tree.insert({ bounds: enemy.getBounds(), payload: enemy });
}
// Query nearby objects
const nearby = tree.queryRect(player.getBounds());
for (const item of nearby) {
if (player.intersectsWith(item.payload)) {
resolveCollision(player, item.payload);
}
}
Quadtree.insert(item) auto-subdivides when a quadrant exceeds maxItems (default 8). queryRect(rect) returns all items whose bounds overlap the query rectangle. queryPoint(x, y) returns items containing the point. The maxDepth parameter (default 5) limits subdivision depth.
The engine’s hit-test system uses a quadtree internally. You can visualise the quadtree partitions through the HitTestLayer debug overlay — quadrant boundaries are drawn as faint gray lines.
Swept (continuous) collision detection
Discrete collision checks test whether two shapes overlap at their current position. Fast-moving objects can pass straight through a thin wall between frames — this is the tunneling problem.
ExoJS provides swept collision functions in swept-collision.ts for the most common gameplay pairs. A sweep moves a shape by a delta vector (dx, dy) and returns the earliest impact along the path as a SweptHit:
interface SweptHit {
t: number; // fraction of move at first contact (0 = already overlapping, 1 = just touches)
x: number; // hit position X (where the moving shape's origin lands)
y: number; // hit position Y
normalX: number; // contact normal pointing away from the target
normalY: number;
}
Available sweep functions
import {
sweepRectangle,
sweepCircleVsRectangle,
sweepCircleVsCircle,
sweepRectangleAgainst,
sweepCircleAgainst,
substepSweep,
} from '@codexo/exojs';
| Function | Moving shape | Target | Notes |
|---|---|---|---|
sweepRectangle | Rectangle | Rectangle | Slab method, exact |
sweepCircleVsCircle | Circle | Circle | Quadratic, exact |
sweepCircleVsRectangle | Circle | Rectangle | Minkowski expansion (V1 — slightly over-collides at corners) |
sweepRectangleAgainst | Rectangle | Rectangle[] | Batch — returns earliest hit across all targets |
sweepCircleAgainst | Circle | Circle[] | Batch — returns earliest hit across all targets |
substepSweep | any | any | Generator of evenly-spaced snapshots for arbitrary pair discrete-step fallback |
Tunneling-safe movement
import { sweepRectangle } from '@codexo/exojs';
// In your update loop:
const playerBounds = player.getBounds();
const hit = sweepRectangle(playerBounds, velocityX * delta, velocityY * delta, wall.getBounds());
if (hit !== null) {
// Move only as far as the impact point
player.move(hit.x - playerBounds.x, hit.y - playerBounds.y);
// Optionally slide along the surface by removing the normal component
const remaining = 1 - hit.t;
const dot = velocityX * hit.normalX + velocityY * hit.normalY;
const slideX = (velocityX - dot * hit.normalX) * remaining * delta;
const slideY = (velocityY - dot * hit.normalY) * remaining * delta;
player.move(slideX, slideY);
} else {
// No hit — full move
player.move(velocityX * delta, velocityY * delta);
}
sweepRectangle handles the already-overlapping case: it returns t = 0 with the deepest-penetration-axis normal so you can resolve immediately without a separate discrete check.
The batch helpers (sweepRectangleAgainst, sweepCircleAgainst) include a swept-AABB broadphase skip — they skip targets whose bounds do not overlap the swept path at all before running the precise test. Use them when you have a list of static tiles or obstacles.
substepSweep is a fallback generator for shape pairs that lack a closed-form swept test. It yields evenly-spaced snapshots along the move path so you can run your own intersectsWith check at each step:
import { substepSweep } from '@codexo/exojs';
for (const snapshot of substepSweep(fromX, fromY, dx, dy, maxStepSize)) {
polygon.setPosition(snapshot.x, snapshot.y);
if (polygon.intersectsWith(target)) {
// Hit at snapshot.t — resolve here
break;
}
}
When the built-in system fits
ExoJS collision is the right choice for:
- Top-down or side-view 2D games with simple push-out physics
- Overlap-based triggers (pickups, zone entries, sensor volumes)
- Hit testing smarter than simple AABB
- Tile-grid or quadtree-spatial broad-phase narrowing
- Fast-moving objects where tunneling is a concern (use the sweep functions)
It is not a replacement for a full physics engine. If you need joints, rigid-body simulation, constraint chains, or restitution, integrate a dedicated physics library and feed ExoJS drawable transforms from the physics step.
Examples
Two animated sprites with collision detection — rotation, scale, and containment feedback with cyan (contained) and red (overlapping) tinting.
Where to go next
The next recipe, Orb Dodge, is the capstone — it wires scenes, input, graphics, and collision into a complete playable game with spawning, scoring, and a restart flow.