
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 complexity makes naive physics simulation impractical. We need clever algorithms and approximations.
Key Approximations We’ll Use
- Discrete Time Steps: Instead of continuous motion, we update in small increments (typically 1/60th second)
- Simplified Shapes: We use circles instead of complex polygons - collision math is drastically simpler
- Impulse-Based Collisions: Rather than simulating contact forces over time, we apply instantaneous impulses
- Position Correction: We allow slight overlaps then gently push objects apart
- 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:
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):
Discrete Approximation (Our Simulation):
The smaller , 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:
Real Air Resistance (Complex):
Our Simplified Air Resistance:
Where 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:
Collision Condition:
Collision Normal:
Penetration Depth:
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:
- They deform at the contact point
- Kinetic energy converts to potential energy (compression)
- The potential energy converts back to kinetic energy (expansion)
- 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:
Relative Velocity Along Collision Normal:
Impulse Magnitude (including restitution):
Velocity Update:
Where 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:
Position Updates:
Where:
- prevents jittering from tiny overlaps
- 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:
-
Linear Force Response: Real springs become non-linear under extreme stretching. We use simple linear relationship:
-
Velocity Damping: Real damping involves air resistance and internal friction. We use simple velocity-proportional damping
-
No Compression: Our springs only pull, never push (like a rope). Real springs can compress and buckle
Spring Force (Hooke’s Law):
Where:
- is spring stiffness
- is unit vector from anchor to ball
- Negative sign provides restoring force toward equilibrium
With Damping:
Distance and Direction:
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:
- Variable Contact Velocity: Different points on a rotating object move at different speeds. The tip moves faster than the base
- Angular Momentum Transfer: Real collisions can cause both objects to spin. We only transfer linear momentum
- Contact Duration: Real contacts happen over time. We approximate as instantaneous
Rotating Object Physics:
Vane Tip Position:
Vane Tip Velocity:
Momentum Transfer (simplified):
Where:
- is angular velocity (radians/second)
- is vane length
- 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:
- Forces First: Calculate all forces before any position updates
- Integration Next: Update all positions using accumulated forces
- Collision Detection: Find overlaps after movement
- Collision Response: Apply impulses to separate overlapping objects
- 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:
Decoupled from Rendering:
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:
Slingshot Launch Velocity:
Where 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
- Spatial Partitioning: Divide space into grid cells to only check nearby objects
- Sleeping Bodies: Skip calculations for stationary objects
- Broad Phase: Quick AABB checks before detailed collision detection
- Fixed Timestep: Ensure consistent simulation regardless of frame rate
- 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 to nearly , 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!