Flappy Bird Game in Python

I first played Flappy Bird on my phone and immediately wanted to build it myself. The original game is deceptively simple – tap to flap, avoid pipes, rack up a score. But once I started building it with Python and Pygame, I realised the mechanics are actually a great exercise in game loop design, sprite rendering, and basic physics simulation.

In this tutorial, I will walk through building a fully playable Flappy Bird clone from scratch. You will learn how to structure a game with multiple screens (welcome and gameplay), handle real-time input, detect collisions, and track scores – all using Python and Pygame.

Flappy Bird game in Python with Pygame

TLDR

  • Pygame handles game window creation, event loops, rendering, and collision detection
  • The game uses four key Python functions: welcomeScreen, mainGame, isCollide, and getRandomPipe
  • Collision detection checks screen boundaries and pipe rectangles each frame
  • Score increments when the bird crosses the midpoint of a pipe pair
  • Game assets (images, sounds) are loaded once at startup and stored in dictionaries

Building Flappy Bird Game in Python

1. Importing Modules

The first step is importing the necessary Python modules — see the random module guide for details. The random module generates pipe heights, sys provides exit() for a clean shutdown, and Pygame handles all the graphics and audio.


import random
import sys
import pygame
from pygame.locals import *

pygame.locals imports constants like KEYDOWN and QUIT directly into the namespace, keeping the code cleaner.


Output: Module imports produce no visible output but make pygame and supporting modules available.

2. Global Variables

These variables define the game window dimensions, frame rate, and the ground position. The game uses a fixed 289×511 pixel canvas – the original Flappy Bird mobile resolution scaled up slightly.


fps = 32
screen_width = 289
screen_height = 511
screen = pygame.display.set_mode((screen_width, screen_height))
ground_y = int(screen_height * 0.8)
game_images = {}
game_sounds = {}
player_path = 'gallery/images/bird.png'
background_path = 'gallery/images/background.png'
pipe_path = 'gallery/images/pipe.png'
title_path = 'gallery/images/title.png'
message_path = 'gallery/images/message.png'
base_path = 'gallery/images/base.png'

pygame.display.set_mode() creates the window. ground_y is set to 80% of the screen height, leaving room for the base image. All images and sounds are stored in dictionaries for easy access during the game loop.


Output: No output — this just defines constants. Attempting to run this as a standalone script produces no visible result.

3. The __main__ Function

The entry point initialises Pygame, loads all assets, and kicks off the game loop that alternates between the welcome screen and the main game.


if __name__ == "__main__":
    pygame.init()
    fps_clock = pygame.time.Clock()
    pygame.display.set_caption('Flappy Bird')

    game_images['numbers'] = (
        pygame.image.load('gallery/images/0.png').convert_alpha(),
        pygame.image.load('gallery/images/1.png').convert_alpha(),
        pygame.image.load('gallery/images/2.png').convert_alpha(),
        pygame.image.load('gallery/images/3.png').convert_alpha(),
        pygame.image.load('gallery/images/4.png').convert_alpha(),
        pygame.image.load('gallery/images/5.png').convert_alpha(),
        pygame.image.load('gallery/images/6.png').convert_alpha(),
        pygame.image.load('gallery/images/7.png').convert_alpha(),
        pygame.image.load('gallery/images/8.png').convert_alpha(),
        pygame.image.load('gallery/images/9.png').convert_alpha(),
    )

    game_images['message'] = pygame.image.load(message_path).convert_alpha()
    game_images['base'] = pygame.image.load(base_path).convert_alpha()
    game_images['pipe'] = (
        pygame.image.load(pipe_path).convert_alpha(),
        pygame.transform.rotate(pygame.image.load(pipe_path).convert_alpha(), 180),
    )
    game_images['background'] = pygame.image.load(background_path).convert()
    game_images['player'] = pygame.image.load(player_path).convert_alpha()
    game_images['title'] = pygame.image.load(title_path).convert_alpha()

    game_sounds['point'] = pygame.mixer.Sound('gallery/sounds/point.wav')
    game_sounds['hit'] = pygame.mixer.Sound('gallery/sounds/hit.wav')
    game_sounds['wing'] = pygame.mixer.Sound('gallery/sounds/wing.wav')

    while True:
        welcomeScreen()
        mainGame()

pygame.init() starts all Pygame modules. The number sprites (0-9) are loaded as a tuple and stored under the “numbers” key – this makes drawing the score on screen straightforward. Pipes are stored in a tuple as a normal image and a 180-degree rotated version for the lower pipe. The while True loop keeps the game running, calling welcomeScreen and then mainGame repeatedly until the player quits.


Output: No output — this sets up the game. The window opens showing the welcome screen.

4. The welcomeScreen Function

This function shows the title screen and waits for the player to press either the UP arrow or SPACE. It runs in a loop until a key is pressed, then plays the wing sound and returns to let the main loop start the game.


def welcomeScreen():
    player_x = int(screen_width / 8)
    player_y = int((screen_height - game_images['player'].get_height()) / 2)
    message_x = int((screen_width - game_images['message'].get_width()) / 2)
    message_y = int(screen_height * 0.2)
    title_x = int((screen_width - game_images['title'].get_width()) / 2)
    title_y = int(screen_height * 0.04)
    base_x = 0

    while True:
        for event in pygame.event.get():
            if event.type == QUIT or (event.type == KEYDOWN and event.key == K_ESCAPE):
                pygame.quit()
                sys.exit()
            if event.type == KEYDOWN and (event.key == pygame.K_UP or event.key == pygame.K_SPACE):
                game_sounds['wing'].play()
                return

        screen.blit(game_images['background'], (0, 0))
        screen.blit(game_images['message'], (message_x, message_y))
        screen.blit(game_images['player'], (player_x, player_y))
        screen.blit(game_images['base'], (base_x, ground_y))
        pygame.display.update()
        fps_clock.tick(fps)

The while True loop keeps the welcome screen rendering at 32 FPS. pygame.event.get() retrieves all events since the last frame. When the player presses UP or SPACE, the wing sound plays and the function returns – control passes back to the __main__ loop which then calls mainGame(). screen.blit() draws each asset to the screen surface in order (background first, then message, player, and base on top).


Output: The welcome screen shows the title, the flappy bird logo, the bird sprite at rest, and the ground base image.

5. The mainGame Function

This is the core game loop. It tracks player position, moves pipes, checks collisions, updates the score, and renders everything every frame until the player crashes.


def mainGame():
    score = 0
    player_x = int(screen_width / 8)
    player_y = int(screen_height / 2)
    base_x = 0

    newPipe1 = getRandomPipe()
    newPipe2 = getRandomPipe()

    upperPipes = [
        {'x': screen_width + 200, 'y': newPipe1[0]['y']},
        {'x': screen_width + 200 + (screen_width // 2), 'y': newPipe2[0]['y']}
    ]
    lowerPipes = [
        {'x': screen_width + 200, 'y': newPipe1[1]['y']},
        {'x': screen_width + 200 + (screen_width // 2), 'y': newPipe2[1]['y']}
    ]

    pipeVelX = -4
    playerVelY = -9
    playerMaxVelY = 10
    playerAccY = 1
    playerFlapVel = -8
    playerFlapped = False

    while True:
        for event in pygame.event.get():
            if event.type == QUIT or (event.type == KEYDOWN and event.key == K_ESCAPE):
                pygame.quit()
                sys.exit()
            if event.type == KEYDOWN and (event.key == pygame.K_UP or event.key == pygame.K_SPACE):
                if player_y > 0:
                    playerVelY = playerFlapVel
                    playerFlapped = True
                    game_sounds['wing'].play()

        crashed = isCollide(player_x, player_y, upperPipes, lowerPipes)
        if crashed:
            game_sounds['hit'].play()
            return

        playerMidPos = player_x + game_images['player'].get_width() / 2
        for pipe in upperPipes:
            pipeMidPos = pipe['x'] + game_images['pipe'][0].get_width() / 2
            if pipeMidPos <= playerMidPos < pipeMidPos + 4:
                score += 1
                game_sounds['point'].play()

        if playerVelY < playerMaxVelY:
            playerVelY += playerAccY

        player_y += playerVelY

        for upperPipe, lowerPipe in zip(upperPipes, lowerPipes):
            upperPipe['x'] += pipeVelX
            lowerPipe['x'] += pipeVelX

        if 0 < upperPipes[0]['x'] < 5:
            newPipe = getRandomPipe()
            upperPipes.append(newPipe[0])
            lowerPipes.append(newPipe[1])

        if upperPipes[0]['x'] < -game_images['pipe'][0].get_width():
            upperPipes.pop(0)
            lowerPipes.pop(0)

        screen.blit(game_images['background'], (0, 0))
        for upperPipe, lowerPipe in zip(upperPipes, lowerPipes):
            screen.blit(game_images['pipe'][0], (upperPipe['x'], upperPipe['y']))
            screen.blit(pygame.transform.rotate(game_images['pipe'][0], 180), (lowerPipe['x'], lowerPipe['y']))
        screen.blit(game_images['base'], (base_x, ground_y))
        screen.blit(game_images['player'], (player_x, player_y))

        digits = list(str(score))
        width = sum(game_images['numbers'][int(d)].get_width() for d in digits)
        Xoffset = (screen_width - width) / 2
        for d in digits:
            screen.blit(game_images['numbers'][int(d)], (Xoffset, screen_height * 0.01))
            Xoffset += game_images['numbers'][int(d)].get_width()

        pygame.display.update()
        fps_clock.tick(fps)

Player velocity starts at -9 (upward) and increases by 1 each frame (gravity). When the player presses UP or SPACE, velocity is set to -8 (a flap), giving a small upward boost. The isCollide() function returns True if the bird hits a pipe or screen boundary, ending the game. Pipes move left at 4 pixels per frame. The score increments when the bird passes a pipe’s midpoint. New pipes are appended when the first pipe reaches x=200, and old pipes are removed once they move off the left edge of the screen. Digits are drawn individually using the pre-loaded number sprites.


Output: During gameplay, the score increments in the top-center of the screen. The game ends when the bird hits a pipe or the ground.

6. Collision Detection and Random Pipes

Two helper functions support the main game loop. isCollide() checks boundaries and pipe rectangles each frame. getRandomPipe() generates pipe pairs with randomised heights so every playthrough is different.


def isCollide(player_x, player_y, upperPipes, lowerPipes):
    if player_y > ground_y - 25 or player_y < 0:
        game_sounds['hit'].play()
        return True

    for pipe in upperPipes:
        pipeHeight = game_images['pipe'][0].get_height()
        if (player_y < pipeHeight + pipe['y']) and \
           (abs(player_x - pipe['x']) < game_images['pipe'][0].get_width() - 15):
            game_sounds['hit'].play()
            return True

    for pipe in lowerPipes:
        if (player_y + game_images['player'].get_height() > pipe['y']) and \
           (abs(player_x - pipe['x']) < game_images['pipe'][0].get_width() - 15):
            game_sounds['hit'].play()
            return True

    return False

The function first checks if the bird is outside the screen vertically (below ground or above the top). Then it iterates over both upper and lower pipe lists, checking if the bird’s bounding rectangle overlaps with any pipe’s bounding rectangle. A 15-pixel horizontal buffer is subtracted from the pipe width to make the collision feel slightly forgiving.


Output: No output — called every frame. Returns True immediately when a collision occurs.


def getRandomPipe():
    pipeHeight = game_images['pipe'][0].get_height()
    offset = screen_width // 3
    y1 = random.randrange(0, int(screen_height * 0.6))
    y2 = y1 + random.randrange(0, int(screen_height * 0.3))
    return [
        {'x': screen_width, 'y': y1},
        {'x': screen_width, 'y': y2}
    ]

random.randrange() picks a height between 0 and 60% of the screen height for the upper pipe opening. The lower pipe opening is offset from the upper by a random value up to 30% of the screen height. This guarantees a gap large enough for the bird to pass through.


Output: Returns a list of two dictionaries, e.g.: [{'x': 289, 'y': 120}, {'x': 289, 'y': 280}]

Conclusion

And that is the complete Flappy Bird game running in Python with Pygame. The architecture splits cleanly across four functions: welcomeScreen() handles the title, mainGame() drives the gameplay loop, isCollide() handles collision logic, and getRandomPipe() generates the obstacles. All game assets load at startup and the game runs at a locked 32 FPS, matching the feel of the original mobile game.

FAQ

Q: What is the purpose of the ground_y variable?

ground_y stores the y-coordinate where the ground base image sits. It is set to 80% of the screen height and is used as both the visual base and the collision boundary for the bottom of the screen.

Q: Why is the player velocity negative when flapping?

The screen coordinate system has y=0 at the top and y increases downward. A negative velocity moves the bird upward, and positive velocity moves it downward due to gravity applied each frame.

Q: What does convert_alpha() do when loading images?

convert_alpha() changes the pixel format of an image to match the display surface and preserves per-pixel alpha (transparency) information, which is essential for the bird and pipe sprites that have transparent regions.

Q: How does the score increment work?

Each pipe pair has a midpoint x-coordinate. When the bird’s x-position crosses that midpoint while moving right, the score increments by 1 and the point sound plays. This ensures the score only increases once per pipe.

Q: Can the game be extended with additional features?

Yes. Common extensions include adding a high score that persists across sessions using JSON storage, varying pipe speeds as the score increases, or replacing the simple rectangle collision with pixel-perfect sprite masks using pygame.mask.

Isha Bansal
Isha Bansal

Hey there stranger!
Do check out my blogs if you are a keen learner!

Hope you like them!

Articles: 185