pygame-patterns
Pygame Patterns
Purpose
Build, refactor, debug, and review pygame projects using idiomatic Python and pygame-specific practices. Use this skill with refactor-python when cleaning up pygame code.
Optimize for a stable main loop, explicit input/update/draw boundaries, predictable timing, cached assets, testable core logic, and architecture that fits the game's actual size.
First Inspection Checklist
Before editing, inspect:
- Python tooling:
pyproject.toml,uv.lock,.python-version, test config, run scripts, package layout, and supported Python versions. - Which package is installed:
pygameorpygame-ce. They both import aspygame; choose one and do not mix them. - Entry point and initialization: where
pygame.init(), mixer/display setup,display.set_mode(), and the main loop happen. - Main loop order: event pumping, input handling, update timing, drawing,
display.flip()/update(), andClock.tick(). - Asset paths/loading for images, sounds, fonts, maps, and data.
- Scene/state model for menus, gameplay, pause, settings, and game-over flows.
- Sprite/entity organization, collision code, coordinate system, and camera/viewport logic.
- Input model: one-shot events versus held-key state.
- Tests, headless support, and any CI constraints.
For uv projects, prefer commands like:
uv run python -m your_game
uv run pytest -q
uv run ruff check .
uv add pygame # original package
uv add pygame-ce # community fork
Do not migrate a non-uv project to uv unless asked.
Core Shape
For small and medium games, prefer a Game application object plus explicit scenes/states. Keep pygame initialization and display creation at the application boundary, not import time.
class Game:
def __init__(self) -> None:
pygame.init()
self.screen = pygame.display.set_mode((1280, 720))
self.clock = pygame.time.Clock()
self.running = True
self.scene: Scene = TitleScene(self)
def run(self) -> None:
while self.running:
dt = self.clock.tick(60) / 1000.0
events = pygame.event.get()
for event in events:
if event.type == pygame.QUIT:
self.running = False
self.scene.handle_events(events)
self.scene.update(dt)
if self.scene.next_scene is not None:
self.scene = self.scene.next_scene
self.scene.draw(self.screen)
pygame.display.flip()
pygame.quit()
A scene owns the sprites, UI, and transition decisions for one mode. Let the main loop perform scene swaps so scenes do not mutate each other directly.
class Scene:
def __init__(self) -> None:
self.next_scene: "Scene | None" = None
def handle_events(self, events) -> None: ...
def update(self, dt: float) -> None: ...
def draw(self, surface: pygame.Surface) -> None: ...
Avoid one giant loop with branches for every menu, pause, gameplay, and game-over mode.
Main Loop, Timing, and Input
- Pump events every frame with
pygame.event.get()or equivalent; otherwise the OS may mark the window unresponsive. - Use
pygame.time.Clock().tick(fps)to cap frame rate and computedtin seconds. - Use
dtfor movement, cooldowns, animation timers, and other frame-rate-sensitive behavior. - Keep loop order consistent: collect events, handle input, update simulation, draw, flip/update display.
- Draw the whole frame unless deliberately using dirty rectangles; use
display.update(rects)only when tracking dirty regions intentionally. - Clamp extreme
dtvalues after stalls/debug pauses when large jumps would break gameplay. - Keep shutdown explicit: exit the loop, call
pygame.quit(), and return an exit code frommain().
Use events for one-shot actions such as quit, pause toggles, menu selection, clicks, and jump/button presses. Use held-key state for continuous actions:
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
player.velocity.x = -PLAYER_SPEED
Avoid scattering direct keyboard checks across unrelated objects. Prefer a small input layer or pass actions to the objects that need them.
For high-volume events such as mouse motion or joystick axes, consider pygame.event.set_blocked(...) / set_allowed(...) only after measuring or seeing queue pressure. Never block QUIT or events required by the current scene/window behavior.
Sprites and Entities
Use pygame.sprite.Sprite and groups when they simplify update, draw, and collision.
class Player(pygame.sprite.Sprite):
def __init__(self, pos: pygame.Vector2, image: pygame.Surface) -> None:
super().__init__()
self.image = image
self.rect = image.get_rect(center=pos)
self.position = pygame.Vector2(self.rect.center)
self.velocity = pygame.Vector2(0, 0)
def update(self, dt: float) -> None:
self.position += self.velocity * dt
self.rect.center = round(self.position.x), round(self.position.y)
Guidelines:
- Keep float world positions separate from integer
Rects for smooth movement. - Use
pygame.Vector2for velocity, acceleration, direction, and camera math. - Use groups for batch
update(),draw(), and collision checks. - Keep entities focused. Move spawning, level loading, persistence, and global orchestration to scenes/systems.
- Prefer composition once sprite inheritance becomes deep.
Rendering and Asset Handling
Load assets once, convert them after display initialization, then reuse them.
- Do not load images, sounds, fonts, or maps inside
draw()or per-frameupdate()paths. - Use
.convert_alpha()for transparent sprites and.convert()for opaque backgrounds. - Cache fonts, rendered static text, and transformed images keyed by parameters.
- Keep original source images; do not destructively scale/rotate the only copy each frame.
- Centralize asset paths with
pathlib.Pathorimportlib.resourcesfor packaged games. - Keep UI layout separate from gameplay simulation; use
Rectalignment helpers for readable positioning.
Example resource helper:
ASSET_DIR = Path(__file__).resolve().parent / "assets"
def load_image(name: str, *, alpha: bool = True) -> pygame.Surface:
image = pygame.image.load(str(ASSET_DIR / name))
return image.convert_alpha() if alpha else image.convert()
Coordinates, Camera, and Collision
Keep world coordinates separate from screen coordinates. Sprites store world positions/rects; the camera converts to screen positions only at draw time.
class Camera:
def __init__(self, view_size: tuple[int, int]) -> None:
self.offset = pygame.Vector2(0, 0)
self.view_size = view_size
def follow(self, target: pygame.Vector2) -> None:
width, height = self.view_size
self.offset.x = target.x - width / 2
self.offset.y = target.y - height / 2
def world_view(self) -> pygame.Rect:
width, height = self.view_size
return pygame.Rect(round(self.offset.x), round(self.offset.y), width, height)
def apply(self, world_rect: pygame.Rect) -> pygame.Rect:
return world_rect.move(-round(self.offset.x), -round(self.offset.y))
Guidelines:
- Cull large worlds with
camera.world_view().colliderect(sprite.rect), not a screen-space rect at(0, 0). - Clamp the camera to level bounds when you do not want to reveal empty space.
- Pass the camera or offset into draw methods; individual sprites should not read or mutate global camera state.
- Choose the cheapest collision that fits:
Rect, circle/radius, spatial partition/tile broad phase, thenpygame.maskonly when pixel-perfect checks are necessary. - Keep collision detection separate from collision response when that improves clarity.
- Keep collision resolution deterministic and simple before adding complex physics.
For arcade games, variable timestep with capped FPS is often enough. Use a fixed-step accumulator only when deterministic physics or replay/network behavior requires it.
Audio
- Initialize the mixer deliberately if frequency, channel count, or buffer size matters.
- Load
pygame.mixer.Soundeffects once and reuse them. - Use channels when overlapping sounds need control.
- Keep streamed music separate from short sound effects.
- Avoid blocking loads during gameplay.
Testing and Headless Runs
Make core game behavior testable without opening a window:
- Extract pure logic for scoring, movement rules, cooldowns, state transitions, inventory, AI decisions, level parsing, and save/load.
- Keep pygame display/audio initialization at boundaries.
- Set dummy SDL drivers before pygame initialization when CI/headless tests need them:
import os
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
- Use
pytestfor pure logic tests. - Avoid brittle tests that depend on exact frame timing unless timing is the behavior under test.
- Remember that
.convert()/.convert_alpha()require a display surface, even if it uses the dummy video driver.
Packaging and Layout
Prefer an importable package with an explicit entry point:
project/
├── pyproject.toml
├── src/
│ └── my_game/
│ ├── __init__.py
│ ├── __main__.py
│ ├── game.py
│ ├── scenes.py
│ ├── sprites.py
│ └── assets/
└── tests/
def main() -> int:
game = Game()
game.run()
return 0
if __name__ == "__main__":
raise SystemExit(main())
Preserve existing run commands, save/data formats, and public imports while reorganizing internals.
Performance Debugging
Measure before optimizing or doing performance-driven refactors. Profiling is especially useful when touching per-frame rendering, collision, asset loading, pathfinding, or large sprite groups.
Useful tools:
- On-screen FPS overlay using
clock.get_fps(), re-rendered only when the shown value changes. cProfilefor a representative run:uv run python -m cProfile -o game.prof -m my_game.time.perf_counter()around suspect loop sections behind a debug flag.pygame.time.get_ticks()for coarse game-event timing.
Common hot spots:
- Unconverted surfaces.
- Per-frame
transform.scale/rotate/smoothscaleon unchanged inputs. - Per-frame
Font.renderfor text that rarely changes. - Pixel-perfect masks where rect/radius checks suffice.
- Linear collision scans over large worlds without broad-phase filtering.
- Asset or audio loads inside
update()/draw(). - Excessive event volume that the game never consumes.
Refactoring Moves
- Move script-level code into
main()andGame.run(). - Split
handle_events(),update(dt), anddraw(surface). - Extract scenes from giant mode conditionals one mode at a time.
- Centralize asset loading and caching, then replace ad-hoc loads file by file.
- Convert duplicated entity dictionaries into sprites, dataclasses, or focused systems.
- Replace magic numbers with named constants for screen size, tile size, speeds, layers, colors, and timings.
- Separate world state from rendering; pass
surfaceand camera/offset explicitly. - Move collision code into focused helpers or systems.
- Add pure tests around game logic before changing gameplay behavior.
For large projects, do not split a god file first. Establish seams first: single initialization boundary, explicit main loop, scene interface, asset loader, and world-versus-screen coordinate boundary. Then move modules along those seams. Verify by running the game and focused tests after each step.
Common Antipatterns
Avoid:
- Calling
pygame.init()ordisplay.set_mode()at import time. - Forgetting to process events or running a busy loop without
Clock.tick(). - Loading assets, creating fonts, rendering unchanged text, or transforming unchanged images every frame.
- Keeping all game logic in one giant file or one giant loop.
- Using mutable globals for game state that scenes/entities should own.
- Mixing input, physics, drawing, asset loading, persistence, and scene transitions in one function.
- Using integer rect positions as the only positions for smooth moving entities.
- Using pixel-perfect masks for everything.
- Blocking on file/network operations during gameplay.
- Letting entities call
pygame.displaydirectly instead of drawing to the surface passed in. - Catching all exceptions in the main loop and continuing with corrupted state.
Review Checklist
Before reporting back:
- The main loop processes events, updates, draws, flips/updates display, and caps FPS.
- Assets and expensive transforms are outside per-frame paths or cached.
- Movement/timers use clear units and
dtwhere appropriate. - Scenes/states are explicit enough for the game's complexity.
- Display/audio initialization happens at the boundary.
- World and screen coordinates are not accidentally mixed.
- Core logic can be tested without a visible window when practical.
- uv or other project tooling was respected.
More from jwd83/skills
system-verilog-expert
Expert SystemVerilog guidance for RTL, interfaces, and verification-adjacent code with EDA-friendly design patterns. Use when designing, reviewing, or refactoring hardware-oriented SystemVerilog and when spotting HDL antipatterns that cause timing, lint, CDC, or tool-flow pain.
11simplify
Simplifies and refines code for clarity, consistency, and maintainability while preserving all functionality. Focuses on recently modified code unless instructed otherwise.
10grill-me
Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me".
9wiki-me
Build or extend a project wiki — a persistent, interlinked markdown knowledge base that the LLM incrementally maintains from your sources. Use when the user wants to start a wiki, add new sources, or synthesize notes into a living knowledge base rather than one-off RAG.
9godot-gdscript-patterns
Master Godot 4 GDScript patterns including signals, scenes, state machines, and optimization. Use when building Godot games, implementing game systems, or learning GDScript best practices.
9next-step
Resume an in-progress codebase from its actual current state, identify the next smallest defensible implementation step, make that change, and update tests, docs, or plans to match. Use when a rewrite, migration, refactor, feature, or cleanup is underway and you want the agent to move the work forward safely without inventing a whole new roadmap.
6