Module 8 of 20 Phase: Movement & Collision

The Movement System
Behind Every Great Platformer

"Game feel" mostly comes down to movement math. This module builds a full velocity-based movement system — gravity, friction, jumping, double jump, dash — the exact ingredients that separate a floaty, unresponsive controller from a satisfying one.

🏃
12 Lessons
This module
~3 hrs
Estimated time
📊
Intermediate
Difficulty
🐍
pygame.Vector2
Primary tool
Section 1 · Position & Velocity

Position, Velocity & Speed

Every moving object needs a position (where it is) and a velocity (how fast, and in which direction, it's currently moving). Speed is simply the magnitude (length) of the velocity vector, ignoring direction.

player_movement.py
import pygame

class Player:
    def __init__(self, x, y):
        self.pos = pygame.Vector2(x, y)
        self.vel = pygame.Vector2(0, 0)
        self.max_speed = 300   # pixels per second

    def update(self, dt):
        self.pos += self.vel * dt   # dt = seconds since last frame

Using pygame.Vector2 instead of two separate x/y floats makes movement math (adding, scaling, normalizing) far cleaner than manual arithmetic.

Section 2 · Acceleration & Friction

Acceleration & Friction

Instantly snapping to max speed feels robotic. Real "game feel" comes from acceleration (ramping velocity up gradually while a key is held) and friction (velocity decaying back to zero when no input is given).

accel_friction.py
ACCEL = 1800      # pixels/sec^2
FRICTION = 1400   # pixels/sec^2

def update(self, dt, move_dir):
    if move_dir != 0:
        self.vel.x += move_dir * ACCEL * dt
        self.vel.x = max(-self.max_speed, min(self.max_speed, self.vel.x))
    else:
        # friction pulls velocity toward zero without overshooting past it
        if self.vel.x > 0:
            self.vel.x = max(0, self.vel.x - FRICTION * dt)
        elif self.vel.x < 0:
            self.vel.x = min(0, self.vel.x + FRICTION * dt)
    self.pos += self.vel * dt
Section 3 · Gravity & Jumping

Gravity, Jumping & Double Jump

Gravity is just a constant downward acceleration added to vertical velocity every frame. A jump sets vertical velocity to a negative (upward) value instantly — gravity then pulls it back down naturally, producing a parabolic arc for free.

gravity_jump.py
GRAVITY = 1600       # pixels/sec^2
JUMP_FORCE = -650    # negative = upward
MAX_JUMPS = 2        # 1 = single jump, 2 = double jump

def update(self, dt, on_ground):
    if on_ground:
        self.jumps_left = MAX_JUMPS

    self.vel.y += GRAVITY * dt          # always falling unless jumping counters it
    self.pos += self.vel * dt

def try_jump(self):
    if self.jumps_left > 0:
        self.vel.y = JUMP_FORCE
        self.jumps_left -= 1
💡
Key insight: Double jump is just "allow try_jump() to fire again while airborne" — track a jumps_left counter reset only when grounded, exactly like the code above.
Section 4 · Dash & Boundaries

Dash & Screen Boundaries

💨
Dash
A short burst of high velocity in the facing direction for a fixed duration (e.g. 150ms), usually followed by a cooldown timer so it can't be spammed. Often ignores normal deceleration for its duration.
🧱
Screen Boundaries
Clamp self.pos.x/self.pos.y to [0, SCREEN_WIDTH - width] and [0, SCREEN_HEIGHT - height] after moving, so the player can never leave the visible play area.
dash_and_bounds.py
def try_dash(self, facing):
    if self.dash_cooldown <= 0:
        self.vel.x = facing * 900
        self.dash_cooldown = 0.6   # seconds

def clamp_to_screen(self, screen_w, screen_h, width, height):
    self.pos.x = max(0, min(self.pos.x, screen_w - width))
    self.pos.y = max(0, min(self.pos.y, screen_h - height))
Section 5 · Smooth & Camera-Relative Movement

Smooth Movement & Camera-Relative Motion

"Smooth" movement usually means interpolating (lerping) toward a target value each frame instead of snapping instantly — used for things like a follow-camera easing toward the player rather than locking rigidly onto them.

Camera-relative movement means the player's ON-SCREEN input direction stays consistent even as a camera rotates or the world scrolls — you transform input direction by the camera's orientation before applying it to velocity. In a 2D game with no camera rotation, this usually just means drawing everything at world_pos - camera_offset (covered fully in Module 15).

TechniqueFormulaUse case
Linear interpolation (lerp)a + (b - a) * tSmooth camera follow, easing UI elements
Direct snappos = targetInstant, responsive player control
Visual Reference

Velocity Over Time: Acceleration & Friction

Graphing vel.x against time turns the accel/friction code from Section 2 into a shape: a rising ramp while the key is held (ACCEL pushing toward max_speed), a flat plateau once max speed is hit, then a falling ramp back to zero once FRICTION takes over after release.

key released accelerating friction decay 0 100 200 300 velocity (px/s) 0ms 300ms 600ms
Velocity over time: acceleration while held, friction after release

🧠 Quick Knowledge Check

1. What is "speed" in relation to a velocity vector?
2. How is double jump typically implemented?
3. Why is JUMP_FORCE a negative number in the example code?
4. What does friction do when there is no movement input?
5. What does linear interpolation (lerp) provide that a direct snap doesn't?
Finished Module 8?

Mark it complete to track your progress through Complete Pygame Mastery 2026.

🗒 Cheat Sheet 📝 Worksheet