Code of the Day
AdvancedPhysics and Simulation

Lab: Physics system

Build a reusable PhysicsBody component, wire it into a tile platformer, and add gravity-driven bouncing projectiles.

Lab · optionalGame DevAdvanced40 min
Recommended first
By the end of this lesson you will be able to:
  • 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 vel and update its rect each frame; also move the player when they stand on it.
  • Add wall-bouncing: in PhysicsBody.move_and_collide, negate vel.x instead of zeroing it when hitting a wall, and apply the same BOUNCE_DAMPEN factor.
  • Experiment with different GRAVITY and JUMP_SPEED values. 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.

Finished reading? Mark it complete to track your progress.

On this page