I am trying to implement NEAT into a very basic endless runner game. I made the game following a Pygame tutorial (https://www.youtube.com/watch?v=ZV8TNrwqG1Y). Though I have made quite a few changes, the core game has remained the same. It is a simple 2D endless runner game, where you can control the player (a square), move it left or right, and jump. A series of other squares come in from the right and you get a point each time you jump over an obstacle for the first time.
Apologies if the format of the question isn’t how it is supposed to be, I’m quite new to this platform.
After implement NEAT
The fitness goes up by a very small amount after the first generation, but from there on out its almost completely random. I have run multiple sessions of 50 generations (each of 50 population size), and I have noticed that by the end, the average fitness of the population seems to be increasing but here’s the thing: its completely random. The NN evolves to perform these random actions in a loop and it finds some success from it, and it thinks thats the right thing to do. It doesnt understand that its supposed to avoid the squares(obstacles). Some genomes look like they understand that they need to avoid the obstacles, but they are not chosen as the more random pattern repeating genomes are chosen as they, by sheer luck?, end up getting a better fitness score.
Here is the entire code so far:
import pygame
import random
import neat
import pickle
import time
pygame.init()
# Colors
white = (255, 255, 255)
black = (0, 0, 0)
green = (0, 255, 0)
red = (255, 0, 0)
orange = (255, 165, 0)
yellow = (255, 255, 0)
# Screen size
WIDTH = 450
HEIGHT = 300
# Screen
screen = pygame.display.set_mode([WIDTH, HEIGHT])
pygame.display.set_caption("ML project")
background = black
fps = 60
font = pygame.font.Font('freesansbold.ttf', 16)
timer = pygame.time.Clock()
# NEAT configuration
def eval_genomes(genomes, config):
global running
for genome_id, genome in genomes:
net = neat.nn.FeedForwardNetwork.create(genome, config)
genome.fitness = 0
player_x = 50
player_y = 200
y_change = 0
x_change = 0
gravity = 1
# obstacles = [300, 450, 600]
ranges = [(300, 320), (400, 450), (500, 600)]
obstacles = [random.randint(start, end) for start, end in ranges]
obstacles_speed = 3
active = True
score = 0
genome_running = True
jump_penalty = 0
last_jump_time = time.time()
jump_delay = 0.35
start_time = time.time()
move_reward = 0
once_only = [True, True, True]
inAir = False
jump_once = True
collision_logged = True
while genome_running:
survival_time = time.time() - start_time
bonus = survival_time * 0.5
timer.tick(fps)
screen.fill(background)
distance_bw0 = obstacles[0] - player_x
distance_bw1 = obstacles[1] - player_x
distance_bw2 = obstacles[2] - player_x
# normalized_neg1_to_1 = 1 - (distance_bw0 / WIDTH)
# normalized_neg1_to_1 = normalized_neg1_to_1 * 2 - 1
# twonormalized_neg1_to_1 = 1 - (distance_bw1 / WIDTH)
# twonormalized_neg1_to_1 = twonormalized_neg1_to_1 * 2 - 1
# threenormalized_neg1_to_1 = 1 - (distance_bw2 / WIDTH)
# threenormalized_neg1_to_1 = threenormalized_neg1_to_1 * 2 - 1
distance_bw0y = 200 - player_y
distance_bw1y = 200 - player_y
distance_bw2y = 200 - player_y
if not active:
instruction_text = font.render('SpaceBar to Start', True, white, black)
screen.blit(instruction_text, (150, 50))
instruction_text = font.render('SpaceBar to jump, left and right keys to move', True, white, black)
screen.blit(instruction_text, (65, 90))
score_text = font.render(f'Score: {score}', True, white, black)
screen.blit(score_text, (180, 240))
score_text = font.render(f'Fitness: {int(genome.fitness)}', True, white, black)
screen.blit(score_text, (180, 260))
score_text = font.render(f'Bonus for time: {round(bonus, 3)}', True, white, black)
screen.blit(score_text, (180, 280))
# score_text = font.render(f'hmm : {obstacles[0]}', True, white, black)
# screen.blit(score_text, (180, 270))
pos_text = font.render(f'Obstacle1x: {distance_bw0} Obstacle2x: {distance_bw1} Obstacle3x: {distance_bw2} ', True, white, black)
screen.blit(pos_text, (10, 50))
pos_text = font.render(f'Obstacle1y: {distance_bw0y} Obstacle2y: {distance_bw1y} Obstacle3y: {distance_bw2y} ', True, white, black)
screen.blit(pos_text, (10, 30))
pos_text = font.render(f'Safe: {"Yes" if inAir else "No"}', True, white, black)
screen.blit(pos_text, (10, 10))
floor = pygame.draw.rect(screen, white, [0, 220, WIDTH, 5])
player = pygame.draw.rect(screen, green, [player_x, player_y, 20, 20])
obstacle0 = pygame.draw.rect(screen, red, [obstacles[0], 200, 20, 20])
obstacle1 = pygame.draw.rect(screen, yellow, [obstacles[1], 200, 20, 20])
obstacle2 = pygame.draw.rect(screen, orange, [obstacles[2], 200, 20, 20])
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
genome_running = False
if active:
output = net.activate([player_x, player_y, distance_bw0, distance_bw1, distance_bw2, distance_bw0y,
distance_bw1y, distance_bw2y, inAir])
# output = net.activate([player_x, player_y, distance_bw0, distance_bw1, distance_bw2])
current_time = time.time()
if output[0] > 0.5:
if y_change == 0 and (current_time - last_jump_time) > jump_delay:
y_change = 18
last_jump_time = current_time
if output[1] > 0.5 and output[2] < 0.5:
x_change = -4
# genome.fitness += 0.25
elif output[2] > 0.5 and output[1] < 0.5:
x_change = 4
# genome.fitness += 0.25
elif output[3] > 0.5 and output[1] < 0.5 and output[2] < 0.5:
pass
for i in range(len(obstacles)):
obstacles[i] -= obstacles_speed
if obstacles[i] < player_x and once_only[i]:
score += 1
genome.fitness += 1
once_only[i] = False
if obstacles[i] < -20:
obstacles[i] = random.randint(470, 570)
once_only[i] = True
if player.colliderect(obstacle0) or player.colliderect(obstacle1) or player.colliderect(obstacle2) or player_x == 0 or player_x == 430 and collision_logged:
active = False
genome.fitness -= 1
genome.fitness += bonus
genome_running = False
print(f"Collision! Genome {genome_id} fitness: {round(genome.fitness, 3)} Score {score}")
collision_logged = False
# if output[0] < 0.5:
# x_change = 0
# if output[1] < 0.5:
# x_change = 0
if output[0] <= 0.5 and output[1] <= 0.5:
x_change = 0
if 0 <= player_x <= 430:
player_x += x_change
# if x_change != 0 and player_x > 0:
# genome.fitness += 0.25
if player_x < 0:
player_x = 0
if player_x > 430:
player_x = 430
if y_change > 0 or player_y < 200:
player_y -= y_change
y_change -= gravity
if jump_once:
# genome.fitness -= 10
jump_once = False
if player_y > 200:
player_y = 200
jump_once = True
if player_y == 200 and y_change < 0:
y_change = 0
jump_once = True
if player_y > 170:
inAir = False
else:
inAir = True
pygame.display.flip()
# genome.fitness += jump_penalty
# genome.fitness += move_reward
config_path = "file.txt"
config = neat.config.Config(neat.DefaultGenome, neat.DefaultReproduction,
neat.DefaultSpeciesSet, neat.DefaultStagnation,
config_path)
p = neat.Population(config)
p.add_reporter(neat.StdOutReporter(True))
stats = neat.StatisticsReporter()
p.add_reporter(stats)
winner = p.run(eval_genomes, 50)
with open('winner.pkl', 'wb') as f:
pickle.dump(winner, f)
print("Training completed. Best genome saved.")
pygame.quit()
And here is the Config file:
[NEAT]
fitness_criterion = max
fitness_threshold = 400
pop_size = 20
reset_on_extinction = False
[DefaultStagnation]
species_fitness_func = max
max_stagnation = 20
species_elitism = 2
[DefaultReproduction]
elitism = 2
survival_threshold = 0.2
[DefaultGenome]
# node activation options
activation_default = relu
activation_mutate_rate = 1.0
activation_options = relu
# node aggregation options
aggregation_default = sum
aggregation_mutate_rate = 0.0
aggregation_options = sum
# node bias options
bias_init_mean = 3.0
bias_init_stdev = 1.0
bias_max_value = 30.0
bias_min_value = -30.0
bias_mutate_power = 0.5
bias_mutate_rate = 0.7
bias_replace_rate = 0.1
# genome compatibility options
compatibility_disjoint_coefficient = 1.0
compatibility_weight_coefficient = 0.5
# connection add/remove rates
conn_add_prob = 0.5
conn_delete_prob = 0.5
# connection enable options
enabled_default = True
enabled_mutate_rate = 0.01
feed_forward = True
initial_connection = full_direct
# node add/remove rates
node_add_prob = 0.2
node_delete_prob = 0.2
# network parameters
num_hidden = 1
num_inputs = 9
num_outputs = 4
# node response options
response_init_mean = 1.0
response_init_stdev = 0.0
response_max_value = 30.0
response_min_value = -30.0
response_mutate_power = 0.0
response_mutate_rate = 0.0
response_replace_rate = 0.0
# connection weight options
weight_init_mean = 0.0
weight_init_stdev = 1.0
weight_max_value = 30
weight_min_value = -30
weight_mutate_power = 0.5
weight_mutate_rate = 0.8
weight_replace_rate = 0.1
[DefaultSpeciesSet]
compatibility_threshold = 3.0