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 Pygamepygame.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 computerwindow.fill("black")paints the background blackpygame.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:
PlayerandFallingSquareeach 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(), andcolliderect()does the overlap check - Drawing Phase: each object knows how to draw itself; the loop just calls
draw()on everything and hands the result topygame.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? ChangePlayer.speed. Want harder difficulty? AdjustFallingSquare's startingspeedor theSPEED_MULTIPLIERconstant. 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
FallingSquarepick a random color each time it spawns (userandom.choice()on a list of colors) - Add a second
FallingSquareinstance and update and draw both - Display a "Game Over" screen when time runs out
Harder Extensions:
- Keep a list of
FallingSquareinstances instead of just one - Add a high score system (save to a file)
- Add a
PowerUpclass 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.