Skip to main content

Collision Response

Making Objects React to Collisions

Collision response is where physics gets exciting! When objects collide, they exchange momentum, bounce apart, or stick together. Understanding collision response lets you create everything from billiard games to explosive effects! 💥🎱

Understanding Collision Response

🎱 The Billiards Analogy

Think of collision response like a pool table:

graph TD A["Collision Response"] --> B["Detection Phase"] A --> C["Resolution Phase"] A --> D["Post-Processing"] B --> E["Broad Phase"] B --> F["Narrow Phase"] C --> G["Impulse Calculation"] C --> H["Position Correction"] C --> I["Velocity Update"] D --> J["Callbacks"] D --> K["Effects"] D --> L["Sound"]

Interactive Collision Response Simulator

Click and drag to launch balls! Watch momentum transfer!

Mode: Elastic | Total Momentum: 0 | Energy: 0

Collision Detection and Response Basics

import pygame
import math
from typing import Any

class CollisionSystem:
    """Basic collision detection and response"""
    
    @staticmethod
    def check_circle_collision(obj1: Any, obj2: Any) -> bool:
        """Check if two circles are colliding"""
        dx = obj2.position.x - obj1.position.x
        dy = obj2.position.y - obj1.position.y
        distance = math.sqrt(dx * dx + dy * dy)
        
        return distance < (obj1.radius + obj2.radius)
    
    @staticmethod
    def resolve_collision(obj1: Any, obj2: Any, restitution: float = 0.8) -> None:
        """Resolve collision between two circular objects"""
        # Calculate collision vector
        dx = obj2.position.x - obj1.position.x
        dy = obj2.position.y - obj1.position.y
        distance = math.sqrt(dx * dx + dy * dy)
        
        # Check if actually colliding
        if distance >= obj1.radius + obj2.radius:
            return
        
        # Collision normal (unit vector)
        if distance > 0:
            nx = dx / distance
            ny = dy / distance
        else:
            # Objects are on top of each other, use arbitrary normal
            nx, ny = 1, 0
            distance = 0.01
        
        # Relative velocity
        dvx = obj2.velocity.x - obj1.velocity.x
        dvy = obj2.velocity.y - obj1.velocity.y
        
        # Relative velocity along collision normal
        dvn = dvx * nx + dvy * ny
        
        # Don't resolve if objects are separating
        if dvn > 0:
            return
        
        # Calculate impulse magnitude
        impulse = 2 * dvn / (1/obj1.mass + 1/obj2.mass)
        impulse *= (1 + restitution)
        
        # Apply impulse to velocities
        obj1.velocity.x += (impulse * nx) / obj1.mass
        obj1.velocity.y += (impulse * ny) / obj1.mass
        obj2.velocity.x -= (impulse * nx) / obj2.mass
        obj2.velocity.y -= (impulse * ny) / obj2.mass
        
        # Separate objects to prevent overlap
        overlap = obj1.radius + obj2.radius - distance
        if overlap > 0:
            separate_x = nx * overlap * 0.5
            separate_y = ny * overlap * 0.5
            
            obj1.position.x -= separate_x
            obj1.position.y -= separate_y
            obj2.position.x += separate_x
            obj2.position.y += separate_y

# Momentum conservation
class MomentumSystem:
    @staticmethod
    def elastic_collision_1d(m1: float, v1: float, m2: float, v2: float) -> tuple[float, float]:
        """Calculate velocities after 1D elastic collision"""
        # Conservation of momentum and energy
        v1_final = ((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2)
        v2_final = ((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2)
        
        return v1_final, v2_final
    
    @staticmethod
    def inelastic_collision_1d(m1: float, v1: float, m2: float, v2: float) -> tuple[float, float]:
        """Calculate velocity after perfectly inelastic collision"""
        # Objects stick together
        v_final = (m1 * v1 + m2 * v2) / (m1 + m2)
        return v_final, v_final
    
    @staticmethod
    def partial_inelastic_collision(m1: float, v1: float, m2: float, v2: float, restitution: float) -> tuple[float, float]:
        """Calculate velocities with coefficient of restitution"""
        # e = (v2_final - v1_final) / (v1_initial - v2_initial)
        v1_final = (m1 * v1 + m2 * v2 - m2 * restitution * (v1 - v2)) / (m1 + m2)
        v2_final = (m1 * v1 + m2 * v2 + m1 * restitution * (v1 - v2)) / (m1 + m2)
        
        return v1_final, v2_final

Advanced Collision Response

# 2D collision response with rotation
from typing import Any, Optional
class AdvancedCollisionResponse:
    def __init__(self) -> None:
        self.contact_points: list[Any] = []
        
    def resolve_collision_with_rotation(self, body1: Any, body2: Any, contact_point: pygame.math.Vector2) -> None:
        """Resolve collision including rotational effects"""
        # Vector from center of mass to contact point
        r1 = contact_point - body1.position
        r2 = contact_point - body2.position
        
        # Relative velocity at contact point
        v1_contact = body1.velocity + pygame.math.Vector2(-r1.y, r1.x) * body1.angular_velocity
        v2_contact = body2.velocity + pygame.math.Vector2(-r2.y, r2.x) * body2.angular_velocity
        relative_velocity = v2_contact - v1_contact
        
        # Contact normal
        normal = (body2.position - body1.position).normalize()
        
        # Relative velocity along normal
        velocity_along_normal = relative_velocity.dot(normal)
        
        if velocity_along_normal > 0:
            return  # Objects are separating
        
        # Restitution
        restitution = min(body1.restitution, body2.restitution)
        
        # Calculate impulse scalar
        r1_cross_n = r1.x * normal.y - r1.y * normal.x
        r2_cross_n = r2.x * normal.y - r2.y * normal.x
        
        inv_mass_sum = 1/body1.mass + 1/body2.mass
        inv_mass_sum += r1_cross_n * r1_cross_n / body1.moment_of_inertia
        inv_mass_sum += r2_cross_n * r2_cross_n / body2.moment_of_inertia
        
        impulse_magnitude = -(1 + restitution) * velocity_along_normal / inv_mass_sum
        impulse = normal * impulse_magnitude
        
        # Apply linear impulse
        body1.velocity -= impulse / body1.mass
        body2.velocity += impulse / body2.mass
        
        # Apply angular impulse
        body1.angular_velocity -= r1_cross_n * impulse_magnitude / body1.moment_of_inertia
        body2.angular_velocity += r2_cross_n * impulse_magnitude / body2.moment_of_inertia
        
        # Apply friction
        self.apply_friction(body1, body2, normal, impulse_magnitude, contact_point)
    
    def apply_friction(self, body1: Any, body2: Any, normal: pygame.math.Vector2, normal_impulse: float, contact_point: pygame.math.Vector2) -> None:
        """Apply friction at contact point"""
        # Calculate tangent vector
        r1 = contact_point - body1.position
        r2 = contact_point - body2.position
        
        v1_contact = body1.velocity + pygame.math.Vector2(-r1.y, r1.x) * body1.angular_velocity
        v2_contact = body2.velocity + pygame.math.Vector2(-r2.y, r2.x) * body2.angular_velocity
        relative_velocity = v2_contact - v1_contact
        
        # Remove normal component to get tangential velocity
        tangent = relative_velocity - normal * relative_velocity.dot(normal)
        
        if tangent.length_squared() < 0.0001:
            return  # No tangential velocity
        
        tangent = tangent.normalize()
        
        # Friction coefficient
        friction = math.sqrt(body1.friction * body2.friction)
        
        # Calculate friction impulse
        friction_impulse = tangent * (-friction * abs(normal_impulse))
        
        # Apply friction
        body1.velocity -= friction_impulse / body1.mass
        body2.velocity += friction_impulse / body2.mass
        
        # Apply rotational friction
        r1_cross_t = r1.x * tangent.y - r1.y * tangent.x
        r2_cross_t = r2.x * tangent.y - r2.y * tangent.x
        
        body1.angular_velocity -= r1_cross_t * friction * abs(normal_impulse) / body1.moment_of_inertia
        body2.angular_velocity += r2_cross_t * friction * abs(normal_impulse) / body2.moment_of_inertia

# Continuous collision detection
class ContinuousCollisionDetection:
    @staticmethod
    def swept_circle_collision(obj1: Any, obj2: Any, dt: float) -> Optional[float]:
        """Detect collision between moving circles"""
        # Future positions
        future_pos1 = obj1.position + obj1.velocity * dt
        future_pos2 = obj2.position + obj2.velocity * dt
        
        # Check if paths intersect
        # This is simplified - full implementation would use ray-circle intersection
        steps = 10
        for i in range(steps + 1):
            t = i / steps
            
            pos1 = obj1.position + obj1.velocity * (dt * t)
            pos2 = obj2.position + obj2.velocity * (dt * t)
            
            distance = (pos2 - pos1).length()
            if distance < obj1.radius + obj2.radius:
                return t * dt  # Return time of collision
        
        return None  # No collision
    
    @staticmethod
    def resolve_at_time(obj1: Any, obj2: Any, collision_time: float, dt: float) -> None:
        """Resolve collision at specific time"""
        # Move objects to collision point
        obj1.position += obj1.velocity * collision_time
        obj2.position += obj2.velocity * collision_time
        
        # Resolve collision
        CollisionSystem.resolve_collision(obj1, obj2)
        
        # Continue movement for remaining time
        remaining_time = dt - collision_time
        obj1.position += obj1.velocity * remaining_time
        obj2.position += obj2.velocity * remaining_time

Separating Axis Theorem (SAT)

# SAT for polygon collision
from typing import Any, Optional
class SATCollision:
    @staticmethod
    def get_axes(vertices: list[tuple[float, float]]) -> list[pygame.math.Vector2]:
        """Get separating axes for a polygon"""
        axes = []
        for i in range(len(vertices)):
            p1 = vertices[i]
            p2 = vertices[(i + 1) % len(vertices)]
            
            # Edge vector
            edge = pygame.math.Vector2(p2[0] - p1[0], p2[1] - p1[1])
            
            # Perpendicular (normal)
            normal = pygame.math.Vector2(-edge.y, edge.x)
            if normal.length() > 0:
                normal = normal.normalize()
                axes.append(normal)
        
        return axes
    
    @staticmethod
    def project_polygon(vertices: list[tuple[float, float]], axis: pygame.math.Vector2) -> tuple[float, float]:
        """Project polygon onto axis"""
        min_proj = float('inf')
        max_proj = float('-inf')
        
        for vertex in vertices:
            projection = vertex[0] * axis.x + vertex[1] * axis.y
            min_proj = min(min_proj, projection)
            max_proj = max(max_proj, projection)
        
        return min_proj, max_proj
    
    @staticmethod
    def check_collision(poly1: list[tuple[float, float]], poly2: list[tuple[float, float]]) -> Optional[dict]:
        """Check collision between two polygons using SAT"""
        axes = SATCollision.get_axes(poly1) + SATCollision.get_axes(poly2)
        
        min_overlap = float('inf')
        min_axis = None
        
        for axis in axes:
            min1, max1 = SATCollision.project_polygon(poly1, axis)
            min2, max2 = SATCollision.project_polygon(poly2, axis)
            
            # Check for gap
            if max1 < min2 or max2 < min1:
                return None  # No collision
            
            # Calculate overlap
            overlap = min(max1, max2) - max(min1, min2)
            if overlap < min_overlap:
                min_overlap = overlap
                min_axis = axis
        
        # Collision detected
        return {
            'axis': min_axis,
            'overlap': min_overlap
        }
    
    @staticmethod
    def resolve_sat_collision(obj1: Any, obj2: Any, collision_data: Optional[dict]) -> None:
        """Resolve collision based on SAT data"""
        if not collision_data:
            return
        
        axis = collision_data['axis']
        overlap = collision_data['overlap']
        
        # Determine separation direction
        center1 = obj1.get_center()
        center2 = obj2.get_center()
        
        direction = pygame.math.Vector2(center2.x - center1.x, center2.y - center1.y)
        if direction.dot(axis) < 0:
            axis = -axis
        
        # Separate objects
        total_mass = obj1.mass + obj2.mass
        obj1.position -= axis * (overlap * obj2.mass / total_mass)
        obj2.position += axis * (overlap * obj1.mass / total_mass)
        
        # Apply impulse
        relative_velocity = obj2.velocity - obj1.velocity
        velocity_along_axis = relative_velocity.dot(axis)
        
        if velocity_along_axis > 0:
            return
        
        restitution = min(obj1.restitution, obj2.restitution)
        impulse = -(1 + restitution) * velocity_along_axis / (1/obj1.mass + 1/obj2.mass)
        
        obj1.velocity -= axis * (impulse / obj1.mass)
        obj2.velocity += axis * (impulse / obj2.mass)

Complete Collision Response Demo

import pygame
import math
import random

class CollisionResponseDemo:
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((800, 600))
        pygame.display.set_caption("Collision Response Demo")
        self.clock = pygame.time.Clock()
        self.font = pygame.font.Font(None, 24)
        
        # Physics objects
        self.circles = []
        self.polygons = []
        
        # Collision settings
        self.restitution = 0.8
        self.show_collisions = True
        self.show_vectors = True
        
        # Initialize demo
        self.setup_demo()
        
    def setup_demo(self):
        """Create demo objects"""
        # Create circles of different sizes
        for i in range(5):
            x = 150 + i * 120
            y = 200 + random.randint(-50, 50)
            radius = 20 + i * 5
            mass = radius / 10
            
            circle = CircleObject(x, y, radius, mass)
            circle.velocity = pygame.math.Vector2(
                random.uniform(-100, 100),
                random.uniform(-100, 100)
            )
            self.circles.append(circle)
        
        # Create polygons
        # Triangle
        triangle = PolygonObject(
            [(0, -20), (20, 20), (-20, 20)],
            300, 400, 2
        )
        self.polygons.append(triangle)
        
        # Square
        square = PolygonObject(
            [(-20, -20), (20, -20), (20, 20), (-20, 20)],
            500, 400, 3
        )
        self.polygons.append(square)
    
    def handle_events(self):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return False
            elif event.type == pygame.MOUSEBUTTONDOWN:
                # Add new circle at mouse position
                x, y = pygame.mouse.get_pos()
                circle = CircleObject(x, y, 25, 2)
                circle.velocity = pygame.math.Vector2(
                    random.uniform(-200, 200),
                    random.uniform(-200, 200)
                )
                self.circles.append(circle)
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_c:
                    self.circles.clear()
                    self.polygons.clear()
                elif event.key == pygame.K_r:
                    self.setup_demo()
                elif event.key == pygame.K_v:
                    self.show_vectors = not self.show_vectors
                elif event.key == pygame.K_SPACE:
                    # Apply random impulse to all objects
                    for circle in self.circles:
                        impulse = pygame.math.Vector2(
                            random.uniform(-100, 100),
                            random.uniform(-100, 100)
                        )
                        circle.velocity += impulse
        
        return True
    
    def update(self, dt):
        """Update physics simulation"""
        # Update circles
        for circle in self.circles:
            circle.update(dt)
            
            # Wall collisions
            if circle.position.x - circle.radius < 0:
                circle.position.x = circle.radius
                circle.velocity.x *= -self.restitution
            elif circle.position.x + circle.radius > 800:
                circle.position.x = 800 - circle.radius
                circle.velocity.x *= -self.restitution
            
            if circle.position.y - circle.radius < 0:
                circle.position.y = circle.radius
                circle.velocity.y *= -self.restitution
            elif circle.position.y + circle.radius > 600:
                circle.position.y = 600 - circle.radius
                circle.velocity.y *= -self.restitution
        
        # Update polygons
        for poly in self.polygons:
            poly.update(dt)
            poly.constrain_to_screen(800, 600, self.restitution)
        
        # Circle-circle collisions
        for i in range(len(self.circles)):
            for j in range(i + 1, len(self.circles)):
                self.resolve_circle_collision(self.circles[i], self.circles[j])
        
        # Circle-polygon collisions
        for circle in self.circles:
            for poly in self.polygons:
                self.resolve_circle_polygon_collision(circle, poly)
        
        # Polygon-polygon collisions
        for i in range(len(self.polygons)):
            for j in range(i + 1, len(self.polygons)):
                self.resolve_polygon_collision(self.polygons[i], self.polygons[j])
    
    def resolve_circle_collision(self, c1, c2):
        """Resolve collision between two circles"""
        dx = c2.position.x - c1.position.x
        dy = c2.position.y - c1.position.y
        distance = math.sqrt(dx * dx + dy * dy)
        
        if distance < c1.radius + c2.radius and distance > 0:
            # Collision detected
            normal = pygame.math.Vector2(dx / distance, dy / distance)
            
            # Mark collision for visualization
            c1.is_colliding = True
            c2.is_colliding = True
            
            # Separate
            overlap = c1.radius + c2.radius - distance
            c1.position -= normal * (overlap * 0.5)
            c2.position += normal * (overlap * 0.5)
            
            # Calculate relative velocity
            relative_velocity = c2.velocity - c1.velocity
            velocity_along_normal = relative_velocity.dot(normal)
            
            if velocity_along_normal > 0:
                return
            
            # Apply impulse
            impulse = 2 * velocity_along_normal / (1/c1.mass + 1/c2.mass)
            impulse_vector = normal * impulse * (1 + self.restitution)
            
            c1.velocity += impulse_vector / c1.mass
            c2.velocity -= impulse_vector / c2.mass
    
    def resolve_circle_polygon_collision(self, circle, polygon):
        """Resolve collision between circle and polygon"""
        # Simplified - check against polygon edges
        closest_point = None
        min_distance = float('inf')
        
        vertices = polygon.get_world_vertices()
        
        for i in range(len(vertices)):
            p1 = vertices[i]
            p2 = vertices[(i + 1) % len(vertices)]
            
            # Find closest point on edge
            edge_point = self.closest_point_on_segment(circle.position, p1, p2)
            distance = (circle.position - edge_point).length()
            
            if distance < min_distance:
                min_distance = distance
                closest_point = edge_point
        
        if closest_point and min_distance < circle.radius:
            # Collision detected
            circle.is_colliding = True
            polygon.is_colliding = True
            
            # Calculate normal
            normal = (circle.position - closest_point).normalize()
            
            # Separate
            overlap = circle.radius - min_distance
            circle.position += normal * overlap
            
            # Apply impulse
            relative_velocity = circle.velocity - polygon.velocity
            velocity_along_normal = relative_velocity.dot(normal)
            
            if velocity_along_normal < 0:
                impulse = -velocity_along_normal * (1 + self.restitution)
                circle.velocity += normal * impulse
    
    def resolve_polygon_collision(self, poly1, poly2):
        """Resolve collision between two polygons using SAT"""
        collision = SATCollision.check_collision(
            poly1.get_world_vertices(),
            poly2.get_world_vertices()
        )
        
        if collision:
            poly1.is_colliding = True
            poly2.is_colliding = True
            SATCollision.resolve_sat_collision(poly1, poly2, collision)
    
    def closest_point_on_segment(self, point, seg_start, seg_end):
        """Find closest point on line segment"""
        segment = seg_end - seg_start
        t = max(0, min(1, (point - seg_start).dot(segment) / segment.length_squared()))
        return seg_start + segment * t
    
    def draw(self):
        """Draw everything"""
        self.screen.fill((30, 30, 40))
        
        # Draw grid
        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 circles
        for circle in self.circles:
            circle.draw(self.screen, self.show_vectors)
        
        # Draw polygons
        for poly in self.polygons:
            poly.draw(self.screen, self.show_vectors)
        
        # Draw UI
        self.draw_ui()
    
    def draw_ui(self):
        """Draw user interface"""
        texts = [
            f"Restitution: {self.restitution:.1f}",
            f"Objects: {len(self.circles) + len(self.polygons)}",
            "Click: Add Circle | C: Clear | R: Reset | V: Vectors",
            "Space: Apply Impulse"
        ]
        
        # Calculate total momentum and energy
        total_momentum = pygame.math.Vector2(0, 0)
        total_energy = 0
        
        for circle in self.circles:
            total_momentum += circle.velocity * circle.mass
            total_energy += 0.5 * circle.mass * circle.velocity.length_squared()
        
        texts.append(f"Momentum: {total_momentum.length():.1f}")
        texts.append(f"Energy: {total_energy:.1f}")
        
        y_offset = 10
        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):
        running = True
        dt = 0
        
        while running:
            running = self.handle_events()
            self.update(dt)
            self.draw()
            pygame.display.flip()
            dt = self.clock.tick(60) / 1000.0
        
        pygame.quit()

# Physics object classes
class CircleObject:
    def __init__(self, x, y, radius, mass):
        self.position = pygame.math.Vector2(x, y)
        self.velocity = pygame.math.Vector2(0, 0)
        self.radius = radius
        self.mass = mass
        self.restitution = 0.8
        self.is_colliding = False
        self.color = (random.randint(100, 255),
                     random.randint(100, 255),
                     random.randint(100, 255))
    
    def update(self, dt):
        self.position += self.velocity * dt
        self.is_colliding = False
    
    def draw(self, screen, show_vectors):
        # Draw circle
        color = (255, 255, 255) if self.is_colliding else self.color
        pygame.draw.circle(screen, color,
                         (int(self.position.x), int(self.position.y)),
                         self.radius, 2 if self.is_colliding else 0)
        
        if not self.is_colliding:
            pygame.draw.circle(screen, self.color,
                             (int(self.position.x), int(self.position.y)),
                             self.radius - 2)
        
        # Draw velocity vector
        if show_vectors and self.velocity.length() > 0.1:
            end_pos = self.position + self.velocity * 0.3
            pygame.draw.line(screen, (0, 255, 0),
                           (self.position.x, self.position.y),
                           (end_pos.x, end_pos.y), 2)

class PolygonObject:
    def __init__(self, vertices, x, y, mass):
        self.vertices = vertices  # Local coordinates
        self.position = pygame.math.Vector2(x, y)
        self.velocity = pygame.math.Vector2(0, 0)
        self.angle = 0
        self.angular_velocity = 0
        self.mass = mass
        self.moment_of_inertia = self.calculate_moment_of_inertia()
        self.restitution = 0.8
        self.friction = 0.5
        self.is_colliding = False
        self.color = (random.randint(100, 255),
                     random.randint(100, 255),
                     random.randint(100, 255))
    
    def calculate_moment_of_inertia(self):
        """Calculate moment of inertia for polygon"""
        # Simplified - treat as point masses at vertices
        moment = 0
        for vertex in self.vertices:
            r_squared = vertex[0]**2 + vertex[1]**2
            moment += self.mass * r_squared / len(self.vertices)
        return moment
    
    def get_world_vertices(self):
        """Get vertices in world coordinates"""
        world_vertices = []
        cos_a = math.cos(self.angle)
        sin_a = math.sin(self.angle)
        
        for vertex in self.vertices:
            # Rotate
            x = vertex[0] * cos_a - vertex[1] * sin_a
            y = vertex[0] * sin_a + vertex[1] * cos_a
            
            # Translate
            world_vertices.append(pygame.math.Vector2(
                x + self.position.x,
                y + self.position.y
            ))
        
        return world_vertices
    
    def get_center(self):
        return self.position
    
    def update(self, dt):
        self.position += self.velocity * dt
        self.angle += self.angular_velocity * dt
        self.is_colliding = False
    
    def constrain_to_screen(self, width, height, restitution):
        """Keep polygon on screen"""
        vertices = self.get_world_vertices()
        
        for vertex in vertices:
            if vertex.x < 0 or vertex.x > width:
                self.velocity.x *= -restitution
                self.position.x = max(50, min(width - 50, self.position.x))
            
            if vertex.y < 0 or vertex.y > height:
                self.velocity.y *= -restitution
                self.position.y = max(50, min(height - 50, self.position.y))
    
    def draw(self, screen, show_vectors):
        vertices = self.get_world_vertices()
        
        # Draw polygon
        points = [(v.x, v.y) for v in vertices]
        color = (255, 255, 255) if self.is_colliding else self.color
        pygame.draw.polygon(screen, color, points, 2 if self.is_colliding else 0)
        
        if not self.is_colliding:
            # Fill with transparency effect
            for i in range(len(points)):
                p1 = points[i]
                p2 = points[(i + 1) % len(points)]
                p3 = (self.position.x, self.position.y)
                pygame.draw.polygon(screen, self.color, [p1, p2, p3], 0)
        
        # Draw velocity vector
        if show_vectors and self.velocity.length() > 0.1:
            end_pos = self.position + self.velocity * 0.3
            pygame.draw.line(screen, (0, 255, 0),
                           (self.position.x, self.position.y),
                           (end_pos.x, end_pos.y), 2)

if __name__ == "__main__":
    demo = CollisionResponseDemo()
    demo.run()

Best Practices

⚡ Collision Response Tips

Practice Exercises

🎯 Collision Response Challenges!

  1. Pool Game: Realistic billiards with spin and friction
  2. Newton's Cradle: Conservation of momentum demonstration
  3. Breakout Clone: Ball bouncing off bricks and paddle
  4. Domino Effect: Chain reactions with falling dominoes
  5. Asteroid Field: Multiple objects with varying masses
  6. Soft Body: Objects that deform on collision

Key Takeaways

🏋️‍♂️ Practice Exercise: Cushion-Bounce Billiards

🏋️‍♂️ Exercise 1: One Reflection Formula, Four Walls

Objective: Build a Pygame demo where a single ball bounces around inside a rectangular play area using the vector reflection formula from chat-43 vectors lesson: R = I − 2(I·N)N, where I is the incident velocity vector and N is the wall's inward-pointing unit normal. Instead of the convenient-but-narrow shortcut velocity.x *= -1 (which only works for axis-aligned walls), the dot-product reflection generalizes to arbitrary wall angles — the lesson's pool-table cushion bounces, polygon-wall collisions, and rotated bumper geometry all use this exact formula. To prove generalization, the demo includes a single 45° tilted bumper at the center of the play area: same formula, different N — N = Vector2(sin(45°), -cos(45°)). Restitution is applied as a post-reflection magnitude scale: v_after = e * reflected. This directly exercises the chat-43 vectors lesson's normalize-and-dot-product patterns (the formula needs N to be a unit vector for the algebra to cancel correctly, and I·N is the same dot-product Q2 from chat-43 vectors quiz tested as the front/behind classifier).

Instructions:

  1. Reuse PhysicsObject: position, velocity (both Vector2), and radius; set RESTITUTION = 0.95 for a near-elastic billiard feel.
  2. Define four wall normals as INWARD-pointing unit vectors (pointing into the play area): top N = (0, 1), bottom N = (0, -1), left N = (1, 0), right N = (-1, 0) — Pygame Y grows down so 'down' is +y per chat-44 M2.
  3. Write a reflect(I, N) helper: return I - 2 * I.dot(N) * N — the chat-43 vectors lesson's formula verbatim, expressed in pygame.math.Vector2 arithmetic.
  4. Each frame, integrate position += velocity * dt; then check each wall: if the ball is past the wall AND moving INTO the wall (Best Practice 'Check Velocity: Don't resolve if objects are already separating' — use velocity.dot(N) < 0 as the gate), reflect via the helper and scale by RESTITUTION.
  5. Add a 45° tilted bumper at center: a small filled triangle (just for display) plus a BUMPER_N = Vector2(sin(π/4), -cos(π/4)).normalize() wall normal. If (ball.position - bumper_position).dot(BUMPER_N) < ball.radius AND velocity.dot(BUMPER_N) < 0, the ball is inside the bumper's surface AND approaching — reflect with the same helper.
  6. Best Practice 'Separate First': after reflecting, push the ball back outside the wall by setting position just inside the playable region so the next frame doesn't re-trigger the same collision (the chat-44 M2 floor-clamp pattern, generalized).
  7. Spawn the ball with an initial diagonal velocity (e.g. Vector2(280, -180)) so all four walls and the bumper get hit within a few seconds.
💡 Hint

Three subtleties worth pre-stating: (a) the reflection formula requires N to be a unit vector — I.dot(N) measures the component of I along N, and 2 * (I·N) * N only cancels the perpendicular-to-wall component cleanly when N has length 1. The four axis-aligned walls satisfy this trivially, but the 45° bumper needs an explicit .normalize() call. (b) The 'don't resolve if separating' gate (velocity.dot(N) < 0) prevents a classic pinball glitch where if the ball penetrates a wall by 1 px and the post-reflection velocity is small, the next frame's penetration check would re-fire the reflection AGAIN even though the ball is now moving AWAY — each redundant fire would push the ball BACK into the wall, trapping it in jitter. (c) For a perfectly horizontal floor (N = (0, -1)), the reflection formula reduces algebraically to velocity.y *= -1 exactly, which is why chat-44 M2's gravity demo could use that simpler form — they're not different formulas, the simple one is a special case.

✅ Example Solution
import pygame
import math
from pygame.math import Vector2

pygame.init()
W, H = 600, 400
RESTITUTION = 0.95
RADIUS = 12
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("Cushion-Bounce Billiards")
clock = pygame.time.Clock()
font = pygame.font.SysFont(None, 20)

def reflect(I: Vector2, N: Vector2) -> Vector2:
    """Vector reflection formula from chat-43 vectors lesson:
       R = I - 2 * (I . N) * N    (N must be a unit vector)"""
    return I - 2 * I.dot(N) * N

# Wall normals point INWARD (into the play area)
WALLS = [
    ("top",    Vector2(0,  1), lambda b: b.position.y - RADIUS < 0),
    ("bottom", Vector2(0, -1), lambda b: b.position.y + RADIUS > H),
    ("left",   Vector2( 1, 0), lambda b: b.position.x - RADIUS < 0),
    ("right",  Vector2(-1, 0), lambda b: b.position.x + RADIUS > W),
]
# 45-degree tilted bumper at center
BUMPER_POS = Vector2(W // 2, H // 2)
BUMPER_N = Vector2(math.sin(math.pi/4), -math.cos(math.pi/4)).normalize()
BUMPER_R = 22

class Ball:
    def __init__(self) -> None:
        self.position: Vector2 = Vector2(80, 80)
        self.velocity: Vector2 = Vector2(280, -180)
        self.bounces: int = 0

    def update(self, dt: float) -> None:
        self.position += self.velocity * dt
        # Four walls
        for name, N, hit in WALLS:
            if hit(self) and self.velocity.dot(N) < 0:
                self.velocity = reflect(self.velocity, N) * RESTITUTION
                # Best Practice 'Separate First': clamp position back inside
                self.position.x = max(RADIUS, min(W - RADIUS, self.position.x))
                self.position.y = max(RADIUS, min(H - RADIUS, self.position.y))
                self.bounces += 1
        # 45-degree bumper
        offset = self.position - BUMPER_POS
        if offset.dot(BUMPER_N) < RADIUS and offset.length() < BUMPER_R + RADIUS:
            if self.velocity.dot(BUMPER_N) < 0:
                self.velocity = reflect(self.velocity, BUMPER_N) * RESTITUTION
                # Push out along normal so next frame doesn't re-fire
                self.position += BUMPER_N * (RADIUS - offset.dot(BUMPER_N))
                self.bounces += 1

ball = Ball()
running = True
while running:
    dt = clock.tick(60) / 1000.0
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
    ball.update(dt)
    screen.fill((20, 20, 30))
    # Draw 45-degree bumper as a small triangle perpendicular to its normal
    perp = Vector2(-BUMPER_N.y, BUMPER_N.x) * BUMPER_R
    pygame.draw.polygon(screen, (255, 200, 80), [
        (BUMPER_POS - perp), (BUMPER_POS + perp),
        (BUMPER_POS + BUMPER_N * 8)])
    pygame.draw.circle(screen, (100, 200, 255),
                       (int(ball.position.x), int(ball.position.y)), RADIUS)
    hud = font.render(f"bounces={ball.bounces}  |v|={ball.velocity.length():.0f}",
                      True, (255, 255, 255))
    screen.blit(hud, (10, 10))
    pygame.display.flip()
pygame.quit()

🎯 Quick Quiz

Question 1: The chat-43 vectors lesson's reflection formula is R = I − 2(I·N)N where I is the incident velocity and N is the wall's inward-pointing unit normal. What does the term (I·N)N represent geometrically?

Question 2: The lesson's Best Practice 'Check Velocity: Don't resolve if objects are already separating' says to gate collision response on a sign condition before reflecting. For a wall with inward-pointing unit normal N and ball velocity v, what's the correct gate?

Question 3: When two billiard balls collide head-on, the lesson's Best Practice 'Use Proper Mass: Heavier objects should move less' says to scale the impulse by inverse mass: v1 += impulse / m1 and v2 -= impulse / m2. Why does dividing by mass make the heavier ball move less?

What's Next?

Now that you understand collision response, next we'll combine everything into a simple physics engine that can handle complex simulations!