Skip to main content

Lesson 2: Drawing Shapes & Surfaces

  • Module 1: Your First Game Window
  • Lesson 2 of 14
  • ⏱️ About 1 h (instruction + lab)

Your game loop can already draw one circle; now you will paint whole scenes with rectangles, lines, polygons and text. Along the way you learn how Surfaces let you build a picture once and stamp it anywhere, which is how every sprite in this course will be drawn.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Draw rectangles, circles, ellipses, lines and polygons with pygame.draw, either filled or as outlines.
  • Place shapes using pygame's screen grid, where (0, 0) is the top-left corner and y grows downward.
  • Describe colors as RGB tuples and keep calculated colors legal with a clamp_color() helper.
  • Build a Surface once, including a see-through SRCALPHA Surface, and blit it as many times as you like.
  • Show text on screen with a font that is created once, before the game loop.

Project: a Sunset Scene: a house on a hill, drifting see-through clouds, and a sun that sets while the sky darkens.

In This Lesson

📐 The Screen Is a Grid

Think of the window as a sheet of graph paper where every square is one pixel. To draw anything you give pygame a pixel's address as (x, y):

  • x counts pixels from the left edge, growing to the right.
  • y counts pixels from the top edge, growing downward. This is the opposite of the graphs from math class, and it catches everyone at first.
  • (0, 0) is the top-left corner. In an 800 × 600 window, x runs from 0 to 799 and y from 0 to 599, so (800, 600) is just past the bottom-right corner.
An 8 by 8 grid of square cells, with column numbers 0 to 7 along the top and row numbers 0 to 7 down the left side. Some cells are filled coral-red to form a small heart. A label points at the cell in column 5, row 1 and reads pixel (5, 1).
A Surface is a grid of pixels. Column numbers (x) grow to the right and row numbers (y) grow down, starting from (0, 0) in the top-left corner. The highlighted cell is pixel (5, 1): five across, one down.

🔮 Predict, then check

In an 800 × 600 window, where do these points land: (400, 300), (790, 10) and (10, 590)? Say "top-left", "center" and so on out loud, then check with the Shape Builder demo below: it labels the position of each new shape it draws. (Answers: the center, near the top-right corner, near the bottom-left corner.)

🎨 Colors and clamp_color()

A screen pixel mixes red, green and blue light. In pygame a color is a tuple of those three amounts, each a whole number from 0 (none) to 255 (full):

TupleColorWhy
(255, 0, 0)RedFull red, no green, no blue
(255, 255, 0)YellowRed and green light mix to yellow
(255, 255, 255)WhiteAll three at full strength
(0, 0, 0)BlackNo light at all
(128, 128, 128)GrayEqual amounts of all three
(135, 206, 235)Sky blueMostly blue and green, some red

Store the colors you use as named constants at the top of your program (SKY = (135, 206, 235)), so a later change happens in one place. pygame also knows several hundred color names: pygame.Color("tomato") is (255, 99, 71).

When a calculated color goes out of range

Games often calculate colors: a sky that darkens at sunset, a hit flash that fades, a health bar that turns from green to red. Math like that can easily produce a channel below 0 or above 255, and pygame refuses it:

seconds = 12
sky = (135 - 12 * seconds, 206 - 18 * seconds, 235 - 16 * seconds)   # (-9, -10, 43)
screen.fill(sky)   # ValueError: invalid color ... each element must be an integer in the range [0, 255]

The fix is a tiny helper that forces every channel into range and turns it into a whole number. You will use it for every color you calculate in this course:

def clamp_color(color):
    """Return color with every channel forced to a whole number from 0 to 255."""
    return tuple(max(0, min(255, int(channel))) for channel in color)


print(clamp_color((-9, -10, 43.8)))      # (0, 0, 43)
print(clamp_color((300, 128, 12)))       # (255, 128, 12)

Read it from the inside out: min(255, ...) caps a channel at 255, max(0, ...) lifts it to at least 0, and int() drops the fraction. The tuple(... for channel in color) part does that to each channel in turn, just like a for loop that builds a new tuple. It works for 4-channel colors too (see Surfaces below).

🔷 The Drawing Functions

Every function in pygame.draw follows the same pattern: where to draw (a Surface, usually screen), what color, then the shape's numbers, then an optional width.

FunctionShape numbersExample
rect(x, y, width, height), with (x, y) the top-left cornerpygame.draw.rect(screen, RED, (50, 60, 120, 80))
circlecenter (x, y), then radiuspygame.draw.circle(screen, BLUE, (400, 300), 50)
ellipsethe rectangle the oval fits insidepygame.draw.ellipse(screen, PINK, (300, 350, 160, 80))
linestart (x, y), end (x, y), then thicknesspygame.draw.line(screen, WHITE, (0, 500), (800, 500), 3)
polygona list of corner points, in orderpygame.draw.polygon(screen, GREEN, [(400, 100), (460, 200), (340, 200)])

The width argument decides filled or hollow. Leave it out (or pass 0) and the shape is filled; pass 3 and you get a 3-pixel outline drawn just inside the shape's edge. A line has no inside, so its width is simply its thickness. There is also pygame.draw.arc for part of an oval; its angles are given in radians, a unit for angles that this course comes back to later.

Six pygame drawing primitives in a 3-by-2 grid. Top row: rect with its top-left corner labeled x, y and its size labeled w and h; circle with a center crosshair and a radius arrow; polygon as a triangle with vertices p1, p2 and p3. Bottom row: line with start and end points and a width callout; ellipse inside its bounding rectangle; arc as part of an outline over a dashed bounding rectangle, with start and stop angles. Each shape is paired with its pygame.draw function signature.
The drawing primitives with their pygame.draw signatures. Positions are tinted teal, sizes amber, the line's end point blue and the arc's angles red. Every function takes the Surface and the color first.

Try it: each button draws one random shape and prints the matching pygame.draw call with the same numbers. Switch between filled and outline mode, and notice that each new shape covers the ones drawn before it.

pygame.draw.polygon(...)

Here is a complete program that uses all five functions. Run it, then change one number at a time and predict what will move.

import pygame

pygame.init()
screen = pygame.display.set_mode((800, 600))
pygame.display.set_caption("Shape Gallery")
clock = pygame.time.Clock()

BACKGROUND = (20, 24, 36)
RED = (255, 107, 107)
GREEN = (74, 222, 128)
BLUE = (96, 165, 250)
YELLOW = (250, 204, 21)
PINK = (244, 114, 182)
WHITE = (240, 240, 240)

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

    screen.fill(BACKGROUND)

    # Rectangles: (x, y, width, height). The second one is a 4-pixel outline.
    pygame.draw.rect(screen, RED, (50, 60, 120, 80))
    pygame.draw.rect(screen, GREEN, (220, 60, 120, 80), 4)

    # Circles: center, radius. Then a 6-pixel ring.
    pygame.draw.circle(screen, BLUE, (470, 100), 45)
    pygame.draw.circle(screen, YELLOW, (640, 100), 45, 6)

    # An ellipse fills the rectangle you give it.
    pygame.draw.ellipse(screen, PINK, (50, 220, 200, 100))

    # A hexagon: six corner points, listed in order around the shape.
    hexagon = [(420, 210), (480, 245), (480, 315), (420, 350), (360, 315), (360, 245)]
    pygame.draw.polygon(screen, WHITE, hexagon, 3)

    # Lines: start, end, thickness. A little fan of them.
    for end_x in range(560, 761, 50):
        pygame.draw.line(screen, YELLOW, (660, 380), (end_x, 230), 2)

    # Draw order matters: the green bar is drawn last, so it sits on top.
    pygame.draw.rect(screen, BLUE, (100, 450, 300, 60))
    pygame.draw.rect(screen, GREEN, (250, 470, 300, 60))

    pygame.display.flip()

pygame.quit()

💡 Why this matters: draw order is layering

pygame has no idea that a hill is "in front of" the sun. It simply paints pixels in the order you call the functions, so whatever you draw later covers what is already there. Draw your scene back to front: sky, then far objects, then near objects, then the score on top. You will lean on this in the exercise to make the sun disappear behind a hill.

✅ Growth Mindset: Nobody Places Shapes Right the First Time

If your roof floats above the house or your sun hides off-screen, that is not a sign you are bad at this; it is how placing shapes works for everyone, including professional artists who tweak positions for hours. Use the habit that makes it quick: change one number, run, look, repeat. Sketch the scene on paper with a few coordinates written next to the corners, and remember that bigger y means lower on the screen. You don't have a feel for the grid yet; by the end of the exercise you will.

🧱 Surfaces: Build Once, Draw Many

The screen you draw on is a Surface: a rectangle of pixels in memory. You can make your own Surfaces too, draw on them once, and then copy them onto the screen as often as you like. Copying one Surface onto another is called blitting (from "block transfer"):

tree = pygame.Surface((60, 100))          # a new 60 x 100 picture (black to start)
pygame.draw.rect(tree, (120, 72, 40), (25, 60, 10, 40))   # draw ON the tree, not on the screen
screen.blit(tree, (200, 350))             # copy it so its TOP-LEFT corner lands at (200, 350)

Shapes drawn on a Surface use that Surface's grid, so (0, 0) is the tree's own top-left corner, wherever the tree ends up on screen. That is the big win: you design a thing once, in its own little coordinate grid, then place copies anywhere.

See-through Surfaces with SRCALPHA

A new Surface starts out solid black, so a round tree would sit inside a black box. For shapes with see-through parts, ask for per-pixel alpha with pygame.SRCALPHA. Such a Surface starts fully transparent, and you draw on it with 4-number colors: (red, green, blue, alpha), where alpha 0 is invisible and 255 is solid.

Two details trip people up:

  • The alpha only matters on an SRCALPHA Surface. The screen itself has no alpha channel, so pygame.draw.circle(screen, (255, 0, 0, 50), ...) draws a solid red circle. To get a see-through circle, draw it on an SRCALPHA Surface and blit that.
  • Drawing does not blend. It writes your color, alpha included, straight into the Surface's pixels. Blending happens when you blit the Surface onto another one. So three overlapping puffs drawn with alpha 170 make one evenly see-through cloud, not a cloud with darker overlaps.
import pygame

pygame.init()
screen = pygame.display.set_mode((800, 600))
pygame.display.set_caption("Build Once, Draw Many")
clock = pygame.time.Clock()


def make_tree():
    """Draw one tree on its own see-through Surface and return it."""
    tree = pygame.Surface((80, 120), pygame.SRCALPHA)       # starts fully transparent
    pygame.draw.rect(tree, (120, 72, 40), (34, 70, 12, 50))                # trunk
    pygame.draw.circle(tree, (34, 139, 34), (40, 45), 38)                  # leaves
    pygame.draw.circle(tree, (60, 170, 60), (28, 32), 14)                  # lighter patch
    return tree


def make_fog():
    """A wide, pale, half-transparent band."""
    fog = pygame.Surface((800, 120), pygame.SRCALPHA)
    fog.fill((220, 225, 235, 110))
    return fog


tree = make_tree()          # built ONCE, before the loop
fog = make_fog()
tree_spots = [(60, 380), (180, 360), (330, 390), (470, 350), (620, 375)]

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

    screen.fill((135, 206, 235))
    pygame.draw.rect(screen, (76, 175, 80), (0, 470, 800, 130))
    for spot in tree_spots:
        screen.blit(tree, spot)         # the same Surface, stamped five times
    screen.blit(fog, (0, 430))          # the trees show through the fog
    pygame.display.flip()

pygame.quit()

If you want a whole Surface to fade in or out, surface.set_alpha(128) makes every pixel half transparent when it is blitted. Per-pixel alpha (SRCALPHA) is for shapes with see-through parts; set_alpha is for fading the whole thing.

🔤 Text on the Screen

pygame can't draw text directly onto the screen. Instead, a Font renders your text into a brand-new Surface, and you blit that Surface like any other:

font = pygame.font.Font(None, 36)         # None = pygame's built-in font, 36 pixels tall

# ...inside the game loop, in the draw step:
label = font.render(f"Score: {score}", True, (255, 255, 255))
screen.blit(label, (10, 10))
  • render(text, antialias, color): True for antialias smooths the letter edges. Add a fourth argument, a background color, to put the text on a solid box so it stays readable over a busy scene.
  • Create the font once, before the loop. Loading a font reads a font file from disk; doing that 60 times a second is wasted work. Rendering the text each frame is fine, because the score changes.
  • pygame.font.SysFont("arial", 24) uses a font installed on the computer, and quietly falls back to the built-in one if it can't find it.
  • The built-in font has no emoji, so they show up as empty boxes. Stick to plain letters, digits and punctuation, or draw little icons with shapes.

✅ Growth Mindset: Errors Are Directions

ValueError: invalid color or TypeError: argument 1 must be pygame.surface.Surface can look scary, but read them as directions. The first says "a color is outside 0 to 255", so reach for clamp_color(). The second (it ends with not tuple) usually means the arguments are in the wrong order: the Surface always comes first, then the color. When something fails, print the values just before the failing line (print(sky)). Seeing the real numbers turns a mystery into a small fix.

🏋️ Practice Exercise: Sunset Scene

Objective: draw a small scene of a house on a hill under see-through clouds, then make the sun set and the sky darken without any color ever going out of range.

Time: about 30 minutes. Starter file: sunset_scene_starter.py (your instructor has it). It shows a blue sky and a strip of ground; its numbered comments match the steps below.

  1. Run the starter: you should see a light blue sky over dark green ground. (≈ 2 min)
  2. Draw the sun, a circle at (620, sun_y) with radius 60, and then the hill, an ellipse in the rectangle (-200, 380, 1200, 440). Swap the two lines and see what changes. (≈ 5 min)
  3. Fill in draw_house(): a body rectangle, the same rectangle again as a 4-pixel outline, a triangle roof with pygame.draw.polygon, a door and a window. (≈ 7 min)
  4. Fill in make_cloud(): a 160 × 70 SRCALPHA Surface with three overlapping white ellipses in the color (255, 255, 255, 170). The loop already blits it three times. (≈ 5 min)
  5. Make the sun sink (sun_y = 120 + SUN_SPEED * seconds) and make sky_color() subtract SKY_FADE * seconds from each channel of SKY_DAY. Run it and wait about 11 seconds: it crashes with a ValueError. (≈ 4 min)
  6. Write clamp_color() and run it again. The sky now fades to black and stays there. (≈ 4 min)
  7. Create a font once, before the loop, and draw Sunset: 3.2 s on a dark box in the top-left corner every frame. (≈ 3 min)

You are done when:

  • the sun slides down and disappears behind the hill, not in front of it;
  • you can see the sky through the clouds;
  • the program runs for a full minute without an error while the sky fades to black;
  • closing the window prints a line like Final sky color: (0, 0, 0).
💡 Hint

For the roof, the two bottom corners sit a little wider than the house's top edge ((285, 360) and (515, 360)) and the peak sits above the middle ((400, 270)): smaller y means higher. If your clouds show up as black boxes, the Surface is missing pygame.SRCALPHA. If sky_color() gives you trouble, unpack first: r, g, b = SKY_DAY and fade_r, fade_g, fade_b = SKY_FADE, then build the tuple (r - fade_r * seconds, ...) and pass it to clamp_color().

✅ Example Solution

If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top and and frame_budget() in the loop. They let the instructor's checker run the program automatically for a fixed number of frames; when you run it yourself they do nothing. You never need to write them.

"""Sunset Scene: Intro Lesson 2 practice exercise (solution).

A sun sinks behind a green hill while the sky slowly darkens. Every color
the program calculates goes through clamp_color(), so no channel can leave
the 0-255 range. Close the window to quit.
"""
import pygame


WIDTH, HEIGHT = 800, 600
FPS = 60

SKY_DAY = (135, 206, 235)      # light blue at the start
SKY_FADE = (12, 18, 16)        # how much red, green and blue drop per second
SUN_COLOR = (255, 200, 60)
SUN_SPEED = 25                 # pixels per second (downward)
HILL_COLOR = (76, 175, 80)
GROUND_COLOR = (56, 142, 60)
HOUSE_COLOR = (205, 133, 63)
OUTLINE_COLOR = (70, 40, 20)
ROOF_COLOR = (165, 42, 42)
WINDOW_COLOR = (255, 235, 150)
TEXT_COLOR = (240, 240, 240)
LABEL_BG = (30, 30, 40)
CLOUD_SPOTS = [(60, 70), (300, 40), (480, 110)]


def clamp_color(color):
    """Return color with every channel forced to a whole number from 0 to 255."""
    return tuple(max(0, min(255, int(channel))) for channel in color)


def sky_color(seconds):
    """The sky color after the sunset has run for this many seconds."""
    r, g, b = SKY_DAY
    fade_r, fade_g, fade_b = SKY_FADE
    return clamp_color((r - fade_r * seconds, g - fade_g * seconds, b - fade_b * seconds))


def make_cloud():
    """Build a see-through cloud once. The caller blits it as often as it likes."""
    cloud = pygame.Surface((160, 70), pygame.SRCALPHA)     # starts fully transparent
    puff = (255, 255, 255, 170)                            # alpha 170: partly see-through
    pygame.draw.ellipse(cloud, puff, (0, 20, 90, 50))
    pygame.draw.ellipse(cloud, puff, (45, 0, 80, 60))
    pygame.draw.ellipse(cloud, puff, (80, 20, 80, 50))
    return cloud


def draw_house(screen):
    """A house standing on the ground: body, outline, roof, door and window."""
    pygame.draw.rect(screen, HOUSE_COLOR, (300, 360, 200, 150))
    pygame.draw.rect(screen, OUTLINE_COLOR, (300, 360, 200, 150), 4)      # width 4 = outline
    pygame.draw.polygon(screen, ROOF_COLOR, [(285, 360), (515, 360), (400, 270)])
    pygame.draw.rect(screen, OUTLINE_COLOR, (380, 440, 40, 70))            # door
    pygame.draw.rect(screen, WINDOW_COLOR, (320, 390, 45, 40))             # window
    pygame.draw.rect(screen, OUTLINE_COLOR, (320, 390, 45, 40), 3)


def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Sunset Scene")
    clock = pygame.time.Clock()
    font = pygame.font.Font(None, 30)       # fonts are created once, before the loop
    cloud = make_cloud()                    # so is the cloud Surface

    seconds = 0.0
    running = True
    while running:
        dt = clock.tick(FPS) / 1000

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

        seconds += dt
        sun_y = 120 + SUN_SPEED * seconds

        # Draw back to front: whatever is drawn later covers what is already there.
        screen.fill(sky_color(seconds))
        pygame.draw.circle(screen, SUN_COLOR, (620, sun_y), 60)
        pygame.draw.ellipse(screen, HILL_COLOR, (-200, 380, 1200, 440))    # hides the setting sun
        pygame.draw.rect(screen, GROUND_COLOR, (0, 500, WIDTH, 100))
        draw_house(screen)
        for spot in CLOUD_SPOTS:
            screen.blit(cloud, spot)

        label = font.render(f"Sunset: {seconds:.1f} s", True, TEXT_COLOR, LABEL_BG)
        screen.blit(label, (10, 10))
        pygame.display.flip()

    pygame.quit()
    print(f"Final sky color: {sky_color(seconds)}")


if __name__ == "__main__":
    main()

📓 Learning Journal

Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:

  • Key concepts you learned today
  • Techniques that clicked (and the ones that haven't, yet)
  • Questions or confusion to bring to the next session
  • Ideas to try in your own game
  • Progress and feelings: how did this lesson go for you?

✍️ This lesson's prompts:

  1. Explain "y grows downward" to a friend using something they see every day (a page of a book, a spreadsheet, a building's floors). Which comparison works best for you?
  2. Pick a scene from a game you like. List its layers from back to front, the order you would draw them in.
  3. What happened when your sky went out of range? Write down, in your own words, what clamp_color() protects you from.

📝 Summary

You learned to see the window as a grid of pixels with (0, 0) at the top-left and y growing downward, and you filled it with rectangles, circles, ellipses, lines, polygons and text. Colors are RGB tuples, and any color you calculate goes through clamp_color() so it never leaves the 0 to 255 range. You also built your own Surfaces, including see-through SRCALPHA ones, drew on them once, and blitted them as many times as you liked. That build-once, blit-many idea is exactly how images and sprites work later in the course.

🎓 Key Takeaways

  • (0, 0) is the top-left corner; x grows right and y grows down.
  • Every pygame.draw function takes the Surface, the color, the shape's numbers and an optional width: 0 fills, a positive number draws an outline.
  • Draw back to front: whatever is drawn later covers what is already there.
  • Pass every calculated color through clamp_color(); pygame raises ValueError for channels outside 0 to 255.
  • Build Surfaces (and fonts) once, before the loop, then blit them every frame. Use pygame.SRCALPHA and RGBA colors for see-through parts.

🔭 Looking Ahead

Your scene looks good, but you can't touch it yet. In the next lesson, Keyboard, Mouse & Gamepad, you read the player's keys, clicks, mouse wheel and controllers, and make a square run and jump.

❓ Common Questions

Why does y grow downward? That seems backwards.

Old TV-style screens drew the picture one line at a time from the top, the same way you read a page, and computer graphics kept that habit, so the top-left pixel is the natural starting point. The HTML canvas and most image editors count the same way, so the habit you build now carries over to other tools.

I used a color with four numbers but my shape is not see-through. Why?

The alpha (fourth) number only matters on a Surface created with pygame.SRCALPHA. The screen has no alpha channel, so the shape is drawn solid. Draw the shape on an SRCALPHA Surface and blit that Surface onto the screen instead.

Do I have to redraw everything every frame?

Yes: screen.fill() wipes the frame, so anything you don't draw again disappears. That is what makes movement possible. The cost of redrawing is exactly why Surfaces help: build a complicated picture once, then a single blit redraws it each frame.

Can I use color names instead of tuples?

Yes. pygame.Color("tomato") or even just the string "tomato" works wherever pygame expects a color. Tuples are still handy when you calculate colors, because you can do math on each channel and pass the result through clamp_color().

My outline looks a little thinner or thicker than I expected.

For rectangles, circles and ellipses the outline grows inward from the shape's edge, so a 4-pixel outline on a 200-pixel-wide rectangle still fits in 200 pixels. Polygons and lines are a little different: very thick polygon outlines can spread outside the points you gave. Stick to small widths (1 to 6) and you will rarely notice.

My text shows little boxes instead of emoji.

pygame's built-in font has no emoji, so it draws an empty box for each one. Use plain text ("Lives: 3"), or draw a small heart or star with the shape functions.

🎯 Quick Quiz

Question 1: In an 800 × 600 window, which point is closest to the bottom-left corner?

Question 2: What does the last argument do in pygame.draw.rect(screen, RED, (50, 60, 120, 80), 4)?

Question 3: Your sky color is (135 - 12 * t, 206 - 18 * t, 235 - 16 * t) with no clamping. What happens once t reaches 15 seconds?

Question 4: You want a cloud whose edges let the sky show through. How should you create its Surface?

Question 5: Where should font = pygame.font.Font(None, 30) go in a game?

🌟 Going Further

  • Gradient sky: instead of one fill(), draw 60 horizontal lines, each 10 pixels thick, whose color gets a little darker toward the top. Use clamp_color() for every one.
  • Stars at night: once the sky is dark, draw a few small white circles. Make them fade in by drawing them on an SRCALPHA Surface and raising its set_alpha() value over time.
  • Rounded corners: pygame.draw.rect takes a border_radius= keyword. Give your house's windows rounded corners.
  • Drifting clouds: give each cloud its own x position and add 20 * dt to it every frame, wrapping back to the left when it leaves the screen, as the square did in The Game Loop.
  • Read the docs: skim the pygame-ce pages for pygame.draw, pygame.Surface and pygame.font. Find one drawing function or argument you haven't used yet.