๐Ÿ My First Python Game ยท Episode 3 of 7 ยท See All Episodes
๐ŸŽฏ Episode 3 ยท Beginner+ ยท Builds on Episodes 1-2

Stay Alive!
Dodge & Collect

Gems rain down, so do bombs. Grab the good, dodge the bad, chase a high score. You will master lists of moving objects, spawn timers, and difficulty curves.

๐Ÿ‘ถ Ages 10+ โฑ๏ธ ~1.5 Hours ๐Ÿ Python โœ“ Free
๐Ÿ“‹ Lists of objects โฒ๏ธ Spawn timers ๐ŸŽฒ Weighted random ๐Ÿ’ฃ Hazards ๐Ÿ“ˆ Difficulty curve ๐Ÿ… High score
โญ
0 XP
Level 1
๐Ÿ”ฅ0
Your Progress 0 / 6 steps
๐Ÿงฉ
Need the game setup? Each episode builds a new game on the same "usual template", import pygameimport pygame, pygame.init()pygame.init(), the window and the while runningwhile running game loop. If you're starting here or need that template, grab it in one click: Episode 1 full template โ†’ ยท Python Cheatsheet โ†’
1
๐Ÿƒ
Setup & the Runner
Template + a player locked to the ground line
Active
๐ŸŽฏ
Goal for this step

Standard setup with a player that runs along the bottom of the screen.

  • 1New game.py with the usual template (800ร—480).
  • 2Player Rect at the bottom: y fixed at 430, x moves with arrows at speed 8, this game is about horizontal reflexes.
  • 3Clamp to the screen edges (Episode 1 skill!).
  • 4Draw the player teal.
game.py
player = pygame.Rect(380, 430, 40, 40)

keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
    player.x -= 8
if keys[pygame.K_RIGHT]:
    player.x += 8
player.x = max(0, min(800 - player.width, player.x))player = pygame.Rect(380, 430, 40, 40)

keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
    player.x -= 8
if keys[pygame.K_RIGHT]:
    player.x += 8
player.x = max(0, min(800 - player.width, player.x))
๐Ÿ“ฆ Full starter file , the Episode 1 pygame template

The standard 800ร—480 window, the clock and the while runningwhile running game loop. Save it as game.pygame.py and run it: a dark window opens. Every step below adds to this same file.

game.py
import pygame
pygame.init()
screen = pygame.display.set_mode((800, 480))
pygame.display.set_caption("My Python Game")
clock = pygame.time.Clock()

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

    screen.fill((25, 30, 60))      # everything you build below is drawn here
    pygame.display.flip()
    clock.tick(60)                 # 60 frames per second

pygame.quit()import pygame
pygame.init()
screen = pygame.display.set_mode((800, 480))
pygame.display.set_caption("My Python Game")
clock = pygame.time.Clock()

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

    screen.fill((25, 30, 60))      # everything you build below is drawn here
    pygame.display.flip()
    clock.tick(60)                 # 60 frames per second

pygame.quit()
โœ๏ธ
Fill in the Blanks
+15 XP
This game locks the playerโ€™s position and only moves along . The clamp uses max and together.
๐Ÿง 
Knowledge Check
+15 XP
Player speed is 8 here vs 5 in the platformer. What does higher speed change?
ANothing noticeable
BThe game feel, faster reactions possible, so falling objects can be faster too
CThe window size
2
โฒ๏ธ
The Spawn Timer
Spawn a new falling object every 40 frames
Locked
๐ŸŽฏ
Goal for this step

Build a frame-counting timer that spawns falling gems into a list.

  • 1Falling things live in a list. Each is a small dictionary: {"rect": Rect, "kind": "gem"}.
  • 2A spawn timer is just a counter: spawn_timer += 1 each frame; at 40, spawn and reset to 0.
  • 340 frames at 60 FPS โ‰ˆ 0.66 seconds between spawns.
  • 4Move every object down each frame, and delete the ones past the bottom (the [:] copy trick).
game.py
import random
things = []
spawn_timer = 0

# inside the loop:
spawn_timer += 1
if spawn_timer >= 40:
    spawn_timer = 0
    things.append({
        "rect": pygame.Rect(random.randint(10, 766), -24, 24, 24),
        "kind": "gem"
    })

for t in things[:]:
    t["rect"].y += 5
    if t["rect"].y > 480:
        things.remove(t)import random
things = []
spawn_timer = 0

# inside the loop:
spawn_timer += 1
if spawn_timer >= 40:
    spawn_timer = 0
    things.append({
        "rect": pygame.Rect(random.randint(10, 766), -24, 24, 24),
        "kind": "gem"
    })

for t in things[:]:
    t["rect"].y += 5
    if t["rect"].y > 480:
        things.remove(t)
โŒจ๏ธ
Code Challenge
+20 XP
Complete the spawn timer:
game.py
spawn_timer += 1
if spawn_timer >= spawn_timer += 1
if spawn_timer >= :
    spawn_timer = :
    spawn_timer = 
    things.
    things.({"rect": pygame.Rect(random.randint(10, 766), -24, 24, 24), "kind": "gem"})({"rect": pygame.Rect(random.randint(10, 766), -24, 24, 24), "kind": "gem"})
๐Ÿ’ก Hint: Spawn at the threshold, reset the counter, and add to the end of the list.
๐Ÿง 
Knowledge Check
+15 XP
A frame-counter timer at 60 FPS with threshold 40 spawns roughly everyโ€ฆ
A40 seconds
B0.66 seconds (40 รท 60)
C4 seconds
3
๐Ÿ’ฃ
Bombs: Weighted Random
25% of spawns are bombs, colour-coded danger
Locked
๐ŸŽฏ
Goal for this step

Mix hazards into the spawn using weighted randomness.

  • 1At spawn time, roll: kind = "bomb" if random.random() < 0.25 else "gem".
  • 2random.random() gives 0.0-1.0, so < 0.25 is a 25% chance.
  • 3Draw gems as teal diamonds (ellipse) and bombs as red circles, instant readability matters more than pretty.
  • 4Tune the ratio: 40% bombs is panic, 10% is a snooze.
game.py
kind = "bomb" if random.random() < 0.25 else "gem"
things.append({
    "rect": pygame.Rect(random.randint(10, 766), -24, 24, 24),
    "kind": kind
})

# drawing:
for t in things:
    colour = (248, 113, 113) if t["kind"] == "bomb" else (77, 217, 224)
    pygame.draw.ellipse(screen, colour, t["rect"])kind = "bomb" if random.random() < 0.25 else "gem"
things.append({
    "rect": pygame.Rect(random.randint(10, 766), -24, 24, 24),
    "kind": kind
})

# drawing:
for t in things:
    colour = (248, 113, 113) if t["kind"] == "bomb" else (77, 217, 224)
    pygame.draw.ellipse(screen, colour, t["rect"])
โœ๏ธ
Fill in the Blanks
+15 XP
random.random() returns a number between 0.0 and . Checking < 0.25 gives a % chance of a bomb.
๐Ÿง 
Knowledge Check
+15 XP
To make bombs rarer, 10%, the check becomesโ€ฆ
Arandom.random() < 0.10
Brandom.random() > 0.10
Crandom.randint(0, 10)
4
๐Ÿ’ฅ
Catch or Boom
Gems score, bombs cost a life
Locked
๐ŸŽฏ
Goal for this step

Handle both collision outcomes and show score + lives.

  • 1Loop the list (copy!), test colliderect with the player.
  • 2Gem โ†’ score += 1, remove. Bomb โ†’ lives -= 1, remove.
  • 3Missing a GEM is fine in this game, only bombs hurt. Different rules than Episode 1, notice the design choice!
  • 4HUD: score left, hearts right.
game.py
score, lives = 0, 3

for t in things[:]:
    if player.colliderect(t["rect"]):
        if t["kind"] == "gem":
            score += 1
        else:
            lives -= 1
        things.remove(t)score, lives = 0, 3

for t in things[:]:
    if player.colliderect(t["rect"]):
        if t["kind"] == "gem":
            score += 1
        else:
            lives -= 1
        things.remove(t)
โŒจ๏ธ
Code Challenge
+20 XP
Branch on what the player touched:
game.py
if player.colliderect(t["rect"]):
    if t["kind"] == if player.colliderect(t["rect"]):
    if t["kind"] == :
        score += 1
    else:
        lives -= :
        score += 1
    else:
        lives -= 
    things.
    things.(t)(t)
๐Ÿ’ก Hint: Compare the kind string; bombs cost one life; either way the object leaves the list.
๐Ÿง 
Knowledge Check
+15 XP
In Episode 1 missing fruit lost a life; here only touching bombs does. Why vary the rules?
APygame versions differ
BDifferent rules create different tension, dodging vs catching are different skills
CLives are deprecated
5
๐Ÿ“ˆ
The Difficulty Curve
Faster falls, quicker spawns, more bombs over time
Locked
๐ŸŽฏ
Goal for this step

Scale three difficulty knobs with score so the game always ends.

  • 1Fall speed: 5 + score // 10.
  • 2Spawn threshold: max(12, 40 - score // 2), faster spawns but never below 12 frames.
  • 3Bomb chance: min(0.5, 0.25 + score * 0.005), creeps up, capped at 50%.
  • 4A good arcade game is unbeatable by design, the difficulty curve guarantees an exciting death. Chase the high score instead!
game.py
fall_speed = 5 + score // 10
spawn_at = max(12, 40 - score // 2)
bomb_chance = min(0.5, 0.25 + score * 0.005)

if spawn_timer >= spawn_at:
    ...
    kind = "bomb" if random.random() < bomb_chance else "gem"fall_speed = 5 + score // 10
spawn_at = max(12, 40 - score // 2)
bomb_chance = min(0.5, 0.25 + score * 0.005)

if spawn_timer >= spawn_at:
    ...
    kind = "bomb" if random.random() < bomb_chance else "gem"
๐Ÿ’ก
min() and max() as caps and floors, the same clamp trick you use on positions works on difficulty too.
โœ๏ธ
Fill in the Blanks
+15 XP
The spawn threshold has a floor of frames thanks to max(), and the bomb chance is capped at thanks to min().
๐Ÿง 
Knowledge Check
+15 XP
Why cap the bomb chance at 50%?
APython floats stop at 0.5
BPast a point the game stops being hard-but-fair and becomes pure luck, caps protect the fun
CBombs are expensive to draw
6
๐Ÿ…
High Score & Game Over
Persist the best score to a file, real saving!
Locked
๐ŸŽฏ
Goal for this step

End the game cleanly and save the high score to disk so it survives restarts.

  • 1Game over at 0 lives, freeze updates, show final score and press-R (Episode 1 pattern).
  • 2Load the high score at startup from highscore.txt (default 0 if the file does not exist, use try/except).
  • 3On game over, if score beats it: write the new record back to the file.
  • 4That is genuine file I/O, your first persistent save data!
game.py
def load_highscore():
    try:
        with open("highscore.txt") as f:
            return int(f.read())
    except:
        return 0

def save_highscore(value):
    with open("highscore.txt", "w") as f:
        f.write(str(value))

high = load_highscore()

# when the game ends:
if score > high:
    high = score
    save_highscore(high)def load_highscore():
    try:
        with open("highscore.txt") as f:
            return int(f.read())
    except:
        return 0

def save_highscore(value):
    with open("highscore.txt", "w") as f:
        f.write(str(value))

high = load_highscore()

# when the game ends:
if score > high:
    high = score
    save_highscore(high)
โŒจ๏ธ
Code Challenge
+20 XP
Save a new record only when it IS a record:
game.py
if score > if score > :
    high = score
    :
    high = score
    (high)(high)
๐Ÿ’ก Hint: Compare against the loaded best, then call the writing function.
๐Ÿง 
Knowledge Check
+15 XP
Why wrap the file-reading in try/except?
AFiles are dangerous
BThe first ever run has no highscore.txt, except returns a default 0 instead of crashing
CTo make it faster
โœ… See the complete finished Dodge game , all 6 steps assembled

A runner, a spawn timer, falling gems and bombs, gem-scores-bomb-costs-a-life collisions, a difficulty curve that scales with score, and a high score saved to highscore.txthighscore.txt , one file. Save it as game.pygame.py, run python game.pypython game.py, and play. Stuck on a step? Compare your file against this.

game.py
import pygame, random
pygame.init()
screen = pygame.display.set_mode((800, 480))
pygame.display.set_caption("Gem Dash")
clock = pygame.time.Clock()
font = pygame.font.SysFont(None, 32)
big_font = pygame.font.SysFont(None, 72)

def load_highscore():
    try:
        with open("highscore.txt") as f:
            return int(f.read())
    except:
        return 0

def save_highscore(value):
    with open("highscore.txt", "w") as f:
        f.write(str(value))

player = pygame.Rect(380, 430, 40, 40)
things = []
spawn_timer = 0
score = 0
lives = 3
game_over = False
high = load_highscore()

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_r and game_over:
                things = []
                spawn_timer = 0
                score = 0
                lives = 3
                game_over = False

    if not game_over:
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]:
            player.x -= 8
        if keys[pygame.K_RIGHT]:
            player.x += 8
        player.x = max(0, min(800 - player.width, player.x))

        # difficulty scales with score
        fall_speed = 5 + score // 10
        spawn_at = max(12, 40 - score // 2)
        bomb_chance = min(0.5, 0.25 + score * 0.005)

        spawn_timer += 1
        if spawn_timer >= spawn_at:
            spawn_timer = 0
            kind = "bomb" if random.random() < bomb_chance else "gem"
            things.append({"rect": pygame.Rect(random.randint(10, 766), -24, 24, 24), "kind": kind})

        for t in things[:]:
            t["rect"].y += fall_speed
            if player.colliderect(t["rect"]):
                if t["kind"] == "gem":
                    score += 1
                else:
                    lives -= 1
                things.remove(t)
            elif t["rect"].y > 480:
                things.remove(t)

        if lives <= 0:
            game_over = True
            if score > high:
                high = score
                save_highscore(high)

    # draw
    screen.fill((25, 30, 60))
    pygame.draw.rect(screen, (45, 212, 191), player)
    for t in things:
        colour = (248, 113, 113) if t["kind"] == "bomb" else (77, 217, 224)
        pygame.draw.ellipse(screen, colour, t["rect"])
    screen.blit(font.render("Score: " + str(score), True, (255, 255, 255)), (12, 12))
    screen.blit(font.render("Best: " + str(high), True, (255, 255, 255)), (12, 44))
    screen.blit(font.render("Lives: " + str(max(0, lives)), True, (248, 113, 113)), (680, 12))
    if game_over:
        msg = big_font.render("GAME OVER", True, (255, 209, 102))
        screen.blit(msg, (210, 190))
        screen.blit(font.render("Press R to restart", True, (255, 255, 255)), (300, 270))

    pygame.display.flip()
    clock.tick(60)

pygame.quit()import pygame, random
pygame.init()
screen = pygame.display.set_mode((800, 480))
pygame.display.set_caption("Gem Dash")
clock = pygame.time.Clock()
font = pygame.font.SysFont(None, 32)
big_font = pygame.font.SysFont(None, 72)

def load_highscore():
    try:
        with open("highscore.txt") as f:
            return int(f.read())
    except:
        return 0

def save_highscore(value):
    with open("highscore.txt", "w") as f:
        f.write(str(value))

player = pygame.Rect(380, 430, 40, 40)
things = []
spawn_timer = 0
score = 0
lives = 3
game_over = False
high = load_highscore()

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_r and game_over:
                things = []
                spawn_timer = 0
                score = 0
                lives = 3
                game_over = False

    if not game_over:
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]:
            player.x -= 8
        if keys[pygame.K_RIGHT]:
            player.x += 8
        player.x = max(0, min(800 - player.width, player.x))

        # difficulty scales with score
        fall_speed = 5 + score // 10
        spawn_at = max(12, 40 - score // 2)
        bomb_chance = min(0.5, 0.25 + score * 0.005)

        spawn_timer += 1
        if spawn_timer >= spawn_at:
            spawn_timer = 0
            kind = "bomb" if random.random() < bomb_chance else "gem"
            things.append({"rect": pygame.Rect(random.randint(10, 766), -24, 24, 24), "kind": kind})

        for t in things[:]:
            t["rect"].y += fall_speed
            if player.colliderect(t["rect"]):
                if t["kind"] == "gem":
                    score += 1
                else:
                    lives -= 1
                things.remove(t)
            elif t["rect"].y > 480:
                things.remove(t)

        if lives <= 0:
            game_over = True
            if score > high:
                high = score
                save_highscore(high)

    # draw
    screen.fill((25, 30, 60))
    pygame.draw.rect(screen, (45, 212, 191), player)
    for t in things:
        colour = (248, 113, 113) if t["kind"] == "bomb" else (77, 217, 224)
        pygame.draw.ellipse(screen, colour, t["rect"])
    screen.blit(font.render("Score: " + str(score), True, (255, 255, 255)), (12, 12))
    screen.blit(font.render("Best: " + str(high), True, (255, 255, 255)), (12, 44))
    screen.blit(font.render("Lives: " + str(max(0, lives)), True, (248, 113, 113)), (680, 12))
    if game_over:
        msg = big_font.render("GAME OVER", True, (255, 209, 102))
        screen.blit(msg, (210, 190))
        screen.blit(font.render("Press R to restart", True, (255, 255, 255)), (300, 270))

    pygame.display.flip()
    clock.tick(60)

pygame.quit()
๐ŸŽ‰๐Ÿ†๐ŸŽฎโœจ๐ŸŽ‰
Workshop Complete!
Spawn timers, object lists and a difficulty curve, you built a proper score-chaser. Episode 4 slows things down with a maze to solve.
0
Total XP
1
Level
0
Best Streak
0%
Accuracy
โ–ถ Episode 4: Maze Explorer โ†’
โญ View My Progress & Certificates

This workshop was free and took many hours to build. If it helped you learn something new, consider supporting the project.

☕ Support on Ko-fi