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:
- Momentum Transfer: Fast ball hits slow ball, speeds exchange
- Angle of Reflection: Balls bounce at predictable angles
- Energy Conservation: Total energy stays (mostly) the same
- Spin Effects: English adds complexity to collisions
- Multiple Collisions: Chain reactions and combinations
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
- Separate First: Move objects apart before applying impulses
- Check Velocity: Don't resolve if objects are already separating
- Use Proper Mass: Heavier objects should move less
- Continuous Detection: For fast objects, check between frames
- Impulse Method: More stable than directly modifying velocities
- Energy Conservation: Monitor total energy to detect bugs
- Rotational Effects: Include angular momentum for realism
Practice Exercises
🎯 Collision Response Challenges!
- Pool Game: Realistic billiards with spin and friction
- Newton's Cradle: Conservation of momentum demonstration
- Breakout Clone: Ball bouncing off bricks and paddle
- Domino Effect: Chain reactions with falling dominoes
- Asteroid Field: Multiple objects with varying masses
- Soft Body: Objects that deform on collision
Key Takeaways
- 💥 Collision response = detection + resolution
- ⚡ Impulse-based resolution is stable and realistic
- 🎱 Momentum is conserved in closed systems
- 📐 SAT works for any convex polygon
- 🔄 Include rotation for complex interactions
- ⏱️ Continuous detection prevents tunneling
- 🎮 Balance realism with gameplay needs
🏋️♂️ 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:
- Reuse
PhysicsObject:position,velocity(bothVector2), andradius; setRESTITUTION = 0.95for a near-elastic billiard feel. - Define four wall normals as INWARD-pointing unit vectors (pointing into the play area): top
N = (0, 1), bottomN = (0, -1), leftN = (1, 0), rightN = (-1, 0)— Pygame Y grows down so 'down' is +y per chat-44 M2. - Write a
reflect(I, N)helper:return I - 2 * I.dot(N) * N— the chat-43 vectors lesson's formula verbatim, expressed inpygame.math.Vector2arithmetic. - 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' — usevelocity.dot(N) < 0as the gate), reflect via the helper and scale by RESTITUTION. - 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.radiusANDvelocity.dot(BUMPER_N) < 0, the ball is inside the bumper's surface AND approaching — reflect with the same helper. - Best Practice 'Separate First': after reflecting, push the ball back outside the wall by setting
positionjust inside the playable region so the next frame doesn't re-trigger the same collision (the chat-44 M2 floor-clamp pattern, generalized). - 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!