Code of the Day
AdvancedVisual Effects

Lab: Visual effects

Add particle explosions on enemy death, screen shake on player hit, hit flash on taking damage, and a fade transition to the complete game.

Lab · optionalGame DevAdvanced35 min
Recommended first
By the end of this lesson you will be able to:
  • Trigger a particle burst when an enemy dies
  • Start screen shake and a hit flash whenever the player takes damage
  • Apply a fade-to-black / fade-from-black on scene transitions

This lab assumes you have the tile platformer + AI enemy from the previous modules. You will wire all four visual effect systems into that game. If you are working fresh, the stub code below gives you enough scaffolding to practise the integration pattern.

Pyodide (the in-browser Python runner) does not support pygame's display system. Read through this code carefully in the browser, then run it locally: pip install pygame followed by python lab_effects.py.

Step 1 — Import and instantiate the effect systems

At the top of your game file, after pygame is initialised, create one instance of each effect class (copy the three classes from the screen-effects-code lesson):

from dataclasses import dataclass
import random, math

# Paste ScreenShake, HitFlash, FadeOverlay, Particle, and emit_burst here
# (or import them from a shared effects.py module)

shake = ScreenShake()
flash = HitFlash(W, H)
fade  = FadeOverlay(W, H)
particles = []

Creating one instance of each at module level keeps the main loop clean and avoids re-allocating surfaces every frame.

Step 2 — Particle explosion on enemy death

In your Enemy.update (or wherever you remove the enemy), call emit_burst and extend the global particle list:

# Inside the main loop, after updating enemies:
alive_enemies = []
for enemy in enemies:
    enemy.update(dt, player.pos)
    if enemy.hp <= 0:
        particles.extend(emit_burst(enemy.pos.x, enemy.pos.y, count=35))
        shake.start(frames=10, magnitude=6)
    else:
        alive_enemies.append(enemy)
enemies = alive_enemies

The shake call here is optional but reinforces the impact of killing an enemy. A modest value (10 frames, magnitude 6) avoids being obnoxious.

Step 3 — Screen shake and flash on player damage

When the player is hit, trigger both effects simultaneously:

def player_take_damage(player, amount):
    player.hp -= amount
    shake.start(frames=12, magnitude=7)
    flash.trigger(frames=3, colour=(255, 60, 60), alpha=150)

A red flash for damage is conventional and immediately legible. White is used for powerful hits or boss impacts where you want more contrast.

Step 4 — Fade transition between scenes

Use FadeOverlay when transitioning from the game scene to a game-over screen or the next level:

# In the game loop, when the win/loss condition is met:
if player.hp <= 0 and not transitioning:
    fade.fade_out(speed=380)
    transitioning = True

# After the fade-out completes, switch scenes:
if transitioning and fade.done and not fade_in_started:
    current_scene = GameOverScene()
    fade.fade_in(speed=300)
    fade_in_started = True

The two-step approach (fade out → swap scene → fade in) prevents the player from seeing a single frame of the new scene before the fade completes.

Step 5 — Putting it all together

import pygame
import sys, random, math
from dataclasses import dataclass

pygame.init()
W, H   = 640, 480
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("Effects lab")
clock  = pygame.time.Clock()
Vector2 = pygame.math.Vector2

# ── Paste effect classes here (ScreenShake, HitFlash, FadeOverlay) ────────────
# ── Paste Particle dataclass and emit_burst here ──────────────────────────────

shake     = ScreenShake()
flash     = HitFlash(W, H)
fade      = FadeOverlay(W, H)
particles = []

# Minimal stand-in for player and enemy
player_pos  = Vector2(100, 380)
player_hp   = 100
enemy_pos   = Vector2(400, 380)
enemy_hp    = 60

font         = pygame.font.SysFont(None, 26)
transitioning = False
fade_in_done  = False

fade.fade_in(speed=400)    # open the scene with a fade-in


def player_take_damage(amount):
    global player_hp
    player_hp = max(0, player_hp - amount)
    shake.start(12, 7)
    flash.trigger(3, (255, 60, 60), 150)


def kill_enemy():
    global enemy_hp
    particles.extend(emit_burst(enemy_pos.x, enemy_pos.y, count=35))
    shake.start(10, 6)
    enemy_hp = 0


running = True
while running:
    dt = min(clock.tick(60) / 1000.0, 0.05)

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_h:   player_take_damage(20)
            if event.key == pygame.K_k:   kill_enemy()
            if event.key == pygame.K_t and not transitioning:
                fade.fade_out(380)
                transitioning = True

    # Trigger fade-in after fade-out completes
    if transitioning and fade.done and not fade_in_done:
        fade.fade_in(300)
        fade_in_done = True

    shake.update()
    fade.update(dt)

    # Update and cull particles
    particles[:] = [p for p in particles if p.lifetime > 0]
    for p in particles:
        p.update(dt)

    # ── Draw (world offset by shake) ──────────────────────────────────────────
    ox, oy = shake.offset
    screen.fill((20, 20, 35))

    # Ground
    pygame.draw.rect(screen, (80, 60, 40),
                     pygame.Rect(0 + ox, 410 + oy, W, 70))
    # Player
    pygame.draw.rect(screen, (100, 200, 120),
                     pygame.Rect(int(player_pos.x)+ox, int(player_pos.y)+oy, 32, 40))
    # Enemy (if alive)
    if enemy_hp > 0:
        pygame.draw.rect(screen, (200, 60, 60),
                         pygame.Rect(int(enemy_pos.x)+ox, int(enemy_pos.y)+oy, 32, 32))

    # Particles (drawn in world space — include shake offset)
    for p in particles:
        frac   = p.life_frac()
        r      = max(1, int(p.radius * frac))
        colour = tuple(int(c * frac) for c in p.colour)
        pygame.draw.circle(screen, colour,
                           (int(p.pos.x)+ox, int(p.pos.y)+oy), r)

    # Flash and fade on top of world
    flash.draw(screen)
    fade.draw(screen)

    # HUD (unaffected by shake — drawn after all effects except fade)
    hud = font.render(
        f"HP:{player_hp}  Enemy HP:{enemy_hp}  H=damage  K=kill  T=transition",
        True, (200, 200, 200))
    screen.blit(hud, (10, 10))
    pygame.display.flip()

pygame.quit()
sys.exit()

What to try next

  • Apply the shake offset only to world-space objects (player, enemies, tiles) and leave the HUD at its original position — observe how this clarifies the separation between game world and interface.
  • Add a smoke particle system that continuously trails the enemy as it moves.
  • Implement a "level clear" sequence: trigger a fade-out, load the next level, fade in.
  • Experiment with different emit_burst parameters — fewer, faster particles for a sharp crack; many slow particles for a billowing explosion.
Finished reading? Mark it complete to track your progress.

On this page