Skip to content
Back to skills

Pygame

ASecurity

Use when building 2D games in Python with pygame - game loop and delta time, sprites and groups, collision detection, drawing and surfaces, events and input, sound and fonts, camera, or performance optimization

  • 21 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 2, 2026
developmenttypescriptpythongobashapiperformancedocumentation

Works with

  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 4 files and shows the line behind each finding

Scanned October 2, 2026

npx -y skills add CodeAtCode/oss-ai-skills --skill pygame --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Pygame?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Pygame
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/codeatcode-pygame/badge)](https://www.skillsdirectory.com/skills/codeatcode-pygame)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: pygame
description: Use when building 2D games in Python with pygame - game loop and delta time, sprites and groups, collision detection, drawing and surfaces, events and input, sound and fonts, camera, or performance optimization
metadata:
  author: mte90
  version: 2.0.0
  tags:
    - python
    - game-development
    - 2d-games
    - pygame
    - graphics
    - game-engine
---

# Pygame

Python game development library.

## Quick Start

```bash
pip install pygame-ce  # Community Edition - actively maintained
```

```python
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.

```python
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):

```python
# 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

```python
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:**

```python
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 |

```python
# 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

```python
# 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?"

```python
# 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.

```python
# 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.

```python
# 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

```python
# 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.

```python
# 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

```python
# 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

```python
# 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

```python
# 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/

Files in this skill

  • SKILL.md14.3 KB
  • references/drawing-surfaces.md1.7 KB
  • references/performance.md3.2 KB
  • references/sprites-camera-ui.md3.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…