You've read how games actually work. The loop, the state, the physics. Now you're going to build one.

By the end of this tutorial, you'll have a working game where you move a square left and right with arrow keys and catch falling squares to score points. It's simple, but it contains all the core pieces: a game loop, input handling, collision detection, and rendering. The same architecture powers everything from Flappy Bird to complex 3D games.

We're going to build this the way the previous post described it: entities holding their own state, and a loop with three clearly separated phases - input, update, draw. Each object will know its own position and how to draw itself, and the loop's job is just to call the right methods in the right order, every frame.

This tutorial assumes you've never written a game before, but you've written some Python, including a bit of classes. We'll start with setup, then build the game piece by piece. After each step, you'll run your code and see exactly what we just added.


Step 0: The Setup

1. Installing Python

First, check if Python is already on your computer:

python --version

You should see something like Python 3.9.0 or higher. If you see "command not found", download Python from python.org and install it.

2. Installing Pygame

Pygame is a library that handles drawing graphics and reading input. Install it with:

pip install pygame

You should see Successfully installed pygame.

3. Verify It Works

Create a file called test.py with this code:

import pygame
print("Pygame version:", pygame.version.ver)

Run it:

python test.py

If you see a version number, you're ready. If you get an error, try pip3 install pygame instead.

Step 1: Create a Window

Create a file called game.py with this code:

import pygame
 
WINDOW_WIDTH = 800
WINDOW_HEIGHT = 600
 
pygame.init()
 
window = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
pygame.display.set_caption("Catch the Square")
 
clock = pygame.time.Clock()
 
while True:
    # ---- Update Phase ----
 
    # Limit frame-rate at 60fps
    clock.tick(60)
 
    # Close window with X button
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            exit(0)
 
    # ---- Drawing Phase ----
 
    # Clear display with black color
    window.fill("black")
 
    # Show contents of the display to window
    pygame.display.update()

Run it:

python game.py

You should see a black window. Click the X to close it.

What's happening:

  • pygame.init() starts Pygame
  • pygame.display.set_mode() creates the window
  • The while True: loop is your game loop, and it already has comments marking out the update and drawing phases from the first post
  • clock.tick(60) pauses each frame to maintain exactly 60 FPS, which keeps that 16-millisecond frame budget consistent on every computer
  • window.fill("black") paints the background black
  • pygame.display.update() displays what we drew

Notice there's no input phase yet - there's nothing to read input for. We'll add it once we have a player to move.

Step 2: Give the Player Its Own Class

In the previous post, the game world was described as a list of objects, each holding its own state - position, velocity, whatever it needs. Instead of loose variables like player_x and player_y floating around the file, let's give the player a proper Player class that owns its own numbers.

Add this above the game loop:

class Player:
    def __init__(self) -> None:
        self.speed = 5
 
        self.width = 50
        self.height = 50
        
        # Position in center of window
        self.x = (WINDOW_WIDTH - self.width) / 2
        # Position at 80% of window vertically
        self.y = WINDOW_HEIGHT * 0.8
 
    def move_left(self) -> None:
        self.x -= self.speed
 
    def move_right(self) -> None:
        self.x += self.speed
 
    def draw(self, window) -> None:
        pygame.draw.rect(window, "white", (self.x, self.y, self.width, self.height))

Then create a player instance right before the loop:

player = Player()

And add the drawing call in the drawing phase, before pygame.display.update():

    # Draw player
    player.draw(window)

Run it and you'll see a stationary white square near the bottom of the window. All the player's state - position, size, speed - lives inside that one object now, instead of scattered variables.

Step 3: Add an Input Phase to Move the Player

The first post described input as its own phase: read the full state of the input devices, then let update react to it. Let's add that phase explicitly.

Add this at the very top of the loop body, before the update phase:

    # ---- Input Phase ----
 
    # Get which keys are being pressed
    keys = pygame.key.get_pressed()
 
    # Left arrow key is pressed
    if keys[pygame.K_LEFT]:
        player.move_left()
 
    # Right arrow key is pressed
    if keys[pygame.K_RIGHT]:
        player.move_right()

Run it:

python game.py

Press the left and right arrow keys and the square moves. Notice the shape of the loop now: input reads the devices and calls methods on the player, update phase handles frame-rate and the window's close event, and drawing paints the result. Three phases, in order, exactly as described.

Note on coordinates: In Pygame, (0, 0) is the top-left corner. X increases going right, Y increases going down. That's why WINDOW_HEIGHT * 0.8 puts the player near the bottom rather than the top.

Step 4: A Falling Square With Its Own Class

The square that falls from the top deserves the same treatment. It has its own state - position and fall speed - and its own behavior: falling, and respawning at the top when it reaches the bottom. That belongs in a FallingSquare class.

Add import random to the top of the file, then add this class:

class FallingSquare:
    def __init__(self) -> None:
        self.speed = 3
 
        self.width = 50
        self.height = 50
 
        self.x = 0
        self.y = 0
 
        self.spawn()
 
    def spawn(self) -> None:
        # Spawn at random position within the window
        self.x = random.randint(0, WINDOW_WIDTH - self.width)
        self.y = 0
 
    def move_down(self) -> None:
        self.y += self.speed
 
        if self.y >= WINDOW_HEIGHT:
            self.spawn()
 
    def draw(self, window) -> None:
        pygame.draw.rect(window, "red", (self.x, self.y, self.width, self.height))

Create an instance next to the player, before the loop:

square = FallingSquare()

In the update phase, move it down each frame:

    # Move square down
    square.move_down()

And draw it alongside the player:

    # Draw square and player
    square.draw(window)
    player.draw(window)

You should see a red square falling from a random position at the top. Each time it reaches the bottom, spawn() resets it to a new random position - the class figures out its own respawning, nobody outside it needs to know how.

Step 5: Collision Detection With Hitboxes

The first post described a hitbox as an invisible rectangle wrapped around an object, used for overlap checks - and that rectangle doesn't have to be built by hand every time. Pygame's Rect is exactly that rectangle, and it already knows how to check for overlap with another one.

Add a method to both Player and FallingSquare that returns their hitbox:

    def get_hitbox(self) -> pygame.Rect:
        return pygame.Rect(self.x, self.y, self.width, self.height)

While you're there, simplify each class's draw method to reuse it:

    # In Player
    def draw(self, window) -> None:
        pygame.draw.rect(window, "white", self.get_hitbox())

    # In FallingSquare
    def draw(self, window) -> None:
        pygame.draw.rect(window, "red", self.get_hitbox())

Now add a score counter before the loop:

score = 0

And in the update phase, after moving the square down, check for a collision:

    # Collision detection
    if player.get_hitbox().colliderect(square.get_hitbox()):
        square.spawn()
        score += 1

Run it and catch the red square. When the two hitboxes overlap, the square respawns and your score increases. colliderect() is doing exactly the four-edge comparison described in the first post - it's just that Pygame provides it for you instead of you writing the comparisons by hand.

Step 6: Display the Score and Timer

Let's show the score on screen and add a 30-second time limit.

Add import time at the top, and this constant near your other constants:

# in seconds
GAME_DURATION = 30

Before the loop, add a font and a start time:

font = pygame.font.Font(None, 36)
start_time = time.time()

At the very top of the loop body, compute how much time is left:

    elapsed_time = time.time() - start_time
    time_remaining = GAME_DURATION - elapsed_time

In the update phase, end the game when time runs out:

    # Check if player ran out of time
    if time_remaining <= 0:
        pygame.quit()
        exit(0)

And in the drawing phase, before pygame.display.update(), draw the score and timer:

    # Draw score
    score_text = font.render(f"Score: {score}", True, "white")
    window.blit(score_text, (10, 10))
 
    # Draw remaining time
    time_text = font.render(f"Time: {int(time_remaining)}s", True, "white")
    window.blit(time_text, (10, 50))

Now you have 30 seconds to catch as many squares as possible. The timer counts down in the top-left corner.

Step 7: Progressive Difficulty

Recall from the first post that "balancing" a game is just tuning constants - not rewriting logic. Let's make the square speed up a little every time it's caught.

Add this constant near the top of the file:

# 10% increase
SPEED_MULTIPLIER = 1.1

Add a method to FallingSquare that applies it:

    def increase_speed(self) -> None:
        self.speed *= SPEED_MULTIPLIER

Then call it in the collision check, right after the square respawns:

    # Collision detection
    if player.get_hitbox().colliderect(square.get_hitbox()):
        square.spawn()
        square.increase_speed()
        score += 1

Now catch several squares and watch them fall faster as your score goes up. Each catch multiplies speed by 1.1, so the square gets 10% faster every time. Tweak that constant to adjust difficulty - a smaller multiplier ramps up more gently, a bigger one gets brutal fast.


Your Complete Game

Here's the complete, working code. Paste this into game.py:

import pygame
import random
import time
 
# ---- Constants ----
 
WINDOW_WIDTH = 800
WINDOW_HEIGHT = 600
 
# 10% increase
SPEED_MULTIPLIER = 1.1
 
# in seconds
GAME_DURATION = 30
 
pygame.init()
 
class Player:
    def __init__(self) -> None:
        self.speed = 5
 
        self.width = 50
        self.height = 50
        
        # Position in center of window
        self.x = (WINDOW_WIDTH - self.width) / 2
        # Position at 80% of window vertically
        self.y = WINDOW_HEIGHT * 0.8
 
    def move_left(self) -> None:
        self.x -= self.speed
 
    def move_right(self) -> None:
        self.x += self.speed
 
    def get_hitbox(self) -> pygame.Rect:
        return pygame.Rect(self.x, self.y, self.width, self.height)
    
    def draw(self, window) -> None:
        pygame.draw.rect(window, "white", self.get_hitbox())
 
class FallingSquare:
    def __init__(self) -> None:
        self.speed = 3
 
        self.width = 50
        self.height = 50
 
        self.x = 0
        self.y = 0
 
        self.spawn()
 
    def spawn(self) -> None:
        # Spawn at random position within the window
        self.x = random.randint(0, WINDOW_WIDTH - self.width)
        self.y = 0
 
    def move_down(self) -> None:
        self.y += self.speed
 
        if self.y >= WINDOW_HEIGHT:
            self.spawn()
 
    def increase_speed(self) -> None:
        self.speed *= SPEED_MULTIPLIER
 
    def get_hitbox(self) -> pygame.Rect:
        return pygame.Rect(self.x, self.y, self.width, self.height)
    
    def draw(self, window) -> None:
        pygame.draw.rect(window, "red", self.get_hitbox())
 
# ---- Game Objects ----
 
window = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
pygame.display.set_caption("Catch the Square")
 
font = pygame.font.Font(None, 36)
clock = pygame.time.Clock()
 
player = Player()
square = FallingSquare()
 
score = 0
 
start_time = time.time()
 
# ---- Game Loop ----
 
while True:
    elapsed_time = time.time() - start_time
    time_remaining = GAME_DURATION - elapsed_time
 
    # ---- Input Phase ----
 
    # Get which keys are being pressed
    keys = pygame.key.get_pressed()
 
    # Left arrow key is pressed
    if keys[pygame.K_LEFT]:
        player.move_left()
 
    # Right arrow key is pressed
    if keys[pygame.K_RIGHT]:
        player.move_right()
 
    # ---- Update Phase ----
 
    # Limit frame-rate at 60fps
    clock.tick(60)
 
    # Close window with X button
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            exit(0)
 
    # Move square down
    square.move_down()
 
    # Collision detection
    if player.get_hitbox().colliderect(square.get_hitbox()):
        square.spawn()
        square.increase_speed()
        score += 1
 
    # Check if player ran out of time
    if time_remaining <= 0:
        pygame.quit()
        exit(0)
 
    # ---- Drawing Phase ----
 
    # Clear display with black color
    window.fill("black")
 
    # Draw square and player
    square.draw(window)
    player.draw(window)
 
    # Draw score
    score_text = font.render(f"Score: {score}", True, "white")
    window.blit(score_text, (10, 10))
 
    # Draw remaining time
    time_text = font.render(f"Time: {int(time_remaining)}s", True, "white")
    window.blit(time_text, (10, 50))
 
    # Show contents of the display to window
    pygame.display.update()

Run it:

python game.py

You now have a playable game. Use arrow keys to move your white square and catch the red squares falling from the top.


What You Just Built

Your game contains all the core architecture described in the first post:

  • Game Loop - while True: runs 60 times per second, with the input, update, and drawing phases marked out explicitly
  • Entities as Objects: Player and FallingSquare each hold their own state - position, size, speed - instead of loose variables
  • Input Phase: pygame.key.get_pressed() reads what keys are held down, then calls methods on the player
  • Update Phase: moving the square, checking collisions, checking the timer - the numbers get recalculated, nothing is drawn yet
  • Collision Detection: each object exposes a get_hitbox(), and colliderect() does the overlap check
  • Drawing Phase: each object knows how to draw itself; the loop just calls draw() on everything and hands the result to pygame.display.update()

The entire game is still just numbers, arithmetic, a collision check, and rendering - the classes just give each object a tidy place to keep its own numbers and behavior.

Everything else is tuning. Want the player faster? Change Player.speed. Want harder difficulty? Adjust FallingSquare's starting speed or the SPEED_MULTIPLIER constant. This is why understanding the architecture matters. Tweak the numbers and you change the entire feel of the game.

Next Steps: Make It Your Own

Easy Extensions:

  • Make FallingSquare pick a random color each time it spawns (use random.choice() on a list of colors)
  • Add a second FallingSquare instance and update and draw both
  • Display a "Game Over" screen when time runs out

Harder Extensions:

  • Keep a list of FallingSquare instances instead of just one
  • Add a high score system (save to a file)
  • Add a PowerUp class that slows falling squares or adds time

Each of these is just tweaking numbers, or adding a new class alongside the ones you already have. You have all the tools.


Share Your Game

You've built a real game from scratch. You understand the loop, the state, collision detection, and rendering. Now it's your turn to experiment.

Try adding one of the extensions above, or create something entirely new. Then tell us what you built - what you changed, and what surprised you. That's how you learn: by making, breaking, and fixing.

You didn't just learn to use a game engine. You learned to think like a game developer. Now prove it.