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:
- World: Container for all physics objects and settings
- Bodies: Physical objects with mass, position, velocity
- Shapes: Collision geometry (circles, polygons)
- Constraints: Joints, springs, and connections
- Forces: Gravity, wind, explosions, user input
- Integrator: Updates positions and velocities over time
- Solver: Resolves collisions and constraints
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
- Fixed Timestep: Use fixed timestep for deterministic physics
- Sub-stepping: Multiple small steps for stability
- Spatial Partitioning: Use quadtrees/grids for broad phase
- Sleep States: Don't simulate resting objects
- Continuous Collision: For fast-moving objects
- Constraint Solving: Iterate multiple times for accuracy
- Memory Pooling: Reuse objects to reduce allocation
- Profile Performance: Identify and optimize bottlenecks
Practice Exercises
๐ฏ Physics Engine Challenges!
- Ragdoll Physics: Connected bodies with joint limits
- Soft Bodies: Mass-spring systems for deformable objects
- Vehicle Simulation: Wheels, suspension, and steering
- Fluid Simulation: Particle-based water physics
- Destruction System: Breaking objects into fragments
- Rope Physics: Flexible constraints for rope simulation
Key Takeaways
- ๐๏ธ Physics engines combine all physics concepts
- โ๏ธ Separate concerns: bodies, shapes, constraints, forces
- ๐ Integration moves objects based on forces
- ๐ฅ Collision detection finds overlaps
- โก Impulse resolution separates colliding objects
- ๐ Constraints maintain relationships between bodies
- โฑ๏ธ Sub-stepping improves stability
- ๐ฎ Balance accuracy with performance
๐๏ธโโ๏ธ 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:
- Define a
Bodyclass withposition,velocity,acceleration(allVector2),mass,restitution,radius,color. Oneapply_force(force)method doesself.acceleration += force / self.mass(M1). - Define a
Worldclass that holdsself.bodies(list),self.gravity(Vector2(0, 980)per chat-44 M2: positive y because Pygame Y grows down), and aself.sizetuple for the playable rect. - Write
World.step(dt): loop overself.bodiesdoing the canonical update order โ (a) apply gravity; (b) integratevelocity += acceleration * dt; position += velocity * dt; (c) resolve walls via reflect-and-clamp; (d)acceleration = Vector2(0, 0). - Write a
reflect(I, N)helper at module scope:return I - 2 * I.dot(N) * N(M4 / chat-43 vectors). - 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'), setvelocity = reflect(velocity, N) * restitutionand clamppositionback inside the playable rect (M4 Best Practice 'Separate First'). - On-ground friction: if a body is on the bottom (
position.y + radius >= H - 1), decayvelocity.x *= (1 - 1.5 * dt)(M3 friction pattern). - M3 resting threshold for stability:
if abs(velocity.y) < 5 and on_ground: velocity.y = 0;if abs(velocity.x) < 1: velocity.x = 0. - 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!