Learn how to build a 2D physics engine from scratch, starting with basic motion and building up to springs, constraints, and interactive simulations.

Building a 2D Physics Engine: From Newton to Pinball


I was always wondering how games create realistic physics without making computation explode. In this article, I am going to discuss how a complete 2D physics engine is built. We’ll start with basic Newton’s laws and gradually add features until we have springs, rotating agitators, and a full interactive physics playground.

Interactive Demo: Physics Playground

Here is the end products. Click and drag to launch balls like a slingshot, or grab existing balls to re-launch them. Use the tool palette to add springs, agitators, and obstacles. Adjust the bounciness and air resistance sliders to see how they affect the simulation:

The Computational Challenge

Real-world physics is continuous - objects move smoothly through space and time. But computers work in discrete steps. So we need to bridge this gap with clever approximations.

The Naive Approach Would Kill Performance

Consider simulating 100 bouncing balls:

  • Each ball needs to check collisions with 99 others
  • That’s 100 x 99 = 9,900 checks per frame
  • At 60 FPS, that’s 594,000 collision checks per second!
  • With 1,000 balls? 59.9 million checks per second!

This O(n2)O(n^2) complexity makes naive physics simulation impractical. We need clever algorithms and approximations.

Key Approximations We’ll Use

  1. Discrete Time Steps: Instead of continuous motion, we update in small increments (typically 1/60th second)
  2. Simplified Shapes: We use circles instead of complex polygons - collision math is drastically simpler
  3. Impulse-Based Collisions: Rather than simulating contact forces over time, we apply instantaneous impulses
  4. Position Correction: We allow slight overlaps then gently push objects apart
  5. Spatial Partitioning: We only check nearby objects for collisions (though our demo is simple enough to skip this)

Part 1: The Physics Simulation

Let’s build our physics engine step by step, focusing purely on the simulation logic first. We’ll worry about drawing things later.

1.1 Physics Bodies: The Foundation

Every object in our physics world needs to track its state. In real physics, objects have complex shapes, rotation, deformation, and countless properties. We simplify dramatically:

interface PhysicsBody {
  position: { x: number; y: number };      // Where is it?
  velocity: { x: number; y: number };      // How fast is it moving?
  acceleration: { x: number; y: number };  // How is velocity changing?
  mass: number;                            // How heavy is it?
  radius: number;                          // How big is it? (we'll use circles)
  forces: { x: number; y: number };        // What forces act on it?
}

Why circles? Circle collision detection requires only a distance check. Polygon collision detection requires complex algorithms like the Separating Axis Theorem. By using circles, we trade perfect accuracy for 10-100x performance gains.

1.2 Newton’s Second Law: F = ma

The core of any physics engine is Newton’s second law. In the real world, forces are continuous - gravity pulls constantly, springs apply smooth forces. We approximate by accumulating forces each frame:

Fnet=iFi\vec{F}_{net} = \sum_{i} \vec{F}_i

a=Fnetm\vec{a} = \frac{\vec{F}_{net}}{m}

Where multiple forces (gravity, springs, collisions) are summed before calculating acceleration.

function applyForce(body: PhysicsBody, force: { x: number; y: number }) {
  // Forces accumulate until we integrate
  body.forces.x += force.x;
  body.forces.y += force.y;
}

This accumulation approach lets us handle multiple forces (gravity, springs, collisions) cleanly.

1.3 Integration - Making Things Move

Here’s where we make our biggest approximation. Real motion is continuous, described by differential equations. We approximate using Euler integration - basically assuming velocity and acceleration are constant over each tiny time step:

Continuous Physics (Reality):

dvdt=a(t)\frac{d\vec{v}}{dt} = \vec{a}(t) dxdt=v(t)\frac{d\vec{x}}{dt} = \vec{v}(t)

Discrete Approximation (Our Simulation):

vn+1=vn+anΔt\vec{v}_{n+1} = \vec{v}_n + \vec{a}_n \cdot \Delta t xn+1=xn+vnΔt\vec{x}_{n+1} = \vec{x}_n + \vec{v}_n \cdot \Delta t

The smaller Δt\Delta t, the more accurate the approximation.

function updatePhysics(body: PhysicsBody, dt: number) {
  // Step 1: Calculate acceleration from forces (F = ma, so a = F/m)
  body.acceleration.x = body.forces.x / body.mass;
  body.acceleration.y = body.forces.y / body.mass;
  
  // Step 2: Update velocity based on acceleration
  body.velocity.x += body.acceleration.x * dt;
  body.velocity.y += body.acceleration.y * dt;
  
  // Step 3: Update position based on velocity
  body.position.x += body.velocity.x * dt;
  body.position.y += body.velocity.y * dt;
  
  // Step 4: Clear forces for next frame
  body.forces = { x: 0, y: 0 };
}

The dt Parameter: This is our time step, typically 1/60 second. Smaller steps = more accurate but slower. Larger steps = faster but objects might tunnel through walls!

Why Euler? More accurate methods exist (Verlet, RK4) but Euler is:

  • Dead simple to implement
  • Stable enough for games
  • Fast to compute

1.4 Adding Gravity and Air Resistance

Real air resistance is complex, involving surface area, drag coefficients, and velocity-squared relationships. We use a simple damping factor:

Gravity Force:

Fgravity=mg\vec{F}_{gravity} = m \vec{g}

Real Air Resistance (Complex):

Fdrag=12ρCdAv2v^\vec{F}_{drag} = -\frac{1}{2} \rho C_d A |\vec{v}|^2 \hat{v}

Our Simplified Air Resistance:

vnew=voldkfriction\vec{v}_{new} = \vec{v}_{old} \cdot k_{friction}

Where kfriction0.99k_{friction} \approx 0.99 provides subtle damping without complex calculations.

const GRAVITY = { x: 0, y: 300 };  // Pixels per second squared
const AIR_FRICTION = 0.99;         // Slight damping each frame

function updatePhysicsWithEnvironment(body: PhysicsBody, dt: number) {
  // Apply gravity
  body.forces.x += GRAVITY.x * body.mass;
  body.forces.y += GRAVITY.y * body.mass;
  
  // Apply air friction (simplified)
  body.velocity.x *= AIR_FRICTION;
  body.velocity.y *= AIR_FRICTION;
  
  // Continue with normal physics update
  updatePhysics(body, dt);
}

2. Collision Detection

Now that our objects can move, they need to interact. This is where physics engines earn their keep.

The Challenge of Continuous Collision Detection

In the real world, objects can’t pass through each other. In our discrete simulation, a fast-moving ball can “tunnel” through a wall between frames:

Frame 1: Ball is left of wall
Frame 2: Ball is right of wall (oops, it passed through!)

Professional engines use “continuous collision detection” (CCD) - expensive ray casting to prevent tunneling. We use a simpler approach: keep velocities reasonable and time steps small.

2.1 Boundary Collisions

The simplest collision is with the world boundaries. We detect when a ball tries to leave and reflect its velocity:

function checkBoundaryCollision(body: PhysicsBody, width: number, height: number, restitution: number) {
  // Left and right walls
  if (body.position.x - body.radius < 0) {
    body.position.x = body.radius;
    body.velocity.x = Math.abs(body.velocity.x) * restitution;
  } else if (body.position.x + body.radius > width) {
    body.position.x = width - body.radius;
    body.velocity.x = -Math.abs(body.velocity.x) * restitution;
  }
  
  // Top and bottom walls
  if (body.position.y - body.radius < 0) {
    body.position.y = body.radius;
    body.velocity.y = Math.abs(body.velocity.y) * restitution;
  } else if (body.position.y + body.radius > height) {
    body.position.y = height - body.radius;
    body.velocity.y = -Math.abs(body.velocity.y) * restitution;
  }
}

The restitution parameter controls bounciness (0 = no bounce, 1 = perfect bounce).

2.2 Circle vs Circle Collision

This is where our circle approximation pays off. For complex shapes, collision detection involves checking if any edge of shape A intersects any edge of shape B - potentially hundreds of checks per pair. For circles, it’s a single distance calculation:

Circle Collision Detection:

d=pbpa=(xbxa)2+(ybya)2d = |\vec{p}_b - \vec{p}_a| = \sqrt{(x_b - x_a)^2 + (y_b - y_a)^2}

Collision Condition:

d<ra+rbd < r_a + r_b

Collision Normal:

n^=pbpapbpa\hat{n} = \frac{\vec{p}_b - \vec{p}_a}{|\vec{p}_b - \vec{p}_a|}

Penetration Depth:

penetration=(ra+rb)d\text{penetration} = (r_a + r_b) - d

function checkCircleCollision(a: PhysicsBody, b: PhysicsBody) {
  const dx = b.position.x - a.position.x;
  const dy = b.position.y - a.position.y;
  const distance = Math.sqrt(dx * dx + dy * dy);
  const minDistance = a.radius + b.radius;
  
  if (distance < minDistance && distance > 0) {
    // Collision detected!
    return {
      colliding: true,
      normal: { x: dx / distance, y: dy / distance },
      penetration: minDistance - distance
    };
  }
  return null;
}

3. Collision Response

Detecting collisions is only half the battle. Real collisions happen over time - materials compress, heat is generated, sound waves propagate. We approximate this complex process with an instantaneous “impulse.”

The Physics of Collisions

When two balls collide in real life:

  1. They deform at the contact point
  2. Kinetic energy converts to potential energy (compression)
  3. The potential energy converts back to kinetic energy (expansion)
  4. Some energy is lost to heat/sound (inelastic collision)

This entire process might take milliseconds. We simulate it as an instantaneous momentum exchange.

3.1 Impulse-Based Collision Response

Conservation of momentum governs collisions. The total momentum before collision equals total momentum after collision (plus any energy loss):

Conservation of Momentum:

mava,before+mbvb,before=mava,after+mbvb,afterm_a \vec{v}_{a,before} + m_b \vec{v}_{b,before} = m_a \vec{v}_{a,after} + m_b \vec{v}_{b,after}

Relative Velocity Along Collision Normal:

vrel=(vbva)n^v_{rel} = (\vec{v}_b - \vec{v}_a) \cdot \hat{n}

Impulse Magnitude (including restitution):

j=(1+e)vrel1ma+1mbj = \frac{-(1 + e) v_{rel}}{\frac{1}{m_a} + \frac{1}{m_b}}

Velocity Update:

va,new=va,oldjn^ma\vec{v}_{a,new} = \vec{v}_{a,old} - \frac{j \hat{n}}{m_a} vb,new=vb,old+jn^mb\vec{v}_{b,new} = \vec{v}_{b,old} + \frac{j \hat{n}}{m_b}

Where ee is the coefficient of restitution (0 = inelastic, 1 = elastic).

function resolveCollision(a: PhysicsBody, b: PhysicsBody, collision: any, restitution: number) {
  // Calculate relative velocity
  const relativeVelocity = {
    x: b.velocity.x - a.velocity.x,
    y: b.velocity.y - a.velocity.y
  };
  
  // Velocity along collision normal
  const velocityAlongNormal = 
    relativeVelocity.x * collision.normal.x +
    relativeVelocity.y * collision.normal.y;
  
  // Don't resolve if velocities are separating
  if (velocityAlongNormal > 0) return;
  
  // Calculate impulse magnitude
  const j = -(1 + restitution) * velocityAlongNormal;
  const invMassSum = 1 / a.mass + 1 / b.mass;
  const impulse = j / invMassSum;
  
  // Apply impulse
  a.velocity.x -= (impulse * collision.normal.x) / a.mass;
  a.velocity.y -= (impulse * collision.normal.y) / a.mass;
  b.velocity.x += (impulse * collision.normal.x) / b.mass;
  b.velocity.y += (impulse * collision.normal.y) / b.mass;
}

3.2 Position Correction

Here’s another approximation. By the time we detect a collision, the objects might already be overlapping. In the real world, objects never truly overlap - they deform. We approximate by allowing slight overlap then gently pushing objects apart:

Position Correction Formula:

correction=max(penetrationslop,0)1ma+1mbpercentcorrection = \frac{\max(penetration - slop, 0)}{\frac{1}{m_a} + \frac{1}{m_b}} \cdot percent

Position Updates:

pa,new=pa,oldcorrectionn^ma\vec{p}_{a,new} = \vec{p}_{a,old} - \frac{correction \cdot \hat{n}}{m_a} pb,new=pb,old+correctionn^mb\vec{p}_{b,new} = \vec{p}_{b,old} + \frac{correction \cdot \hat{n}}{m_b}

Where:

  • slop0.01slop \approx 0.01 prevents jittering from tiny overlaps
  • percent0.2percent \approx 0.2 controls how aggressively we correct (too high causes oscillation)
function correctPositions(a: PhysicsBody, b: PhysicsBody, collision: any) {
  const percent = 0.2;    // How much to correct
  const slop = 0.01;      // Allowance to prevent jittering
  
  const correction = Math.max(collision.penetration - slop, 0) /
    (1 / a.mass + 1 / b.mass) * percent;
  
  const correctionVector = {
    x: correction * collision.normal.x,
    y: correction * collision.normal.y
  };
  
  a.position.x -= correctionVector.x / a.mass;
  a.position.y -= correctionVector.y / a.mass;
  b.position.x += correctionVector.x / b.mass;
  b.position.y += correctionVector.y / b.mass;
}

4. Advanced Features

Now let’s add some interesting physics features that make simulations more dynamic.

4.1 Static Obstacles

Real-world physics involves immovable objects - walls, floors, barriers. In our simulation, these need infinite mass (they never accelerate) and perfect rigidity (they never deform). We simulate this by treating them as special cases in collision response:

The Challenge: How do you simulate an object with infinite mass? The math breaks down when you divide by infinity.

Our Solution: Skip the impulse calculation entirely and directly adjust the colliding object’s velocity and position. This simulates a collision with an immovable object.

interface Obstacle {
  x: number;
  y: number;
  type: 'circle' | 'triangle';
  size: number;
  rotation?: number;  // For triangles
  color: string;
}

// Circle obstacle collision
function checkCircleObstacleCollision(body: PhysicsBody, obstacle: Obstacle) {
  if (obstacle.type === 'circle') {
    const dx = body.position.x - obstacle.x;
    const dy = body.position.y - obstacle.y;
    const distance = Math.sqrt(dx * dx + dy * dy);
    const minDistance = body.radius + obstacle.size;
    
    if (distance < minDistance && distance > 0) {
      const normal = { x: dx / distance, y: dy / distance };
      const penetration = minDistance - distance;
      return { normal, penetration };
    }
  }
  // Triangle collision is more complex - we check against each edge
  return null;
}

4.2 Spring Physics

Real springs follow Hooke’s Law: the force is proportional to displacement from rest position. But real springs also have complex behaviors - they can vibrate at natural frequencies, exhibit resonance, and undergo plastic deformation. We approximate with a simplified spring-damper system:

Physics Approximations:

  1. Linear Force Response: Real springs become non-linear under extreme stretching. We use simple linear relationship: F=kxF = -k*x

  2. Velocity Damping: Real damping involves air resistance and internal friction. We use simple velocity-proportional damping

  3. No Compression: Our springs only pull, never push (like a rope). Real springs can compress and buckle

Spring Force (Hooke’s Law):

Fspring=k(current lengthrest length)d^\vec{F}_{spring} = -k \cdot (\text{current length} - \text{rest length}) \cdot \hat{d}

Where:

  • kk is spring stiffness
  • d^\hat{d} is unit vector from anchor to ball
  • Negative sign provides restoring force toward equilibrium

With Damping:

Ftotal=Fspring+Fdamping\vec{F}_{total} = \vec{F}_{spring} + \vec{F}_{damping} Fdamping=cv\vec{F}_{damping} = -c \cdot \vec{v}

Distance and Direction:

d=pballpanchord = |\vec{p}_{ball} - \vec{p}_{anchor}| d^=pballpanchord\hat{d} = \frac{\vec{p}_{ball} - \vec{p}_{anchor}}{d}

Springs add dynamic behavior to our simulation:

interface Spring {
  anchorX: number;
  anchorY: number;
  restLength: number;
  stiffness: number;
  damping: number;
  ball: PhysicsBody;
}

function updateSpring(spring: Spring, dt: number, gravity: { x: number; y: number }) {
  const ball = spring.ball;
  
  // Calculate spring force using Hooke's law
  const dx = ball.position.x - spring.anchorX;
  const dy = ball.position.y - spring.anchorY;
  const distance = Math.sqrt(dx * dx + dy * dy);
  
  if (distance > 0) {
    const stretch = distance - spring.restLength;
    const forceMagnitude = spring.stiffness * stretch;
    
    // Apply spring force
    const fx = -(dx / distance) * forceMagnitude;
    const fy = -(dy / distance) * forceMagnitude;
    
    ball.forces.x += fx;
    ball.forces.y += fy;
    
    // Apply damping
    ball.forces.x -= ball.velocity.x * spring.damping;
    ball.forces.y -= ball.velocity.y * spring.damping;
  }
  
  // Don't forget gravity!
  ball.forces.y += gravity.y * ball.mass;
  
  // Update the ball physics
  updatePhysics(ball, dt);
  
  // Constraint: prevent excessive stretching
  const maxStretch = spring.restLength * 2.5;
  if (distance > maxStretch) {
    ball.position.x = spring.anchorX + (dx / distance) * maxStretch;
    ball.position.y = spring.anchorY + (dy / distance) * maxStretch;
  }
}

4.3 Rotating Agitators

Real rotating objects have complex collision dynamics - they impart angular momentum, create different collision velocities at different contact points, and can “fling” objects. Our approximation uses simplified line-segment collision with velocity-based impulse transfer:

Physics Challenges:

  1. Variable Contact Velocity: Different points on a rotating object move at different speeds. The tip moves faster than the base
  2. Angular Momentum Transfer: Real collisions can cause both objects to spin. We only transfer linear momentum
  3. Contact Duration: Real contacts happen over time. We approximate as instantaneous

Rotating Object Physics:

θ(t)=θ0+ωt\theta(t) = \theta_0 + \omega t

Vane Tip Position:

ptip=pcenter+R(cos(θ)sin(θ))\vec{p}_{tip} = \vec{p}_{center} + R \begin{pmatrix} \cos(\theta) \\ \sin(\theta) \end{pmatrix}

Vane Tip Velocity:

vtip=Rω(sin(θ)cos(θ))\vec{v}_{tip} = R \omega \begin{pmatrix} -\sin(\theta) \\ \cos(\theta) \end{pmatrix}

Momentum Transfer (simplified):

Δvball=kvtipcollision_strength\Delta \vec{v}_{ball} = k \cdot \vec{v}_{tip} \cdot collision\_strength

Where:

  • ω\omega is angular velocity (radians/second)
  • RR is vane length
  • kk is transfer efficiency

To add chaos to our simulation, let’s create rotating agitators:

interface Agitator {
  x: number;
  y: number;
  rotation: number;
  size: number;
  speed: number;  // radians per second
}

function updateAgitator(agitator: Agitator, dt: number) {
  agitator.rotation += agitator.speed * dt;
}

function checkCircleAgitatorCollision(body: PhysicsBody, agitator: Agitator) {
  // Check collision with each of the 4 vanes (X pattern)
  for (let i = 0; i < 4; i++) {
    const angle = (i * Math.PI / 2) + agitator.rotation;
    const vaneEndX = agitator.x + Math.cos(angle) * agitator.size;
    const vaneEndY = agitator.y + Math.sin(angle) * agitator.size;
    
    // Simplified: check distance from vane line segment
    // In practice, you'd do proper line-circle intersection
    const collision = checkLineCircleCollision(
      agitator.x, agitator.y, vaneEndX, vaneEndY, body
    );
    
    if (collision) {
      // Calculate vane velocity at contact point
      const vaneSpeed = agitator.speed * collision.contactDistance;
      
      // Add "flinging" effect
      body.velocity.x += collision.normal.y * vaneSpeed * 0.5;
      body.velocity.y += -collision.normal.x * vaneSpeed * 0.5;
      
      return collision;
    }
  }
  return null;
}

5. The Complete Physics System

Now we orchestrate all these components into a cohesive simulation. The main challenge is the order of operations - which calculations happen first can dramatically affect the results:

Critical Ordering Decisions:

  1. Forces First: Calculate all forces before any position updates
  2. Integration Next: Update all positions using accumulated forces
  3. Collision Detection: Find overlaps after movement
  4. Collision Response: Apply impulses to separate overlapping objects
  5. Position Correction: Gently push apart any remaining overlaps

This ordering prevents “fighting” between different systems and ensures stable simulation.

Let’s put it all together into a working physics simulation:

class PhysicsSimulation {
  bodies: PhysicsBody[] = [];
  obstacles: Obstacle[] = [];
  springs: Spring[] = [];
  agitators: Agitator[] = [];
  
  gravity = { x: 0, y: 300 };
  restitution = 0.8;
  airFriction = 0.99;
  
  update(dt: number) {
    // 1. Update agitators
    this.agitators.forEach(agitator => {
      agitator.rotation += agitator.speed * dt;
    });
    
    // 2. Update springs
    this.springs.forEach(spring => {
      updateSpring(spring, dt, this.gravity);
    });
    
    // 3. Update regular bodies
    this.bodies.forEach(body => {
      // Apply gravity
      body.forces.y += this.gravity.y * body.mass;
      
      // Update physics
      updatePhysics(body, dt);
      
      // Apply air friction
      body.velocity.x *= this.airFriction;
      body.velocity.y *= this.airFriction;
      
      // Check boundaries
      checkBoundaryCollision(body, 600, 400, this.restitution);
    });
    
    // 4. Handle collisions between bodies
    for (let i = 0; i < this.bodies.length; i++) {
      for (let j = i + 1; j < this.bodies.length; j++) {
        const collision = checkCircleCollision(this.bodies[i], this.bodies[j]);
        if (collision) {
          resolveCollision(this.bodies[i], this.bodies[j], collision, this.restitution);
          correctPositions(this.bodies[i], this.bodies[j], collision);
        }
      }
    }
    
    // 5. Handle collisions with obstacles and agitators
    [...this.bodies, ...this.springs.map(s => s.ball)].forEach(body => {
      // Obstacle collisions
      this.obstacles.forEach(obstacle => {
        const collision = checkCircleObstacleCollision(body, obstacle);
        if (collision) {
          // Treat obstacle as infinite mass
          body.position.x += collision.normal.x * collision.penetration;
          body.position.y += collision.normal.y * collision.penetration;
          
          const velocityAlongNormal = 
            body.velocity.x * collision.normal.x + 
            body.velocity.y * collision.normal.y;
          
          if (velocityAlongNormal < 0) {
            const impulse = -velocityAlongNormal * (1 + this.restitution);
            body.velocity.x += impulse * collision.normal.x;
            body.velocity.y += impulse * collision.normal.y;
          }
        }
      });
      
      // Agitator collisions
      this.agitators.forEach(agitator => {
        const collision = checkCircleAgitatorCollision(body, agitator);
        if (collision) {
          // Apply collision response and flinging effect
        }
      });
    });
  }
}

Part 2: Visualization and Rendering

Now that we have a complete physics simulation, we need to visualize it. The challenge is bridging the gap between our abstract physics world (positions, velocities, forces) and the visual representation on screen.

The Rendering Challenge

Frame Rate Independence: Physics should run at a consistent rate regardless of display refresh rate. A 60Hz monitor and 120Hz monitor should show identical physics behavior.

Smooth Animation: We need to draw new frames whenever the browser is ready, typically 60 times per second, using requestAnimationFrame.

6.1 The Canvas Rendering Loop

Modern browsers provide requestAnimationFrame which calls our render function when the browser is ready to draw. This creates a smooth animation loop:

let lastTime = 0;
const PHYSICS_TIMESTEP = 1/60; // Fixed 60 FPS physics

function gameLoop(currentTime: number) {
  const deltaTime = (currentTime - lastTime) / 1000; // Convert to seconds
  lastTime = currentTime;
  
  // Fixed timestep physics
  physicsSimulation.update(PHYSICS_TIMESTEP);
  
  // Variable timestep rendering
  render(deltaTime);
  
  requestAnimationFrame(gameLoop);
}

Why Fixed Timestep Physics? This ensures consistent physics regardless of frame rate. A game running at 30 FPS will have identical physics to one running at 120 FPS.

Fixed Timestep Algorithm:

Δtphysics=160=0.0167 seconds (constant)\Delta t_{physics} = \frac{1}{60} = 0.0167 \text{ seconds (constant)}

Decoupled from Rendering:

trender=variable (depends on display refresh rate)t_{render} = \text{variable (depends on display refresh rate)} tphysics=always Δtphysicst_{physics} = \text{always } \Delta t_{physics}

6.2 Drawing the Physics World

The HTML5 Canvas API provides our drawing surface. We translate our physics coordinates to screen coordinates:

function render(ctx: CanvasRenderingContext2D) {
  // Clear the canvas
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  // Draw all physics bodies
  bodies.forEach(body => {
    ctx.beginPath();
    ctx.arc(body.position.x, body.position.y, body.radius, 0, Math.PI * 2);
    ctx.fillStyle = body.color;
    ctx.fill();
    ctx.strokeStyle = '#333';
    ctx.lineWidth = 2;
    ctx.stroke();
  });
  
  // Draw springs as lines
  springs.forEach(spring => {
    ctx.beginPath();
    ctx.moveTo(spring.anchorX, spring.anchorY);
    ctx.lineTo(spring.ball.position.x, spring.ball.position.y);
    ctx.strokeStyle = '#666';
    ctx.lineWidth = 3;
    ctx.stroke();
    
    // Draw anchor point
    ctx.beginPath();
    ctx.arc(spring.anchorX, spring.anchorY, 5, 0, Math.PI * 2);
    ctx.fillStyle = '#333';
    ctx.fill();
  });
  
  // Draw rotating agitators
  agitators.forEach(agitator => {
    ctx.save();
    ctx.translate(agitator.x, agitator.y);
    ctx.rotate(agitator.rotation);
    
    // Draw X pattern
    ctx.strokeStyle = '#e11d48';
    ctx.lineWidth = 4;
    ctx.beginPath();
    ctx.moveTo(-agitator.size, 0);
    ctx.lineTo(agitator.size, 0);
    ctx.moveTo(0, -agitator.size);
    ctx.lineTo(0, agitator.size);
    ctx.stroke();
    
    ctx.restore();
  });
}

6.3 User Interaction

Converting mouse events to physics interactions requires coordinate transformation and state management:

Coordinate Transformation:

pworld=pmousepcanvas_offset\vec{p}_{world} = \vec{p}_{mouse} - \vec{p}_{canvas\_offset}

Slingshot Launch Velocity:

vlaunch=k(pstartpend)\vec{v}_{launch} = k \cdot (\vec{p}_{start} - \vec{p}_{end})

Where kk is velocity scaling factor (typically 2-4 for good feel).

function handleMouseDown(event: MouseEvent) {
  const rect = canvas.getBoundingClientRect();
  const x = event.clientX - rect.left;
  const y = event.clientY - rect.top;
  
  if (selectedTool === 'ball') {
    // Start slingshot mode
    dragStart = { x, y };
    isDragging = true;
  } else if (selectedTool === 'select') {
    // Check if clicking on existing ball
    const clickedBody = bodies.find(body => {
      const dx = body.position.x - x;
      const dy = body.position.y - y;
      return Math.sqrt(dx * dx + dy * dy) < body.radius;
    });
    
    if (clickedBody) {
      draggedBody = clickedBody;
      isDragging = true;
    }
  }
}

function handleMouseMove(event: MouseEvent) {
  if (!isDragging) return;
  
  const rect = canvas.getBoundingClientRect();
  const x = event.clientX - rect.left;
  const y = event.clientY - rect.top;
  
  if (draggedBody) {
    // Move existing ball
    draggedBody.position.x = x;
    draggedBody.position.y = y;
    draggedBody.velocity.x = 0;
    draggedBody.velocity.y = 0;
  } else if (dragStart) {
    // Update slingshot preview
    dragEnd = { x, y };
  }
}

function handleMouseUp(event: MouseEvent) {
  if (dragStart && dragEnd && selectedTool === 'ball') {
    // Launch new ball with slingshot velocity
    const velocityScale = 3;
    const newBall = {
      position: { x: dragStart.x, y: dragStart.y },
      velocity: { 
        x: (dragStart.x - dragEnd.x) * velocityScale,
        y: (dragStart.y - dragEnd.y) * velocityScale
      },
      acceleration: { x: 0, y: 0 },
      forces: { x: 0, y: 0 },
      mass: 1,
      radius: 15,
      color: getRandomColor()
    };
    
    bodies.push(newBall);
  }
  
  // Reset drag state
  isDragging = false;
  dragStart = null;
  dragEnd = null;
  draggedBody = null;
}

6.4 React Integration

Integrating with React requires careful management of the animation loop and component lifecycle:

const PhysicsDemo: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const animationFrameId = useRef<number>();
  const [isRunning, setIsRunning] = useState(false);
  
  const gameLoop = useCallback((currentTime: number) => {
    if (!isRunning) return;
    
    // Update physics
    updatePhysics(1/60);
    
    // Render
    const canvas = canvasRef.current;
    if (!canvas) return;
    
    const ctx = canvas.getContext('2d');
    if (!ctx) return;
    
    render(ctx);
    
    animationFrameId.current = requestAnimationFrame(gameLoop);
  }, [isRunning]);
  
  useEffect(() => {
    if (isRunning) {
      animationFrameId.current = requestAnimationFrame(gameLoop);
    } else {
      if (animationFrameId.current) {
        cancelAnimationFrame(animationFrameId.current);
      }
    }
    
    return () => {
      if (animationFrameId.current) {
        cancelAnimationFrame(animationFrameId.current);
      }
    };
  }, [isRunning, gameLoop]);
  
  return (
    <div className="physics-demo">
      <canvas
        ref={canvasRef}
        width={600}
        height={400}
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
      />
      {/* UI controls */}
    </div>
  );
};

Optimization Techniques Summary

  1. Spatial Partitioning: Divide space into grid cells to only check nearby objects
  2. Sleeping Bodies: Skip calculations for stationary objects
  3. Broad Phase: Quick AABB checks before detailed collision detection
  4. Fixed Timestep: Ensure consistent simulation regardless of frame rate
  5. Object Pooling: Reuse objects to reduce garbage collection

Conclusion

We’ve built a functional 2D physics engine from scratch, implementing:

  • Basic motion and forces
  • Collision detection and response
  • Spatial optimization
  • Constraints and joints
  • Friction and rotation

The key insight is that approximations and clever algorithms make real-time physics simulation feasible. By using spatial partitioning, we reduced collision checks from O(n2)O(n^2) to nearly O(n)O(n), making it possible to simulate hundreds of objects smoothly.

This foundation can be extended with more collision shapes (polygons, capsules), better integration methods (RK4), and advanced features like continuous collision detection. The principles remain the same: simulate just enough physics to look believable while keeping computation manageable.

Next Steps

  • Add polygon collision detection using the Separating Axis Theorem (SAT)
  • Implement continuous collision detection for fast-moving objects
  • Add soft-body physics using mass-spring systems
  • Explore fluid simulation using particle systems
  • Optimize further with QuadTree or R-Tree spatial structures

Physics simulation is a deep rabbit hole, but understanding these fundamentals opens up a world of creative possibilities!