Pygame
Python game development library.
Quick Start
pip install pygame-ce # Community Edition - actively maintained
import pygame
pygame.init()
screen = pygame.display.set_mode((800, 600))
clock = pygame.time.Clock()
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
screen.fill((0, 0, 0))
# Draw game objects
pygame.display.flip()
clock.tick(60) # Limit to 60 FPS
pygame.quit()
For full API reference, see https://www.pygame.org/docs/ref/
Game Loop Architecture
The Non-Negotiable Discipline
A game loop has three phases: handle events → update simulation → render. Never derive motion from raw frame counts—always use delta time (dt).
Critical pattern: dt = clock.tick(FPS) / 1000 without clamping lets a stalled frame (e.g., system suspend, GC pause) teleport entities through walls. A 5-second stall at 60 FPS yields dt = 5.0, moving a 100 px/s entity 500 pixels in one update.
Rule: Always clamp max_dt to prevent tunneling.
Fixed Timestep with Render Interpolation
The robust pattern: fixed physics steps, interpolated rendering for smoothness.
class Game:
def __init__(self):
pygame.init()
self.screen = pygame.display.set_mode((800, 600))
self.clock = pygame.time.Clock()
self.running = True
# Fixed timestep: physics always runs at 60 Hz
self.fixed_dt = 1/60
self.accumulator = 0.0
self.max_dt = 1/10 # Clamp: ignore frames > 100ms (prevents tunneling)
def run(self):
while self.running:
# Calculate delta time (seconds)
dt = self.clock.tick(60) / 1000.0
# Clamp to prevent spiral of death on stalled frames
if dt > self.max_dt:
dt = self.max_dt
# Handle events
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
# Fixed timestep update loop
self.accumulator += dt
while self.accumulator >= self.fixed_dt:
self.update(self.fixed_dt)
self.accumulator -= self.fixed_dt
# Render with interpolation (smooths visual updates)
interp = self.accumulator / self.fixed_dt
self.render(interp)
pygame.quit()
def update(self, dt):
"""Physics and game logic at fixed 60 Hz"""
# Movement: position += velocity * dt
# Collision detection
# AI decisions
pass
def render(self, interp):
"""Render with interpolation factor (0.0 to 1.0)"""
self.screen.fill((0, 0, 0))
# Interpolate positions for smooth rendering between physics steps
for sprite in self.all_sprites:
render_x = sprite.prev_x + (sprite.x - sprite.prev_x) * interp
render_y = sprite.prev_y + (sprite.y - sprite.prev_y) * interp
self.screen.blit(sprite.image, (render_x, render_y))
pygame.display.flip()
Why this matters:
- Consistent physics regardless of framerate drops
- Deterministic simulation (network games, replays)
- No "spiral of death" when frame time exceeds update time
- Interpolation makes rendering smooth even when physics runs slower
Variable delta alternative (simpler, less precise):
# Simpler: variable timestep (acceptable for non-critical physics)
dt = self.clock.tick(60) / 1000.0
if dt > 0.1: # Clamp to 100ms max
dt = 0.1
self.update(dt)
Update/Draw/Collision Placement
def update(self, dt):
# 1. Handle input (keyboard/mouse state)
# 2. Update positions: pos += vel * dt
# 3. Update animations (frame += dt * fps)
# 4. Collision detection (after positions change)
# 5. Game logic (score, state transitions)
def render(self, interp):
# 1. Clear screen (fill or restore background)
# 2. Draw static background (once, cached)
# 3. Draw sprites/groups (sorted by z-order if needed)
# 4. Draw UI overlay (HUD, score, health)
# 5. Flip display
Sprite and Group Cost
Why Groups Are the Bottleneck
Group.update() calls .update() on every sprite each frame. For 1000 sprites, that's 1000 function calls. The cost is not the group—it's the work each sprite does.
Profile before optimizing:
import time
# Measure update cost
start = time.perf_counter()
all_sprites.update()
update_ms = (time.perf_counter() - start) * 1000
# Measure draw cost
start = time.perf_counter()
all_sprites.draw(screen)
draw_ms = (time.perf_counter() - start) * 1000
print(f"Update: {update_ms:.2f}ms, Draw: {draw_ms:.2f}ms")
# Budget: < 8ms update, < 8ms draw for 60 FPS headroom
Group Types and Their Costs
| Group Type | Cost | Use When |
|------------|------|----------|
| Group | O(n) update, O(n) draw | Most cases, sprites with .update() |
| GroupSingle | O(1) access | Single entity (player, boss) |
| LayeredUpdates | O(n) + layer sorting | Z-order rendering, depth sorting |
| Sprite (manual) | O(1) | Single sprite, no group overhead |
# GroupSingle: single entity, faster access
player = pygame.sprite.GroupSingle()
player.add(PlayerSprite())
player.update() # Updates only the single sprite
player.draw(screen)
# LayeredUpdates: control draw order by layer
layers = pygame.sprite.LayeredUpdates()
layers.add(background, layers=[0]) # Draw first
layers.add(player, layers=[1])
layers.add(enemies, layers=[2])
layers.add(particles, layers=[3]) # Draw last (on top)
layers.change_layer(player, 5) # Move player to top
When to Draw Manually
Skip groups when:
- Sprites don't need
.update()(static background tiles) - You need custom draw order per frame
- You're using dirty rect optimization
# Manual draw: skip group overhead for static tiles
for tile in background_tiles:
screen.blit(tile.image, tile.rect) # No update() call
# Custom z-order: sort before draw
sprites_to_draw = sorted(all_sprites, key=lambda s: s.z_index)
for sprite in sprites_to_draw:
screen.blit(sprite.image, sprite.rect)
GroupCollide vs Manual Collision
GroupCollide returns a dict keyed by sprite—expensive if you only need "did anything hit?"
# Expensive: groupcollide builds full dict
hits = pygame.sprite.groupcollide(bullets, enemies, False, False)
# hits = {bullet1: [enemy1, enemy2], bullet2: [enemy3], ...}
# Cheaper: spritecollide, short-circuit on first hit
for bullet in bullets:
if pygame.sprite.spritecollideany(bullet, enemies):
bullet.kill()
break # Stop checking
Collision Detection Decision Guide
Collision Strategies by Cost
| Strategy | Cost | Use When |
|----------|------|----------|
| AABB (rect.colliderect) | O(1), fast | Most games, initial filter |
| spritecollide (rect) | O(n) per sprite | Small sprite counts (< 100) |
| groupcollide (dict) | O(n×m) | Need full collision map |
| Mask (collide_mask) | O(pixels) | Pixel-perfect needed |
| Circle (collide_circle) | O(1), approximate | Round sprites, medium precision |
The Failure Modes
Tunneling at high velocity: A bullet moving 200 px/frame skips a 32-pixel enemy entirely.
Fix: Use continuous collision (swept AABB) or subdivide high-velocity updates.
# Subdivide to prevent tunneling
def update_high_velocity(bullet, dt, subdivisions=4):
sub_dt = dt / subdivisions
for _ in range(subdivisions):
bullet.x += bullet.vx * sub_dt
bullet.y += bullet.vy * sub_dt
if pygame.sprite.spritecollideany(bullet, enemies):
return True # Collision detected
return False
O(n²) broad-phase: 500 bullets × 500 enemies = 250,000 checks per frame.
Fix: AABB pre-filter with spatial partitioning.
# AABB pre-filter: reject distant sprites first
def collide_with_filter(bullet, enemies):
# Fast bounding box check
for enemy in enemies:
if not bullet.rect.colliderect(enemy.rect):
continue # Skip expensive mask check
# Only now do the expensive check
if bullet.mask and enemy.mask:
offset = (enemy.rect.x - bullet.rect.x,
enemy.rect.y - bullet.rect.y)
if bullet.mask.overlap(enemy.mask, offset):
return True
return False
# For large counts: use a quadtree or grid
# (See references/performance.md for spatial partitioning)
Mask vs Colorkey vs Rect
# Rect collision (fastest, least precise)
if player.rect.colliderect(enemy.rect):
handle_collision()
# Colorkey: simple transparency, no pixel-perfect
image = pygame.image.load("sprite.png").convert()
image.set_colorkey((0, 0, 0)) # Black is transparent
# Mask: pixel-perfect (slow, use as second filter)
mask = pygame.mask.from_surface(image) # Expensive, do once at load
# Store mask on sprite
sprite.mask = mask
# Collision: rect first (AABB), then mask
if sprite1.rect.colliderect(sprite2.rect):
offset = (sprite2.rect.x - sprite1.rect.x,
sprite2.rect.y - sprite1.rect.y)
if sprite1.mask.overlap(sprite2.mask, offset):
handle_pixel_perfect_collision()
Rule: Always use AABB as first filter. Mask collision is 10-100× slower than rect.
Surface and Blit Pitfalls
Per-Frame Allocation
Never allocate Surfaces inside the game loop.
# Bad: Allocates new surface every frame (triggers GC, causes stutter)
while running:
text = font.render(score_text, True, WHITE)
screen.blit(text, (10, 10))
# Good: Cache the surface, update only when score changes
class HUD:
def __init__(self):
self.score_font = pygame.font.Font(None, 36)
self.score_surface = None
self.score = 0
def set_score(self, value):
if value != self.score:
self.score = value
self.score_surface = self.score_font.render(
str(value), True, WHITE
)
def draw(self, screen):
if self.score_surface:
screen.blit(self.score_surface, (10, 10))
convert() and convert_alpha() at Load Time
# Bad: Converts on every load (or worse, every frame)
image = pygame.image.load("sprite.png").convert()
# Good: Load once at startup, convert immediately
class AssetManager:
def __init__(self):
self.player = pygame.image.load("player.png").convert_alpha()
self.tile = pygame.image.load("tile.png").convert()
# Opaque → convert(), transparent → convert_alpha()
Rule of thumb:
convert(): Opaque images, no transparency (20-30% faster blit)convert_alpha(): Images with alpha channels or colorkeys- Never call
convert()inside the game loop
set_alpha vs Alpha Channel
# Bad: set_alpha on many sprites (slow, recomputes every blit)
for sprite in hundreds_of_sprites:
sprite.image.set_alpha(128)
# Good: Use alpha channel in the image itself (precomputed)
# Load with transparency already baked in
sprite = pygame.image.load("fading.png").convert_alpha()
Rule: set_alpha() on a Surface is slower than having the alpha channel in the image data. Pre-render semi-transparent surfaces once.
blit vs blits Batching
# Bad: Multiple individual blit calls
for sprite in sprites:
screen.blit(sprite.image, sprite.rect)
# Good: Batched blits (single call, faster)
screen.blits([(s.image, s.rect) for s in sprites])
# Even better: pre-compute the list
blit_list = [(s.image, s.rect) for s in sprites]
screen.blits(blit_list)
Rule: blits() is 10-30% faster than individual blit() calls for large sprite counts.
When Not to Use Pygame
Browser-Targeted Games
Problem: Pygbag (pygame-to-WebAssembly) has limitations:
- Large binary size (~10MB+ initial load)
- Input latency in browser
- No access to native APIs (file system, notifications)
- Mobile browser performance issues
Alternative: Native JS/TypeScript game engines (Phaser, Babylon.js) or Unity/Godot export to WebGL.
Hardware-Accelerated 3D or Heavy 2D Sprite Counts
Problem: Pygame uses software rendering (SDL2 software renderer by default). Even with HWSURFACE, it's not a full 3D pipeline.
Symptoms:
-
1000 moving sprites at 60 FPS
- Need for 3D transformations (rotation in 3D space, perspective)
- Shader effects (bloom, depth of field)
Alternative:
- 2D: Godot (GDScript, built-in sprite batching)
- 3D: Panda3D, Ursina, or OpenGL/DirectX directly
- High-performance 2D: Pyglet (OpenGL-backed)
Projects Needing a Retained-Mode Scene Graph
Problem: Pygame is immediate-mode. You redraw everything every frame. No scene graph, no object retention.
Symptoms:
- Complex UI with nested widgets
- Need for editor-time scene composition
- Undo/redo history of scene changes
Alternative: Godot (built-in scene system), Unity, or a retained-mode UI library (Dear PyGui for tools).
Games That Must Ship on Mobile
Problem: Pygame has no official mobile support. Pygbag targets web, not native mobile.
Symptoms:
- Need iOS/Android app store distribution
- Need native mobile features (push notifications, in-app purchases)
- Need touch-optimized input handling
Alternative: Godot, Unity, or Flutter with game plugins.
Deep Dives
Load these reference files on demand for detailed implementations:
- Drawing & Surfaces:
references/drawing-surfaces.md— Colors, shapes, surface operations, transforms - Performance Optimization:
references/performance.md— Image conversion, RLEACCEL, batched blits, dirty rects, pre-rendered surfaces - Sprites, Camera & UI:
references/sprites-camera-ui.md— Sprite classes, groups, camera implementation, button UI elements
References
- Official Documentation: https://www.pygame.org/docs/
- Pygame Wiki: https://www.pygame.org/wiki/
- KidsCanCode Pygame Tutorials: https://kidscancode.org/pygame_tutorials/