Code of the Day
AdvancedPhysics and Simulation

Gravity in code

Add gravity, a jump impulse, ground collision, and coyote time to a pygame player — the complete platformer physics foundation.

Game DevAdvanced12 min read
By the end of this lesson you will be able to:
  • Add gravity to a player's vertical velocity each frame
  • Zero vertical velocity on ground collision to prevent sinking
  • Implement a jump impulse gated on the coyote-time counter
  • Track coyote time with a frame counter

This lesson assembles the concepts from the previous two lessons into a working player class. The code below is a complete, runnable pygame program showing a player that falls, jumps, and lands correctly — including coyote time.

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 platformer.py.

The player with gravity

import pygame
import sys

pygame.init()
screen = pygame.display.set_mode((640, 480))
pygame.display.set_caption("Gravity demo")
clock = pygame.time.Clock()

Vector2 = pygame.math.Vector2

# ── Constants ─────────────────────────────────────────────────────────────────

GRAVITY        = 900    # px/s² downward
JUMP_SPEED     = 420    # px/s upward (negative y)
COYOTE_FRAMES  = 6      # grace frames after leaving the ground
JUMP_BUFFER_F  = 8      # frames a buffered jump press stays active

GROUND_Y   = 400        # y-coordinate of the ground surface
FLOOR_RECT = pygame.Rect(0, GROUND_Y, 640, 80)


# ── Player ────────────────────────────────────────────────────────────────────

class Player:
    WIDTH  = 32
    HEIGHT = 48

    def __init__(self, x, y):
        self.pos    = Vector2(x, y)
        self.vel    = Vector2(0, 0)
        self.rect   = pygame.Rect(x, y, self.WIDTH, self.HEIGHT)

        self.on_ground     = False
        self.coyote        = 0   # frames remaining
        self.jump_buffer   = 0   # frames remaining

    # ── Input / physics ───────────────────────────────────────────────────────

    def handle_jump_input(self, keys_just_pressed):
        """Call once per frame with this frame's freshly-pressed keys."""
        if keys_just_pressed.get(pygame.K_SPACE):
            self.jump_buffer = JUMP_BUFFER_F

    def update(self, dt):
        # Horizontal movement
        keys = pygame.key.get_pressed()
        self.vel.x = 0
        if keys[pygame.K_LEFT]:  self.vel.x = -220
        if keys[pygame.K_RIGHT]: self.vel.x =  220

        # Gravity
        self.vel.y += GRAVITY * dt

        # Move
        self.pos   += self.vel * dt
        self.rect.topleft = (int(self.pos.x), int(self.pos.y))

        # Ground collision
        self.on_ground = False
        if self.rect.colliderect(FLOOR_RECT) and self.vel.y >= 0:
            self.on_ground = True
            self.vel.y     = 0
            self.pos.y     = FLOOR_RECT.top - self.HEIGHT
            self.rect.top  = int(self.pos.y)

        # Coyote time: reset when on ground, tick down in the air
        if self.on_ground:
            self.coyote = COYOTE_FRAMES
        elif self.coyote > 0:
            self.coyote -= 1

        # Consume a buffered jump
        if self.jump_buffer > 0:
            self.jump_buffer -= 1
            if self.coyote > 0:
                self.vel.y  = -JUMP_SPEED
                self.coyote = 0          # consume the coyote window
                self.jump_buffer = 0

        # Screen bounds
        self.pos.x = max(0, min(640 - self.WIDTH, self.pos.x))
        self.rect.x = int(self.pos.x)

    # ── Rendering ─────────────────────────────────────────────────────────────

    def draw(self, surface):
        pygame.draw.rect(surface, (100, 200, 120), self.rect)


# ── Main loop ─────────────────────────────────────────────────────────────────

player = Player(300, 300)

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

    player.handle_jump_input(just_pressed)
    player.update(dt)

    screen.fill((20, 20, 35))
    pygame.draw.rect(screen, (80, 60, 40), FLOOR_RECT)
    player.draw(screen)
    pygame.display.flip()

pygame.quit()
sys.exit()

Key design decisions

Separating handle_jump_input from update — the jump buffer is set from the event queue (one call per keydown), while the buffer is consumed inside update (once per frame). Keeping them separate means the buffer logic is not sensitive to event polling order.

Coyote time is a frame counter, not a boolself.coyote = COYOTE_FRAMES resets on every grounded frame, so the grace window is always fresh. Decrementing in the air naturally expires it. The jump check if self.coyote > 0 works equally well for "actually on ground" and "just left the ground."

vel.y >= 0 guard on ground collision — without this check, the player would be snapped to the floor while jumping upward through a floor from below. Only cancel downward velocity when moving downward.

min(dt, 0.05) cap — if the window loses focus for several seconds and then regains it, dt could be huge, sending the player through geometry. Capping at 0.05 s (the equivalent of 20 fps) limits the maximum physics step.

This uses a single flat ground rectangle. In a real platformer you would loop over a list of platform rects (or use pygame sprite collision) and apply the same logic to each. The collision resolution must also handle horizontal walls and ceilings — check which axis the overlap occurs on first.

Where to go next

Next: lab — physics system — integrate this Player physics into the tile-based platformer from the intermediate tier, and add bouncing projectiles.

Finished reading? Mark it complete to track your progress.

On this page