A Flappy Bird That Teaches Itself to Play

#I Made a Flappy Bird That Teaches Itself to Play (No ML Libraries Required)

So I had this idea: what if instead of training a neural network the normal way (gradient descent, backprop, all that jazz), I just... let birds die until the good ones survived? Turns out that's basically how evolution works, and it also works surprisingly well for games.

This is the story of my neuroevolution project, a self-evolving Flappy Bird agent powered by a genetic algorithm, built from scratch in Python with pygame. No PyTorch. No TensorFlow. Just math, randomness, and a lot of dead birds.

***

#The Big Idea: Evolution, but for Code

The core concept is called neuroevolution, you evolve the weights of a neural network using a genetic algorithm instead of computing gradients. Here's the loop in plain English:

  1. Spawn a population of birds, each with a randomly wired tiny "brain" (a genome)
  2. Let them all fly at the same time
  3. The ones that survive longer get to reproduce
  4. Mix and mutate their genomes to make the next generation
  5. Repeat until something smart emerges

It's the same idea behind why giraffes have long necks, except instead of necks, we're evolving numbers in a list.

***

#The Bird's Brain (utils.py)

Each bird's genome is just 21 floating-point numbers, the weights and biases of a tiny neural network. The network takes two inputs:

And it outputs one decision: flap or don't flap.

def decide_flap(genome, bird_y, gap_y):
    bird_y_n = bird_y / 600.0
    gap_y_n = gap_y / 600.0
 
    w_input_hidden = genome[:10]
    b_hidden = genome[10:15]
    w_hidden_output = genome[15:20]
    b_output = genome[20]
 
    hidden = []
    for j in range(5):
        w1 = w_input_hidden[2*j]
        w2 = w_input_hidden[2*j + 1]
        h = sigmoid(w1 * bird_y_n + w2 * gap_y_n + b_hidden[j])
        hidden.append(h)
 
    total = sum(h * w for h, w in zip(hidden, w_hidden_output)) + b_output
    out = sigmoid(total)
    return out > 0.5

The architecture is dead simple: 2 inputs → 5 hidden neurons → 1 output. The inputs are normalized to [0, 1] before going in. I learned the hard way that raw pixel values make activations explode. Also used a numerically stable sigmoid to avoid overflow issues:

def sigmoid(x):
    if x >= 0:
        z = math.exp(-x)
        return 1 / (1 + z)
    else:
        z = math.exp(x)
        return z / (1 + z)

Small thing, but it matters.

***

#The Evolution Engine

This is where the magic happens. After every generation, we take the population's fitness scores (how long each bird survived) and build the next generation using three classic genetic operators:

#1. Selection: Survival of the Fittest (Kind of)

I used roulette wheel selection: birds with higher fitness scores get a proportionally bigger "slice" of the wheel, so they're more likely to be picked as parents. But even bad birds have a chance: this keeps diversity alive.

def roulette_wheel_selection(population, fitnesses):
    total_fitness = sum(fitnesses)
    if total_fitness <= 0:
        return random.choice(population)
    pick = random.uniform(0, total_fitness)
    current = 0
    for genome, fit in zip(population, fitnesses):
        current += fit
        if current > pick:
            return genome
    return population[-1]

#2. Crossover (Mixing Genomes)

Two parent genomes get spliced together at a random point, producing two children. Think of it like swapping halves of a recipe between two good cooks.

def crossover_pair(parent1, parent2):
    point = random.randint(1, len(parent1) - 1)
    child1 = parent1[:point] + parent2[point:]
    child2 = parent2[:point] + parent1[point:]
    return child1, child2

#3. Mutation (Keeping Things Interesting)

Each gene has a 15% chance of getting nudged by a small random value. Without this, the population would converge to the same solution and get stuck.

def mutate(genome, mutation_rate=0.15, mutation_strength=0.2):
    new_genome = []
    for gene in genome:
        if random.random() < mutation_rate:
            gene += random.uniform(-mutation_strength, mutation_strength)
        new_genome.append(gene)
    return new_genome

#Elitism (Don't Throw Away Your Best Bird)

One thing I added: elitism. The best-performing genome from each generation always survives unchanged into the next one. This prevents a situation where random mutation accidentally destroys something that was working great.

best_idx = max(range(pop_size), key=lambda i: fitnesses[i])
elite = population[best_idx][:]
# ...
new_population[0] = elite  # always slot 0
***

#The Game Loop (main.py)

The main loop runs 100 generations, each time spawning 30 birds simultaneously. They all fly at once, and the loop keeps going until every single bird is dead. Then we evolve and start the next round.

POP_SIZE = 30
population = [[random.uniform(-1, 1) for _ in range(21)] for _ in range(POP_SIZE)]
 
for gen in range(100):
    birds = [Bird(genome) for genome in population]
    fitnesses = [0] * POP_SIZE
 
    while running:
        # ... pygame event loop, draw pipes, move them ...
 
        for i, bird in enumerate(birds):
            if not bird.dead:
                bird.update(GRAVITY)
                gap_y = pipe_height + pipe_gap / 2
                if decide_flap(bird.genome, bird.y, gap_y):
                    bird.flap()
                if check_collision(bird, pipes):
                    bird.dead = True
                else:
                    fitnesses[i] += 1  # reward for staying alive
                bird.draw(screen)
 
        if all_dead:
            running = False
 
    population = next_generation(population, fitnesses)

Fitness is literally just "how many frames did you survive?" Simple, but it works.

***

#The Bird Model (model.py)

The Bird class is pretty lean. It holds a genome, tracks position and velocity, handles gravity, and draws itself as a yellow circle:

class Bird:
    def __init__(self, genome, x=200, y=300):
        self.genome = genome
        self.velocity = 0
        self.dead = False
 
    def flap(self, jump_velocity=-8):
        self.velocity = jump_velocity
 
    def update(self, gravity=0.5):
        self.velocity += gravity
        self.y += self.velocity
        if self.y >= 600 - self.radius:
            self.dead = True
        if self.y <= self.radius:
            self.dead = True

The physics are minimal, just gravity accumulating on velocity each frame. Hits the floor or ceiling? You're dead.

***

#What I Learned

The "no ML library" constraint was actually great. Building the neural network by hand forced me to really understand what's happening: forward pass, how weights and biases interact, why you normalize inputs. When you're just calling model.fit(), a lot of that stays invisible.

Genetic algorithms are surprisingly intuitive. The analogy to real evolution holds up well. Selection pressure, diversity, elitism, once you name the concepts, the code almost writes itself.

Small networks can do surprising things. A 2→5→1 network with 21 parameters is tiny, but it's enough to learn "if I'm above the gap, don't flap; if I'm below it, flap." That's the whole skill.

Tuning mutation rate matters a lot. Too low and the population converges fast and gets stuck. Too high and you're basically just randomizing every generation. 0.15 with strength 0.2 felt like a sweet spot after some experimenting.

***

#Try It Yourself

git clone https://github.com/devdezzies/neuroevolution
cd neuroevolution
uv sync  # or pip install pygame
python main.py

Watch 30 yellow circles try to not die. By generation 5 or so, at least a few of them usually start to figure it out. By generation 20+, it's genuinely satisfying to watch.

***

demo
demo

This project was a fun way to explore how intelligence can emerge from randomness. No fancy libraries, no GPU, just selection pressure and a bunch of birds learning to not crash. Honestly, that's kind of beautiful.

Repo: github.com/devdezzies/neuroevolution