Quick answer: A fade loop that doesn’t pump events never sees QUIT. Inside any blocking sequence, call pygame.event.get() and honor QUIT — or restructure as state machine ticks in your main loop.
A 1-second fade-to-black is implemented as a tight for-loop with sleeps. During the fade, clicking the window’s X does nothing — QUIT never reaches your handler.
The OS Needs You to Pump
Pygame events arrive when you call event.get / event.pump. A blocking inner loop that just renders + sleeps starves the queue; QUIT and other events pile up unread.
Pump Inside the Inner Loop
for alpha in range(255, -1, -5):
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit(); sys.exit()
draw_fade(alpha)
pygame.display.flip()
clock.tick(60)
Honor QUIT (and any input you care about) inside every blocking loop — not just the main one.
Better: One Main Loop
Restructure the fade as a state in the main loop — track fade_alpha and decrement each frame. The main loop’s existing event handling covers QUIT for free. No inner loops to remember to pump.
Verifying
Start a fade. Press the window close button mid-fade — the game quits immediately. Same for ESC or any other quit shortcut you handle.
“Inner loops that don’t pump events become event black holes. Pump inside, or fold the loop into the main tick.”
A single main loop with state-driven transitions is almost always cleaner than nested inner loops — and never has the ‘can’t quit during X’ bug.