All articles
26 min read

Collision Detection: How to Build Realistic Physics Interactions With Code

collision detectionphysics simulationgame developmentcreative codingJavaScriptcanvasalgorithmAABBSAT

Collision detection is one of the most satisfying problems in computer science. Every bouncing ball, every platformer jump, every billiard simulation, every creative coding particle system relies on it. At its core, the question is simple: do these two shapes overlap? But the answers range from trivially fast rectangle checks to elegant mathematical theorems about convex geometry.

This article covers the full spectrum of collision detection techniques through 8 interactive code examples you can run in your browser. We start with the simplest bounding box test and build up to a complete physics playground with gravity, friction, and multi-shape collisions.

What Is Collision Detection and Why Does It Matter?

Collision detection determines whether two or more geometric objects intersect in space. In real-time applications — games, simulations, creative coding — this check runs every frame (60 times per second), so performance matters as much as correctness.

The field divides neatly into two phases:

  • Broad phase: quickly eliminate pairs that cannot possibly collide using spatial partitioning (grids, quadtrees, sweep-and-prune)
  • Narrow phase: perform precise geometric intersection tests on remaining candidate pairs (AABB, circle, SAT polygon)

We will explore both phases in detail, starting with narrow phase primitives and then building broad phase structures on top.

1. AABB Collision Detection

The Axis-Aligned Bounding Box is the workhorse of collision detection. Two AABBs collide when they overlap on both axes simultaneously. The test is four comparisons:

// AABB overlap test
collides = (a.x < b.x + b.w) &&
           (a.x + a.w > b.x) &&
           (a.y < b.y + b.h) &&
           (a.y + a.h > b.y);

Drag the rectangles below to see AABB collision in action. When boxes overlap, they turn red and the collision normal is displayed:

<!DOCTYPE html>
<html><head><style>*{margin:0}body{background:#111;display:flex;justify-content:center;align-items:center;height:100vh}canvas{border-radius:8px}</style></head><body>
<canvas id="c" width="700" height="500"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const boxes=[
  {x:150,y:180,w:120,h:80,col:'#4ecdc4'},
  {x:400,y:220,w:100,h:100,col:'#ff6b6b'},
  {x:280,y:100,w:90,h:130,col:'#ffe66d'},
  {x:450,y:80,w:110,h:70,col:'#a8e6cf'}
];
let drag=null,ox=0,oy=0;
function aabb(a,b){return a.xb.x&&a.yb.y}
function collisionNormal(a,b){
  const cx1=a.x+a.w/2,cy1=a.y+a.h/2,cx2=b.x+b.w/2,cy2=b.y+b.h/2;
  const dx=cx2-cx1,dy=cy2-cy1;
  const ox=(a.w/2+b.w/2)-Math.abs(dx),oy=(a.h/2+b.h/2)-Math.abs(dy);
  if(ox0?1:-1,ny:0,d:ox};
  return{nx:0,ny:dy>0?1:-1,d:oy};
}
function draw(){
  x.fillStyle='#111';x.fillRect(0,0,700,500);
  x.fillStyle='#fff';x.font='14px monospace';
  x.fillText('Drag rectangles to test AABB collision',10,25);
  const cols=[];
  for(let i=0;i{hit.add(i);hit.add(j)});
  boxes.forEach((b,i)=>{
    x.fillStyle=hit.has(i)?'rgba(255,50,50,0.6)':b.col+'99';
    x.fillRect(b.x,b.y,b.w,b.h);
    x.strokeStyle=hit.has(i)?'#ff3333':b.col;x.lineWidth=2;
    x.strokeRect(b.x,b.y,b.w,b.h);
    x.fillStyle='#fff';x.font='11px monospace';
    x.fillText(`[${Math.round(b.x)},${Math.round(b.y)}]`,b.x+4,b.y+14);
  });
}
c.onmousedown=e=>{const r=c.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;
  for(let i=boxes.length-1;i>=0;i--){const b=boxes[i];if(mx>=b.x&&mx<=b.x+b.w&&my>=b.y&&my<=b.y+b.h){drag=i;ox=mx-b.x;oy=my-b.y;break}}};
c.onmousemove=e=>{if(drag===null)return;const r=c.getBoundingClientRect();
  boxes[drag].x=e.clientX-r.left-ox;boxes[drag].y=e.clientY-r.top-oy;draw()};
c.onmouseup=()=>{drag=null};
draw();
</script>
</body></html>

AABB is fast — just four comparisons — but it only works for axis-aligned rectangles. Rotated shapes need a different approach. Still, AABB is the foundation: even complex collision systems use AABB as a first-pass filter before running more expensive tests.

2. Circle-Circle Collision

Circle collision is equally elegant. Two circles collide when the distance between their centers is less than the sum of their radii:

dx = b.x - a.x;
dy = b.y - a.y;
dist = Math.sqrt(dx*dx + dy*dy);
collides = dist < a.r + b.r;

For elastic collision response, we reflect velocities along the collision normal (the line connecting centers). Click anywhere to add new circles:

<!DOCTYPE html>
<html><head><style>*{margin:0}body{background:#111;display:flex;justify-content:center;align-items:center;height:100vh}canvas{border-radius:8px}</style></head><body>
<canvas id="c" width="700" height="500"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=700,H=500;
const cols=['#4ecdc4','#ff6b6b','#ffe66d','#a8e6cf','#c3a6ff','#ff9a76','#88d8b0','#f7dc6f'];
let balls=[];
function addBall(px,py){
  const r=12+Math.random()*20;
  balls.push({x:px,y:py,r,vx:(Math.random()-0.5)*4,vy:(Math.random()-0.5)*4,
    col:cols[balls.length%cols.length],m:r*r});
}
for(let i=0;i<12;i++)addBall(80+Math.random()*(W-160),80+Math.random()*(H-160));
c.onclick=e=>{const r=c.getBoundingClientRect();addBall(e.clientX-r.left,e.clientY-r.top)};
function step(){
  balls.forEach(b=>{b.x+=b.vx;b.y+=b.vy;
    if(b.x-b.r<0){b.x=b.r;b.vx*=-0.95}
    if(b.x+b.r>W){b.x=W-b.r;b.vx*=-0.95}
    if(b.y-b.r<0){b.y=b.r;b.vy*=-0.95}
    if(b.y+b.r>H){b.y=H-b.r;b.vy*=-0.95}
  });
  for(let i=0;i0.001){
      const nx=dx/dist,ny=dy/dist;
      const overlap=(a.r+b.r)-dist;
      const totalM=a.m+b.m;
      a.x-=nx*overlap*(b.m/totalM);a.y-=ny*overlap*(b.m/totalM);
      b.x+=nx*overlap*(a.m/totalM);b.y+=ny*overlap*(a.m/totalM);
      const dvx=a.vx-b.vx,dvy=a.vy-b.vy;
      const dvn=dvx*nx+dvy*ny;
      if(dvn>0){
        const imp=2*dvn/totalM;
        a.vx-=imp*b.m*nx;a.vy-=imp*b.m*ny;
        b.vx+=imp*a.m*nx;b.vy+=imp*a.m*ny;
      }
    }
  }
}
function draw(){
  x.fillStyle='#111';x.fillRect(0,0,W,H);
  balls.forEach(b=>{
    x.beginPath();x.arc(b.x,b.y,b.r,0,Math.PI*2);
    x.fillStyle=b.col+'44';x.fill();x.strokeStyle=b.col;x.lineWidth=2;x.stroke();
    x.beginPath();x.arc(b.x+b.r*0.2,b.y-b.r*0.2,b.r*0.15,0,Math.PI*2);
    x.fillStyle='rgba(255,255,255,0.3)';x.fill();
  });
  x.fillStyle='#fff';x.font='14px monospace';
  x.fillText(`Click to add circles — Elastic collision with mass-based response (${balls.length} balls)`,10,25);
}
function loop(){step();draw();requestAnimationFrame(loop)}
loop();
</script>
</body></html>

Elastic collisions conserve both momentum and kinetic energy. The mass of each circle is proportional to its area (r²), so larger circles transfer less velocity in a collision — just like in real physics.

3. Line Segment Intersection

Line segment intersection is crucial for raycasting, laser reflections, and wire-based puzzles. We use the parametric intersection method: given segments P1→P2 and P3→P4, we solve for parameters t and u where the lines cross. If both t and u are in [0,1], the segments intersect.

Drag the endpoints to see intersection points highlighted in real time:

<!DOCTYPE html>
<html><head><style>*{margin:0}body{background:#111;display:flex;justify-content:center;align-items:center;height:100vh}canvas{border-radius:8px}</style></head><body>
<canvas id="c" width="700" height="500"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const segs=[
  {x1:100,y1:120,x2:600,y2:380,col:'#4ecdc4'},
  {x1:150,y1:400,x2:550,y2:80,col:'#ff6b6b'},
  {x1:80,y1:250,x2:620,y2:250,col:'#ffe66d'},
  {x1:350,y1:50,x2:350,y2:450,col:'#a8e6cf'},
  {x1:200,y1:50,x2:500,y2:450,col:'#c3a6ff'},
];
let drag=null,ox=0,oy=0;
function intersect(s1,s2){
  const dx1=s1.x2-s1.x1,dy1=s1.y2-s1.y1,dx2=s2.x2-s2.x1,dy2=s2.y2-s2.y1;
  const den=dx1*dy2-dy1*dx2;
  if(Math.abs(den)<1e-10)return null;
  const t=((s2.x1-s1.x1)*dy2-(s2.y1-s1.y1)*dx2)/den;
  const u=((s2.x1-s1.x1)*dy1-(s2.y1-s1.y1)*dx1)/den;
  if(t>=0&&t<=1&&u>=0&&u<=1)return{x:s1.x1+t*dx1,y:s1.y1+t*dy1};
  return null;
}
function draw(){
  x.fillStyle='#111';x.fillRect(0,0,700,500);
  const pts=[];
  segs.forEach((s,i)=>{
    x.strokeStyle=s.col;x.lineWidth=2;x.beginPath();
    x.moveTo(s.x1,s.y1);x.lineTo(s.x2,s.y2);x.stroke();
    [[s.x1,s.y1],[s.x2,s.y2]].forEach(([px,py])=>{
      x.beginPath();x.arc(px,py,7,0,Math.PI*2);
      x.fillStyle=s.col;x.fill();x.strokeStyle='#fff';x.lineWidth=1;x.stroke();
    });
    for(let j=i+1;j{
    x.beginPath();x.arc(p.x,p.y,8,0,Math.PI*2);
    x.fillStyle='#fff';x.fill();
    x.beginPath();x.arc(p.x,p.y,12,0,Math.PI*2);
    x.strokeStyle='rgba(255,255,255,0.4)';x.lineWidth=2;x.stroke();
    x.beginPath();x.arc(p.x,p.y,18,0,Math.PI*2);
    x.strokeStyle='rgba(255,255,255,0.15)';x.lineWidth=1;x.stroke();
  });
  x.fillStyle='#fff';x.font='14px monospace';
  x.fillText('Drag endpoints — '+pts.length+' intersection'+(pts.length!==1?'s':'')+' found',10,25);
}
function closest(mx,my){
  let best=null,bd=900;
  segs.forEach((s,i)=>{
    [[s.x1,s.y1,'1'],[s.x2,s.y2,'2']].forEach(([px,py,end])=>{
      const d=Math.hypot(mx-px,my-py);
      if(d{const r=c.getBoundingClientRect();drag=closest(e.clientX-r.left,e.clientY-r.top)};
c.onmousemove=e=>{if(!drag)return;const r=c.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;
  const s=segs[drag.seg];
  if(drag.end==='1'){s.x1=mx;s.y1=my}else{s.x2=mx;s.y2=my}
  draw()};
c.onmouseup=()=>{drag=null};
draw();
</script>
</body></html>

The parametric method is numerically stable and gives us the exact intersection point — not just a boolean. This makes it perfect for physics engines that need to know where a collision happened, not just if it happened.

4. SAT (Separating Axis Theorem)

The Separating Axis Theorem is the most powerful narrow-phase collision detection algorithm for convex polygons. The theorem states: two convex shapes do not overlap if and only if there exists an axis along which their projections are separated.

For polygons, we only need to check axes perpendicular to each edge. If all projections overlap, we know the shapes collide — and the axis with the smallest overlap gives us the Minimum Translation Vector (MTV) to push the shapes apart.

Drag the rotating polygons to test SAT collision. The MTV arrow shows the shortest direction to separate them:

<!DOCTYPE html>
<html><head><style>*{margin:0}body{background:#111;display:flex;justify-content:center;align-items:center;height:100vh}canvas{border-radius:8px}</style></head><body>
<canvas id="c" width="700" height="500"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d'),W=700,H=500;
function mkPoly(cx,cy,n,r){const v=[];for(let i=0;i<n;i++){const a=Math.PI*2*i/n-Math.PI/2;v.push([r*Math.cos(a),r*Math.sin(a)])}return{cx,cy,verts:v,angle:0,n,r,va:0.005+Math.random()*0.01*(Math.random()<0.5?1:-1)}}
const polys=[mkPoly(200,250,5,70),mkPoly(450,250,4,65),mkPoly(350,150,6,50)];
polys[0].col='#4ecdc4';polys[1].col='#ff6b6b';polys[2].col='#ffe66d';
let drag=null,ox=0,oy=0;
function worldVerts(p){return p.verts.map(([vx,vy])=>{const c2=Math.cos(p.angle),s=Math.sin(p.angle);return[p.cx+vx*c2-vy*s,p.cy+vx*s+vy*c2]})}
function project(verts,ax,ay){let mn=1e9,mx=-1e9;verts.forEach(([vx,vy])=>{const d=vx*ax+vy*ay;mn=Math.min(mn,d);mx=Math.max(mx,d)});return[mn,mx]}
function sat(p1,p2){
  const v1=worldVerts(p1),v2=worldVerts(p2);
  let minO=1e9,mtvAx=0,mtvAy=0;
  const edges=[];
  [v1,v2].forEach(vs=>{for(let i=0;i<vs.length;i++){const j=(i+1)%vs.length;const ex=vs[j][0]-vs[i][0],ey=vs[j][1]-vs[i][1];const l=Math.hypot(ex,ey);edges.push([-ey/l,ex/l])}});
  for(const[ax,ay]of edges){
    const[mn1,mx1]=project(v1,ax,ay),[mn2,mx2]=project(v2,ax,ay);
    const overlap=Math.min(mx1,mx2)-Math.max(mn1,mn2);
    if(overlap<=0)return null;
    if(overlapp.angle+=p.va);
  const hits=new Set();
  for(let i=0;i<polys.length;i++)for(let j=i+1;j<polys.length;j++){
    const r=sat(polys[i],polys[j]);
    if(r){hits.add(i);hits.add(j);
      const mx2=(polys[i].cx+polys[j].cx)/2,my2=(polys[i].cy+polys[j].cy)/2;
      x.strokeStyle='#fff';x.lineWidth=2;x.beginPath();
      x.moveTo(mx2,my2);x.lineTo(mx2+r.nx*r.overlap*1.5,my2+r.ny*r.overlap*1.5);x.stroke();
      x.beginPath();x.arc(mx2+r.nx*r.overlap*1.5,my2+r.ny*r.overlap*1.5,4,0,Math.PI*2);
      x.fillStyle='#fff';x.fill();
      x.fillStyle='rgba(255,255,255,0.12)';x.font='11px monospace';
      x.fillText('MTV: '+r.overlap.toFixed(1)+'px',mx2+10,my2-10);
    }
  }
  polys.forEach((p,i)=>{
    const v=worldVerts(p);
    x.beginPath();x.moveTo(v[0][0],v[0][1]);
    for(let k=1;k<v.length;k++)x.lineTo(v[k][0],v[k][1]);
    x.closePath();
    x.fillStyle=hits.has(i)?'rgba(255,50,50,0.35)':p.col+'33';x.fill();
    x.strokeStyle=hits.has(i)?'#ff3333':p.col;x.lineWidth=2;x.stroke();
  });
  x.fillStyle='#fff';x.font='14px monospace';
  x.fillText('Drag polygons — SAT collision with MTV (Minimum Translation Vector)',10,25);
  requestAnimationFrame(draw);
}
c.onmousedown=e=>{const r=c.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;
  for(let i=polys.length-1;i>=0;i--){if(Math.hypot(mx-polys[i].cx,my-polys[i].cy){if(drag===null)return;const r=c.getBoundingClientRect();
  polys[drag].cx=e.clientX-r.left-ox;polys[drag].cy=e.clientY-r.top-oy};
c.onmouseup=()=>{drag=null};
draw();
</script>
</body></html>

SAT is elegant because it reduces polygon collision to a series of 1D interval overlap tests. It works for any convex polygon — triangles, pentagons, hexagons — and gives both the collision boolean and the resolution vector in one pass. For concave polygons, you decompose them into convex parts first.

Broad Phase vs Narrow Phase

The examples above are all narrow phase algorithms: precise tests between pairs of shapes. But with 1000 objects, checking every pair means 499,500 tests per frame. That scales as O(n²) and quickly becomes too slow.

Broad phase algorithms solve this by partitioning space so we only test objects that are near each other. The two most common approaches are spatial hashing (uniform grid) and quadtrees (adaptive subdivision).

5. Spatial Hashing

Spatial hashing divides the world into a uniform grid. Each object is placed into the cell(s) it overlaps. We only test pairs that share at least one cell. For uniformly distributed objects of similar size, this brings collision detection close to O(n).

This demo shows 200+ particles with the grid cells visualized. Active cells (containing 2+ particles) are highlighted:

<!DOCTYPE html>
<html><head><style>*{margin:0}body{background:#111;display:flex;justify-content:center;align-items:center;height:100vh}canvas{border-radius:8px}</style></head><body>
<canvas id="c" width="700" height="500"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d'),W=700,H=500,CS=50;
const COLS=Math.ceil(W/CS),ROWS=Math.ceil(H/CS);
const balls=[];
for(let i=0;i<220;i++)balls.push({x:Math.random()*W,y:Math.random()*H,r:4+Math.random()*4,
  vx:(Math.random()-0.5)*3,vy:(Math.random()-0.5)*3,col:'#4ecdc4'});
let checks=0;
function step(){
  const grid=new Map();
  balls.forEach((b,i)=>{
    b.x+=b.vx;b.y+=b.vy;
    if(b.xW-b.r){b.x=W-b.r;b.vx*=-1}
    if(b.yH-b.r){b.y=H-b.r;b.vy*=-1}
    b.hit=false;
    const minC=Math.max(0,Math.floor((b.x-b.r)/CS)),maxC=Math.min(COLS-1,Math.floor((b.x+b.r)/CS));
    const minR=Math.max(0,Math.floor((b.y-b.r)/CS)),maxR=Math.min(ROWS-1,Math.floor((b.y+b.r)/CS));
    for(let row=minR;row<=maxR;row++)for(let col=minC;col<=maxC;col++){
      const key=row*COLS+col;if(!grid.has(key))grid.set(key,[]);grid.get(key).push(i);
    }
  });
  checks=0;
  const tested=new Set();
  grid.forEach(ids=>{
    for(let i=0;i<ids.length;i++)for(let j=i+1;j<ids.length;j++){
      const k=Math.min(ids[i],ids[j])*balls.length+Math.max(ids[i],ids[j]);
      if(tested.has(k))continue;tested.add(k);checks++;
      const a=balls[ids[i]],b=balls[ids[j]];
      const dx=b.x-a.x,dy=b.y-a.y,dist=Math.hypot(dx,dy);
      if(dist0.01){
        a.hit=true;b.hit=true;
        const nx=dx/dist,ny=dy/dist,overlap=a.r+b.r-dist;
        a.x-=nx*overlap*0.5;a.y-=ny*overlap*0.5;
        b.x+=nx*overlap*0.5;b.y+=ny*overlap*0.5;
        const dvn=(a.vx-b.vx)*nx+(a.vy-b.vy)*ny;
        if(dvn>0){a.vx-=dvn*nx;a.vy-=dvn*ny;b.vx+=dvn*nx;b.vy+=dvn*ny}
      }
    }
  });
  return grid;
}
function draw(){
  const grid=step();
  x.fillStyle='#111';x.fillRect(0,0,W,H);
  // Draw grid
  x.strokeStyle='rgba(255,255,255,0.06)';x.lineWidth=1;
  for(let r=0;r<=ROWS;r++){x.beginPath();x.moveTo(0,r*CS);x.lineTo(W,r*CS);x.stroke()}
  for(let cc=0;cc<=COLS;cc++){x.beginPath();x.moveTo(cc*CS,0);x.lineTo(cc*CS,H);x.stroke()}
  // Highlight active cells
  grid.forEach((ids,key)=>{if(ids.length>=2){
    const col2=key%COLS,row=Math.floor(key/COLS);
    x.fillStyle='rgba(78,205,196,0.08)';x.fillRect(col2*CS,row*CS,CS,CS);
  }});
  balls.forEach(b=>{
    x.beginPath();x.arc(b.x,b.y,b.r,0,Math.PI*2);
    x.fillStyle=b.hit?'#ff6b6b':b.col;x.fill();
  });
  x.fillStyle='#fff';x.font='14px monospace';
  x.fillText(balls.length+' particles — Grid '+COLS+'x'+ROWS+' (cell '+CS+'px) — Checks: '+checks+' vs brute '+((balls.length*(balls.length-1)/2)|0),10,25);
  requestAnimationFrame(draw);
}
draw();
</script>
</body></html>

Notice the "Checks" counter versus the brute-force pair count. With 220 particles, brute force needs ~24,000 checks per frame. Spatial hashing typically brings that down to under 2,000 — a 10x improvement. The grid cell size should roughly match the largest object diameter for best performance.

6. Raycasting

Raycasting is essential for continuous collision detection, line-of-sight checks, and laser/projectile physics. A ray has an origin and a direction, and we test it against every shape in the scene to find the nearest intersection.

Move your mouse to aim the ray. It detects intersections with circles and boxes, and shows the reflection angle:

<!DOCTYPE html>
<html><head><style>*{margin:0}body{background:#111;display:flex;justify-content:center;align-items:center;height:100vh}canvas{border-radius:8px}</style></head><body>
<canvas id="c" width="700" height="500"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d'),W=700,H=500;
const origin={x:50,y:250};
let mx=400,my=150;
const circles=[{x:300,y:200,r:40,col:'#4ecdc4'},{x:500,y:300,r:55,col:'#ffe66d'},{x:400,y:400,r:35,col:'#a8e6cf'}];
const boxes=[{x:180,y:350,w:80,h:60,col:'#ff6b6b'},{x:550,y:100,w:70,h:90,col:'#c3a6ff'}];
function rayCircle(ox2,oy2,dx,dy,ci){
  const fx=ox2-ci.x,fy=oy2-ci.y;
  const a2=dx*dx+dy*dy,b=2*(fx*dx+fy*dy),cc=fx*fx+fy*fy-ci.r*ci.r;
  let disc=b*b-4*a2*cc;if(disc<0)return null;disc=Math.sqrt(disc);
  let t=(-b-disc)/(2*a2);if(t<0.001)t=(-b+disc)/(2*a2);
  if(t<0.001)return null;
  const hx=ox2+dx*t,hy=oy2+dy*t;
  const nx=(hx-ci.x)/ci.r,ny=(hy-ci.y)/ci.r;
  return{t,hx,hy,nx,ny};
}
function rayBox(ox2,oy2,dx,dy,b){
  let tmin=(b.x-ox2)/dx,tmax=(b.x+b.w-ox2)/dx;
  if(tmin>tmax)[tmin,tmax]=[tmax,tmin];
  let tymin=(b.y-oy2)/dy,tymax=(b.y+b.h-oy2)/dy;
  if(tymin>tymax)[tymin,tymax]=[tymax,tymin];
  if(tmin>tymax||tymin>tmax)return null;
  const t0=Math.max(tmin,tymin),t1=Math.min(tmax,tymax);
  const t=t0>0.001?t0:t1;if(t<0.001)return null;
  const hx=ox2+dx*t,hy=oy2+dy*t;
  let nx=0,ny=0;const e=0.01;
  if(Math.abs(hx-b.x){const r=c.getBoundingClientRect();mx=e.clientX-r.left;my=e.clientY-r.top};
function draw(){
  x.fillStyle='#111';x.fillRect(0,0,W,H);
  circles.forEach(ci=>{x.beginPath();x.arc(ci.x,ci.y,ci.r,0,Math.PI*2);x.fillStyle=ci.col+'33';x.fill();x.strokeStyle=ci.col;x.lineWidth=2;x.stroke()});
  boxes.forEach(b=>{x.fillStyle=b.col+'33';x.fillRect(b.x,b.y,b.w,b.h);x.strokeStyle=b.col;x.lineWidth=2;x.strokeRect(b.x,b.y,b.w,b.h)});
  // Cast ray with up to 5 bounces
  let ox2=origin.x,oy2=origin.y;
  let ddx=mx-origin.x,ddy=my-origin.y;
  let len=Math.hypot(ddx,ddy);ddx/=len;ddy/=len;
  for(let bounce=0;bounce<6;bounce++){
    let best=null;
    circles.forEach(ci=>{const h2=rayCircle(ox2,oy2,ddx,ddy,ci);if(h2&&(!best||h2.t{const h2=rayBox(ox2,oy2,ddx,ddy,b);if(h2&&(!best||h2.t{
      let t;
      if(vertical)t=(pos-ox2)/ddx;else t=(pos-oy2)/ddy;
      if(t>0.001&&(!best||t0?-1:1);else ny=-sign||(ddy>0?-1:1);
        if(hx2>=0&&hx2<=W&&hy2>=0&&hy2<=H)best={t,hx:hx2,hy:hy2,nx,ny};
      }
    });
    if(!best)break;
    const alpha=bounce===0?0.9:0.9-bounce*0.15;
    x.strokeStyle=`rgba(255,255,100,${Math.max(0.1,alpha)})`;x.lineWidth=bounce===0?2:1;
    x.beginPath();x.moveTo(ox2,oy2);x.lineTo(best.hx,best.hy);x.stroke();
    x.beginPath();x.arc(best.hx,best.hy,4,0,Math.PI*2);x.fillStyle='#fff';x.fill();
    const ref=reflect(ddx,ddy,best.nx,best.ny);
    ox2=best.hx;oy2=best.hy;ddx=ref.rx;ddy=ref.ry;
  }
  // Origin
  x.beginPath();x.arc(origin.x,origin.y,6,0,Math.PI*2);x.fillStyle='#ffe66d';x.fill();
  x.fillStyle='#fff';x.font='14px monospace';
  x.fillText('Move mouse to aim ray — Reflects off shapes and walls (up to 6 bounces)',10,25);
  requestAnimationFrame(draw);
}
draw();
</script>
</body></html>

Raycasting is also used in continuous collision detection (CCD) — casting a ray along an object's velocity to detect collisions before they happen. This prevents the "tunneling" problem where fast objects pass through thin walls between frames.

7. Quadtree Spatial Partitioning

A quadtree recursively subdivides space into four quadrants. Unlike a uniform grid, a quadtree adapts to the distribution of objects — dense areas get more subdivision, sparse areas stay coarse. This makes it ideal for non-uniform distributions.

This visualization shows 500 particles with the quadtree structure drawn in real time. Watch how the tree adapts as particles move:

<!DOCTYPE html>
<html><head><style>*{margin:0}body{background:#111;display:flex;justify-content:center;align-items:center;height:100vh}canvas{border-radius:8px}</style></head><body>
<canvas id="c" width="700" height="500"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d'),W=700,H=500;
const MAX_OBJ=8,MAX_LVL=6;
class QT{
  constructor(l,bx,by,bw,bh){this.l=l;this.bx=bx;this.by=by;this.bw=bw;this.bh=bh;this.objs=[];this.nodes=null}
  split(){const hw=this.bw/2,hh=this.bh/2,xx=this.bx,yy=this.by;
    this.nodes=[new QT(this.l+1,xx+hw,yy,hw,hh),new QT(this.l+1,xx,yy,hw,hh),
      new QT(this.l+1,xx,yy+hh,hw,hh),new QT(this.l+1,xx+hw,yy+hh,hw,hh)]}
  getIdx(o){const mx2=this.bx+this.bw/2,my2=this.by+this.bh/2;
    const t=o.y=my2,l=o.x=mx2;
    if(r&&t)return 0;if(l&&t)return 1;if(l&&b)return 2;if(r&&b)return 3;return-1}
  insert(o){if(this.nodes){const i=this.getIdx(o);if(i!==-1){this.nodes[i].insert(o);return}}
    this.objs.push(o);
    if(this.objs.length>MAX_OBJ&&this.l{const i=this.getIdx(ob);if(i!==-1&&this.nodes)this.nodes[i].insert(ob);else this.objs.push(ob)})}}
  query(o,found){if(!found)found=[];
    this.objs.forEach(ob=>{if(ob!==o)found.push(ob)});
    if(this.nodes){const i=this.getIdx(o);if(i!==-1)this.nodes[i].query(o,found);
      else this.nodes.forEach(n=>n.query(o,found))}
    return found}
  draw2(ctx){
    ctx.strokeStyle='rgba(78,205,196,0.2)';ctx.lineWidth=1;
    ctx.strokeRect(this.bx,this.by,this.bw,this.bh);
    if(this.nodes)this.nodes.forEach(n=>n.draw2(ctx))}
}
const balls=[];
for(let i=0;i<500;i++){
  const angle=Math.random()*Math.PI*2,sp=0.3+Math.random()*1.5;
  balls.push({x:50+Math.random()*(W-100),y:50+Math.random()*(H-100),r:3,
    vx:Math.cos(angle)*sp,vy:Math.sin(angle)*sp,hit:false});
}
let checks=0;
function step(){
  const qt=new QT(0,0,0,W,H);
  balls.forEach(b=>{
    b.x+=b.vx;b.y+=b.vy;b.hit=false;
    if(b.xW-b.r){b.x=W-b.r;b.vx*=-1}
    if(b.yH-b.r){b.y=H-b.r;b.vy*=-1}
    qt.insert(b);
  });
  checks=0;
  balls.forEach(b=>{
    const near=qt.query(b);
    near.forEach(o=>{
      checks++;
      const dx=o.x-b.x,dy=o.y-b.y,dist=Math.hypot(dx,dy);
      if(dist0.01){
        b.hit=true;o.hit=true;
        const nx=dx/dist,ny=dy/dist,overlap=b.r+o.r-dist;
        b.x-=nx*overlap*0.5;b.y-=ny*overlap*0.5;
        o.x+=nx*overlap*0.5;o.y+=ny*overlap*0.5;
        const dvn=(b.vx-o.vx)*nx+(b.vy-o.vy)*ny;
        if(dvn>0){b.vx-=dvn*nx;b.vy-=dvn*ny;o.vx+=dvn*nx;o.vy+=dvn*ny}
      }
    });
  });
  return qt;
}
function draw(){
  const qt=step();
  x.fillStyle='#111';x.fillRect(0,0,W,H);
  qt.draw2(x);
  balls.forEach(b=>{
    x.beginPath();x.arc(b.x,b.y,b.r,0,Math.PI*2);
    x.fillStyle=b.hit?'#ff6b6b':'#4ecdc4';x.fill();
  });
  x.fillStyle='#fff';x.font='14px monospace';
  x.fillText(balls.length+' particles — Quadtree checks: '+checks+' vs brute '+((balls.length*(balls.length-1)/2)|0),10,25);
  requestAnimationFrame(draw);
}
draw();
</script>
</body></html>

The quadtree visualization reveals the hidden spatial structure of your simulation. Dense clusters trigger deep subdivision, while empty regions remain as large undivided cells. With 500 particles, the quadtree typically reduces collision checks by 90% or more compared to brute force.

Performance Considerations

Choosing the right collision detection strategy depends on your scene:

ScenarioBest ApproachComplexity
Under 50 objectsBrute force (n²)O(n²)
50–1000, uniform sizeSpatial hash grid~O(n)
50–1000, varied sizeQuadtree~O(n log n)
Fast-moving objectsSwept / CCD (raycasting)O(n²) worst
Static geometryBVH (bounding volume hierarchy)O(n log n) build, O(log n) query

General tips for maximum performance:

  • Always use a broad phase for more than ~50 objects
  • AABB as a first-pass filter before SAT or circle tests
  • Use squared distances instead of Math.sqrt where possible
  • Avoid allocating objects in your collision loop — reuse vectors
  • For static geometry, build the spatial structure once and query many times

8. Physics Playground

Finally, let us combine everything into a complete physics system with AABB, circle, and polygon collisions. This playground includes gravity, bounce restitution, and friction. Click anywhere to spawn random shapes:

<!DOCTYPE html>
<html><head><style>*{margin:0}body{background:#111;display:flex;justify-content:center;align-items:center;height:100vh}canvas{border-radius:8px}</style></head><body>
<canvas id="c" width="700" height="500"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d'),W=700,H=500;
const G=0.3,BOUNCE=0.65,FRIC=0.99;
const cols=['#4ecdc4','#ff6b6b','#ffe66d','#a8e6cf','#c3a6ff','#ff9a76','#88d8b0'];
const shapes=[];
function addCircle(px,py){shapes.push({type:'circle',x:px,y:py,r:15+Math.random()*20,
  vx:(Math.random()-0.5)*4,vy:-2-Math.random()*3,col:cols[shapes.length%cols.length],
  m:0,get mass(){return this.r*this.r}})}
function addBox(px,py){const w=25+Math.random()*35,h=25+Math.random()*35;
  shapes.push({type:'box',x:px-w/2,y:py-h/2,w,h,
  vx:(Math.random()-0.5)*4,vy:-2-Math.random()*3,col:cols[shapes.length%cols.length],
  get mass(){return this.w*this.h}})}
function addPoly(px,py){const n=3+Math.floor(Math.random()*4),r=20+Math.random()*20;
  const verts=[];for(let i=0;i{const r=c.getBoundingClientRect(),px=e.clientX-r.left,py=e.clientY-r.top;
  const t=Math.random();if(t<0.4)addCircle(px,py);else if(t<0.7)addBox(px,py);else addPoly(px,py)};
function getBounds(s){
  if(s.type==='circle')return{x:s.x-s.r,y:s.y-s.r,w:s.r*2,h:s.r*2};
  if(s.type==='box')return{x:s.x,y:s.y,w:s.w,h:s.h};
  let mnx=1e9,mny=1e9,mxx=-1e9,mxy=-1e9;
  s.verts.forEach(([vx,vy])=>{const px=s.cx+vx,py=s.cy+vy;mnx=Math.min(mnx,px);mny=Math.min(mny,py);mxx=Math.max(mxx,px);mxy=Math.max(mxy,py)});
  return{x:mnx,y:mny,w:mxx-mnx,h:mxy-mny};
}
function getCx(s){if(s.type==='circle')return[s.x,s.y];if(s.type==='box')return[s.x+s.w/2,s.y+s.h/2];return[s.cx,s.cy]}
function setCx(s,cx2,cy2){if(s.type==='circle'){s.x=cx2;s.y=cy2}else if(s.type==='box'){s.x=cx2-s.w/2;s.y=cy2-s.h/2}else{s.cx=cx2;s.cy=cy2}}
function aabbHit(a,b){return a.xb.x&&a.yb.y}
function circleCircle(a,b){
  const dx=b.x-a.x,dy=b.y-a.y,dist=Math.hypot(dx,dy);
  if(dist>=a.r+b.r||dist<0.001)return null;
  const nx=dx/dist,ny=dy/dist;return{nx,ny,overlap:a.r+b.r-dist}}
function circleBox(ci,bx){
  const cx2=Math.max(bx.x,Math.min(ci.x,bx.x+bx.w)),cy2=Math.max(bx.y,Math.min(ci.y,bx.y+bx.h));
  const dx=ci.x-cx2,dy=ci.y-cy2,dist=Math.hypot(dx,dy);
  if(dist>=ci.r||dist<0.001)return null;
  const nx=dx/dist,ny=dy/dist;return{nx,ny,overlap:ci.r-dist}}
function resolveCollision(a,b,n){
  const[ax,ay]=getCx(a),[bx2,by2]=getCx(b);
  const totalM=a.mass+b.mass;
  const acx2=ax-n.nx*n.overlap*(b.mass/totalM),acy2=ay-n.ny*n.overlap*(b.mass/totalM);
  const bcx2=bx2+n.nx*n.overlap*(a.mass/totalM),bcy2=by2+n.ny*n.overlap*(a.mass/totalM);
  setCx(a,acx2,acy2);setCx(b,bcx2,bcy2);
  const dvx=a.vx-b.vx,dvy=a.vy-b.vy,dvn=dvx*n.nx+dvy*n.ny;
  if(dvn>0){const imp=dvn*(1+BOUNCE)/totalM;
    a.vx-=imp*b.mass*n.nx;a.vy-=imp*b.mass*n.ny;
    b.vx+=imp*a.mass*n.nx;b.vy+=imp*a.mass*n.ny}
}
function step(){
  shapes.forEach(s=>{
    s.vy+=G;s.vx*=FRIC;s.vy*=FRIC;
    if(s.type==='circle'){s.x+=s.vx;s.y+=s.vy;
      if(s.y+s.r>H){s.y=H-s.r;s.vy*=-BOUNCE}
      if(s.y-s.r<0){s.y=s.r;s.vy*=-BOUNCE}
      if(s.x-s.r<0){s.x=s.r;s.vx*=-BOUNCE}
      if(s.x+s.r>W){s.x=W-s.r;s.vx*=-BOUNCE}
    }else if(s.type==='box'){s.x+=s.vx;s.y+=s.vy;
      if(s.y+s.h>H){s.y=H-s.h;s.vy*=-BOUNCE}
      if(s.y<0){s.y=0;s.vy*=-BOUNCE}
      if(s.x<0){s.x=0;s.vx*=-BOUNCE}
      if(s.x+s.w>W){s.x=W-s.w;s.vx*=-BOUNCE}
    }else{s.cx+=s.vx;s.cy+=s.vy;
      if(s.cy+s.r>H){s.cy=H-s.r;s.vy*=-BOUNCE}
      if(s.cy-s.r<0){s.cy=s.r;s.vy*=-BOUNCE}
      if(s.cx-s.r<0){s.cx=s.r;s.vx*=-BOUNCE}
      if(s.cx+s.r>W){s.cx=W-s.r;s.vx*=-BOUNCE}
    }
  });
  for(let i=0;i0&&oy2>0){if(ox20?-1:1,ny:0,overlap:ox2};else n={nx:0,ny:dy2>0?-1:1,overlap:oy2}}
    }
    if(n)resolveCollision(a,b,n);
  }
}
function drawShape(s){
  if(s.type==='circle'){x.beginPath();x.arc(s.x,s.y,s.r,0,Math.PI*2);
    x.fillStyle=s.col+'55';x.fill();x.strokeStyle=s.col;x.lineWidth=2;x.stroke();
  }else if(s.type==='box'){x.fillStyle=s.col+'55';x.fillRect(s.x,s.y,s.w,s.h);
    x.strokeStyle=s.col;x.lineWidth=2;x.strokeRect(s.x,s.y,s.w,s.h);
  }else{x.beginPath();const v=s.verts;x.moveTo(s.cx+v[0][0],s.cy+v[0][1]);
    for(let k=1;k

This playground combines AABB broad-phase filtering with specific narrow-phase tests for each shape pair. The physics response uses impulse-based resolution with configurable restitution (bounciness) and friction. Click rapidly to fill the scene and watch the objects pile up under gravity.

Real-World Applications

Collision detection is everywhere:

  • Game engines — Unity and Unreal use multi-phase collision with AABB trees, GJK for convex hulls, and EPA for penetration depth
  • Physics simulators — Box2D (used in Angry Birds) combines sweep-and-prune broad phase with SAT narrow phase
  • Creative coding — particle systems, generative art, and interactive installations all rely on collision for emergent behavior
  • Robotics — path planning requires continuous collision detection to avoid obstacles
  • CAD software — interference detection ensures parts do not overlap in mechanical assemblies

The algorithms in this article form the foundation of all these systems. Whether you are building a game, a physics simulation, or a piece of generative art, understanding collision detection gives you the power to create worlds where objects interact believably.

Want to explore more physics-driven art? Visit Lumitree to discover unique generative micro-worlds, or browse the full collection of creative coding tutorials for more visual programming guides.

Related articles