Lesson 23: Racing Physics
A racing game is only as good as the way its car feels in a corner: whether it bites, runs wide or kicks its tail out. In this lesson you build a top-down racer whose car steers with a real (if simple) vehicle model, loses grip in believable ways and counts every lap honestly.
šÆ Learning Objectives
By the end of this lesson, you will be able to:
- Build a kinematic bicycle model that turns a car at
v / L Ć tan(steer), with speeds in pixels per second anddtin seconds. - Explain understeer and oversteer as "which axle runs out of grip first", and tune a car toward either one.
- Implement brakes that stop at exactly zero, circle-based wall collisions and car-to-car bounces.
- Build lap logic that only counts a lap at the start/finish line after every checkpoint, and that survives a track change.
- Debug handling problems by reading a live slip-angle readout instead of guessing.
Project: Lap Racer, a two-track top-down racer with an AI rival, lap times and a best lap.
In This Lesson
š What a Racing Game Simulates
Think of the difference between a kart game and a racing simulator. Both move a car around a track, but the kart game throws out almost all real physics in favor of instant, forgiving steering, while the simulator models tires, suspension and weight transfer. Most games sit somewhere in between. The trick is to pick a few physical ideas that players can feel and skip the rest.
This lesson's car keeps only four pieces of state, all of which you already know how to handle from Velocity, Acceleration & Timesteps:
pos: apygame.Vector2in pixels (float, never an integerRect);vel: aVector2in pixels per second, which does not have to point where the car points;heading: the direction of the nose, in radians (0 = facing right; positive turns clockwise on screen, because the y axis points down);- two grip numbers, one per axle, in pixels per second squared.
Every frame the car runs the same small pipeline:
Everything is in pixels and seconds. The heads-up display shows km/h, but only through one declared constant, PX_PER_M = 8 (the game's scale: 8 pixels stand for one meter), so the number on screen is consistent with the physics rather than a label stuck on pixels per second.
š” Why this matters
Separating "where the car points" from "where the car goes" is the whole secret of car handling. Once velocity and heading can disagree, you get drifts, slides and spins for free, and every one of them comes from one or two numbers you can tune.
š² Steering with the Bicycle Model
Picture a bicycle: the back wheel follows the front wheel, and the tighter you turn the handlebars, the smaller the circle you ride. A car behaves the same way at low speed if you merge its two front wheels into one and its two rear wheels into one. That simplification is called the kinematic bicycle model, and it is what most top-down racing games use.
With a wheelbase L (the distance between the axles) and a front-wheel angle Ī“, simple geometry gives the turning circle and the rate at which the nose rotates:
turn_radius = WHEELBASE / math.tan(steer_angle) # pixels: set by geometry alone
yaw_rate = v_fwd / WHEELBASE * math.tan(steer_angle) # radians per second = speed / radius
heading += yaw_rate * dt
Two consequences are worth predicting before you run anything. First, a parked car cannot turn: with v_fwd = 0 the yaw rate is 0. Second, at the same steering angle, twice the speed means twice the yaw rate, because the car covers the same circle twice as fast. The radius depends only on the geometry, not on the car's mass; mass matters for how much grip a real tire has, which the next sections handle separately.
š§ A name you may see: Ackermann steering
Older tutorials sometimes call this formula "Ackermann steering". Ackermann geometry is something different: the linkage that angles a real car's inner front wheel more than the outer one so both roll around the same center. The one-wheel-per-axle formula above is the bicycle model.
Here is the model on its own, with no grip limits yet. Hold the up arrow to roll forward and steer with left and right. Notice that steering does nothing until the car is moving.
import math
import pygame
WHEELBASE = 22 # px
MAX_STEER = math.radians(32) # full lock
ACCEL = 200 # px/s^2
pygame.init()
screen = pygame.display.set_mode((800, 500))
pygame.display.set_caption("Bicycle model")
clock = pygame.time.Clock()
pos = pygame.Vector2(400, 250)
heading = 0.0 # radians, 0 = facing right
speed = 0.0 # px/s along the nose
held = set()
running = True
while running:
dt = clock.tick(60) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
held.add(event.key)
elif event.type == pygame.KEYUP:
held.discard(event.key)
steer = (pygame.K_RIGHT in held) - (pygame.K_LEFT in held)
speed += (ACCEL if pygame.K_UP in held else -ACCEL / 2) * dt
speed = max(0.0, min(240.0, speed))
heading += speed / WHEELBASE * math.tan(steer * MAX_STEER) * dt
pos += pygame.Vector2(1, 0).rotate_rad(heading) * speed * dt
pos.x %= 800
pos.y %= 500
screen.fill((30, 34, 40))
nose = pos + pygame.Vector2(18, 0).rotate_rad(heading)
pygame.draw.circle(screen, (90, 170, 255), pos, 10)
pygame.draw.line(screen, (250, 250, 250), pos, nose, 3)
pygame.display.flip()
pygame.quit()
The nose direction comes from Vector2(1, 0).rotate_rad(heading), which is (cos heading, sin heading), the same unit-circle idea you used in Trigonometry for Games. The program tracks held keys from KEYDOWN/KEYUP events in a set, which also lets the lab's scripted checker drive it.
āļø Throttle, Brakes and Drag
Split the velocity into a forward part (along the nose) and a sideways part (across it) with two dot products. The engine, brakes and air resistance only touch the forward part:
fwd = pygame.Vector2(1, 0).rotate_rad(car.heading)
right = pygame.Vector2(-fwd.y, fwd.x) # 90 degrees clockwise on screen
v_fwd = car.vel.dot(fwd) # px/s along the nose
v_side = car.vel.dot(right) # px/s across the nose (sliding)
v_fwd += ENGINE_ACCEL * throttle * dt
v_fwd -= (ROLLING * v_fwd + DRAG * v_fwd * abs(v_fwd)) * dt
In this model the rolling term grows with speed (a common game simplification; real rolling resistance is nearly constant) and air drag grows with speed squared, so at some speed they cancel the engine and the car stops speeding up. With the lab's constants (ENGINE_ACCEL = 260, ROLLING = 0.4, DRAG = 0.0012) that happens where 0.4 v + 0.0012 v² = 260, at about 327 px/s. You never write a "max speed" line; the top speed falls out of the forces.
Brakes that stop at zero
A classic bug: the brake subtracts a fixed amount along the direction of travel, and when the car is already stopped that direction is undefined, so math.copysign(1, 0.0) quietly returns 1.0 and the "brake" launches the car backward at full braking force. The fix is a helper that moves a value toward a target without passing it:
def move_toward(value, target, max_delta):
"""Move value toward target by at most max_delta, never past it."""
if abs(target - value) <= max_delta:
return target
return value + math.copysign(max_delta, target - value)
if brake > 0:
if v_fwd > 5: # rolling forward: slow down and stop at exactly 0
v_fwd = move_toward(v_fwd, 0.0, BRAKE_DECEL * brake * dt)
else: # stopped: keep holding brake to reverse slowly
v_fwd = max(-MAX_REVERSE, v_fwd - REVERSE_ACCEL * brake * dt)
Reversing is still possible, but now it is a deliberate rule (hold brake when stopped) with its own slower acceleration and a top speed, not an accident of a sign function. Even a huge dt cannot overshoot zero, because move_toward clamps the step.
ā Growth Mindset: Handling Is Tuned, Not Solved
Your first car will probably feel wrong: too twitchy, too floaty, or glued to the road. That is not a sign you got the math wrong; every racing game spends weeks on exactly this. Change one constant at a time, drive the same corner three times, and write down what changed. The slip-angle readout in the lab turns "it feels weird" into a number you can reason about. You don't have a feel for these constants yet, and the only way to get one is laps.
š Grip, Slip, Understeer and Oversteer
Real tires can only push so hard sideways before they slide. Racing drivers call the whole tire's force budget the friction circle: braking, accelerating and cornering all draw from the same limited amount.
This lesson's model gives each axle its own budget, because which axle runs out first is what players feel:
- Front grip caps how fast the nose can rotate. Turning at yaw rate
Ļwhile moving atvneeds a sideways acceleration ofv Ć Ļ. If that is more thanfront_grip, the turn is scaled down. The car turns less than you steered and runs wide: that is understeer. - Rear grip limits how fast sideways sliding can be canceled. When the nose rotates, the old velocity now points partly sideways; the rear tires remove that sideways speed at up to
rear_grippx/s² (much less with the handbrake). If the rear cannot keep up, the car keeps sliding with its nose pointing further into the corner than it is traveling: the tail steps out, which is oversteer.
# Front: cap the turn (understeer)
yaw_rate = v_fwd / WHEELBASE * math.tan(steer * MAX_STEER)
lateral_accel = abs(v_fwd * yaw_rate)
car.understeer = lateral_accel > car.front_grip
if car.understeer:
yaw_rate *= car.front_grip / lateral_accel
# Rear: cancel sliding, but only so fast (oversteer when it can't keep up)
grip = car.rear_grip * (0.2 if handbrake else 1.0)
v_side = move_toward(v_side, 0.0, grip * dt)
car.slip_angle = math.atan2(v_side, abs(v_fwd))
The slip angle is the angle between where the nose points and where the car is actually going. Near zero means the tires are gripping; tens of degrees means a slide. Put it on screen and you can see handling instead of guessing at it.
Try it below. The AI driver laps the same oval as the lab. "Balanced" runs out of front grip first at high speed and pushes wide (amber trail). "Loose rear" lets the tail slide. "Weak front" understeers even at moderate speed.
Real simulators go much further, using empirical tire curves such as Pacejka's "Magic Formula" and modeling weight moving to the front under braking. For a top-down game, two grip numbers already give you the two behaviors players talk about.
š§± Tracks, Walls and Car Contact
Instead of a pile of rectangles, describe the track as a closed centerline (a list of points) plus a half width. That one description drives drawing, walls, checkpoints, the AI and the starting grid.
A rotated car has no honest axis-aligned bounding box: as it turns, a box around it grows and shrinks, so it either sinks into walls or bounces off air. A circle is the same size at every heading, and circle-vs-track is simple: find the nearest point on the centerline, and if the car's circle reaches past the edge, push it back and bounce the outward part of its velocity, exactly like the bounce with restitution from Bounce & Friction.
def keep_inside(self, car):
"""Circle-vs-track collision: push the car back and bounce it off the edge."""
d, q = self.nearest(car.pos) # distance to centerline, closest point
limit = self.half_width - CAR_RADIUS
if d <= limit or d == 0:
return False
n = (car.pos - q) / d # from the centerline out to the wall
car.pos = q + n * limit
v_out = car.vel.dot(n)
if v_out > 0:
car.vel -= n * v_out * (1 + WALL_BOUNCE)
return True
nearest() projects the car onto every segment (clamping the projection between the segment's ends) and keeps the closest point. Car-to-car contact reuses the equal-mass circle impulse from Impulse Collisions (Circles): separate the circles by half the overlap each, then exchange an impulse along the line between their centers only if they are moving toward each other.
closing = (b.vel - a.vel).dot(n) # n points from a to b
if closing < 0: # moving toward each other
j = -(1 + CAR_BOUNCE) * closing / 2 # equal masses
a.vel -= n * j
b.vel += n * j
ā±ļø Checkpoints, Laps and the Grid
A lap is not "crossed the start line"; otherwise a player could drive back and forth over it. A lap is every checkpoint, in order, ending at the start/finish line. Each car stores one number, next_cp, the index of the checkpoint it must reach next. Only that checkpoint counts, so skipping one leaves the index stuck until the player goes back.
def update_progress(car, track, dt):
"""Count checkpoints in order; a lap ends only at checkpoint 0. Returns True on a new lap."""
car.lap_time += dt
if car.pos.distance_to(track.points[car.next_cp]) > track.checkpoint_radius:
return False
finished = car.next_cp == 0
car.next_cp = (car.next_cp + 1) % len(track.points)
if finished:
car.laps += 1
if car.best_lap is None or car.lap_time < car.best_lap:
car.best_lap = car.lap_time
car.lap_time = 0.0
return finished
Three details prevent the three classic lap bugs:
- Start at 1, not 0. Cars spawn on the start line, which is checkpoint 0. If
next_cpstarted at 0, the very first frame would "finish" a lap. - Reset progress when the track changes.
next_cpindexes the old track's list. Switch from a 15-checkpoint track to a 12-checkpoint one mid-lap andtrack.points[13]raisesIndexError. The lab rebuilds the cars whenever the track changes. - Build the grid from the track. Starting spots are computed backward along the last segment and offset left and right, spaced at least two car radii apart, so no car starts inside a wall or inside another car.
The AI rival uses the same data: it aims at track.points[car.next_cp], steers in proportion to the angle error (wrapped into Ā±Ļ with the atan2 trick from Trigonometry for Games), and lifts off or brakes when that error is large.
def ai_controls(car, track):
"""Steer toward the next checkpoint; lift and brake when the turn is sharp."""
to_target = track.points[car.next_cp] - car.pos
error = wrap_angle(math.atan2(to_target.y, to_target.x) - car.heading)
sharp = abs(error) > 0.6
brake = 1.0 if sharp and car.vel.length() > 200 else 0.0
return Controls(throttle=0.0 if brake else (0.5 if sharp else 1.0), brake=brake,
steer=max(-1.0, min(1.0, error * 2.0)))
ā Growth Mindset: Crashes Point at the Bug
If your game crashes with IndexError: list index out of range the moment you press 2, that is good news: the traceback names the exact line where a stale checkpoint index met a shorter list. Bugs that crash are the easy ones. Read the last line of the traceback, then ask "which value was left over from before?"
šļø Practice Exercise: Lap Racer
Objective: finish a two-track top-down racer so the car steers, understeers and slides believably, bounces off walls and the rival, and counts laps only when they are real.
Time: about 45 minutes. Starter file: racing_starter.py (your instructor has it). The track, drawing, AI rival and controls already work; the car cannot turn, never brakes, drives through walls and never finishes a lap. The numbered to-do comments in it match these steps.
- Run the starter and drive straight into the grass: nothing stops you. (ā 2 min)
- To-do 1: compute the bicycle-model yaw rate and cap it with front grip. Drive a slow circle, then a fast one, and watch the
UNDERSTEERtag. (ā 8 min) - To-do 2: let the rear tires cancel sideways speed with
move_toward. Try the handbrake in a corner. (ā 5 min) - To-do 3: brakes that stop at exactly 0, then a slow reverse while the brake stays held. (ā 5 min)
- To-do 4: checkpoint and lap logic in
update_progress. Drive a lap and check the lap time and best lap. (ā 10 min) - To-do 5: circle-vs-track walls in
keep_inside. (ā 8 min) - To-do 6: press 2, drive most of a lap on Chicane, press 1, and read the crash. Then delete the early return in
set_trackso the cars are rebuilt. (ā 5 min)
You are done when:
- the car only turns while moving, and at high speed the
UNDERSTEERtag appears in tight corners; - holding SPACE in a corner makes the slip angle jump and the
SLIDINGtag appear; - braking stops the car, and only continued braking reverses it;
- no lap is counted at the start, a lap is counted after a full lap, and cutting the infield does not count;
- switching tracks at any moment never crashes.
š” Hint
Work in the car's own frame: after the heading changes, recompute fwd and right, split the velocity with two dot products, change v_fwd and v_side separately, and only then rebuild vel = fwd * v_fwd + right * v_side. For the lap logic, remember which checkpoint the car spawns on. If laps never count, print car.next_cp every second and see where it gets stuck.
ā Example Solution
If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top and and frame_budget() in the loop. They let the instructor's checker run the program automatically; when you run it yourself they do nothing.
"""Lap Racer: Advanced Lesson 23 practice exercise (solution).
A top-down racer built on a kinematic bicycle model with separate front and
rear grip (so you can feel understeer and oversteer), circle-vs-track wall
collisions, an AI rival, and lap timing that counts a lap only at the
start/finish line after every other checkpoint.
Keys: arrows or WASD drive (hold brake when stopped to reverse), SPACE is the
handbrake, 1 and 2 switch tracks, R restarts. Close the window to quit.
"""
import math
from dataclasses import dataclass
import pygame
WIDTH, HEIGHT = 960, 600
PX_PER_M = 8 # game scale: 8 px = 1 m (only used for the km/h readout)
CAR_RADIUS = 11 # collision circle, px
CAR_LENGTH, CAR_WIDTH = 30, 16 # drawn size, px
WHEELBASE = 22 # px between the front and rear axles
MAX_STEER = math.radians(32) # front-wheel angle at full lock
ENGINE_ACCEL = 260 # px/s^2 at full throttle
BRAKE_DECEL = 520 # px/s^2 at full brake
REVERSE_ACCEL = 140 # px/s^2 while holding brake when (almost) stopped
MAX_REVERSE = 90 # px/s
ROLLING = 0.4 # 1/s rolling resistance (proportional to speed)
DRAG = 0.0012 # 1/px air drag (proportional to speed squared)
WALL_BOUNCE = 0.3 # restitution against the track edge
CAR_BOUNCE = 0.5 # restitution between cars
GRASS = (46, 104, 58)
ASPHALT = (58, 60, 66)
KERB = (200, 200, 205)
TEXT = (235, 235, 235)
WARN = (255, 200, 80)
@dataclass
class Controls:
throttle: float = 0.0 # 0..1
brake: float = 0.0 # 0..1
steer: float = 0.0 # -1 (left) .. 1 (right)
handbrake: bool = False
def move_toward(value, target, max_delta):
"""Move value toward target by at most max_delta, never past it."""
if abs(target - value) <= max_delta:
return target
return value + math.copysign(max_delta, target - value)
def wrap_angle(a):
"""Wrap an angle in radians into [-pi, pi)."""
return (a + math.pi) % math.tau - math.pi
class Car:
def __init__(self, pos, heading, color, front_grip=700.0, rear_grip=900.0):
self.pos = pygame.Vector2(pos)
self.vel = pygame.Vector2()
self.heading = heading # radians, 0 = facing right, clockwise on screen
self.color = color
self.front_grip = front_grip # most sideways accel the front tires can give, px/s^2
self.rear_grip = rear_grip # how fast the rear tires cancel sliding, px/s^2
self.slip_angle = 0.0 # radians between the nose and the direction of travel
self.understeer = False
self.sliding = False
# Race progress. next_cp starts at 1: the car spawns on the start line (checkpoint 0).
self.next_cp = 1
self.laps = 0
self.lap_time = 0.0
self.best_lap = None
def axes(self):
fwd = pygame.Vector2(1, 0).rotate_rad(self.heading)
right = pygame.Vector2(-fwd.y, fwd.x) # 90 degrees clockwise on screen
return fwd, right
def speed_kmh(self):
return self.vel.length() / PX_PER_M * 3.6
def update(self, c, dt):
# 1. Steering: kinematic bicycle model, capped by what the front tires can grip.
fwd, _ = self.axes()
v_fwd = self.vel.dot(fwd)
yaw_rate = v_fwd / WHEELBASE * math.tan(c.steer * MAX_STEER)
lateral_accel = abs(v_fwd * yaw_rate)
self.understeer = lateral_accel > self.front_grip
if self.understeer:
yaw_rate *= self.front_grip / lateral_accel
self.heading = wrap_angle(self.heading + yaw_rate * dt)
# 2. Split the velocity along the NEW nose direction.
fwd, right = self.axes()
v_fwd = self.vel.dot(fwd)
v_side = self.vel.dot(right)
# 3. Engine, brakes and resistance act along the nose.
v_fwd += ENGINE_ACCEL * c.throttle * dt
if c.brake > 0:
if v_fwd > 5: # rolling forward: slow down and stop at exactly 0
v_fwd = move_toward(v_fwd, 0.0, BRAKE_DECEL * c.brake * dt)
else: # stopped: keep holding brake to reverse slowly
v_fwd = max(-MAX_REVERSE, v_fwd - REVERSE_ACCEL * c.brake * dt)
v_fwd -= (ROLLING * v_fwd + DRAG * v_fwd * abs(v_fwd)) * dt
# 4. The rear tires cancel sideways sliding, but only so fast.
grip = self.rear_grip * (0.2 if c.handbrake else 1.0)
v_side = move_toward(v_side, 0.0, grip * dt)
self.sliding = abs(v_side) > 25
self.slip_angle = math.atan2(v_side, abs(v_fwd)) if self.vel.length_squared() > 1 else 0.0
self.vel = fwd * v_fwd + right * v_side
self.pos += self.vel * dt
def corners(self):
fwd, right = self.axes()
hl, hw = fwd * (CAR_LENGTH / 2), right * (CAR_WIDTH / 2)
return [self.pos + hl + hw, self.pos + hl - hw, self.pos - hl - hw, self.pos - hl + hw]
class Track:
def __init__(self, name, points, half_width=55):
self.name = name
self.points = [pygame.Vector2(p) for p in points] # centerline; also the checkpoints
self.half_width = half_width
self.checkpoint_radius = half_width * 1.6
self.surface = None # drawn once, on first use
def segments(self):
return zip(self.points, self.points[1:] + self.points[:1])
def nearest(self, p):
"""Distance from p to the centerline, and the closest centerline point."""
best_d2, best_q = None, None
for a, b in self.segments():
ab = b - a
t = max(0.0, min(1.0, (p - a).dot(ab) / ab.length_squared()))
q = a + ab * t
d2 = p.distance_squared_to(q)
if best_d2 is None or d2 < best_d2:
best_d2, best_q = d2, q
return math.sqrt(best_d2), best_q
def keep_inside(self, car):
"""Circle-vs-track collision: push the car back and bounce it off the edge."""
d, q = self.nearest(car.pos)
limit = self.half_width - CAR_RADIUS
if d <= limit or d == 0:
return False
n = (car.pos - q) / d # points from the centerline out to the wall
car.pos = q + n * limit
v_out = car.vel.dot(n)
if v_out > 0:
car.vel -= n * v_out * (1 + WALL_BOUNCE)
return True
def start_heading(self):
d = self.points[1] - self.points[0]
return math.atan2(d.y, d.x)
def grid(self, count):
"""Starting spots behind the start line, two abreast, never overlapping."""
back = (self.points[-1] - self.points[0]).normalize()
side = pygame.Vector2(-back.y, back.x)
spots = []
for i in range(count):
row, col = divmod(i, 2)
offset = side * (self.half_width * 0.45) * (1 if col == 0 else -1)
spots.append(self.points[0] + back * (20 + row * 3 * CAR_RADIUS) + offset)
return spots
def render(self):
surf = pygame.Surface((WIDTH, HEIGHT)).convert()
surf.fill(GRASS)
for a, b in self.segments():
pygame.draw.line(surf, KERB, a, b, self.half_width * 2 + 6)
for p in self.points:
pygame.draw.circle(surf, KERB, p, self.half_width + 3)
for a, b in self.segments():
pygame.draw.line(surf, ASPHALT, a, b, self.half_width * 2)
for p in self.points:
pygame.draw.circle(surf, ASPHALT, p, self.half_width)
# Checkered start/finish line across checkpoint 0.
d = (self.points[1] - self.points[0]).normalize()
n = pygame.Vector2(-d.y, d.x)
for k in range(-5, 5):
color = (240, 240, 240) if k % 2 else (20, 20, 20)
c = self.points[0] + n * (k + 0.5) * (self.half_width / 5)
pygame.draw.circle(surf, color, c, self.half_width / 10 + 1)
return surf
def make_oval():
return Track("Oval", [(480, 510), (700, 510), (820, 440), (860, 300), (820, 160), (700, 90),
(480, 90), (260, 90), (140, 160), (100, 300), (140, 440), (260, 510)])
def make_chicane():
return Track("Chicane", [(480, 520), (760, 520), (880, 460), (900, 330), (880, 170), (780, 80),
(620, 80), (540, 190), (420, 190), (340, 80), (180, 80), (80, 170),
(60, 330), (80, 460), (200, 520)])
def update_progress(car, track, dt):
"""Count checkpoints in order; a lap ends only at checkpoint 0. Returns True on a new lap."""
car.lap_time += dt
if car.pos.distance_to(track.points[car.next_cp]) > track.checkpoint_radius:
return False
finished = car.next_cp == 0
car.next_cp = (car.next_cp + 1) % len(track.points)
if finished:
car.laps += 1
if car.best_lap is None or car.lap_time < car.best_lap:
car.best_lap = car.lap_time
car.lap_time = 0.0
return finished
def collide_cars(a, b):
"""Equal-mass circle collision: separate the cars, then exchange a bounce impulse."""
delta = b.pos - a.pos
dist = delta.length()
if dist >= 2 * CAR_RADIUS or dist == 0:
return False
n = delta / dist
overlap = 2 * CAR_RADIUS - dist
a.pos -= n * overlap / 2
b.pos += n * overlap / 2
closing = (b.vel - a.vel).dot(n)
if closing < 0:
j = -(1 + CAR_BOUNCE) * closing / 2
a.vel -= n * j
b.vel += n * j
return True
def ai_controls(car, track):
"""Steer toward the next checkpoint; lift and brake when the turn is sharp."""
to_target = track.points[car.next_cp] - car.pos
error = wrap_angle(math.atan2(to_target.y, to_target.x) - car.heading)
sharp = abs(error) > 0.6
brake = 1.0 if sharp and car.vel.length() > 200 else 0.0
return Controls(throttle=0.0 if brake else (0.5 if sharp else 1.0), brake=brake,
steer=max(-1.0, min(1.0, error * 2.0)))
def player_controls(held):
def down(*keys):
return any(k in held for k in keys)
return Controls(throttle=1.0 if down(pygame.K_UP, pygame.K_w) else 0.0,
brake=1.0 if down(pygame.K_DOWN, pygame.K_s) else 0.0,
steer=(1.0 if down(pygame.K_RIGHT, pygame.K_d) else 0.0)
- (1.0 if down(pygame.K_LEFT, pygame.K_a) else 0.0),
handbrake=down(pygame.K_SPACE))
def fmt_time(t):
return "--:--.--" if t is None else f"{int(t // 60):02d}:{t % 60:05.2f}"
class Race:
def __init__(self, track):
self.set_track(track)
def set_track(self, track):
"""Switching tracks rebuilds the cars, so no car keeps a checkpoint index from the old track."""
self.track = track
spots = track.grid(2)
heading = track.start_heading()
self.player = Car(spots[0], heading, (80, 170, 255))
self.rival = Car(spots[1], heading, (255, 90, 90), front_grip=650.0)
self.cars = [self.player, self.rival]
def update(self, controls, dt):
self.player.update(controls, dt)
self.rival.update(ai_controls(self.rival, self.track), dt)
collide_cars(self.player, self.rival)
for car in self.cars:
self.track.keep_inside(car)
update_progress(car, self.track, dt)
def draw(self, screen, font):
if self.track.surface is None:
self.track.surface = self.track.render()
screen.blit(self.track.surface, (0, 0))
target = self.track.points[self.player.next_cp]
pygame.draw.circle(screen, WARN, target, 8, 2)
for car in self.cars:
pygame.draw.polygon(screen, car.color, car.corners())
fwd, _ = car.axes()
pygame.draw.circle(screen, (250, 250, 250), car.pos + fwd * (CAR_LENGTH / 2 - 4), 3)
p = self.player
lines = [f"{self.track.name} Lap {p.laps + 1} Rival laps {self.rival.laps}",
f"Speed {p.speed_kmh():5.0f} km/h Slip {math.degrees(p.slip_angle):+5.1f} deg",
f"Lap time {fmt_time(p.lap_time)} Best {fmt_time(p.best_lap)}"]
tags = ("UNDERSTEER " if p.understeer else "") + ("SLIDING" if p.sliding else "")
for i, text in enumerate(lines):
screen.blit(font.render(text, True, TEXT), (12, 10 + i * 22))
if tags:
screen.blit(font.render(tags, True, WARN), (12, 76))
help_text = font.render("Arrows/WASD drive SPACE handbrake 1/2 track R restart", True, TEXT)
screen.blit(help_text, (12, HEIGHT - 28))
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Lap Racer")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 24) # created once, before the loop
tracks = [make_oval(), make_chicane()]
race = Race(tracks[0])
held = set() # keys currently held down
running = True
while running:
dt = min(clock.tick(60) / 1000, 0.05) # seconds; capped so a stall can't tunnel a wall
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
held.add(event.key)
if event.key == pygame.K_1:
race.set_track(tracks[0])
elif event.key == pygame.K_2:
race.set_track(tracks[1])
elif event.key == pygame.K_r:
race.set_track(race.track)
elif event.type == pygame.KEYUP:
held.discard(event.key)
race.update(player_controls(held), dt)
race.draw(screen, font)
pygame.display.flip()
pygame.quit()
print(f"Track: {race.track.name} laps: {race.player.laps} best: {fmt_time(race.player.best_lap)}")
print("Race closed cleanly.")
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:
- Describe understeer and oversteer to a friend who doesn't drive, using the "which axle runs out of grip first" idea. Which one would you want a beginner's car to have, and why?
- Which constant made the biggest difference to how your car felt? Record its value before and after.
- Where else could "one piece of data drives drawing, collisions, AI and progress" (the centerline) simplify a game you are planning?
š Summary
You built a car whose nose and velocity are allowed to disagree. The bicycle model turns the nose at a rate set by speed, wheelbase and steering angle; front grip caps that turn, which is understeer; rear grip limits how fast sideways sliding is canceled, which is oversteer. Brakes move speed toward zero without passing it, walls push a circle back onto the track, cars exchange equal-mass impulses, and laps count only when every checkpoint has been reached in order and the car crosses checkpoint 0.
š Key Takeaways
- Kinematic bicycle model:
yaw_rate = v / L Ć tan(Ī“); the turning radius comes from geometry, not mass. - Split velocity into forward and sideways parts with dot products; engines act forward, grip acts sideways.
- Front grip running out first gives understeer; rear grip running out first gives oversteer. The slip angle measures it.
move_towardmakes brakes stop at exactly zero at anydt.- Use circles (or oriented boxes tested with SAT, as in SAT & Rotational Collisions) for rotated cars, never an axis-aligned box.
- Laps: start
next_cpat 1, count at checkpoint 0, and reset progress when the track changes.
š Looking Ahead
Next, Real-time Strategy scales up from one car to dozens of units: factions with their own economies, octile A* on a tile map, production queues timed in seconds and a small build-and-train interface.
ā Common Questions
Why does my car spin in circles when I steer at low speed?
Check the sign and scale of the yaw rate: it must be proportional to v_fwd, so it is tiny at low speed. If you wrote yaw_rate = steer * something without the speed, the car rotates in place.
Should I use a fixed timestep for car physics?
For a game like this, a variable dt capped at 0.05 s works well, and every rule here uses dt correctly. If you want replays or ghost cars that match exactly, run the car update inside the fixed-timestep accumulator from Velocity, Acceleration & Timesteps.
Why not rotate a car image with pygame.transform.rotate?
You can, and many games do (cache the rotated images, as in Images & Sprite Classes). The lab draws a polygon from the car's four corners because it needs no assets and shows the heading clearly. The physics does not change either way.
How is the km/h number calculated?
speed_px_per_s / PX_PER_M * 3.6. Pixels per second divided by pixels per meter gives meters per second, and 1 m/s is 3.6 km/h. Change PX_PER_M and only the display changes.
My AI rival gets stuck on walls. Is that a bug?
It is a limitation of a very simple driver that only aims at the next checkpoint. Aiming slightly past it, slowing earlier for sharp turns, or reversing when its speed stays near zero for a second are all easy improvements.
šÆ Quick Quiz
Question 1: In the kinematic bicycle model, you double the car's forward speed and keep the same steering angle (with plenty of grip). What happens to the yaw rate?
Question 2: In the lab's model, the front tires reach their grip limit before the rear tires do. What does the player see?
Question 3: Why does each car's next_cp start at 1 instead of 0?
Question 4: What does replacing v_fwd -= BRAKE_DECEL * dt with move_toward(v_fwd, 0.0, BRAKE_DECEL * dt) fix?
Question 5: Why does the lab test the car against the walls as a circle rather than with its axis-aligned Rect?
š Going Further
- Drift score: while
abs(slip_angle)is above 15° and speed is above 150 px/s, addslip à speed à dtto a combo; bank it when the slide ends, lose it if you touch a wall. - Ghost car: record the player's
(pos, heading)every 0.05 s during their best lap and replay it as a transparent car on the next lap. - Skid marks: while
car.sliding, stamp dark dots at the rear axle onto the cached track surface (draw them once, not every frame). - Camera: make the track bigger than the window and follow the car with the smoothed camera from Cameras, leading the view along the velocity.
- Read the docs: pygame.math (Vector2), including
rotate_rad,dotanddistance_to. - Coming up in Game Dev III: Advanced: Difficulty & DDA shows how "rubber-band" AI speeds up or slows down rivals depending on how far they are from the player.