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.
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.
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.
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 = 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
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 = 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
Dash & Screen Boundaries
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.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))
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).
| Technique | Formula | Use case |
|---|---|---|
| Linear interpolation (lerp) | a + (b - a) * t | Smooth camera follow, easing UI elements |
| Direct snap | pos = target | Instant, responsive player control |
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.