Implementing Physics and Gravity in Pygame

Implementing Physics and Gravity in Pygame

A system must be built from clean, well-defined components. To simulate motion, we must first represent the object that is to be moved. We will not litter our main program with loose variables representing an object’s position or velocity. This is the path to chaos. Instead, we encapsulate the state and the operations that act upon that state into a class. This is the first rule of order.

Let’s define a Player class. Its sole responsibility, for now, is to maintain its own state and present a clean interface for interacting with that state. The most fundamental state for a visible object is its position. We will also give it a velocity, though we will not use it immediately. The data, x, y, velocity_x, and velocity_y, belong together inside a single conceptual unit.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import pygame
# --- Constants ---
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
self.width = 20
self.height = 20
self.color = WHITE
self.velocity_x = 5 # Represents pixels per frame
self.velocity_y = 0
def draw(self, surface):
"""Draws the player on the provided surface."""
pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))
# --- Main Program Setup ---
pygame.init()
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Physics Simulation")
player = Player(50, SCREEN_HEIGHT // 2)
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# --- Drawing Code ---
screen.fill(BLACK)
player.draw(screen)
pygame.display.flip()
pygame.quit()
import pygame # --- Constants --- SCREEN_WIDTH = 800 SCREEN_HEIGHT = 600 BLACK = (0, 0, 0) WHITE = (255, 255, 255) class Player: def __init__(self, x, y): self.x = x self.y = y self.width = 20 self.height = 20 self.color = WHITE self.velocity_x = 5 # Represents pixels per frame self.velocity_y = 0 def draw(self, surface): """Draws the player on the provided surface.""" pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height)) # --- Main Program Setup --- pygame.init() screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("Physics Simulation") player = Player(50, SCREEN_HEIGHT // 2) running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # --- Drawing Code --- screen.fill(BLACK) player.draw(screen) pygame.display.flip() pygame.quit()
import pygame

# --- Constants ---
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)

class Player:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.width = 20
        self.height = 20
        self.color = WHITE
        self.velocity_x = 5  # Represents pixels per frame
        self.velocity_y = 0

    def draw(self, surface):
        """Draws the player on the provided surface."""
        pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))

# --- Main Program Setup ---
pygame.init()
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Physics Simulation")
player = Player(50, SCREEN_HEIGHT // 2)
running = True

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # --- Drawing Code ---
    screen.fill(BLACK)
    player.draw(screen)
    pygame.display.flip()

pygame.quit()

Executing this program reveals a static white square. It has a position and a velocity defined within its instance, but nothing yet acts upon the velocity to change the position. The object’s behavior is incomplete. Motion is change over time. We must write a function that applies this change. This function, which we will name update, will modify the object’s position based on its velocity. This is the object’s primary behavior, and it belongs within the object’s class definition. The principle is simple: code that operates on data should reside with that data.

The physical formula is clear: new_position = old_position + velocity. Our update method is the direct implementation of this formula. It is a small function that does one thing: it updates the state of the object for a single time step.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
self.width = 20
self.height = 20
self.color = WHITE
self.velocity_x = 5
self.velocity_y = 0
def update(self):
"""Update the player's position based on its velocity."""
self.x += self.velocity_x
self.y += self.velocity_y
def draw(self, surface):
"""Draws the player on the provided surface."""
pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))
class Player: def __init__(self, x, y): self.x = x self.y = y self.width = 20 self.height = 20 self.color = WHITE self.velocity_x = 5 self.velocity_y = 0 def update(self): """Update the player's position based on its velocity.""" self.x += self.velocity_x self.y += self.velocity_y def draw(self, surface): """Draws the player on the provided surface.""" pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))
class Player:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.width = 20
        self.height = 20
        self.color = WHITE
        self.velocity_x = 5
        self.velocity_y = 0

    def update(self):
        """Update the player's position based on its velocity."""
        self.x += self.velocity_x
        self.y += self.velocity_y

    def draw(self, surface):
        """Draws the player on the provided surface."""
        pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))

With the update method now available, we must invoke it from our main loop. The structure of this loop is paramount. A clean simulation loop separates its concerns. It should, in order: handle user input, update the state of all game objects, and then render the new state to the screen. Mixing these responsibilities is a design error that will cost us dearly later.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
# ... (previous setup code, including Player class) ...
player = Player(50, SCREEN_HEIGHT // 2)
clock = pygame.time.Clock() # Create a clock to manage frame rate
running = True
while running:
# 1. Process Input (Events)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# 2. Update Game State
player.update()
# 3. Render (Draw)
screen.fill(BLACK)
player.draw(screen)
pygame.display.flip()
# --- Frame Rate Control ---
clock.tick(60) # Ensure the loop runs at most 60 times per second
pygame.quit()
# ... (previous setup code, including Player class) ... player = Player(50, SCREEN_HEIGHT // 2) clock = pygame.time.Clock() # Create a clock to manage frame rate running = True while running: # 1. Process Input (Events) for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # 2. Update Game State player.update() # 3. Render (Draw) screen.fill(BLACK) player.draw(screen) pygame.display.flip() # --- Frame Rate Control --- clock.tick(60) # Ensure the loop runs at most 60 times per second pygame.quit()
# ... (previous setup code, including Player class) ...

player = Player(50, SCREEN_HEIGHT // 2)
clock = pygame.time.Clock()  # Create a clock to manage frame rate
running = True

while running:
    # 1. Process Input (Events)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # 2. Update Game State
    player.update()

    # 3. Render (Draw)
    screen.fill(BLACK)
    player.draw(screen)
    pygame.display.flip()

    # --- Frame Rate Control ---
    clock.tick(60)  # Ensure the loop runs at most 60 times per second

pygame.quit()

The introduction of pygame.time.Clock is not a minor detail; it is a fundamental requirement for a predictable simulation. Without it, the loop would run as fast as the CPU allows, making our object’s speed dependent on the hardware. This is unacceptable. By calling clock.tick(60), we establish a consistent “heartbeat” for our simulation. Our velocity of 5 now has a concrete meaning: 5 pixels per frame, where a frame is 1/60th of a second. This frame-rate dependency is itself a form of technical debt that we will have to repay, but for now, it provides the stability we need. The current implementation has a clear deficiency: the object will move off the screen and never return. A system must be robust. It must handle its own boundaries. We can add this logic directly into the update method, as it is a behavior of the object itself. For example, to make the object wrap around the screen edges.

A Constant Acceleration

The current motion is linear and uninteresting. An object with constant velocity will continue in a straight line forever, as Newton’s first law dictates. This is not a simulation of physics; it is a simulation of a single, trivial case. To introduce more dynamic behavior, we must introduce a force. The simplest force to model is a constant one, like gravity near the Earth’s surface. A constant force results in a constant acceleration.

Acceleration is the rate of change of velocity. Just as velocity changes position, acceleration changes velocity. The formula is symmetric: new_velocity = old_velocity + acceleration. Our Player object must therefore be extended. It is not enough to know its position and velocity; to be subject to acceleration, it must also have an acceleration state. We will add acceleration_y to represent the constant downward pull of gravity.

The update method’s responsibility must now expand. It is no longer sufficient to simply add velocity to position. The method must first update the velocity itself. The order of operations is not arbitrary; it is dictated by the laws of physics we are simulating. First, apply acceleration to velocity. Then, apply the newly calculated velocity to position. This two-step process occurs within a single time-step, a single call to update.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
self.width = 20
self.height = 20
self.color = WHITE
self.velocity_x = 5
self.velocity_y = 0
self.acceleration_y = 0.5 # A constant downward acceleration
def update(self):
"""Update the player's state for one frame."""
# Update velocity based on acceleration
self.velocity_y += self.acceleration_y
# Update position based on velocity
self.x += self.velocity_x
self.y += self.velocity_y
def draw(self, surface):
"""Draws the player on the provided surface."""
pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))
class Player: def __init__(self, x, y): self.x = x self.y = y self.width = 20 self.height = 20 self.color = WHITE self.velocity_x = 5 self.velocity_y = 0 self.acceleration_y = 0.5 # A constant downward acceleration def update(self): """Update the player's state for one frame.""" # Update velocity based on acceleration self.velocity_y += self.acceleration_y # Update position based on velocity self.x += self.velocity_x self.y += self.velocity_y def draw(self, surface): """Draws the player on the provided surface.""" pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))
class Player:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.width = 20
        self.height = 20
        self.color = WHITE
        self.velocity_x = 5
        self.velocity_y = 0
        self.acceleration_y = 0.5  # A constant downward acceleration

    def update(self):
        """Update the player's state for one frame."""
        # Update velocity based on acceleration
        self.velocity_y += self.acceleration_y

        # Update position based on velocity
        self.x += self.velocity_x
        self.y += self.velocity_y

    def draw(self, surface):
        """Draws the player on the provided surface."""
        pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))

Notice the elegance of this change. The main program loop remains untouched. It still makes a single call to player.update(). The complexity of the motion is hidden—encapsulated—within the Player class where it belongs. The caller is shielded from the implementation details. This is the sign of a clean separation of concerns.

When this new code is executed, the white square will no longer travel in a straight line. It will trace a parabolic arc. It starts with a horizontal velocity and zero vertical velocity. On each frame, the update method adds 0.5 to its velocity_y. The vertical speed increases steadily, pulling the object downwards faster and faster. This is a correct, if simple, simulation of projectile motion under constant gravity.

However, the problem of boundaries is now more pressing. The object accelerates off the bottom of the screen with no mechanism to stop it. A bouncing behavior is a common requirement. This is an interaction between the object and its environment (the floor). The logic for this interaction belongs in the update method. It is a rule that governs the object’s movement. If the object’s bottom edge moves past the screen’s bottom edge, its vertical velocity must be reversed. To make the bounce appear realistic, we should also dampen the velocity, simulating a loss of energy.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
self.width = 20
self.height = 20
self.color = WHITE
self.velocity_x = 5
self.velocity_y = 0
self.acceleration_y = 0.5
def update(self):
"""Update the player's state for one frame."""
self.velocity_y += self.acceleration_y
self.x += self.velocity_x
self.y += self.velocity_y
# Boundary check for the floor
if self.y + self.height > SCREEN_HEIGHT:
self.y = SCREEN_HEIGHT - self.height
self.velocity_y *= -0.8 # Reverse and dampen velocity
def draw(self, surface):
"""Draws the player on the provided surface."""
pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))
class Player: def __init__(self, x, y): self.x = x self.y = y self.width = 20 self.height = 20 self.color = WHITE self.velocity_x = 5 self.velocity_y = 0 self.acceleration_y = 0.5 def update(self): """Update the player's state for one frame.""" self.velocity_y += self.acceleration_y self.x += self.velocity_x self.y += self.velocity_y # Boundary check for the floor if self.y + self.height > SCREEN_HEIGHT: self.y = SCREEN_HEIGHT - self.height self.velocity_y *= -0.8 # Reverse and dampen velocity def draw(self, surface): """Draws the player on the provided surface.""" pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))
class Player:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.width = 20
        self.height = 20
        self.color = WHITE
        self.velocity_x = 5
        self.velocity_y = 0
        self.acceleration_y = 0.5

    def update(self):
        """Update the player's state for one frame."""
        self.velocity_y += self.acceleration_y
        self.x += self.velocity_x
        self.y += self.velocity_y

        # Boundary check for the floor
        if self.y + self.height > SCREEN_HEIGHT:
            self.y = SCREEN_HEIGHT - self.height
            self.velocity_y *= -0.8  # Reverse and dampen velocity

    def draw(self, surface):
        """Draws the player on the provided surface."""
        pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))

This addition to update is a specific rule. First, we check for the boundary condition. If the condition is met, we correct the position to prevent the object from sinking into the floor. This is a crucial step. Then, we modify the state—in this case, reversing and reducing the vertical velocity. The factor -0.8 is a “coefficient of restitution.” A value of -1 would be a perfectly elastic collision, while -0.8 represents an inelastic collision where some energy is lost. This single line of code adds a significant degree of physical realism. The object will now bounce, with each bounce being lower than the last, until it comes to rest on the floor. The system is becoming more robust, more complete. It handles its own state changes in response to defined physical rules and boundaries. But the frame-rate dependency remains a flaw. The value 0.5 for acceleration is not 0.5 m/s²; it is 0.5 pixels/frame². This is a fragile abstraction.

The Heartbeat of Simulation

The current system, while functional, is built upon a fragile foundation. The physics calculations are coupled to the frame rate. An acceleration of 0.5 is not a physical value; it is 0.5 pixels/frame². If the game were to run at 30 frames per second instead of 60, our object would accelerate at half the rate. The outcome of the simulation would change. This is an unacceptable breach of determinism. A physical simulation must be consistent regardless of the speed of the machine running it. The laws of physics do not slow down when the CPU is busy.

The solution is to untangle the simulation’s logic from the rendering loop’s frequency. We must measure the actual time that has elapsed between each frame. This slice of time is commonly known as delta time, or dt. All our physics calculations must be scaled by this dt. The formulas change from a per-frame basis to a per-second basis.

velocity_change = acceleration * dt
position_change = velocity * dt

This change forces us to reconsider the units of our constants. Velocity is no longer in pixels per frame, but pixels per second. Acceleration is no longer in pixels per frame squared, but pixels per second squared. The code must be modified to reflect this more robust model. The pygame.time.Clock.tick() method is our source for this measurement. It returns the number of milliseconds since the last tick, which we can convert to seconds.

The responsibility for measuring time belongs in the main loop. The responsibility for using that measurement to update state belongs in the object. Therefore, the update method must be changed to accept dt as an argument. This makes the dependency explicit and decouples the Player class from the global clock. A clean component does not depend on global state; it receives what it needs through its interface.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
# --- Constants ---
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
# Physics constants scaled by time (seconds)
PLAYER_VELOCITY_X = 200 # pixels per second
GRAVITY = 2000 # pixels per second squared
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
self.width = 20
self.height = 20
self.color = WHITE
self.velocity_x = PLAYER_VELOCITY_X
self.velocity_y = 0
self.acceleration_y = GRAVITY
def update(self, dt):
"""Update the player's state based on the time delta."""
# Update velocity based on acceleration and dt
self.velocity_y += self.acceleration_y * dt
# Update position based on velocity and dt
self.x += self.velocity_x * dt
self.y += self.velocity_y * dt
# Boundary check for the floor
if self.y + self.height > SCREEN_HEIGHT:
self.y = SCREEN_HEIGHT - self.height
self.velocity_y *= -0.8 # Reverse and dampen velocity
def draw(self, surface):
"""Draws the player on the provided surface."""
pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))
# --- Main Program Setup ---
pygame.init()
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Time-based Physics Simulation")
player = Player(50, SCREEN_HEIGHT // 2)
clock = pygame.time.Clock()
running = True
while running:
# Calculate delta time (dt) in seconds
dt = clock.tick(60) / 1000.0
# 1. Process Input (Events)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# 2. Update Game State
player.update(dt)
# 3. Render (Draw)
screen.fill(BLACK)
player.draw(screen)
pygame.display.flip()
pygame.quit()
# --- Constants --- SCREEN_WIDTH = 800 SCREEN_HEIGHT = 600 BLACK = (0, 0, 0) WHITE = (255, 255, 255) # Physics constants scaled by time (seconds) PLAYER_VELOCITY_X = 200 # pixels per second GRAVITY = 2000 # pixels per second squared class Player: def __init__(self, x, y): self.x = x self.y = y self.width = 20 self.height = 20 self.color = WHITE self.velocity_x = PLAYER_VELOCITY_X self.velocity_y = 0 self.acceleration_y = GRAVITY def update(self, dt): """Update the player's state based on the time delta.""" # Update velocity based on acceleration and dt self.velocity_y += self.acceleration_y * dt # Update position based on velocity and dt self.x += self.velocity_x * dt self.y += self.velocity_y * dt # Boundary check for the floor if self.y + self.height > SCREEN_HEIGHT: self.y = SCREEN_HEIGHT - self.height self.velocity_y *= -0.8 # Reverse and dampen velocity def draw(self, surface): """Draws the player on the provided surface.""" pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height)) # --- Main Program Setup --- pygame.init() screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("Time-based Physics Simulation") player = Player(50, SCREEN_HEIGHT // 2) clock = pygame.time.Clock() running = True while running: # Calculate delta time (dt) in seconds dt = clock.tick(60) / 1000.0 # 1. Process Input (Events) for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # 2. Update Game State player.update(dt) # 3. Render (Draw) screen.fill(BLACK) player.draw(screen) pygame.display.flip() pygame.quit()
# --- Constants ---
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)

# Physics constants scaled by time (seconds)
PLAYER_VELOCITY_X = 200  # pixels per second
GRAVITY = 2000           # pixels per second squared

class Player:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.width = 20
        self.height = 20
        self.color = WHITE
        self.velocity_x = PLAYER_VELOCITY_X
        self.velocity_y = 0
        self.acceleration_y = GRAVITY

    def update(self, dt):
        """Update the player's state based on the time delta."""
        # Update velocity based on acceleration and dt
        self.velocity_y += self.acceleration_y * dt

        # Update position based on velocity and dt
        self.x += self.velocity_x * dt
        self.y += self.velocity_y * dt

        # Boundary check for the floor
        if self.y + self.height > SCREEN_HEIGHT:
            self.y = SCREEN_HEIGHT - self.height
            self.velocity_y *= -0.8  # Reverse and dampen velocity

    def draw(self, surface):
        """Draws the player on the provided surface."""
        pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height))

# --- Main Program Setup ---
pygame.init()
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Time-based Physics Simulation")
player = Player(50, SCREEN_HEIGHT // 2)
clock = pygame.time.Clock()
running = True

while running:
    # Calculate delta time (dt) in seconds
    dt = clock.tick(60) / 1000.0

    # 1. Process Input (Events)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # 2. Update Game State
    player.update(dt)

    # 3. Render (Draw)
    screen.fill(BLACK)
    player.draw(screen)
    pygame.display.flip()

pygame.quit()

Observe the changes. The constants now have names that reflect their physical meaning, and their values are scaled appropriately. The update method now correctly accepts dt and uses it to scale the changes in velocity and position. The main loop calculates dt on each iteration and passes it to the object. The system is now far more robust. If the frame rate drops to 30 FPS, dt will double, and the physics calculations will correctly compensate. The object will travel the same distance in the same amount of real time, though the animation will be less smooth. The simulation logic is now independent of the rendering speed. This is a critical step toward a professional system. The object’s behavior is now defined in terms of seconds, a universal unit, not in terms of frames, an arbitrary and volatile one. This decoupling is a hallmark of clean, maintainable design. The system’s components are now more cohesive and less coupled. The Player class could be lifted from this project and used in another with minimal friction.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *