Raspberry Pi Pygame lets you build 2D games in Python using the SDL2-backed Pygame library. Pygame handles the display window, event loop, sprite drawing, collision detection, and audio. All the scaffolding a game needs to let you focus on the game logic. On Bookworm, python3-pygame installs via APT. This guide covers the correct Bookworm install path, the complete game loop structure with frame rate control, keyboard and GPIO input handling, sprite groups and collision detection, and how to deploy a Pygame app full-screen as a kiosk. For related GPIO work in Python, see Python Raspberry Pi: Complete Practical Setup and Usage Guide. For building a GUI-based app rather than a game, see Raspberry Pi Tkinter: Building GUIs and GPIO-Connected Apps.
Last tested: Raspberry Pi OS Bookworm Desktop 64-bit | May 2025 | Raspberry Pi 4 Model B (4GB) | Python 3.11, Pygame 2.1.2, gpiozero 2.0
Key Takeaways
python3-pygameis not guaranteed pre-installed on Raspberry Pi OS Bookworm. Runsudo apt install python3-pygamebefore writing your first script. The package is in the standard APT repository and installs Pygame 2.x with SDL2 support on Pi 4 and Pi 5.- Every Pygame game loop must call
clock.tick(FPS)at the end of each frame. Without it the game runs as fast as the CPU allows, consumes 100% of one core, and produces inconsistent timing across different Pi models. Set FPS to 30 or 60 for most Pi 4 projects. - Pygame is a 2D library. Statements that Pygame “supports 3D rendering” without qualification are misleading. Pygame draws 2D surfaces with SDL2. Achieving 3D visuals requires a projection library or PyOpenGL alongside Pygame; Pygame itself has no 3D rendering pipeline.
Installing Raspberry Pi Pygame on Bookworm
Install Pygame via APT. This installs the system-wide Pygame package built against SDL2:
sudo apt update && sudo apt install -y python3-pygame
Verify the install:
python3 -c "import pygame; print(pygame.ver)"
Expected result: The command prints the Pygame version number (2.1.x on current Bookworm). If it prints an error, the install did not complete. Re-run the install command and check for APT errors.
If additional Python packages are needed alongside Pygame (NumPy, Pillow, etc.), use a virtual environment to avoid Bookworm’s PEP 668 restrictions on pip:
python3 -m venv ~/game-env
source ~/game-env/bin/activate
pip install numpy pillow # or any other packages
# python3-pygame from APT is available system-wide regardless of venv
Pygame itself installs cleanly from APT without a venv. Only packages installed via pip on Bookworm require a virtual environment.

The Pygame Game Loop on Raspberry Pi
Every Pygame program has the same three-part structure: initialise and create the window, run the game loop, then quit cleanly. The game loop runs continuously and on each iteration it processes events, updates game state, draws the frame, and caps the frame rate. Here is a complete minimal example with a moving square:
import pygame
import sys
# Initialise
pygame.init()
WIDTH, HEIGHT = 800, 600
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Pi Pygame Demo")
clock = pygame.Clock()
FPS = 60
# Game state
square_x = 100
square_y = 100
square_speed = 4
SQUARE_SIZE = 40
BLACK = (0, 0, 0)
GREEN = (0, 200, 80)
# Game loop
running = True
while running:
# 1. Process events
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
# 2. Update game state
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
square_x -= square_speed
if keys[pygame.K_RIGHT]:
square_x += square_speed
if keys[pygame.K_UP]:
square_y -= square_speed
if keys[pygame.K_DOWN]:
square_y += square_speed
# Keep square on screen
square_x = max(0, min(WIDTH - SQUARE_SIZE, square_x))
square_y = max(0, min(HEIGHT - SQUARE_SIZE, square_y))
# 3. Draw
screen.fill(BLACK)
pygame.draw.rect(screen, GREEN,
(square_x, square_y, SQUARE_SIZE, SQUARE_SIZE))
# 4. Flip and tick
pygame.display.flip()
clock.tick(FPS)
pygame.quit()
sys.exit()
Expected result: A black 800×600 window opens with a green square. The arrow keys move it. Escape or clicking the window close button exits cleanly. If the window does not appear over SSH, set the display: DISPLAY=:0 python3 demo.py. If the square moves erratically or the frame rate feels wrong, confirm clock.tick(FPS) is inside the loop.
Two display update functions exist and both work: pygame.display.flip() updates the entire screen at once and is correct for most games. pygame.display.update(rect) updates only a specific rectangle region and is faster when only a small portion of the screen changes each frame, but adds complexity. Use flip() unless profiling shows it is a bottleneck.
Handling Input: Keyboard, Mouse, and GPIO on Raspberry Pi Pygame
Pygame handles two categories of input: event-based (fires once when a key is pressed or released) and state-based (checked every frame for held keys).
Event-based input uses pygame.event.get() inside the loop. Use it for one-shot actions like firing a weapon, pausing, or toggling a menu:
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
fire_bullet()
if event.key == pygame.K_p:
toggle_pause()
if event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 1: # left click
pos = pygame.mouse.get_pos()
on_click(pos)
State-based input uses pygame.key.get_pressed() outside the event loop, checked every frame. Use it for continuous movement:
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
player.x -= SPEED
if keys[pygame.K_RIGHT]:
player.x += SPEED
GPIO buttons as game input. Physical buttons connected to GPIO pins work as game controllers using gpiozero. Wire a button between GPIO17 and GND (no external resistor needed; gpiozero enables the internal pull-up). Poll the button state each frame:
from gpiozero import Button
btn_left = Button(17)
btn_right = Button(27)
btn_fire = Button(22)
# Inside game loop:
if btn_left.is_pressed:
player.x -= SPEED
if btn_right.is_pressed:
player.x += SPEED
if btn_fire.is_pressed:
fire_bullet()
Expected result: Button presses move the player or trigger actions exactly as keyboard input does. If a button fires multiple times per press, debounce it with gpiozero’s Button(17, bounce_time=0.05). If no response, verify the GPIO pin number against the Pi’s 40-pin header and confirm gpiozero is installed with python3 -c "import gpiozero".
Sprites, Collision Detection, and Frame Rate
Pygame’s Sprite class and Group containers handle the draw-update pattern cleanly for games with multiple moving objects. Each sprite has an image (its surface) and a rect (its position and size). The group calls update() on all sprites each frame and draw(screen) blits them all at once.
class Ball(pygame.sprite.Sprite):
def __init__(self, x, y):
super().__init__()
self.image = pygame.Surface((20, 20))
self.image.fill((255, 80, 0))
self.rect = self.image.get_rect(center=(x, y))
self.vel_x = 4
self.vel_y = 3
def update(self):
self.rect.x += self.vel_x
self.rect.y += self.vel_y
# Bounce off walls
if self.rect.left <= 0 or self.rect.right >= WIDTH:
self.vel_x *= -1
if self.rect.top <= 0 or self.rect.bottom >= HEIGHT:
self.vel_y *= -1
# Create group and sprite
all_sprites = pygame.sprite.Group()
ball = Ball(400, 300)
all_sprites.add(ball)
# In game loop:
all_sprites.update()
screen.fill(BLACK)
all_sprites.draw(screen)
pygame.display.flip()
clock.tick(FPS)
Collision detection between two sprites uses pygame.sprite.collide_rect(), which checks if their rectangles overlap:
if pygame.sprite.collide_rect(ball, paddle):
ball.vel_y *= -1 # bounce off paddle
# Or detect any collision between a sprite and a group:
hits = pygame.sprite.spritecollide(ball, enemy_group, dokill=True)
# dokill=True removes hit enemies from the group
Frame rate on Pi hardware. Pi 4 runs a simple 2D Pygame game at 60 FPS comfortably for most projects. Pi Zero 2W handles 30 FPS with moderate sprite counts. Pi 5 has no trouble with 60 FPS for anything short of hundreds of sprites with per-pixel collision. Always use clock.tick(FPS). Profile with clock.get_fps() to measure actual frame rate during development:
actual_fps = clock.get_fps()
fps_text = font.render(f"FPS: {actual_fps:.0f}", True, (255, 255, 255))
screen.blit(fps_text, (10, 10))
Expected result: Sprites update and draw correctly each frame. Collisions trigger the expected response. The FPS counter displays actual frame rate. If frame rate drops below target, reduce sprite count, lower resolution, or replace per-pixel collision detection (expensive) with rectangle collision detection. For a full working game project combining Pygame with GPIO input, see Raspberry Pi Robot Basics: The Complete Beginners Guide for the GPIO wiring patterns that complement a Pygame controller.
FAQ
Is Pygame pre-installed on Raspberry Pi OS Bookworm?
Not reliably. On some Raspberry Pi OS Desktop images, python3-pygame may be present, but it should not be assumed. Always run sudo apt install python3-pygame before your first project. Verify the install with python3 -c "import pygame; print(pygame.ver)". The APT package installs Pygame 2.x with SDL2 support and works on Pi 4, Pi 5, and Pi Zero 2W on Bookworm.
What frame rate should I target for Raspberry Pi Pygame games?
60 FPS for Pi 4 and Pi 5 with a moderate number of sprites. 30 FPS for Pi Zero 2W or Pi 3 where CPU headroom is limited. Always call clock.tick(FPS) in the game loop to cap the frame rate regardless of hardware. Without the cap, the game loop runs as fast as the CPU allows and consumes 100% of one core. Use clock.get_fps() during development to measure actual performance and adjust the target accordingly.
Can Raspberry Pi Pygame use physical GPIO buttons as input?
Yes. Import gpiozero alongside Pygame and poll button.is_pressed inside the game loop each frame. Wire buttons between GPIO pins and GND; gpiozero enables the internal pull-up resistor by default. Add bounce_time=0.05 to the Button constructor if a single press registers multiple times. This approach works cleanly alongside keyboard and mouse input, so keyboard controls and physical buttons can coexist in the same game.
What is the difference between pygame.display.flip() and pygame.display.update()?
pygame.display.flip() refreshes the entire screen surface at once. pygame.display.update(rect) refreshes only the specified rectangular region, which is faster when only a small part of the screen changes per frame. For most Pi games, flip() is correct and simpler. Use update(rect) only if profiling shows full-screen flip is a performance bottleneck, which is rare at 60 FPS on Pi 4.
Can Raspberry Pi Pygame run full-screen on a touchscreen or kiosk display?
Yes. Pass pygame.FULLSCREEN as a flag to pygame.display.set_mode(): screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN). Using (0, 0) as the resolution automatically uses the display’s native resolution. For a touchscreen kiosk, hide the mouse cursor with pygame.mouse.set_visible(False). Add touch input handling via pygame.MOUSEBUTTONDOWN events, which SDL2 maps from touch events on supported displays including the official Raspberry Pi 7-inch DSI touchscreen.
References:
- Pygame documentation: pygame.org/docs
- Pygame GitHub: github.com/pygame/pygame
- gpiozero documentation: gpiozero.readthedocs.io
- SDL2 documentation: wiki.libsdl.org/SDL2
- Python venv documentation: docs.python.org/3/library/venv
About the Author
Chuck Wilson has been programming and building with computers since the Tandy 1000 era. His professional background includes CAD drafting, manufacturing line programming, and custom computer design. He runs PidiyLab in retirement, documenting Raspberry Pi and homelab projects that he actually deploys and maintains on real hardware. Every article on this site reflects hands-on testing on specific hardware and OS versions, not theoretical walkthroughs.
Last tested hardware: Raspberry Pi 4 Model B (4GB). Last tested OS: Raspberry Pi OS Bookworm Desktop 64-bit. Python 3.11, Pygame 2.1.2, gpiozero 2.0.

