Animating Objects with Rects

Introduction: From Static Scenes to Motion

Welcome back to Pygame Foundations for your first game! We are now at the third unit out of four, and we have made solid progress: our display is 800 × 600 pixels, the game loop handles quit events and redraws the screen sixty times per second, and we can paint rectangles and circles using RGB colors.

There is one thing missing, though: nothing ever moves. The loop faithfully redraws the same picture sixty times per second, which is a lot of work for a scene that looks like a still image.

In this lesson, we will fix that. We will store the red rectangle's position inside a pygame.Rect object and nudge that position slightly on every frame. The result: a red block that falls steadily from the top of the display, reaches the bottom, and reappears at the top to fall again, while a green block stays parked near the bottom as a landmark.

Meet Pygame Rect: A Box That Remembers Its Position

Before we animate anything, we need a place to keep track of where our object is. Pygame gives us exactly that with pygame.Rect, the standard way to describe a rectangular area in a game.

Python
    # left, top, width, height
    obstacle = pygame.Rect(375, 0, 40, 40)

The four arguments read in a fixed order:

  • 375 is the left edge: 375 pixels from the left side of the display, which is roughly centered horizontally.
  • 0 is the top edge: flush with the very top of the display.
  • 40 and 40 are the width and height in pixels, giving us a small square.

This is what those four numbers mean on the screen:

Diagram of a Pygame window showing a Rect at (375, 0) with width 40 and height 40, and y increasing downward

Once created, the Rect exposes those numbers as attributes we can read and write: obstacle.x, obstacle.y, obstacle.width, and obstacle.height. It also provides edge attributes such as obstacle.top, obstacle.bottom, obstacle.left, and obstacle.right.

Note that y and top refer to the exact same coordinate (the top edge), just as x and left refer to the same horizontal coordinate. We often use .y for movement math and .top or .bottom when checking boundaries to make the code more readable.

Keep in mind that a Rect is pure data; it stores numbers and nothing else. It never draws itself, and it does not know that a screen exists.

Drawing a Rect Instead of a Tuple

Since a Rect is just data, we still need pygame.draw.rect() to put it on the screen. Happily, that function accepts a Rect in the very same slot where we passed a plain tuple in the previous unit.

Python
        pygame.draw.rect(screen, GREEN, (375, 540, 50, 40))  # static: tuple
        pygame.draw.rect(screen, RED, obstacle)              # movable: Rect

Both lines draw a rectangle; the difference is who owns the position:

  • The green rectangle's numbers are written directly into the call, so changing its position means editing that line by hand.
  • The red rectangle's numbers live in the obstacle variable, so any code anywhere in the loop can move it, and the drawing call automatically follows along.

We are deliberately leaving the green rectangle as a tuple. It is static decoration, a floor marker for the player that will arrive in the next unit. The red one is a live game object, so it earns its own Rect.

The Animation Recipe: Update, Then Draw

Animation in a game is less magical than it looks: on every pass through the loop, we change the object's numbers a little and then redraw the entire scene. Because that happens sixty times per second, our eyes read the sequence of still frames as smooth motion.

Python
    obstacle = pygame.Rect(375, 0, 40, 40)
    obstacle_speed = 4  # pixels moved per frame
    ...
        # inside the game loop, after handling events
        obstacle.y += obstacle_speed

obstacle_speed is measured in pixels per frame, not pixels per second, so the frame cap from our first unit matters here. At 60 FPS, our square travels 4×60=2404 \times 60 = 240 pixels every second, crossing the 600-pixel display in about two and a half seconds. Doubling the speed value doubles that pace.

The order inside the loop is important and stays the same from now on: handle events, update positions, clear and draw, then flip the display.

Wrapping the Obstacle Back to the Top

The update line alone has an obvious flaw: obstacle.y grows forever. After roughly two and a half seconds, the square slides past the bottom edge and keeps going, so we are left staring at a scene without the obstacle while its y value climbs into the thousands.

Python
        obstacle.y += obstacle_speed
        if obstacle.top >= HEIGHT:
            obstacle.y = 0

Remember that in Pygame, the y-axis grows downward: y = 0 is the top row of pixels, while y = 600 is the boundary immediately below the final visible row of an 800 × 600 display. For a downward-moving object, the square is fully below the display as soon as its top edge is greater than or equal to the display height. Using obstacle.top >= HEIGHT therefore resets it on the first frame when none of its pixels are visible, avoiding an additional invisible frame.

Setting y back to 0 recycles the same Rect instead of building a new one. That wrap-check habit will pay off in the next course, when a whole list of obstacles needs to keep flowing without piling up in memory.

Putting It All Together

Here is the full program with the Rect, the per-frame update, and the wrap check in place.

Python
import asyncio
import pygame

WIDTH, HEIGHT = 800, 600
FPS = 60
DARK_BLUE = (20, 28, 48)
GREEN = (87, 215, 170)
RED = (220, 50, 50)
YELLOW = (255, 220, 50)


async def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Dodge Game")
    clock = pygame.time.Clock()

    obstacle = pygame.Rect(375, 0, 40, 40)  # new: the falling object
    obstacle_speed = 4                      # new: pixels per frame

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

        obstacle.y += obstacle_speed        # new: move it down
        if obstacle.top >= HEIGHT:          # new: fully below the display?
            obstacle.y = 0                  # new: send it back up

        screen.fill(DARK_BLUE)
        pygame.draw.rect(screen, GREEN, (375, 540, 50, 40))
        pygame.draw.rect(screen, RED, obstacle)
        pygame.draw.circle(screen, YELLOW, (600, 300), 25)
        pygame.display.flip()
        clock.tick(FPS)
        await asyncio.sleep(0)

    pygame.quit()


asyncio.run(main())

Tracing a single frame: we drain the event queue, add 4 to obstacle.y, check whether the square's top has reached the display's bottom boundary, repaint the DARK_BLUE background over the previous frame, draw the GREEN floor marker, draw the RED square at its brand new position, draw the YELLOW circle, update the display with flip(), and let clock.tick(FPS) wait just long enough to keep us at 60 FPS.

On the screen, the red square glides down the middle of the display and returns to the top as soon as its top edge reaches the bottom boundary, producing a steady loop without an extra invisible frame.

Conclusion and Next Steps

Three ideas carried this lesson. First, a pygame.Rect bundles an object's position and size into one variable that we can read and modify. Second, changing that position a little on every frame, then redrawing, is all that animation really is. Third, a single bounds check recycles the object so the motion never stops and no memory is wasted.

In the next unit, we will hand a Rect to the player instead of an obstacle, and drive it with the arrow keys while keeping it inside the display. Before that, it is time to make this fall happen with your own hands: the upcoming practice tasks walk you from a static red block to a Rect, then to a moving Rect, then to an endlessly looping one, one small change at a time.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal