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.