How to consistently scale a pygame application to full screen

This post may be helpful to anyone making a full screen pygame application who wants to keep a consistent aspect ratio across different monitor resolutions.

What's the Problem?

While working on a game for the recent lowrez game jam I noticed pygame exhibiting some strange behaviour. My game ran correctly in window mode but when I switched to full screen the aspect ratio wasn't right. My square game window hadn't maintained it's original aspect ratio at full screen (which would have created black bars at the side of a square game window), nor had it been fully stretched to take up all of my widescreen monitor. It had done something in-between, creating black bars at the sides of my game that were an incorrect size.

To help investigate the problem, I wrote a very basic python script that uses pygame to draw a square in the top left area of the screen. I found that 16:9 monitors seemed to display a range of resolutions correctly while monitors with other aspect ratios (mine is 16:10) displayed rectangles instead of squares. (This was tested across windows and linux mint and several completely different monitors and machines running a mix of AMD and NVIDIA graphics cards).

monitor image 2nd monitor image

These images are the result of the following function.

def fullscreen(game_height):
    """ Sets full screen display mode and draws a square in the top left """
    # game_height = game_width in a square
    screen = pygame.display.set_mode((game_height, game_height), FULLSCREEN)
    screen.fill((255, 255, 255))  # fill white
    pygame.draw.rect(
        screen,  # surface
        (0, 0, 0),  # rgb (black)
        Rect(10, 10, 200, 200))  # (x, y, width, height)
    pygame.display.flip()
    wait_for_keypress()

Generally I've found that pygame's default full-screen option doesn't reliably maintain your game's aspect ratio and may do something unpredictable (we should have squares in the images above and instead we have rectangles). The first monitor detects a 1280x1024 resolution which isn't native but should be supported by the monitor (pygame.display.list_modes() reports that 1280x1024 is a supported mode and windows 10 appears correctly on this monitor if set to that resolution).

There are other limitations to using pygame's default full-screen behavior, as it seems limited in its ability to stretch resolutions up to a factor of 2. If you make a game and want to support the popular laptop screen resolution 1366x768 then you won't want to make your game greater than 768 pixels high. But you can't scale that all the way to 4k (2160 pixels high) using pygame's default full-screen functionality.

When we distribute a game, we want to know our application's aspect ratio is going to be correctly maintained for a range of different set-ups and displays so we need a method to produce a consistent game experience for all our players.

Solution

One solution is to use pygame's scale method to scale our game surface up to the largest square that will fit on the monitor and then blit this surface to the correct location on a native resolution screen object.

def fullscreen_fix(game_height):
    """ Sets full screen display mode and draws a square in the top left """
    # Set the display mode to the current screen resolution
    screen = pygame.display.set_mode((0, 0), FULLSCREEN)

    # create a square pygame surface
    game_surface = pygame.Surface((game_height, game_height))
    game_surface.fill((255, 255, 255))

    # draw a square in the top left
    pygame.draw.rect(game_surface, (0, 0, 0), Rect(10, 10, 200, 200))

    # make the largest square surface that will fit on the screen
    screen_width = screen.get_width()
    screen_height = screen.get_height()
    smallest_side = min(screen_width, screen_height)
    screen_surface = pygame.Surface((smallest_side, smallest_side))

    # scale the game surface up to the larger surface
    pygame.transform.scale(
        game_surface,  # surface to be scaled
        (smallest_side, smallest_side),  # scale up to (width, height)
        screen_surface)  # surface that game_surface will be scaled onto

    # place the larger surface in the centre of the screen
    screen.blit(
        screen_surface,
        ((screen_width - smallest_side) // 2,  # x pos
        (screen_height - smallest_side) // 2))  # y pos

    pygame.display.flip()
    wait_for_keypress()

working monitor image

Now we've maintained our game resolution and aspect ratio (our code draws squares and our monitor is showing squares).

This method should work for all resolutions and screen orientations (landscape or portrait). Testing this with a few games I've written, the scaling takes around 2ms each frame on my modest machine (in a 60fps game, you have 16.67ms per frame).

The code snippets used in this post are available in full at this gist.