Quick answer: pygame.display.toggle_fullscreen() recreates the SDL2 graphics context on Windows. Any Surface that was wrapped with convert() or convert_alpha() is now backed by an invalid pixel format and renders blank. Replace the toggle with an explicit set_mode(), re-convert every cached surface from a registry, and consider switching new projects to pygame.SCALED | pygame.NOFRAME so the logical surface never changes.

You add an F11 hotkey that calls pygame.display.toggle_fullscreen(). It works the first time on Linux and the team says ship it. Two weeks later a Windows playtester reports that pressing F11 turns the entire game into a black screen with the HUD still visible, or worse, makes the player sprite invisible while the world still renders. You toggle back to windowed and most things return — but a few specific images stay broken until the player restarts. The mode toggle silently invalidated the surfaces those images were drawing into, and pygame did not warn you because, technically, the surfaces still exist.

The Symptom

After calling pygame.display.toggle_fullscreen() you see one or more of:

Blank textures. Specific sprites, UI elements, or background tiles render as solid black, solid white, or transparent. The geometry is correct (you can see them being drawn at the right position) but the pixels are missing.

Crashed alpha. Sprites that previously had transparent backgrounds now render with a colored rectangle around them. The convert_alpha format is no longer compatible with the new screen.

Stale screen reference. Code that holds a reference to the surface returned by an earlier set_mode() call is now drawing into a surface that the display never reads. Nothing appears at all, even though the draw calls succeed.

Crash on the next blit. A handful of pygame builds segfault rather than render blank when blitting from an invalidated surface. You see no Python traceback — just a process exit.

What Causes This

Context recreation. SDL2’s fullscreen toggle on Windows tears down the GL or DirectX context and creates a new one to match the fullscreen device. Every texture and surface tied to the old context becomes a dangling pointer at the SDL level. Pygame surfaces wrap these textures, so anything you converted into the screen’s pixel format with Surface.convert() or Surface.convert_alpha() now points at a format that no longer exists. The Python objects survive, but their pixel buffers are unusable.

The screen object changes. pygame.display.set_mode() always returns a new Surface. toggle_fullscreen(), on platforms where it works, mutates the surface in place — but on platforms where it actually recreates the window, the previous return value of set_mode() becomes stale. Code like self.screen = pygame.display.set_mode(...) followed by pygame.display.toggle_fullscreen() may leave self.screen pointing at an obsolete object. Calls to screen.blit(...) succeed silently and write nothing visible.

Display.get_surface lies. After a context recreation, pygame.display.get_surface() returns the new surface, but if you cached the old one, you keep using the wrong reference. There is no warning — pygame trusts you to know which surface you are drawing into.

convert_alpha is the worst offender. convert() alone often survives the toggle because the format is simple. convert_alpha() binds to the screen’s alpha format, which is more sensitive to changes. When the new screen has a different alpha layout, the converted surface produces garbage.

The Fix

Step 1: Drop toggle_fullscreen and use set_mode explicitly. The toggle was a convenience wrapper that hides too much state. Call set_mode() yourself with the flags you want. This gives you a clean handoff point where you know the screen has been recreated and you must re-convert assets.

import pygame

class DisplayManager:
    def __init__(self, size=(1280, 720)):
        self.size = size
        self.fullscreen = False
        self.surfaces = []  # registry for re-convert
        self.screen = pygame.display.set_mode(
            size, pygame.RESIZABLE | pygame.DOUBLEBUF)

    def toggle(self):
        self.fullscreen = not self.fullscreen
        flags = pygame.DOUBLEBUF
        flags |= pygame.FULLSCREEN if self.fullscreen \
                                   else pygame.RESIZABLE

        # Recreate the screen explicitly
        self.screen = pygame.display.set_mode(
            self.size, flags)

        # Re-convert every cached surface
        for entry in self.surfaces:
            entry.refresh(self.screen)

This pattern makes the recreation explicit. Anyone reading the code can see that the screen object is replaced and that surfaces need to refresh.

Step 2: Track every converted surface. Keep a registry of every Surface that was converted to the screen’s format. After a mode change, re-convert each one from its source bytes.

class ManagedImage:
    def __init__(self, path, alpha=True):
        self.path = path
        self.alpha = alpha
        self.raw = pygame.image.load(path)
        self.surface = self._convert()

    def _convert(self):
        return self.raw.convert_alpha() \
            if self.alpha else self.raw.convert()

    def refresh(self, screen):
        # Called after set_mode — rebuild from raw bytes
        self.surface = self._convert()

Holding the raw pygame.image.load() result means you always have a source of truth that does not depend on the current display format. The cost is roughly 2× memory per image, which is negligible for indie games but worth noting if you ship hundreds of textures.

The Modern Approach: SCALED + NOFRAME

For projects that have not yet shipped, the cleanest fix is to stop fighting the toggle entirely. pygame.SCALED renders your game into a logical surface at the resolution you specified, and SDL handles scaling that surface to the actual window size. The logical surface stays valid across mode changes — only the scaling factor changes — so you never have to re-convert anything.

Combine SCALED with NOFRAME for borderless windowed fullscreen, which behaves better than exclusive fullscreen on modern Windows anyway. Borderless fullscreen lets the player Alt-Tab without a mode change, plays nicer with multiple monitors, and avoids the GL context recreation that triggers this whole bug.

The pattern is simple at startup: call set_mode(logical_size, SCALED) and never call toggle_fullscreen() again. To go “fullscreen” on user request, swap to SCALED | FULLSCREEN | NOFRAME with the same logical size. The display surface object stays the same, your converted assets stay valid, and your game scales to fit the new viewport with no extra work.

Verifying the Fix

To reproduce the bug reliably during testing, toggle fullscreen at least three times in succession on a Windows machine while drawing a known sprite that uses convert_alpha. If the sprite stays visible and correct after every toggle, your registry is working. If it goes blank after the second or third toggle, you missed a surface in the registry — usually one created lazily during gameplay rather than at load time.

Add an assert in your debug build that walks the surfaces list and confirms each one’s pixel format matches the current display surface format. Mismatches indicate a surface that escaped the registry. Common culprits are surfaces created by pygame.font.Font.render(), surfaces returned by Surface.subsurface(), and surfaces blitted from third-party libraries like pygame_gui.

“Pygame surfaces are not garbage collected when they become invalid — they just stop drawing. The Python object survives, the SDL pointer does not, and you find out about it the first time a player presses F11.”

Related Issues

If your fullscreen window opens at the wrong resolution rather than going blank, see Pygame Fullscreen Resolution Wrong Size. If toggling causes the OS taskbar to appear over your game, you are probably missing the NOFRAME flag or hitting a Windows DPI scaling issue.

Treat every set_mode call as a context loss event — re-convert everything that touches the screen format.