Quick answer: Pass pygame.SCALED | pygame.RESIZABLE to set_mode. Pygame handles letterboxing automatically and your game keeps its aspect ratio at any window size.
A pixel-art game looks crisp at 640×360. The player drags the window to fullscreen 1920×1080 and the art stretches horizontally; the player’s sprite becomes a wide rectangle. The internal coordinates still use 640×360 but the output buffer is now native resolution, distorted to fit.
The SCALED Flag
Modern Pygame supports the SCALED flag, which decouples logical resolution from window resolution:
import pygame
pygame.init()
screen = pygame.display.set_mode(
(640, 360),
pygame.SCALED | pygame.RESIZABLE | pygame.DOUBLEBUF,
vsync=1
)
Internally, your draws still target a 640×360 surface. SDL scales the result up to whatever the actual window size is, preserving aspect ratio by letterboxing (horizontal bars top/bottom) or pillarboxing (vertical bars left/right). No code changes required in your draw loop.
Custom Scaling Logic
If you need control over the scaling math — for example, integer-only scaling for crisp pixel art — render to a fixed surface and blit it manually:
BASE = (640, 360)
internal = pygame.Surface(BASE)
window = pygame.display.set_mode((1280, 720), pygame.RESIZABLE)
def on_resize(new_size):
global window
window = pygame.display.set_mode(new_size, pygame.RESIZABLE)
def present():
# Compute integer scale factor
scale = min(window.get_width() // BASE[0], window.get_height() // BASE[1])
scale = max(1, scale)
sw, sh = BASE[0] * scale, BASE[1] * scale
# Center the scaled surface
x = (window.get_width() - sw) // 2
y = (window.get_height() - sh) // 2
window.fill((0, 0, 0))
scaled = pygame.transform.scale(internal, (sw, sh))
window.blit(scaled, (x, y))
pygame.display.flip()
Integer scaling avoids the blurry interpolation of fractional scales. At 1280×720, scale = 2 (640→1280). At 1920×1080, scale = 3 (640→1920 with letterbox). At 1700×900, scale = 2 with larger letterbox. Pixel art stays crisp.
Mouse Input Coordinates
When you scale the output, the OS mouse position is in window coordinates, not internal coordinates. Translate before processing:
def window_to_internal(pos):
scale = min(window.get_width() // BASE[0], window.get_height() // BASE[1]) or 1
sw, sh = BASE[0] * scale, BASE[1] * scale
x = (window.get_width() - sw) // 2
y = (window.get_height() - sh) // 2
mx = (pos[0] - x) // scale
my = (pos[1] - y) // scale
return (mx, my)
With SCALED on, Pygame does this translation automatically — you can read mouse positions in the internal coordinate system directly. That’s the strongest argument for using SCALED over manual scaling.
Fullscreen With Letterbox
For fullscreen mode with proper letterboxing:
screen = pygame.display.set_mode(
(640, 360),
pygame.SCALED | pygame.FULLSCREEN,
vsync=1
)
The OS goes to native resolution; Pygame scales 640×360 into the center with bars on the sides.
Verifying
Draw a circle at the center of your internal surface. Resize the window to several sizes — 1280×720, 1920×1080, 800×600. The circle should stay circular and centered. If it elongates, the scaling isn’t aspect-preserving and SCALED isn’t in effect.
“SCALED + RESIZABLE turns a fixed-resolution Pygame game into a window-friendly one with three extra characters of code.”
For pixel art games specifically, the manual integer-scale path gives a sharper result than SCALED’s default linear filter.