Skip to main content

Simple Physics Engine

Building Your Own Physics Engine

Time to combine everything! A physics engine integrates all the concepts we've learned - velocity, gravity, collisions, and forces - into a unified system. Let's build a flexible physics engine you can use in your games! ๐Ÿš€๐ŸŽฎ

Physics Engine Architecture

๐Ÿ—๏ธ The Building Blocks

A physics engine consists of:

graph TD A["Physics Engine"] --> B["World"] B --> C["Bodies"] B --> D["Forces"] B --> E["Constraints"] B --> F["Collision System"] C --> G["RigidBody"] C --> H["StaticBody"] D --> I["Gravity"] D --> J["Springs"] D --> K["User Forces"] E --> L["Joints"] E --> M["Distance"] F --> N["Broad Phase"] F --> O["Narrow Phase"] F --> P["Resolution"]

Interactive Physics Engine Playground

Complete physics simulation with all features!

Bodies: 0 | FPS: 60 | Paused: No

Core Physics Engine Structure

import pygame
import math
from enum import Enum

class BodyType(Enum):
    STATIC = 0
    DYNAMIC = 1
    KINEMATIC = 2

class Shape:
    """Base class for collision shapes"""
    def __init__(self):
        self.type = None
        
class CircleShape(Shape):
    def __init__(self, radius):
        super().__init__()
        self.type = "circle"
        self.radius = radius
        
class BoxShape(Shape):
    def __init__(self, width, height):
        super().__init__()
        self.type = "box"
        self.width = width
        self.height = height
        
class PolygonShape(Shape):
    def __init__(self, vertices):
        super().__init__()
        self.type = "polygon"
        self.vertices = vertices  # List of Vec2

class RigidBody:
    """A physics body in the world"""
    def __init__(self, x, y, shape, body_type=BodyType.DYNAMIC):
        # Transform
        self.position = pygame.math.Vector2(x, y)
        self.angle = 0
        
        # Velocity
        self.linear_velocity = pygame.math.Vector2(0, 0)
        self.angular_velocity = 0
        
        # Acceleration
        self.force = pygame.math.Vector2(0, 0)
        self.torque = 0
        
        # Properties
        self.shape = shape
        self.body_type = body_type
        self.mass = 1.0
        self.inertia = 1.0
        self.restitution = 0.8
        self.friction = 0.3
        self.linear_damping = 0.99
        self.angular_damping = 0.99
        
        # Computed properties
        self._update_mass_properties()
        
    def _update_mass_properties(self):
        """Calculate mass and inertia based on shape"""
        if self.body_type == BodyType.STATIC:
            self.inv_mass = 0
            self.inv_inertia = 0
        else:
            self.inv_mass = 1.0 / self.mass if self.mass > 0 else 0
            
            # Calculate moment of inertia based on shape
            if isinstance(self.shape, CircleShape):
                self.inertia = 0.5 * self.mass * self.shape.radius ** 2
            elif isinstance(self.shape, BoxShape):
                self.inertia = self.mass * (self.shape.width ** 2 + self.shape.height ** 2) / 12
            else:
                # Simplified for polygon
                self.inertia = self.mass * 100  # Approximate
            
            self.inv_inertia = 1.0 / self.inertia if self.inertia > 0 else 0
    
    def apply_force(self, force, point=None):
        """Apply a force to the body"""
        if self.body_type != BodyType.DYNAMIC:
            return
            
        self.force += force
        
        # Apply torque if force is off-center
        if point:
            r = point - self.position
            self.torque += r.x * force.y - r.y * force.x
    
    def apply_impulse(self, impulse, point=None):
        """Apply an instantaneous impulse"""
        if self.body_type != BodyType.DYNAMIC:
            return
            
        self.linear_velocity += impulse * self.inv_mass
        
        if point:
            r = point - self.position
            self.angular_velocity += (r.x * impulse.y - r.y * impulse.x) * self.inv_inertia
    
    def integrate(self, dt):
        """Integrate forces and velocities"""
        if self.body_type != BodyType.DYNAMIC:
            return
        
        # Semi-implicit Euler integration
        self.linear_velocity += self.force * self.inv_mass * dt
        self.angular_velocity += self.torque * self.inv_inertia * dt
        
        # Apply damping
        self.linear_velocity *= self.linear_damping
        self.angular_velocity *= self.angular_damping
        
        # Update position
        self.position += self.linear_velocity * dt
        self.angle += self.angular_velocity * dt
        
        # Clear forces
        self.force = pygame.math.Vector2(0, 0)
        self.torque = 0

Physics World Implementation

class PhysicsWorld:
    """Main physics simulation world"""
    def __init__(self):
        self.bodies = []
        self.constraints = []
        self.gravity = pygame.math.Vector2(0, 980)  # Earth gravity
        self.iterations = 10  # Solver iterations
        self.time_scale = 1.0
        
        # Collision pairs
        self.contacts = []
        
    def add_body(self, body):
        """Add a body to the world"""
        self.bodies.append(body)
        return body
    
    def remove_body(self, body):
        """Remove a body from the world"""
        if body in self.bodies:
            self.bodies.remove(body)
    
    def add_constraint(self, constraint):
        """Add a constraint to the world"""
        self.constraints.append(constraint)
        return constraint
    
    def step(self, dt):
        """Advance the simulation by dt seconds"""
        dt *= self.time_scale
        
        # Sub-stepping for stability
        sub_steps = self.iterations
        sub_dt = dt / sub_steps
        
        for _ in range(sub_steps):
            # Apply forces
            self._apply_forces()
            
            # Integrate velocities
            self._integrate(sub_dt)
            
            # Detect collisions
            self._detect_collisions()
            
            # Solve constraints
            self._solve_constraints()
            
            # Resolve collisions
            self._resolve_collisions()
    
    def _apply_forces(self):
        """Apply external forces like gravity"""
        for body in self.bodies:
            if body.body_type == BodyType.DYNAMIC:
                # Apply gravity
                body.apply_force(self.gravity * body.mass)
    
    def _integrate(self, dt):
        """Integrate body velocities and positions"""
        for body in self.bodies:
            body.integrate(dt)
    
    def _detect_collisions(self):
        """Broad and narrow phase collision detection"""
        self.contacts.clear()
        
        # Simple O(nยฒ) broad phase (should use spatial partitioning for optimization)
        for i in range(len(self.bodies)):
            for j in range(i + 1, len(self.bodies)):
                contact = self._check_collision(self.bodies[i], self.bodies[j])
                if contact:
                    self.contacts.append(contact)
    
    def _check_collision(self, body_a, body_b):
        """Check collision between two bodies"""
        # Circle vs Circle
        if isinstance(body_a.shape, CircleShape) and isinstance(body_b.shape, CircleShape):
            return self._circle_circle_collision(body_a, body_b)
        
        # Circle vs Box
        elif isinstance(body_a.shape, CircleShape) and isinstance(body_b.shape, BoxShape):
            return self._circle_box_collision(body_a, body_b)
        
        # Box vs Box
        elif isinstance(body_a.shape, BoxShape) and isinstance(body_b.shape, BoxShape):
            return self._box_box_collision(body_a, body_b)
        
        return None
    
    def _circle_circle_collision(self, body_a, body_b):
        """Detect collision between two circles"""
        delta = body_b.position - body_a.position
        distance = delta.length()
        radius_sum = body_a.shape.radius + body_b.shape.radius
        
        if distance < radius_sum:
            # Collision detected
            if distance > 0:
                normal = delta / distance
            else:
                normal = pygame.math.Vector2(1, 0)
            
            penetration = radius_sum - distance
            
            return Contact(body_a, body_b, normal, penetration, 
                          body_a.position + normal * body_a.shape.radius)
        
        return None
    
    def _solve_constraints(self):
        """Solve position constraints"""
        for constraint in self.constraints:
            constraint.solve()
    
    def _resolve_collisions(self):
        """Resolve detected collisions"""
        for contact in self.contacts:
            self._resolve_contact(contact)
    
    def _resolve_contact(self, contact):
        """Resolve a single contact"""
        body_a = contact.body_a
        body_b = contact.body_b
        
        # Skip if both static
        if body_a.body_type == BodyType.STATIC and body_b.body_type == BodyType.STATIC:
            return
        
        # Calculate relative velocity
        relative_velocity = body_b.linear_velocity - body_a.linear_velocity
        
        # Velocity along collision normal
        velocity_along_normal = relative_velocity.dot(contact.normal)
        
        # Don't resolve if velocities are separating
        if velocity_along_normal > 0:
            return
        
        # Calculate restitution
        e = min(body_a.restitution, body_b.restitution)
        
        # Calculate impulse scalar
        j = -(1 + e) * velocity_along_normal
        j /= body_a.inv_mass + body_b.inv_mass
        
        # Apply impulse
        impulse = contact.normal * j
        body_a.apply_impulse(-impulse, contact.point)
        body_b.apply_impulse(impulse, contact.point)
        
        # Position correction (to prevent sinking)
        const percent = 0.2  # Penetration percentage to correct
        const slop = 0.01  # Penetration allowance
        correction = max(contact.penetration - slop, 0) / (body_a.inv_mass + body_b.inv_mass) * percent * contact.normal
        
        if body_a.body_type == BodyType.DYNAMIC:
            body_a.position -= correction * body_a.inv_mass
        if body_b.body_type == BodyType.DYNAMIC:
            body_b.position += correction * body_b.inv_mass

class Contact:
    """Collision contact information"""
    def __init__(self, body_a, body_b, normal, penetration, point):
        self.body_a = body_a
        self.body_b = body_b
        self.normal = normal  # From A to B
        self.penetration = penetration
        self.point = point  # Contact point in world space

Constraints and Joints

# Constraint system for connected bodies
class Constraint:
    """Base class for constraints"""
    def solve(self):
        pass

class DistanceConstraint(Constraint):
    """Maintains fixed distance between two bodies"""
    def __init__(self, body_a, body_b, rest_length=None):
        self.body_a = body_a
        self.body_b = body_b
        
        if rest_length is None:
            # Use current distance
            self.rest_length = (body_b.position - body_a.position).length()
        else:
            self.rest_length = rest_length
        
        self.stiffness = 0.5
        self.damping = 0.1
    
    def solve(self):
        """Solve distance constraint"""
        delta = self.body_b.position - self.body_a.position
        current_length = delta.length()
        
        if current_length == 0:
            return
        
        # Calculate correction
        difference = self.rest_length - current_length
        percent = (difference / current_length) * self.stiffness
        offset = delta * percent
        
        # Apply based on mass ratio
        total_inv_mass = self.body_a.inv_mass + self.body_b.inv_mass
        
        if total_inv_mass > 0:
            if self.body_a.body_type == BodyType.DYNAMIC:
                self.body_a.position -= offset * (self.body_a.inv_mass / total_inv_mass)
            if self.body_b.body_type == BodyType.DYNAMIC:
                self.body_b.position += offset * (self.body_b.inv_mass / total_inv_mass)

class HingeConstraint(Constraint):
    """Connects two bodies at a pivot point"""
    def __init__(self, body_a, body_b, anchor_a, anchor_b):
        self.body_a = body_a
        self.body_b = body_b
        self.anchor_a = anchor_a  # Local space
        self.anchor_b = anchor_b  # Local space
    
    def solve(self):
        """Solve hinge constraint"""
        # Transform anchors to world space
        cos_a = math.cos(self.body_a.angle)
        sin_a = math.sin(self.body_a.angle)
        world_anchor_a = self.body_a.position + pygame.math.Vector2(
            self.anchor_a.x * cos_a - self.anchor_a.y * sin_a,
            self.anchor_a.x * sin_a + self.anchor_a.y * cos_a
        )
        
        cos_b = math.cos(self.body_b.angle)
        sin_b = math.sin(self.body_b.angle)
        world_anchor_b = self.body_b.position + pygame.math.Vector2(
            self.anchor_b.x * cos_b - self.anchor_b.y * sin_b,
            self.anchor_b.x * sin_b + self.anchor_b.y * cos_b
        )
        
        # Calculate correction
        delta = world_anchor_b - world_anchor_a
        
        # Apply correction
        total_inv_mass = self.body_a.inv_mass + self.body_b.inv_mass
        
        if total_inv_mass > 0:
            if self.body_a.body_type == BodyType.DYNAMIC:
                self.body_a.position -= delta * (self.body_a.inv_mass / total_inv_mass)
            if self.body_b.body_type == BodyType.DYNAMIC:
                self.body_b.position += delta * (self.body_b.inv_mass / total_inv_mass)

class SpringConstraint(Constraint):
    """Spring force between two bodies"""
    def __init__(self, body_a, body_b, rest_length, spring_constant, damping):
        self.body_a = body_a
        self.body_b = body_b
        self.rest_length = rest_length
        self.spring_constant = spring_constant
        self.damping = damping
    
    def solve(self):
        """Apply spring force"""
        delta = self.body_b.position - self.body_a.position
        current_length = delta.length()
        
        if current_length == 0:
            return
        
        # Spring force (Hooke's law)
        x = current_length - self.rest_length
        force_magnitude = -self.spring_constant * x
        
        # Damping force
        relative_velocity = self.body_b.linear_velocity - self.body_a.linear_velocity
        damping_force = -self.damping * relative_velocity.dot(delta / current_length)
        
        # Total force
        total_force = (force_magnitude + damping_force) * (delta / current_length)
        
        # Apply forces
        if self.body_a.body_type == BodyType.DYNAMIC:
            self.body_a.apply_force(-total_force)
        if self.body_b.body_type == BodyType.DYNAMIC:
            self.body_b.apply_force(total_force)

Complete Physics Engine Example

import pygame
import math
import random

class SimplePhysicsEngine:
    """Complete physics engine example"""
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((800, 600))
        pygame.display.set_caption("Simple Physics Engine")
        self.clock = pygame.time.Clock()
        self.font = pygame.font.Font(None, 24)
        
        # Create physics world
        self.world = PhysicsWorld()
        
        # UI state
        self.running = True
        self.paused = False
        self.debug_draw = False
        self.selected_body = None
        
        # Initialize demo scene
        self.create_demo_scene()
    
    def create_demo_scene(self):
        """Create initial demo scene"""
        # Ground
        ground = RigidBody(400, 580, BoxShape(800, 40), BodyType.STATIC)
        self.world.add_body(ground)
        
        # Walls
        left_wall = RigidBody(10, 300, BoxShape(20, 600), BodyType.STATIC)
        right_wall = RigidBody(790, 300, BoxShape(20, 600), BodyType.STATIC)
        self.world.add_body(left_wall)
        self.world.add_body(right_wall)
        
        # Create a pyramid of boxes
        self.create_pyramid(400, 500, 8)
        
        # Create a chain
        self.create_chain(100, 100, 8)
        
        # Add some bouncing balls
        for _ in range(5):
            x = random.randint(100, 700)
            y = random.randint(50, 200)
            ball = RigidBody(x, y, CircleShape(20))
            ball.linear_velocity = pygame.math.Vector2(
                random.uniform(-200, 200),
                random.uniform(-200, 200)
            )
            ball.restitution = 0.9
            self.world.add_body(ball)
    
    def create_pyramid(self, x, y, rows):
        """Create a pyramid of boxes"""
        box_size = 30
        
        for row in range(rows):
            for col in range(rows - row):
                box_x = x + (col - (rows - row - 1) / 2) * (box_size + 2)
                box_y = y - row * (box_size + 2)
                
                box = RigidBody(box_x, box_y, BoxShape(box_size, box_size))
                box.restitution = 0.4
                self.world.add_body(box)
    
    def create_chain(self, x, y, length):
        """Create a chain of connected bodies"""
        prev_body = None
        link_size = 20
        
        for i in range(length):
            link_x = x + i * (link_size + 10)
            link_y = y
            
            link = RigidBody(link_x, link_y, CircleShape(link_size / 2))
            
            if i == 0:
                # Anchor first link
                link.body_type = BodyType.STATIC
            
            self.world.add_body(link)
            
            if prev_body:
                # Connect to previous link
                constraint = DistanceConstraint(prev_body, link, link_size + 10)
                self.world.add_constraint(constraint)
            
            prev_body = link
    
    def handle_events(self):
        """Handle user input"""
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                self.running = False
            
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_SPACE:
                    self.paused = not self.paused
                elif event.key == pygame.K_d:
                    self.debug_draw = not self.debug_draw
                elif event.key == pygame.K_r:
                    self.world = PhysicsWorld()
                    self.create_demo_scene()
                elif event.key == pygame.K_c:
                    # Clear dynamic bodies
                    self.world.bodies = [b for b in self.world.bodies 
                                        if b.body_type == BodyType.STATIC]
                elif event.key == pygame.K_g:
                    # Toggle gravity
                    if self.world.gravity.y > 0:
                        self.world.gravity = pygame.math.Vector2(0, 0)
                    else:
                        self.world.gravity = pygame.math.Vector2(0, 980)
            
            elif event.type == pygame.MOUSEBUTTONDOWN:
                if event.button == 1:  # Left click
                    # Create body at mouse position
                    x, y = pygame.mouse.get_pos()
                    
                    if pygame.key.get_pressed()[pygame.K_SHIFT]:
                        # Create box with Shift
                        body = RigidBody(x, y, BoxShape(40, 40))
                    else:
                        # Create circle
                        body = RigidBody(x, y, CircleShape(20))
                    
                    # Random initial velocity
                    body.linear_velocity = pygame.math.Vector2(
                        random.uniform(-200, 200),
                        random.uniform(-200, 200)
                    )
                    body.angular_velocity = random.uniform(-5, 5)
                    
                    self.world.add_body(body)
                
                elif event.button == 3:  # Right click
                    # Select body for manipulation
                    x, y = pygame.mouse.get_pos()
                    mouse_pos = pygame.math.Vector2(x, y)
                    
                    for body in self.world.bodies:
                        if body.body_type == BodyType.DYNAMIC:
                            if isinstance(body.shape, CircleShape):
                                if (mouse_pos - body.position).length() < body.shape.radius:
                                    self.selected_body = body
                                    break
            
            elif event.type == pygame.MOUSEBUTTONUP:
                if event.button == 3:
                    self.selected_body = None
        
        # Handle continuous input
        if self.selected_body:
            x, y = pygame.mouse.get_pos()
            mouse_pos = pygame.math.Vector2(x, y)
            
            # Apply force toward mouse
            force = (mouse_pos - self.selected_body.position) * 100
            self.selected_body.apply_force(force)
    
    def update(self, dt):
        """Update simulation"""
        if not self.paused:
            self.world.step(dt)
    
    def draw(self):
        """Draw everything"""
        self.screen.fill((30, 30, 40))
        
        # Draw grid
        if self.debug_draw:
            for x in range(0, 800, 50):
                pygame.draw.line(self.screen, (40, 40, 50), (x, 0), (x, 600))
            for y in range(0, 600, 50):
                pygame.draw.line(self.screen, (40, 40, 50), (0, y), (800, y))
        
        # Draw constraints
        for constraint in self.world.constraints:
            if isinstance(constraint, DistanceConstraint):
                pygame.draw.line(self.screen, (100, 100, 100),
                               constraint.body_a.position,
                               constraint.body_b.position, 1)
        
        # Draw bodies
        for body in self.world.bodies:
            self.draw_body(body)
        
        # Draw selected body highlight
        if self.selected_body:
            pygame.draw.circle(self.screen, (255, 255, 0),
                             (int(self.selected_body.position.x),
                              int(self.selected_body.position.y)),
                             30, 2)
        
        # Draw UI
        self.draw_ui()
    
    def draw_body(self, body):
        """Draw a single body"""
        # Choose color based on body type
        if body.body_type == BodyType.STATIC:
            color = (100, 100, 100)
        else:
            # Dynamic bodies get random colors
            random.seed(id(body))
            color = (random.randint(100, 255),
                    random.randint(100, 255),
                    random.randint(100, 255))
        
        if isinstance(body.shape, CircleShape):
            # Draw circle
            pygame.draw.circle(self.screen, color,
                             (int(body.position.x), int(body.position.y)),
                             int(body.shape.radius))
            
            # Draw rotation indicator
            end_x = body.position.x + math.cos(body.angle) * body.shape.radius
            end_y = body.position.y + math.sin(body.angle) * body.shape.radius
            pygame.draw.line(self.screen, (255, 255, 255),
                           (body.position.x, body.position.y),
                           (end_x, end_y), 2)
        
        elif isinstance(body.shape, BoxShape):
            # Draw rotated box
            cos_a = math.cos(body.angle)
            sin_a = math.sin(body.angle)
            
            # Calculate corners
            hw = body.shape.width / 2
            hh = body.shape.height / 2
            
            corners = [
                (-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)
            ]
            
            # Transform to world space
            world_corners = []
            for corner in corners:
                x = corner[0] * cos_a - corner[1] * sin_a + body.position.x
                y = corner[0] * sin_a + corner[1] * cos_a + body.position.y
                world_corners.append((x, y))
            
            pygame.draw.polygon(self.screen, color, world_corners)
        
        # Draw velocity vector in debug mode
        if self.debug_draw and body.body_type == BodyType.DYNAMIC:
            vel_end = body.position + body.linear_velocity * 0.1
            pygame.draw.line(self.screen, (0, 255, 0),
                           (body.position.x, body.position.y),
                           (vel_end.x, vel_end.y), 2)
    
    def draw_ui(self):
        """Draw user interface"""
        y_offset = 10
        
        texts = [
            f"Bodies: {len(self.world.bodies)}",
            f"Constraints: {len(self.world.constraints)}",
            f"Gravity: {'ON' if self.world.gravity.y > 0 else 'OFF'}",
            f"Paused: {'YES' if self.paused else 'NO'}",
            "",
            "Controls:",
            "Click: Add circle | Shift+Click: Add box",
            "Right Click + Drag: Manipulate body",
            "Space: Pause | D: Debug | R: Reset",
            "C: Clear dynamics | G: Toggle gravity"
        ]
        
        for text in texts:
            rendered = self.font.render(text, True, (255, 255, 255))
            self.screen.blit(rendered, (10, y_offset))
            y_offset += 25
    
    def run(self):
        """Main game loop"""
        dt = 0
        
        while self.running:
            self.handle_events()
            self.update(dt)
            self.draw()
            
            pygame.display.flip()
            dt = self.clock.tick(60) / 1000.0  # 60 FPS
        
        pygame.quit()

if __name__ == "__main__":
    engine = SimplePhysicsEngine()
    engine.run()

Best Practices

โšก Physics Engine Tips

Practice Exercises

๐ŸŽฏ Physics Engine Challenges!

  1. Ragdoll Physics: Connected bodies with joint limits
  2. Soft Bodies: Mass-spring systems for deformable objects
  3. Vehicle Simulation: Wheels, suspension, and steering
  4. Fluid Simulation: Particle-based water physics
  5. Destruction System: Breaking objects into fragments
  6. Rope Physics: Flexible constraints for rope simulation

Key Takeaways

๐Ÿ‹๏ธโ€โ™‚๏ธ Practice Exercise: Mini Physics Engine

๐Ÿ‹๏ธโ€โ™‚๏ธ Exercise 1: World + Body + Step in ~80 Lines

Objective: Build a tiny self-contained physics engine that integrates everything from the four prior physics lessons into one cohesive system. The engine has just two classes โ€” Body (M1's PhysicsObject shape: position / velocity / acceleration / mass / restitution) and World (a list of bodies + a gravity vector + a step method) โ€” and exposes one method, World.step(dt), that runs the canonical physics-engine update order: (1) apply per-frame forces (gravity from M2: body.apply_force(world.gravity * body.mass)); (2) integrate Euler (M1: velocity += acceleration * dt; position += velocity * dt); (3) resolve wall collisions using the M4 reflection formula R = I − 2(I·N)N with M3 restitution applied as a magnitude scale; (4) reset acceleration to zero (M1 Best Practice 'Force Accumulation'). Drop 5 balls simultaneously with random initial velocities; the engine handles gravity, wall bounces, friction-on-ground decay, and the M3 resting threshold all in one place. The exercise embodies Key Takeaway 'Separate concerns: bodies, shapes, constraints, forces' by keeping Body's inertial state cleanly separated from World's update logic, and Key Takeaway 'Physics engines combine all physics concepts' by reusing the four prior lessons' formulas verbatim.

Instructions:

  1. Define a Body class with position, velocity, acceleration (all Vector2), mass, restitution, radius, color. One apply_force(force) method does self.acceleration += force / self.mass (M1).
  2. Define a World class that holds self.bodies (list), self.gravity (Vector2(0, 980) per chat-44 M2: positive y because Pygame Y grows down), and a self.size tuple for the playable rect.
  3. Write World.step(dt): loop over self.bodies doing the canonical update order โ€” (a) apply gravity; (b) integrate velocity += acceleration * dt; position += velocity * dt; (c) resolve walls via reflect-and-clamp; (d) acceleration = Vector2(0, 0).
  4. Write a reflect(I, N) helper at module scope: return I - 2 * I.dot(N) * N (M4 / chat-43 vectors).
  5. Wall collision per body: for each of the 4 walls (top/bottom/left/right), if the body has crossed AND velocity.dot(N) < 0 (M4 Best Practice 'Check Velocity'), set velocity = reflect(velocity, N) * restitution and clamp position back inside the playable rect (M4 Best Practice 'Separate First').
  6. On-ground friction: if a body is on the bottom (position.y + radius >= H - 1), decay velocity.x *= (1 - 1.5 * dt) (M3 friction pattern).
  7. M3 resting threshold for stability: if abs(velocity.y) < 5 and on_ground: velocity.y = 0; if abs(velocity.x) < 1: velocity.x = 0.
  8. Spawn 5 bodies in a single list comprehension with random initial positions and velocities; press R to respawn the lot.
๐Ÿ’ก Hint

The canonical update order matters โ€” forces first, then integrate, then resolve, then reset. If you reset acceleration BEFORE integrating, the gravity force you just applied gets thrown away and the bodies float (the lesson's 'Integrator' building block depends on accumulated acceleration being readable when it runs). If you resolve collisions BEFORE integrating, you're checking penetration against last frame's positions and last frame's velocities โ€” the resolution will fix already-correct bodies and miss this frame's new penetrations. The four-step order โ€” forces, integrate, resolve, reset โ€” is the sequence that all real physics engines (Box2D, Chipmunk, Bullet) use, just at vastly larger scale. The 'Separate concerns' Key Takeaway from this lesson IS the engine architecture: Body holds state that integration mutates, World holds the orchestration logic. Adding new force types (springs, drag, wind) means adding new apply_force calls in the World.step preamble; adding new collision shapes means changing the wall-collision step; the rest stays untouched. That orthogonality is why this 80-line engine extends naturally to thousands of lines without rewriting.

โœ… Example Solution
import pygame
import random
from pygame.math import Vector2

pygame.init()
W, H = 600, 400
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("Mini Physics Engine")
clock = pygame.time.Clock()
font = pygame.font.SysFont(None, 20)

def reflect(I, N):
    """M4 / chat-43 vectors: R = I - 2 * (I . N) * N (N must be unit)"""
    return I - 2 * I.dot(N) * N

class Body:
    def __init__(self, x, y, vx, vy, restitution, color):
        self.position = Vector2(x, y)
        self.velocity = Vector2(vx, vy)
        self.acceleration = Vector2(0, 0)
        self.mass = 1.0
        self.restitution = restitution
        self.radius = 12
        self.color = color

    def apply_force(self, force):
        self.acceleration += force / self.mass  # M1: F = ma

class World:
    WALLS = [
        (Vector2(0,  1), "top"),
        (Vector2(0, -1), "bottom"),
        (Vector2( 1, 0), "left"),
        (Vector2(-1, 0), "right"),
    ]

    def __init__(self, size):
        self.bodies = []
        self.gravity = Vector2(0, 980)  # M2: positive y because Pygame Y grows down
        self.size = size               # (W, H)

    def step(self, dt):
        Wp, Hp = self.size
        for b in self.bodies:
            # 1. Apply forces (M2 gravity as F = m * g)
            b.apply_force(self.gravity * b.mass)
            # 2. Integrate (M1 Euler)
            b.velocity += b.acceleration * dt
            b.position += b.velocity * dt
            # 3. Resolve walls (M4 reflection + M3 restitution)
            for N, name in self.WALLS:
                penetrated = (
                    (name == "top"    and b.position.y - b.radius < 0) or
                    (name == "bottom" and b.position.y + b.radius > Hp) or
                    (name == "left"   and b.position.x - b.radius < 0) or
                    (name == "right"  and b.position.x + b.radius > Wp)
                )
                if penetrated and b.velocity.dot(N) < 0:
                    b.velocity = reflect(b.velocity, N) * b.restitution
                    # Best Practice 'Separate First': clamp inside
                    b.position.x = max(b.radius, min(Wp - b.radius, b.position.x))
                    b.position.y = max(b.radius, min(Hp - b.radius, b.position.y))
            # On-ground friction (M3)
            on_ground = b.position.y + b.radius >= Hp - 1
            if on_ground:
                b.velocity.x *= (1 - 1.5 * dt)
                # M3 resting threshold
                if abs(b.velocity.y) < 5: b.velocity.y = 0
                if abs(b.velocity.x) < 1: b.velocity.x = 0
            # 4. Reset acceleration each frame (M1 Best Practice 'Force Accumulation')
            b.acceleration = Vector2(0, 0)

def spawn_bodies(world):
    world.bodies = [
        Body(
            random.randint(50, W - 50), random.randint(40, 120),
            random.randint(-200, 200), random.randint(-100, 0),
            random.uniform(0.6, 0.9),
            (random.randint(120, 255),
             random.randint(120, 255),
             random.randint(120, 255)))
        for _ in range(5)
    ]

world = World((W, H))
spawn_bodies(world)
running = True
while running:
    dt = clock.tick(60) / 1000.0
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN and event.key == pygame.K_r:
            spawn_bodies(world)
    world.step(dt)
    screen.fill((20, 20, 30))
    for b in world.bodies:
        pygame.draw.circle(screen, b.color,
                           (int(b.position.x), int(b.position.y)), b.radius)
    hud = font.render(
        f"R = respawn 5 bodies ยท gravity={world.gravity.y:.0f}",
        True, (255, 255, 255))
    screen.blit(hud, (10, 10))
    pygame.display.flip()
pygame.quit()

๐ŸŽฏ Quick Quiz

Question 1: The lesson's Best Practice 'Fixed Timestep: Use fixed timestep for deterministic physics' contradicts chat-44 M1's variable-dt pattern (dt = clock.tick(60) / 1000.0). Why does a real physics engine want a FIXED timestep instead of the variable one?

Question 2: The lesson's Key Takeaway 'Separate concerns: bodies, shapes, constraints, forces' shows up in the architecture as Body (mass, position, velocity) decoupled from Shape (radius, vertices). Why is this separation valuable rather than just stuffing everything into one Body class?

Question 3: The lesson's Best Practice 'Sleep States: Don't simulate resting objects' pairs with chat-44 M3's Best Practice 'Threshold Values'. What's the optimization, and how does it compose with M3's threshold?

What's Next?

Congratulations! You've completed the Game Physics section! You now have a solid foundation in physics simulation for games. Next, move on to the Intermediate Module where you'll learn about AI, pathfinding, procedural generation, and more advanced game development techniques!