Lab: Physics system
Build a reusable PhysicsBody component, wire it into a tile platformer, and add gravity-driven bouncing projectiles.
- Build a standalone PhysicsBody component that stores position, velocity, and handles gravity
- Attach a PhysicsBody to the Player and integrate it with tile collision
- Add a projectile that uses the same PhysicsBody and bounces on floor impact
In the previous lessons you built physics directly into a Player class. That
approach works for one entity. When a projectile, an enemy, and a falling crate
all need gravity and collision, duplicating the physics logic across every class
becomes brittle. The fix is a PhysicsBody component: a small object that
handles position, velocity, and integration. Any entity can own one.
Pyodide (the in-browser Python runner) does not support pygame's display
system. Read through this code carefully in the browser, then run it locally:
pip install pygame followed by python lab_physics.py.
Step 1 — The PhysicsBody component
import pygame
Vector2 = pygame.math.Vector2
GRAVITY = 900 # px/s²
class PhysicsBody:
"""A self-contained physics component. Attach to any entity."""
def __init__(self, x, y, width, height):
self.pos = Vector2(x, y)
self.vel = Vector2(0, 0)
self.rect = pygame.Rect(x, y, width, height)
self.on_ground = False
def apply_gravity(self, dt):
self.vel.y += GRAVITY * dt
def move_and_collide(self, dt, platforms):
"""
Euler-integrate position, then resolve collisions with a list of
pygame.Rect platforms. Sets self.on_ground as a side-effect.
"""
self.on_ground = False
self.pos += self.vel * dt
self.rect.topleft = (int(self.pos.x), int(self.pos.y))
for plat in platforms:
if not self.rect.colliderect(plat):
continue
# Resolve the smallest overlap axis
overlap_x = min(self.rect.right - plat.left,
plat.right - self.rect.left)
overlap_y = min(self.rect.bottom - plat.top,
plat.bottom - self.rect.top)
if overlap_y < overlap_x:
if self.vel.y > 0: # landing on top
self.pos.y = plat.top - self.rect.height
self.vel.y = 0
self.on_ground = True
else: # hitting ceiling
self.pos.y = plat.bottom
self.vel.y = 0
else:
if self.vel.x > 0: # hitting right wall
self.pos.x = plat.left - self.rect.width
else: # hitting left wall
self.pos.x = plat.right
self.vel.x = 0
self.rect.topleft = (int(self.pos.x), int(self.pos.y))The key insight: move_and_collide resolves on the smallest overlap axis,
which correctly handles corner cases without requiring separate x/y passes.
Step 2 — Player with a PhysicsBody
Replace the player's inline position/velocity fields with a PhysicsBody:
JUMP_SPEED = 420
COYOTE_FRAMES = 6
JUMP_BUFFER_F = 8
class Player:
W, H = 32, 48
def __init__(self, x, y):
self.body = PhysicsBody(x, y, self.W, self.H)
self.coyote = 0
self.jump_buffer = 0
@property
def rect(self):
return self.body.rect
def handle_input(self, just_pressed):
if just_pressed.get(pygame.K_SPACE):
self.jump_buffer = JUMP_BUFFER_F
def update(self, dt, platforms):
# Horizontal
keys = pygame.key.get_pressed()
self.body.vel.x = 0
if keys[pygame.K_LEFT]: self.body.vel.x = -220
if keys[pygame.K_RIGHT]: self.body.vel.x = 220
self.body.apply_gravity(dt)
self.body.move_and_collide(dt, platforms)
# Coyote
if self.body.on_ground:
self.coyote = COYOTE_FRAMES
elif self.coyote > 0:
self.coyote -= 1
# Jump buffer
if self.jump_buffer > 0:
self.jump_buffer -= 1
if self.coyote > 0:
self.body.vel.y = -JUMP_SPEED
self.coyote = 0
self.jump_buffer = 0
def draw(self, surface):
pygame.draw.rect(surface, (100, 200, 120), self.rect)The player's update now delegates all physics to self.body and only keeps
control logic (coyote, jump buffer) for itself.
Step 3 — Bouncing projectiles
A projectile uses the exact same PhysicsBody but adds a bounce on floor
collision. After move_and_collide, check on_ground and flip + dampen vel.y:
BOUNCE_DAMPEN = 0.55 # retain 55% of speed on each bounce
MIN_BOUNCE = 60 # below this speed, stop bouncing
class Projectile:
W, H = 12, 12
def __init__(self, x, y, vel_x):
self.body = PhysicsBody(x, y, self.W, self.H)
self.body.vel = Vector2(vel_x, -300)
self.dead = False
@property
def rect(self):
return self.body.rect
def update(self, dt, platforms):
self.body.apply_gravity(dt)
self.body.move_and_collide(dt, platforms)
if self.body.on_ground:
bounce_speed = abs(self.body.vel.y) * BOUNCE_DAMPEN
if bounce_speed < MIN_BOUNCE:
self.dead = True # come to rest
else:
self.body.vel.y = -bounce_speed
self.body.on_ground = False
def draw(self, surface):
if not self.dead:
pygame.draw.circle(surface, (220, 120, 40),
self.rect.center, self.W // 2)
# ── Assemble and run ──────────────────────────────────────────────────────────
def main():
pygame.init()
screen = pygame.display.set_mode((640, 480))
pygame.display.set_caption("Physics lab")
clock = pygame.time.Clock()
platforms = [
pygame.Rect(0, 420, 640, 60), # ground
pygame.Rect(150, 320, 160, 16), # platform 1
pygame.Rect(380, 240, 160, 16), # platform 2
]
player = Player(60, 360)
projectiles = []
running = True
while running:
dt = min(clock.tick(60) / 1000.0, 0.05)
just_pressed = {}
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN:
just_pressed[event.key] = True
if event.key == pygame.K_f: # fire projectile
px, py = player.rect.midright
projectiles.append(Projectile(px, py, 280))
player.handle_input(just_pressed)
player.update(dt, platforms)
projectiles = [p for p in projectiles if not p.dead]
for p in projectiles:
p.update(dt, platforms)
screen.fill((20, 20, 35))
for plat in platforms:
pygame.draw.rect(screen, (80, 60, 40), plat)
for p in projectiles:
p.draw(screen)
player.draw(screen)
pygame.display.flip()
pygame.quit()
if __name__ == "__main__":
main()What to try next
- Add a second platform type (moving platform). Give it a
veland update its rect each frame; also move the player when they stand on it. - Add wall-bouncing: in
PhysicsBody.move_and_collide, negatevel.xinstead of zeroing it when hitting a wall, and apply the sameBOUNCE_DAMPENfactor. - Experiment with different
GRAVITYandJUMP_SPEEDvalues. Plot the apex height:(JUMP_SPEED ** 2) / (2 * GRAVITY)— adjust both to keep the same apex while changing how "snappy" the fall feels.
Notice the main loop stayed the same when you added projectiles. That is the
payoff of the component pattern: the loop iterates a list and calls update /
draw on each item, indifferent to whether the item is a player or a
projectile.
Gravity in code
Add gravity, a jump impulse, ground collision, and coyote time to a pygame player — the complete platformer physics foundation.
Finite state machines for NPC AI
Model NPC behaviour with a finite state machine — distinct states, explicit transition conditions, and cooldowns that prevent rapid state flickering.