Lesson 8: Impulse Collisions (Circles)
A cue ball stops dead and the ball it hits rolls away; a bowling ball barely notices a pin while the pin goes flying. In this lesson you make moving circles collide with each other the way real objects do, using one short formula that respects mass and bounciness.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain momentum and use the head-on collision formulas to predict speeds after a hit.
- Build a circle-circle collision response: detect, find the normal, check that the balls approach, apply an impulse.
- Separate overlapping circles so the lighter one moves more, without ever dividing by zero.
- Reflect a ball off a wall with
Vector2.reflect()and with a restitution-aware version. - Debug sticking, jittering and "energy from nowhere" by checking momentum and kinetic energy.
Project: Pool Break, a cue ball breaking a rack of six balls on a table with cushions.
In This Lesson
๐ฑ Momentum: the Billiards Picture
Picture a pool table. When the cue ball hits another ball, speed passes from one to the other. Physics tracks this with momentum: mass times velocity, p = m * v. A slow bowling ball and a fast tennis ball can carry the same momentum.
When two balls hit, they push on each other with equal and opposite forces for the same tiny moment (Newton's third law). Whatever momentum one ball loses, the other gains, so the total momentum of the pair is the same before and after the hit. Kinetic energy (0.5 * m * v ** 2) is a different story: a perfectly bouncy hit keeps it, a squishy hit turns some of it into heat and sound.
Games resolve the hit with an impulse: an instant change of momentum, pushed along the line between the two centers (the contact normal). The same restitution e you used for floors sets how bouncy the hit is.
Try it below. Bigger balls are heavier (the label shows the mass). The two Head-on buttons set up the classic tests from the next section.
In Elastic mode the energy number stays constant. The momentum number jumps whenever a ball touches a wall, because the wall pushes on it from outside the pair; between wall hits it stays put, no matter how many balls collide.
๐ Collisions on a Line
Start with the simplest case: two balls on a line, hitting head-on. With masses m1, m2, speeds before u1, u2 and restitution e, keeping momentum and applying restitution gives the speeds after:
v1 = (m1 * u1 + m2 * u2 - m2 * e * (u1 - u2)) / (m1 + m2)
v2 = (m1 * u1 + m2 * u2 + m1 * e * (u1 - u2)) / (m1 + m2)
This program runs five classic cases and checks the momentum each time:
# Head-on collisions on a line: velocities after the hit, and a momentum check.
def collide_1d(m1, u1, m2, u2, e):
"""Velocities after a head-on hit with restitution e (1 = elastic, 0 = they stick)."""
total = m1 * u1 + m2 * u2
v1 = (total - m2 * e * (u1 - u2)) / (m1 + m2)
v2 = (total + m1 * e * (u1 - u2)) / (m1 + m2)
return v1, v2
cases = [
("equal masses, elastic", 1, 300, 1, 0, 1.0),
("heavy hits light", 4, 300, 1, 0, 1.0),
("light hits heavy", 1, 300, 4, 0, 1.0),
("equal masses, clay", 1, 300, 1, 0, 0.0),
("equal masses, e = 0.5", 1, 300, 1, 0, 0.5),
]
for name, m1, u1, m2, u2, e in cases:
v1, v2 = collide_1d(m1, u1, m2, u2, e)
before = m1 * u1 + m2 * u2
after = m1 * v1 + m2 * v2
print(f"{name:22} v1 = {v1:7.1f} v2 = {v2:6.1f} momentum {before} -> {after:.0f}")
equal masses, elastic v1 = 0.0 v2 = 300.0 momentum 300 -> 300
heavy hits light v1 = 180.0 v2 = 480.0 momentum 1200 -> 1200
light hits heavy v1 = -180.0 v2 = 120.0 momentum 300 -> 300
equal masses, clay v1 = 150.0 v2 = 150.0 momentum 300 -> 300
equal masses, e = 0.5 v1 = 75.0 v2 = 225.0 momentum 300 -> 300
Read the table like a pool player. Equal masses and a perfect bounce swap speeds: the cue ball stops dead. A heavy ball keeps going after hitting a light one, and a light ball bounces backward off a heavy one. With clay (e = 0) the two move off together. In every row the momentum is unchanged.
๐ฏ Circle vs Circle: the Impulse
In 2D, balls hit at angles, but the push still acts along one line: the normal from one center to the other. So you turn the 2D problem into the 1D one along that line. Here are the steps for balls a and b, each with pos, vel, mass and radius.
1. Detect. Two circles touch when the distance between their centers is less than the sum of the radii. Comparing squared distances skips a square root for the many pairs that aren't touching:
delta = b.pos - a.pos
min_dist = a.radius + b.radius
if delta.length_squared() < min_dist * min_dist:
dist = delta.length()
2. The normal. Divide by the distance to get a length-1 vector from a to b. If both centers are in exactly the same place, the distance is 0 and there is no direction, so pick one instead of dividing by zero:
if dist > 0:
normal = delta / dist
else:
normal = pygame.Vector2(1, 0) # any direction works
3. The approach check. Take the velocity of b relative to a and measure it along the normal. Negative means they are closing in. Zero or positive means they are already moving apart, maybe because last step's impulse already did its job while they still overlap. Bouncing them again would push them back together and make them stick or jitter:
closing = (b.vel - a.vel).dot(normal)
if closing >= 0:
return False # separating: no impulse
4. The impulse. One number, j, says how hard the push is. Heavy balls resist it: each ball's velocity changes by j divided by its own mass, in opposite directions.
inv_a, inv_b = 1 / a.mass, 1 / b.mass
j = -(1 + e) * closing / (inv_a + inv_b)
a.vel -= normal * (j * inv_a)
b.vel += normal * (j * inv_b)
Because a loses exactly j of momentum along the normal and b gains exactly j, the total is always kept, for any e. And along the normal, these four lines give the same answers as the head-on formulas from the last section; the Pool Break tests check that.
โ Growth Mindset: One Step at a Time
This is the most formula-heavy code in the module, and it is completely normal if it looks like a wall of symbols right now. Build it in the order above and test after each step: first draw a red outline when two balls touch, then draw the normal as a line, then print closing, and only then add the impulse. Each small step you can see working is a foothold for the next one.
๐งฒ Pushing Overlaps Apart
Collisions are found after a step moves the balls, so by then they already overlap a little. The impulse fixes their velocities, but not the overlap. Left alone, overlaps build up: stacks sink into each other and slow pairs stay stuck together. So first push them apart along the normal, just far enough to touch.
Who moves how far? The same rule as the impulse: the lighter ball moves more. Split the overlap by inverse mass:
overlap = min_dist - dist
push = normal * (overlap / (inv_a + inv_b))
a.pos -= push * inv_a # heavy ball: small inv_mass, small move
b.pos += push * inv_b # light ball: big inv_mass, big move
With masses 1 and 3 and an overlap of 4 px, push has length 3: the light ball moves 3 px and the heavy one 1 px, and they now just touch. Do this before the approach check, so overlapping balls are separated even when they are already moving apart.
Here is everything together in a complete program with three head-on tests. Press 1, 2 or 3 and compare the numbers with the table in Collisions on a Line.
import math
import pygame
STEP = 1 / 120
MAX_FRAME = 0.25
SCENARIOS = { # key: (label, mass A, mass B, restitution)
pygame.K_1: ("equal masses, elastic", 1.0, 1.0, 1.0),
pygame.K_2: ("heavy A, elastic", 4.0, 1.0, 1.0),
pygame.K_3: ("equal masses, clay", 1.0, 1.0, 0.0),
}
def start(key):
label, ma, mb, e = SCENARIOS[key]
a = {"pos": pygame.Vector2(150, 200), "vel": pygame.Vector2(300, 0), "mass": ma}
b = {"pos": pygame.Vector2(500, 200), "vel": pygame.Vector2(0, 0), "mass": mb}
for ball in (a, b):
ball["radius"] = 16 * math.sqrt(ball["mass"]) # bigger = heavier
return label, e, [a, b]
def resolve(a, b, e):
delta = b["pos"] - a["pos"]
min_dist = a["radius"] + b["radius"]
if delta.length_squared() >= min_dist ** 2:
return
dist = delta.length()
normal = delta / dist if dist > 0 else pygame.Vector2(1, 0)
inv_a, inv_b = 1 / a["mass"], 1 / b["mass"]
push = normal * ((min_dist - dist) / (inv_a + inv_b)) # remove the overlap
a["pos"] -= push * inv_a
b["pos"] += push * inv_b
closing = (b["vel"] - a["vel"]).dot(normal)
if closing < 0: # only if approaching
j = -(1 + e) * closing / (inv_a + inv_b)
a["vel"] -= normal * (j * inv_a)
b["vel"] += normal * (j * inv_b)
pygame.init()
screen = pygame.display.set_mode((800, 400))
pygame.display.set_caption("Head-on collisions: press 1, 2 or 3")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 28)
label, e, balls = start(pygame.K_1)
accumulator = 0.0
running = True
while running:
accumulator += min(clock.tick(60) / 1000, MAX_FRAME)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key in SCENARIOS:
label, e, balls = start(event.key)
while accumulator >= STEP:
for ball in balls:
ball["pos"] += ball["vel"] * STEP
resolve(balls[0], balls[1], e)
accumulator -= STEP
momentum = sum(ball["mass"] * ball["vel"].x for ball in balls)
energy = sum(0.5 * ball["mass"] * ball["vel"].length_squared() for ball in balls)
screen.fill((16, 20, 32))
for ball, color in zip(balls, ((120, 200, 255), (255, 170, 90))):
pygame.draw.circle(screen, color, ball["pos"], ball["radius"])
lines = [f"{label} (e = {e})",
f"A {balls[0]['vel'].x:6.1f} px/s B {balls[1]['vel'].x:6.1f} px/s",
f"momentum {momentum:6.1f} kinetic energy {energy:8.0f}"]
for i, text in enumerate(lines):
screen.blit(font.render(text, True, (230, 230, 230)), (10, 10 + i * 26))
pygame.display.flip()
pygame.quit()
๐ช Walls and Reflection
A wall is like a ball with infinite mass: it never moves, so the ball takes the whole bounce. For a wall with a length-1 normal n pointing back into the play area, a perfect bounce is the reflection formula:
reflected = vel - 2 * vel.dot(n) * n # R = I - 2(IยทN)N
reflected = vel.reflect(n) # pygame-ce does the same for you
vel.dot(n) * n is the part of the velocity that points into the wall. Subtracting it twice flips that part and leaves the sliding part alone. For a floor, where n = (0, -1), it is exactly vel.y = -vel.y: the floor bounce you already know is a special case.
To add restitution, flip the into-the-wall part and keep only e of it. Replace the 2 with 1 + e, and only bounce if the ball is moving into the wall, the same approach check as for two balls:
if vel.dot(n) < 0: # moving into the wall
vel -= (1 + e) * vel.dot(n) * n # e = 1 gives vel.reflect(n)
A common shortcut scales the whole reflected velocity by e. That also slows the sliding part, so a ball skimming along a cushion loses speed it shouldn't. Only the part going into the wall should shrink.
โ Growth Mindset: Let the Numbers Check You
Physics bugs often look "almost right", which makes them hard to spot by eye. Put the total momentum and kinetic energy on screen, as the Pool Break HUD does. If the energy ever goes up after a ball-to-ball hit, you have found a bug, and the numbers tell you which hit caused it. Checking your own work this way is a skill professionals use every day.
๐๏ธ Practice Exercise: Pool Break
Objective: make a cue ball break a rack of six balls, with ball-to-ball impulses that respect mass and restitution, overlaps pushed apart, and cushions that bounce only the part of the velocity going into them.
Time: about 35 minutes. Starter file: pool_starter.py (your instructor has it). The cue ball already rolls, slows on the felt and bounces off the cushions, but it passes straight through the other balls. Its numbered comments match the steps below.
- Run the starter and press SPACE. The cue ball drives straight through the rack. (โ 3 min)
- In
resolve_pair(), push overlapping balls apart by inverse mass. The cue ball now shoves the rack but never bounces off it. (โ 7 min) - Add the approach check. (โ 5 min)
- Apply the impulse and return
True. Press R and SPACE: the rack should scatter. (โ 8 min) - In
bounce_off_cushions(), replacereflect()with the restitution version, so only the part going into the cushion shrinks. (โ 5 min) - Press H for a heavy cue ball (mass 4) and break again. Watch the momentum and energy readout: what happens to them between cushion hits? (โ 7 min)
You are done when:
- the break scatters the rack, and balls bounce off each other and the cushions;
- no two balls stay overlapped or stuck together;
- with the heavy cue ball, the cue ball keeps rolling forward after the hit;
- a ball skimming along a cushion keeps its sliding speed;
- closing the window prints
Worst overlap: 0.0 px(or very close to it).
๐ก Hint
If balls stick together and shiver, the approach check is missing or has the wrong sign: bounce only when closing < 0. If everything flies apart far too fast, check the minus sign in j = -(1 + e) * closing / (inv_a + inv_b); closing is negative, so j comes out positive. Remember that a gets -= and b gets +=, because the normal points from a to b.
โ Example Solution
If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top, plus an extra and frame_budget() condition on the main loop. They let the instructor's checker run the program automatically; when you run it yourself they do nothing.
"""Pool Break: Intermediate Lesson 8 practice exercise (solution).
A cue ball breaks a rack of six balls. Ball-to-ball hits use an impulse
along the contact normal (with mass and restitution), only when the
balls are approaching, after pushing them apart. Cushions reflect only
the part of the velocity that points into the cushion.
SPACE: shoot. H: heavy cue ball (mass 4) on/off. R: re-rack.
Close the window to quit.
"""
import math
import pygame
WIDTH, HEIGHT = 800, 600
TABLE = pygame.Rect(40, 80, 720, 480)
STEP = 1 / 120
MAX_FRAME = 0.25
RADIUS = 14
BALL_E = 0.95 # restitution between two balls
CUSHION_E = 0.8 # restitution against a cushion
ROLL_DECEL = 90.0 # felt slows every ball by this many px/s each second
SHOT_SPEED = 900.0
COLORS = [(250, 200, 60), (70, 120, 230), (220, 70, 70), (150, 80, 200), (250, 140, 50), (60, 170, 90)]
class Ball:
def __init__(self, x, y, color, mass=1.0, radius=RADIUS):
self.pos = pygame.Vector2(x, y)
self.vel = pygame.Vector2(0, 0)
self.color = color
self.mass = mass
self.radius = radius
def resolve_pair(a, b, e):
"""Separate two overlapping balls, then bounce them apart with an impulse.
Returns True when an impulse was applied.
"""
delta = b.pos - a.pos
min_dist = a.radius + b.radius
dist_sq = delta.length_squared()
if dist_sq >= min_dist * min_dist:
return False # not touching
dist = math.sqrt(dist_sq)
if dist > 0:
normal = delta / dist # unit vector from a to b
else:
normal = pygame.Vector2(1, 0) # exactly on top of each other: pick any direction
inv_a, inv_b = 1 / a.mass, 1 / b.mass
# 1. Positional correction: remove the overlap, the lighter ball moves more.
overlap = min_dist - dist
push = normal * (overlap / (inv_a + inv_b))
a.pos -= push * inv_a
b.pos += push * inv_b
# 2. Approach check: only bounce if they are moving toward each other.
closing = (b.vel - a.vel).dot(normal)
if closing >= 0:
return False
# 3. Impulse along the normal: j = -(1 + e) * closing / (1/ma + 1/mb)
j = -(1 + e) * closing / (inv_a + inv_b)
a.vel -= normal * (j * inv_a)
b.vel += normal * (j * inv_b)
return True
def bounce_off_cushions(ball, table, e):
"""Keep a ball on the table. Only the part of the velocity INTO a cushion bounces."""
walls = [
(ball.pos.x - ball.radius < table.left, pygame.Vector2(1, 0)),
(ball.pos.x + ball.radius > table.right, pygame.Vector2(-1, 0)),
(ball.pos.y - ball.radius < table.top, pygame.Vector2(0, 1)),
(ball.pos.y + ball.radius > table.bottom, pygame.Vector2(0, -1)),
]
for hit, normal in walls: # normals point back onto the table
if hit and ball.vel.dot(normal) < 0: # past the cushion AND moving into it
ball.vel -= (1 + e) * ball.vel.dot(normal) * normal
ball.pos.x = max(table.left + ball.radius, min(table.right - ball.radius, ball.pos.x))
ball.pos.y = max(table.top + ball.radius, min(table.bottom - ball.radius, ball.pos.y))
def rack(cue_mass):
balls = [Ball(TABLE.left + 180, TABLE.centery, (240, 240, 240), mass=cue_mass)]
gap = 2 * RADIUS + 0.5 # tiny gap: nothing starts overlapping
x0 = TABLE.left + 480
color = 0
for row in range(3):
for k in range(row + 1):
y = TABLE.centery + (k - row / 2) * gap
balls.append(Ball(x0 + row * gap * 0.866, y, COLORS[color]))
color += 1
return balls
def physics_step(balls, dt):
"""One fixed step: move, slow down on the felt, cushions, then every pair once."""
impulses = 0
for ball in balls:
ball.pos += ball.vel * dt
ball.vel.move_towards_ip((0, 0), ROLL_DECEL * dt) # constant slow-down, stops at 0
bounce_off_cushions(ball, TABLE, CUSHION_E)
for i in range(len(balls)):
for k in range(i + 1, len(balls)):
if resolve_pair(balls[i], balls[k], BALL_E):
impulses += 1
return impulses
def totals(balls):
momentum = pygame.Vector2(0, 0)
energy = 0.0
for ball in balls:
momentum += ball.vel * ball.mass
energy += 0.5 * ball.mass * ball.vel.length_squared()
return momentum, energy
def worst_overlap(balls):
worst = 0.0
for i in range(len(balls)):
for k in range(i + 1, len(balls)):
gap = balls[i].pos.distance_to(balls[k].pos) - (balls[i].radius + balls[k].radius)
worst = max(worst, -gap)
return worst
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Pool Break")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 26)
cue_mass = 1.0
balls = rack(cue_mass)
accumulator = 0.0
impulses = 0
shots = 0
running = True
while running:
accumulator += min(clock.tick(60) / 1000, MAX_FRAME)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE and balls[0].vel.length_squared() == 0:
balls[0].vel.update(SHOT_SPEED, 0)
shots += 1
elif event.key == pygame.K_h:
cue_mass = 4.0 if cue_mass == 1.0 else 1.0
balls[0].mass = cue_mass
elif event.key == pygame.K_r:
balls = rack(cue_mass)
while accumulator >= STEP:
impulses += physics_step(balls, STEP)
accumulator -= STEP
momentum, energy = totals(balls)
screen.fill((20, 24, 32))
pygame.draw.rect(screen, (30, 110, 70), TABLE)
pygame.draw.rect(screen, (100, 70, 40), TABLE, 6)
for ball in balls:
pygame.draw.circle(screen, ball.color, ball.pos, ball.radius)
lines = [
f"momentum ({momentum.x:+6.0f}, {momentum.y:+6.0f}) kinetic energy {energy:9.0f}",
f"cue mass {cue_mass:.0f} (H) impulses {impulses} SPACE shoot, R re-rack",
]
for i, text in enumerate(lines):
screen.blit(font.render(text, True, (230, 230, 230)), (10, 10 + i * 24))
pygame.display.flip()
pygame.quit()
print(f"Shots: {shots}")
print(f"Impulses applied: {impulses}")
print(f"Worst overlap: {worst_overlap(balls):.1f} px")
if __name__ == "__main__":
main()
๐ Learning Journal
Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:
- Key concepts you learned today
- Techniques that clicked (and the ones that haven't, yet)
- Questions or confusion to bring to the next session
- Ideas to try in your own game
- Progress and feelings: how did this lesson go for you?
โ๏ธ This lesson's prompts:
- Explain the four steps of a circle-circle collision (detect, normal, approach check, impulse) as if to a friend who plays pool but doesn't program.
- What happened to the momentum and energy readout with the heavy cue ball? Why do you think so?
- Name a game you'd like to make where colliding circles are the main mechanic (marbles, bumper cars, air hockey...). Which restitution and masses would you choose?
๐ Summary
When two objects collide, the momentum one loses the other gains, so the total is kept; restitution decides how much of the closing speed turns into bounce. On a line, two formulas predict the result, from speed swaps to clay hits. In 2D you work along the contact normal: detect with squared distances, build the normal (with a guard for zero distance), push the overlap apart by inverse mass, skip pairs that are already separating, and apply one impulse j in opposite directions. Walls are infinitely heavy balls: Vector2.reflect() gives a perfect bounce, and vel -= (1 + e) * vel.dot(n) * n adds restitution without slowing the sliding part.
๐ Key Takeaways
- Momentum
m * vis kept in every collision; kinetic energy is kept only whene = 1. - Equal masses in a perfect head-on hit swap speeds.
- Impulse:
j = -(1 + e) * closing / (1/ma + 1/mb), applied along the normal in opposite directions. - Only resolve pairs that are approaching (
closing < 0), and push overlaps apart by inverse mass first. - Guard against zero distance before dividing to get the normal.
- Walls: reflect only the into-the-wall part of the velocity, scaled by e.
๐ญ Looking Ahead
That completes the physics toolkit for this course. In the next lesson, Game States & Scenes, you organize a game into menus, gameplay and pause screens, so all this physics has a proper home.
โ Common Questions
Why check every pair? Isn't that slow?
With n balls there are n * (n - 1) / 2 pairs: 21 for the seven balls in Pool Break, which is nothing. With hundreds of objects, use the spatial hash from Spatial Hashing & Object Pools to test only nearby pairs.
Balls still overlap a little after a big break. Is that a bug?
Each step resolves each pair once, so when three balls press together, fixing one pair can push into another. It usually settles within a few steps. If you need tighter stacks, run the pair loop two or three times per step.
A very fast ball sometimes passes straight through another. Why?
Collisions are only checked after each step, so a ball that moves farther than its own diameter in one step can skip past. Keep speeds below about 2 * radius / STEP, use a smaller step, or cap the speed. (Checking the whole path between steps is covered in the Advanced course.)
How do I make a ball that can't be pushed, like a bumper?
Give it an inverse mass of 0 (infinite mass). Store inv_mass instead of mass, and set it to 0 for fixed objects; the formulas then move only the other ball, exactly like a wall.
What restitution feels right for pool?
Tune by feel. Around 0.9 to 0.95 between balls gives a lively break in this program, with a little less on the cushions. Try the numbers in Pool Break and trust your eyes.
๐ฏ Quick Quiz
Question 1: Ball A (mass 1) moving at 300 px/s hits ball B (mass 1, at rest) head-on, with e = 1. What happens?
Question 2: What is the approach check (if closing >= 0: return) for?
Question 3: Two balls of mass 1 and mass 3 overlap by 4 px. With positional correction by inverse mass, how far does each move?
Question 4: Which quantity does resolve_pair() keep exactly the same, whatever the restitution?
Question 5: A ball moving at (200, -100) hits the top cushion, whose normal is (0, 1), with e = 0.5. What is its new velocity?
๐ Going Further
- Aim the shot: let the mouse set the cue direction (
(mouse - cue.pos).normalize(), guarded against a zero vector) and hold SPACE to charge the power. - Pockets: add six pocket circles; a ball whose center enters a pocket is removed and scores a point.
- Bumpers: add fixed round bumpers with inverse mass 0 that always send a ball away at a fixed speed (say 700 px/s) along the normal, for a pinball feel. As in Bounce & Friction, give a bouncy bumper its own rule instead of an
eabove 1. - Iterate the solver: run the pair loop three times per step and compare the "Worst overlap" line on a tight rack.
- Read the docs: pygame.math.Vector2:
reflect,dot,distance_toandlength_squared. - Coming up in Game Dev III: Advanced: SAT & Rotational Collisions handles boxes and polygons, spin and fast objects checked along their whole path.