Guide

Guide Recipes Collision detection

Collision detection

Validate collision flow and response with interactive shapes.

Advanced ~6 min read

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:

ShapeConstructorCollision type
Rectanglenew Rectangle(x, y, width, height)AABB
Circlenew Circle(x, y, radius)Center + radius
Polygonnew Polygon(points, x?, y?)Convex polygon (SAT)
Ellipsenew Ellipse(x, y, rx, ry?)Radius pair
Linenew 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:

FieldTypeMeaning
overlapnumberPenetration depth (positive)
projectionNVectorUnit normal of the minimum-translation axis
projectionVVectorMTV = projectionN × overlap — the push-out vector
shapeAinBbooleantrue if A is fully inside B
shapeBinAbooleantrue if B is fully inside A
shapeACollidableReference to the first shape
shapeBCollidableReference to the second shape

collidesWith returns null in two distinct cases:

  1. The shapes do not overlap — the usual no-contact case.
  2. The pair is not implemented — e.g. Line against any shape, or Ellipse against Ellipse / Polygon. Use intersectsWith for 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 — always null; use intersectsWith instead.

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';
FunctionMoving shapeTargetNotes
sweepRectangleRectangleRectangleSlab method, exact
sweepCircleVsCircleCircleCircleQuadratic, exact
sweepCircleVsRectangleCircleRectangleMinkowski expansion (V1 — slightly over-collides at corners)
sweepRectangleAgainstRectangleRectangle[]Batch — returns earliest hit across all targets
sweepCircleAgainstCircleCircle[]Batch — returns earliest hit across all targets
substepSweepanyanyGenerator 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

Rectangles Collision Open in Playground View source
import { Application, Color, Graphics, Scene, Sprite, Texture } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

const CLEAR_TINT = new Color(120, 200, 255);
const OVERLAP_TINT = new Color(255, 90, 90);

class RectanglesCollisionScene extends Scene {
    private boxA!: Sprite;
    private boxB!: Sprite;
    private overlap!: Graphics;
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { gradient: 'image/hue-ramp.png' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;
        const texture = loader.get(Texture, 'gradient');

        // Two axis-aligned rectangles (no rotation) so collision is a true AABB
        // test. Explicit width/height drive the sprite scale; anchor 0.5 keeps the
        // drag offset and the bounds centred on the pointer-grabbed point.
        this.boxA = this.makeBox(texture, 220, 150);
        this.boxA.setPosition(width / 2 - 180, height / 2);

        this.boxB = this.makeBox(texture, 170, 200);
        this.boxB.setPosition(width / 2 + 180, height / 2);

        this.overlap = new Graphics();

        this.hud = mountControls({
            title: 'Rectangles Collision',
            controls: [{ keys: 'Drag', action: 'move a rectangle' }],
            status: 'No overlap',
            hint: 'Drag either rectangle over the other — the AABB overlap region lights up.',
        });
    }

    private makeBox(texture: Texture, w: number, h: number): Sprite {
        const box = new Sprite(texture).setAnchor(0.5);
        box.width = w;
        box.height = h;
        box.setTint(CLEAR_TINT);
        box.interactive = true;
        box.draggable = true;
        return box;
    }

    override update(): void {
        const overlapping = this.boxA.intersectsWith(this.boxB);

        this.boxA.setTint(overlapping ? OVERLAP_TINT : CLEAR_TINT);
        this.boxB.setTint(overlapping ? OVERLAP_TINT : CLEAR_TINT);

        this.overlap.clear();

        if (overlapping) {
            const a = this.boxA.getBounds();
            const b = this.boxB.getBounds();
            const left = Math.max(a.left, b.left);
            const top = Math.max(a.top, b.top);
            const right = Math.min(a.right, b.right);
            const bottom = Math.min(a.bottom, b.bottom);
            const w = Math.max(0, right - left);
            const h = Math.max(0, bottom - top);

            this.overlap.fillColor = new Color(255, 255, 255, 0.85);
            this.overlap.drawRectangle(left, top, w, h);
            this.hud.setStatus(`OVERLAP — ${Math.round(w)}×${Math.round(h)} px`);
        } else {
            this.hud.setStatus('No overlap');
        }
    }

    override draw(context): void {
        context.backend.clear(new Color(18, 22, 30));
        context.render(this.boxA);
        context.render(this.boxB);
        context.render(this.overlap);
    }
}

app.start(new RectanglesCollisionScene());

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.