# Space shooter game using pygame

I have made a space shooter game in pygame with over 800 lines of code (including blank lines). The aim of the game is to kill all of the ships in the game. You start with a small spaceship and one enemy. When you kill an enemy, two more spawn if there are less than 40 of them, otherwise one spawns. When you kill a spaceship, you get ammo, health or fuel back and 1 xp. As you get more xp, your ship upgrades. When you reach max. upgrade, no more enemies spawn.

Controls:

• mouse: aims ship towards your mouse, uses fuel
• left click: propels ship in direction ship is facing, uses fuel
• right click: shoots in direction ship is facing, uses ammo

## Code:

things.py

import math
import pygame
import os

os.chdir(os.path.dirname(os.path.abspath(__file__)))

MAX_AREA = 30000  # 50000
BULLET_DAMAGE = 1
GUN_TIME = 8
BULLET_START_POS = pygame.Vector2(4, 0)
BULLET_SPEED = 40
BULLET_LIFE = 100
FUEL_USAGE_ACCELERATION = 1
FUEL_USAGE_TURN = 0.1

SHIP_SCALE = 4
GUN_POSITIONS = [
[pygame.Vector2(6, 4), pygame.Vector2(6, -4)],
[pygame.Vector2(4, 4), pygame.Vector2(4, -4), pygame.Vector2(19, 0)],
[pygame.Vector2(4, 4), pygame.Vector2(4, -4), pygame.Vector2(19, 0)],
[
pygame.Vector2(4, 4),
pygame.Vector2(4, -4),
pygame.Vector2(19, 0),
pygame.Vector2(8, 7),
pygame.Vector2(8, -7),
],
[
pygame.Vector2(4, 4),
pygame.Vector2(4, -4),
pygame.Vector2(19, 0),
pygame.Vector2(8, 7),
pygame.Vector2(8, -7),
pygame.Vector2(2, 10),
pygame.Vector2(2, -10),
],
]

GUN_UPGRADE_MULTIPLIERS = 80  # (bullet_speed, bullet_life)
LEVEL_INFO = [
(3, 3, 1),
(3, 3, 1),
(5, 5, 2),
(5, 5, 2),
(7, 5, 2),
]  # (bodies, rockets, turning_rockets)

def level_info_multiplier(info, multiplier):
return lambda level: LEVEL_INFO[level][info] * multiplier

rocket_power = level_info_multiplier(1, 0.1)
ammo_storage = level_info_multiplier(0, 250)
turning_rocket_power = level_info_multiplier(2, 3)
health = level_info_multiplier(0, 20)
ship_damage = level_info_multiplier(0, 30)
fuel_storage = level_info_multiplier(0, 1250)

def clamp(n, min_n, max_n):
return min(max(n, min_n), max_n)

def import_image(image_name, scale):
target_size = (int(image.get_width() * scale), int(image.get_height() * scale))
return pygame.transform.scale(image, target_size)

def dir_dis_to_xy(direction, distance):
return pygame.Vector2(
)

def xy_to_dir_dis(xy):
return math.degrees(math.atan2(xy.y, xy.x)), math.sqrt(
(0 - xy.x) ** 2 + (0 - xy.y) ** 2
)

class Thing:
def __init__(self, image, spawn_pos=pygame.Vector2(0), spawn_direction=0):
self.image = image
self.pos = spawn_pos
self.direction = spawn_direction
self.size = pygame.Vector2(0)
self.draw_pos = pygame.Vector2(0)
self.rot_center()

def draw(self, screen, pos=None, offset=pygame.Vector2(0)):
self.rot_center(pos=pos)
screen.blit(self.rotated_image, self.draw_pos + offset)

def rot_center(self, pos=None):
self.rotated_image = pygame.transform.rotate(self.image, self.direction)
if pos != None:
self.rect = self.rotated_image.get_rect(center=pos)
else:
self.rect = self.rotated_image.get_rect(center=self.pos)
self.size.update(self.rect.size)
self.draw_pos.update(self.rect.topleft)

pos = (other.pos - self.pos) - ((other.size / 2) - (self.size / 2))

class Bullet(Thing):
def __init__(self, image, spawn_pos, direction, speed, time_alive):
super().__init__(image, spawn_pos, direction)
self.velocity = dir_dis_to_xy(self.direction, speed)
self.time_alive = time_alive

def update(self):
self.pos += self.velocity
self.time_alive -= 1

class Spaceship(Thing):
def __init__(self, images, level, spawn_pos=pygame.Vector2(0), spawn_direction=0):
super().__init__(images[level], spawn_pos, spawn_direction)
self.level = level
self.images = images
self.level_to_stats()

self.fire_rate = 0
self.gun_timer = 0

self.acceleration = 0
self.velocity = pygame.Vector2(0)
self.turn = 0

self.ammo = self.max_ammo
self.health = self.max_health
self.fuel = self.max_fuel

def level_to_stats(self):
self.image = self.images[self.level]
self.rocket_power = rocket_power(self.level)
self.max_ammo = ammo_storage(self.level)
self.turning_power = turning_rocket_power(self.level)
self.max_health = health(self.level)
self.ship_damage = ship_damage(self.level)
self.bullet_speed = BULLET_SPEED
self.bullet_life = BULLET_LIFE
self.guns_activated = self.level
self.max_fuel = fuel_storage(self.level)

def update(self):
fuel_usage = (abs(self.turn) * FUEL_USAGE_TURN) + (
self.acceleration * FUEL_USAGE_ACCELERATION
)
if self.fuel - fuel_usage >= 0:
self.fuel -= fuel_usage
self.direction += self.turn
self.velocity += dir_dis_to_xy(self.direction, self.acceleration)
self.pos += self.velocity

bullets_to_spawn = []

self.gun_timer += self.fire_rate
if self.gun_timer >= GUN_TIME:
self.gun_timer = 0

for gun_pos in GUN_POSITIONS[self.guns_activated]:
if self.ammo:
self.ammo -= 1
direction, distance = xy_to_dir_dis(
(gun_pos * SHIP_SCALE) + BULLET_START_POS
)
bullets_to_spawn.append(
Bullet(
bullet_image,
self.pos
+ dir_dis_to_xy(direction + self.direction, distance),
self.direction,
self.bullet_speed,
self.bullet_life,
)
)

return bullets_to_spawn

def control(self, acceleration, turn_amount, fire_rate, guns_activated=None):
self.turn = clamp(turn_amount, -self.turning_power, self.turning_power)
self.acceleration = clamp(acceleration, 0, self.rocket_power)
self.fire_rate = clamp(fire_rate, 0, 1)
if guns_activated != None:
if guns_activated <= self.level:
self.guns_activated = guns_activated

spaceship_images = [
import_image(image_name, SHIP_SCALE)
for image_name in [
"rocket_stage1.png",
"rocket_stage2.png",
"rocket_stage3.png",
"rocket_stage4.png",
"rocket_stage5.png",
]
]
enemy_spaceship_images = [
import_image(image_name, SHIP_SCALE)
for image_name in [
"enemy_stage1.png",
"enemy_stage2.png",
"enemy_stage3.png",
"enemy_stage4.png",
"enemy_stage5.png",
]
]
bullet_image = import_image("bullet.png", int(SHIP_SCALE / 2))


space_shooter.py

import pygame
import os
import random
from win32api import GetSystemMetrics

os.chdir(os.path.dirname(os.path.abspath(__file__)))

TITLE = "Space Shooter"
sys_met = tuple(map(GetSystemMetrics, (0, 1)))
SIZE = (sys_met[0], sys_met[1] - 60)  # (1000, 600)

def turn_to_angle(angle, turn_to_angle):
turn = -(angle - turn_to_angle)

while turn > 180:
turn -= 360
while turn < -180:
turn += 360

return turn

def stars_gen(density, min_size, max_size, star_image, size):
result = pygame.Surface(size, flags=pygame.SRCALPHA).convert_alpha()
star_width, star_height = star_image.get_size()
for x in range(0, int(size.x)):
for y in range(0, int(size.y)):
if random.random() < density:
scale = random.uniform(min_size, max_size)
blit_star = pygame.transform.scale(
star_image, (int(star_width * scale), int(star_height * scale))
)
result.blit(blit_star, (x, y))
return result

class Label:
def __init__(self, pos, size, color, text, text_color):
self.font = pygame.font.SysFont("monospace", 15)
self.rect = pygame.Rect(pos, size)
self.text_color = text_color
self.color = color
self.image = pygame.Surface(size).convert_alpha()
self.image.fill(self.color)
text_image = self.font.render(text, 1, self.text_color).convert_alpha()
text_rect = text_image.get_rect(
center=pygame.math.Vector2(self.image.get_size()) / 2
)
self.image.blit(text_image, text_rect)

def draw(self, win):
win.blit(self.image, self.rect)

class button(Label):
def __init__(self, pos, size, color, text, text_color):
super().__init__(pos, size, color, text, text_color)

def is_hovered_over(self, point):
return self.rect.collidepoint(point)

class Stat(Label):
def __init__(self, pos, size, color, text, text_color):
super().__init__(pos, size, color, text, text_color)

def update_text(self, new_text):
text_image = self.font.render(new_text, 1, self.text_color).convert_alpha()
self.image.fill(self.color)
text_rect = text_image.get_rect(
center=pygame.math.Vector2(self.image.get_size()) / 2
)
self.image.blit(text_image, text_rect)

class InfoBar:
def __init__(self, pos, size, bar_color, background_color, start_value=1):
self.pos = pos
self.size = size
self.value = start_value
self.background_color = background_color
self.bar_color = bar_color
self.image = pygame.Surface(size).convert_alpha()
self.bar = pygame.Surface(size).convert_alpha()
self.bar.fill(self.bar_color)
self.update_value(new_value=start_value)

def update_value(self, change_value_by=0, new_value=0):
if change_value_by:
self.value += change_value_by
else:
self.value = new_value
self.image.fill(self.background_color)
self.image.blit(self.bar, (-self.value * self.size.x, 0))

def draw(self, win):
win.blit(self.image, self.pos)

class space:
def __init__(self, space_image, screen, pos):
self.screen = screen
self.space_image = space_image

def update(self, pos):
screen_size = pygame.Vector2(self.screen.get_size())
pos = pygame.Vector2((pos.x % screen_size.x), (pos.y % screen_size.y))

scroll_surf = pygame.Surface(screen_size).convert_alpha()
scroll_surf.blit(self.space_image, pos)
if pos.x > 0:
scroll_surf.blit(self.space_image, pos - pygame.Vector2(screen_size.x, 0))
else:
scroll_surf.blit(self.space_image, pos + pygame.Vector2(screen_size.x, 0))
if pos.y > 0:
scroll_surf.blit(self.space_image, pos - pygame.Vector2(0, screen_size.y))
else:
scroll_surf.blit(self.space_image, pos + pygame.Vector2(0, screen_size.y))
if pos.x > 0 and pos.y > 0:
scroll_surf.blit(
self.space_image, pos - pygame.Vector2(screen_size.x, screen_size.y)
)
self.screen.blit(scroll_surf, (0, 0))

def handle_events():
spaceships_to_delete = []
for n, spaceship in enumerate(spaceships):
if n == player:
if spaceship.health <= 0:
return True
else:
if spaceship.health <= 0:
spaceships_to_delete.append(n)
spaceships[player].xp += 1
if (
spaceships[player].health + spaceships[player].max_health / 5
< spaceships[player].max_health
):
spaceships[player].health += spaceships[player].max_health / 5
values = [
getattr(spaceships[player], r_t[1])
/ getattr(spaceships[player], r_t[0])
for r_t in player_reward_types
]
reward_type = player_reward_types[values.index(min(values))]
max_value = getattr(spaceships[player], reward_type[0])
to_add = getattr(spaceships[player], reward_type[1]) + (max_value / 5)
before = getattr(spaceships[player], reward_type[1])
after = getattr(spaceships[player], reward_type[1])
print("+", after - before, reward_type[1])

spaceships_to_delete.reverse()
for n in spaceships_to_delete:
spaceships.pop(n)
if (
spaceships[player].level != 4
and spaceships[player].xp > ((spaceships[player].level + 1) * 4) - 1
):
spaceships[player].xp = 0
spaceships[player].level += 1
spaceships[player].level_to_stats()
if spaceships[player].xp < (spaceships[player].level + 1) * 4:
for n in spaceships_to_delete:
spaceships.append(
things.Spaceship(
things.enemy_spaceship_images,
spaceships[player].level,
spawn_pos=pygame.Vector2(
random.randrange(
int(-things.MAX_AREA), int(things.MAX_AREA), 1000
),
random.randrange(
int(-things.MAX_AREA), int(things.MAX_AREA), 1000
),
),
spawn_direction=random.randrange(0, 360),
)
)
if len(spaceships) < 40:
spaceships.append(
things.Spaceship(
things.enemy_spaceship_images,
spaceships[player].level,
spawn_pos=pygame.Vector2(
random.randrange(
int(-things.MAX_AREA), int(things.MAX_AREA), 1000
),
random.randrange(
int(-things.MAX_AREA), int(things.MAX_AREA), 1000
),
),
spawn_direction=random.randrange(0, 360),
)
)

bullets_to_delete = []
for n, bullet in enumerate(bullets):
if bullet.time_alive <= 0:
bullets_to_delete.append(n)
bullets_to_delete.reverse()
for n in bullets_to_delete:
bullets.pop(n)

for event in pygame.event.get():
if event.type == pygame.QUIT:
return True
# if event.type == pygame.KEYDOWN:
# if event.key == pygame.K_q:
#    spaceships[1].health = 0
# if event.key == pygame.K_q:
#    spaceships[player].level -= 1
#    spaceships[player].level_to_stats()
# if event.key == pygame.K_e:
#    spaceships[player].level += 1
#    spaceships[player].level_to_stats()

left, middle, right = pygame.mouse.get_pressed()
mouse_pos = pygame.mouse.get_pos()
mouse_dir = -things.xy_to_dir_dis(
mouse_pos - pygame.Vector2(spaceships[player].rect.center)
)[0]

acceleration = 0
turn_amount = 0
fire_rate = 0
guns_activated = None

if left:
acceleration = spaceships[player].rocket_power
if right:
fire_rate = 1
turn_amount = turn_to_angle(spaceships[player].direction, mouse_dir)

keys = pygame.key.get_pressed()
if keys[pygame.K_1]:
guns_activated = 0
if keys[pygame.K_2]:
guns_activated = 1
if keys[pygame.K_3]:
guns_activated = 3
if keys[pygame.K_4]:
guns_activated = 4

spaceships[player].control(
acceleration, turn_amount, fire_rate, guns_activated=guns_activated
)

for spaceship in spaceships:
if spaceship != spaceships[player]:
acceleration = 0
fire_rate = 0
distance = things.xy_to_dir_dis(spaceships[player].pos - spaceship.pos)[1]
if distance < 10000:
if things.xy_to_dir_dis(
spaceship.velocity
+ things.dir_dis_to_xy(spaceship.direction, spaceship.rocket_power)
)[1] < (((spaceship.level + 3) / 3) * 8):
acceleration = spaceship.rocket_power
else:
acceleration = 0
0
]
if distance < 2000:
fire_rate = 0.3
spaceship.control(
)
else:
if (
things.xy_to_dir_dis(
spaceship.velocity
+ things.dir_dis_to_xy(
spaceship.direction, spaceship.rocket_power
)
)[1]
< 5
):
acceleration = spaceship.rocket_power / 10
# print(spaceship.direction)
spaceship.control(
acceleration,
fire_rate,
)
# print(spaceship.direction)

return False

def draw():
mini_map.fill((0, 0, 0))

draw_offset = (-spaceships[follow].pos) + win_center
space_background.update(draw_offset)
for n, spaceship in enumerate(spaceships):
mini_map_spaceship_pos = ((spaceship.pos / mini_map_size) / 2) + mini_map_middle
if n == follow:
spaceship.draw(screen, pos=win_center)
mini_map.blit(mini_map_dots["player"], mini_map_spaceship_pos)
else:
spaceship.draw(screen, offset=(draw_offset))
mini_map.blit(mini_map_dots["enemy"], mini_map_spaceship_pos)

for bullet in bullets:
bullet.draw(screen, offset=(draw_offset))

for label in labels:
label.draw(screen)
for label, bar in info_bars.values():
label.draw(screen)
bar.draw(screen)
for label, text in info_text.values():
label.draw(screen)
text.draw(screen)

screen.blit(mini_map, mini_map_draw_pos)

def game_logic():
for bullet in bullets:
bullet.update()

if (
bullet.pos.x > things.MAX_AREA
or bullet.pos.x < -things.MAX_AREA
or bullet.pos.y > things.MAX_AREA
or bullet.pos.y < -things.MAX_AREA
):
bullet.time_alive = 0
continue
for spaceship in spaceships:
spaceship.health -= things.BULLET_DAMAGE
bullet.time_alive = 0

for n, spaceship in enumerate(spaceships):
for bullet in spaceship.update():
bullets.append(bullet)

if (
spaceship.pos.x > things.MAX_AREA
or spaceship.pos.x < -things.MAX_AREA
or spaceship.pos.y > things.MAX_AREA
or spaceship.pos.y < -things.MAX_AREA
):
spaceship.health = 0

for n, spaceship in enumerate(spaceships):
collide_checks = spaceships.copy()
collide_checks.pop(n)

for other in collide_checks:
spaceship.health -= other.ship_damage

info_bars["ammo"][1].update_value(
new_value=(-(spaceships[player].ammo / spaceships[player].max_ammo)) + 1
)
info_bars["health"][1].update_value(
new_value=(-(spaceships[player].health / spaceships[player].max_health)) + 1
)
info_bars["fuel"][1].update_value(
new_value=(-(spaceships[player].fuel / spaceships[player].max_fuel)) + 1
)
info_text["speed"][1].update_text(
str(int(things.xy_to_dir_dis(spaceships[player].velocity)[1]))
)
info_text["pos"][1].update_text(
str((int(spaceships[player].pos.x), int(spaceships[player].pos.y)))
)
info_text["guns activated"][1].update_text(
str(spaceships[player].guns_activated + 1)
)
info_text["ship level"][1].update_text(str(spaceships[player].level + 1))
info_text["xp"][1].update_text(str(spaceships[player].xp))

def run_game():
while True:
if handle_events():
break

game_logic()
draw()

pygame.display.update(window_rect)
clock.tick(30)

pygame.quit()

if __name__ == "__main__":
pygame.init()
screen = pygame.display.set_mode(SIZE)
import things

mini_map_size = 200
mini_map_middle = (
pygame.Vector2(things.MAX_AREA, things.MAX_AREA) / mini_map_size
) / 2
mini_map = pygame.Surface(
pygame.Vector2(things.MAX_AREA, things.MAX_AREA) / mini_map_size
).convert_alpha()
mini_map_dots = {
"player": pygame.Surface(pygame.Vector2(1, 1)).convert_alpha(),
"enemy": pygame.Surface(pygame.Vector2(1, 1)).convert_alpha(),
}
mini_map_dots["player"].fill((0, 200, 0))
mini_map_dots["enemy"].fill((200, 0, 0))
mini_map_draw_pos = pygame.Vector2(10, 10)
window_rect = pygame.Rect((0, 0), SIZE)
win_center = pygame.Vector2(window_rect.center)
clock = pygame.time.Clock()
pygame.display.set_caption(TITLE)
pygame.event.set_allowed([pygame.KEYDOWN, pygame.QUIT])

stars = pygame.Surface(pygame.Vector2(screen.get_size())).convert_alpha()
stars.fill((10, 10, 13))
stars.blit(
stars_gen(
0.0001,
0.4,
0.9,
things.import_image("star.png", 1),
pygame.Vector2(screen.get_size()),
),
(0, 0),
)
space_background = space(stars, screen, pygame.Vector2(0))

bullets = []
player = 0
spaceships = [
things.Spaceship(things.spaceship_images, 0, spawn_pos=pygame.Vector2(0, 0))
]
spaceships.append(
things.Spaceship(
things.enemy_spaceship_images,
spaceships[player].level,
spawn_pos=pygame.Vector2(
random.randrange(int(-things.MAX_AREA), int(things.MAX_AREA), 1000),
random.randrange(int(-things.MAX_AREA), int(things.MAX_AREA), 1000),
),
spawn_direction=random.randrange(0, 360),
)
)
# for x in range(int(-things.MAX_AREA), int(things.MAX_AREA), 1000):
# for y in range(int(-things.MAX_AREA), int(things.MAX_AREA), 1000):
# if random.random() < 0.002:
# spaceships.append(things.Spaceship(things.enemy_spaceship_images,
# spaceships[player].level,
# spawn_pos=pygame.Vector2(x, y),
# spawn_direction=random.randrange(0, 360)))

player_reward_types = (
("max_health", "health"),
("max_ammo", "ammo"),
("max_fuel", "fuel"),
)
spaceships[player].xp = 0

# (label, info_bar)
info_bars = {
"ammo": (
Label(
pygame.Vector2(SIZE[0] - 90, SIZE[1] - 65),
pygame.Vector2(60, 16),
(200, 200, 200),
"ammo",
(20, 20, 20),
),
InfoBar(
pygame.Vector2(SIZE[0] - 100, SIZE[1] - 40),
pygame.Vector2(80, 20),
(0, 0, 100),
(100, 100, 100),
0,
),
),
"health": (
Label(
pygame.Vector2(SIZE[0] - 90, SIZE[1] - 125),
pygame.Vector2(60, 16),
(200, 200, 200),
"health",
(20, 20, 20),
),
InfoBar(
pygame.Vector2(SIZE[0] - 100, SIZE[1] - 100),
pygame.Vector2(80, 20),
(0, 0, 100),
(100, 100, 100),
0,
),
),
"fuel": (
Label(
pygame.Vector2(SIZE[0] - 90, SIZE[1] - 185),
pygame.Vector2(60, 16),
(200, 200, 200),
"fuel",
(20, 20, 20),
),
InfoBar(
pygame.Vector2(SIZE[0] - 100, SIZE[1] - 160),
pygame.Vector2(80, 20),
(0, 0, 100),
(100, 100, 100),
0,
),
),
}
# (label, info_text)
info_text = {
"speed": (
Label(
pygame.Vector2(SIZE[0] - 90, SIZE[1] - 245),
pygame.Vector2(60, 16),
(200, 200, 200),
"speed",
(20, 20, 20),
),
Stat(
pygame.Vector2(SIZE[0] - 90, SIZE[1] - 220),
pygame.Vector2(60, 20),
(200, 200, 200),
"0",
(20, 20, 20),
),
),
"pos": (
Label(
pygame.Vector2(70, SIZE[1] - 65),
pygame.Vector2(60, 16),
(200, 200, 200),
"pos",
(20, 20, 20),
),
Stat(
pygame.Vector2(20, SIZE[1] - 40),
pygame.Vector2(160, 20),
(200, 200, 200),
"(0, 0)",
(20, 20, 20),
),
),
"guns activated": (
Label(
pygame.Vector2(30, SIZE[1] - 125),
pygame.Vector2(140, 16),
(200, 200, 200),
"guns activated",
(20, 20, 20),
),
Stat(
pygame.Vector2(70, SIZE[1] - 100),
pygame.Vector2(60, 20),
(200, 200, 200),
"0",
(20, 20, 20),
),
),
"ship level": (
Label(
pygame.Vector2(50, SIZE[1] - 185),
pygame.Vector2(100, 16),
(200, 200, 200),
"ship level",
(20, 20, 20),
),
Stat(
pygame.Vector2(70, SIZE[1] - 160),
pygame.Vector2(60, 20),
(200, 200, 200),
"0",
(20, 20, 20),
),
),
"xp": (
Label(
pygame.Vector2(SIZE[0] - 90, SIZE[1] - 305),
pygame.Vector2(60, 16),
(200, 200, 200),
"xp",
(20, 20, 20),
),
Stat(
pygame.Vector2(SIZE[0] - 90, SIZE[1] - 280),
pygame.Vector2(60, 20),
(200, 200, 200),
"0",
(20, 20, 20),
),
),
}
labels = (
[]
)  # Label(pygame.Vector2(0, 0), pygame.Vector2(60, 20), (200, 200, 200), "hi", (20, 20, 20))]

run_game()


## Screenshots:

finding him...

there he is!

got him!

• One of my first projects was a space game! Cool question, I have verified the code works. Would be cool if you had a GitHub link to share the PNGs to play with :) (if you want to ofc) Commented Apr 1 at 1:45
• @Peilonrayz I don't use github, here is a filemail link (7 days expiry). how far can you get?? Commented Apr 1 at 4:35

The graphics are beautiful. I appreciate the attention to details, like the background stars.

# assets directory

os.chdir(os.path.dirname(os.path.abspath(__file__)))


It's not like there's something else that was going to care about \$PWD. But I'm afraid that global variables make me a little nervous, like time.time(), PRNG state, and PWD.

As an unrelated detail, the Path API tends to be more concise and convenient than the old os.path.* routines.

source_folder = Path(__file__).parent.resolve()


from which we might assign assets, and continue on with
assets / "rocket_stage1.png", etc.

# manifest constants

You have a lot of them, and they all look great. Thank you for that.

nit: Delete # 50000 on MAX_AREA, as it's no longer needed. Consider spelling the value 30_000, so it's easier to read.

# from ... import ...

import pygame


Clearly this works. But consider beefing it up with from pygame import Vector2 and similar, so we can make some repeated expressions a bit shorter.

# gun positions

GUN_POSITIONS has lots of magic numbers such as 6 and 19, which is Fine. What is missing is a helpful github URL showing a blown up graphic, so we can understand how (6, 4) and (19, 0) fit into the graphic that end users will see.

At some point, we might wish to revise that graphic, perhaps because a Level 2 spacecraft has 50% more firepower sprouting from its wings. And perhaps the Art Director will approve the final design. Tying these coordinates to a specific creative image would help the process.

# namedtuple

LEVEL_INFO = [
(3, 3, 1),
(3, 3, 1),
(5, 5, 2),
(5, 5, 2),
(7, 5, 2),
]  # (bodies, rockets, turning_rockets)


Thank you for that very helpful comment.

As it stands, the first parameter to level_info_multiplier is cryptic and is in need of a MANIFEST_CONSTANT, but we have better approaches available.

Consider defining this:

from collections import namedtuple

Level = namedtuple("Level", "bodies, rockets, turning_rockets")


Then we don't need a comment.

And we can refer to .bodies rather than a cryptic ...[0] info subscript.

BTW, the clamp helper is concise and beautiful. Well, generally import_image, dir_dis_to_xy, and xy_to_dir_dis are all very nice helpers. In dir_dis_to_xy I guess pygame has an inconvenient "upside down" convention on Y coords that you have to roll with, sigh. In xy_to_dir_dis I confess I don't see any reason for negating before squaring; I just don't see that there's some story we're advancing there. Whether you negate or not, the square will be positive.

# catalog of object attributes

    def rot_center(self, ...):
self.rotated_image = ...


The __init__ constructor did not mention that attribute.

Clearly it's not necessary, but it is considered polite to introduce all attributes in the constructor. It gives a maintenance engineer a heads-up about what to watch for. Consider defaulting to None, or maybe have a helper return a tuple, which an assignment can unpack.

            self.rect = ...
...


Oh, dear! I see there's more to worry about.

        if pos != None:


As a weird python community thing, please prefer if pos is not None:. Or use ruff or some other linter to help you with odd cases like that.

Also, here it would be simpler to test the positive case and deal with that first.

Better, unconditionally assign this:

    self.rect = self.rotated_image.get_rect(center=pos or self.pos)


The Thing inheritance for Bullet and Spaceship is very nice. Good use of super()!

# vector vs scalar

I found this slightly odd:

class Spaceship(Thing):
def __init__( ... ):
...
self.acceleration = 0
self.velocity = pygame.Vector2(0)


We're simplifying, sure, that's cool. Just saying that I felt an acceleration would be a vector quantity, pointing in a similar direction to velocity, whatever.

In version 2, maybe spaceships operate near massive objects, where acceleration always points down the gravity well....

# short functions

Let me just take a minute to complement you on your beautifully short, comprehensible methods. They do One thing, Well.

Also, helpers like clamp() make some calling code admirably concise.

# modulo

In turn_to_angle I'm not crazy about this:

    while turn > 180:
turn -= 360
while turn < -180:
turn += 360


That seems like what the % mod operator is for.

Also, I'm not happy with the post-condition; I feel it's on the sloppy side.

We would like to

        assert -180 <= turn < 180


Or, at least I think I have seen that [-180, 180) half-open interval as a standard convention.

But the < > inequalities don't align with <=.

Also, I like stars_gen, it is aesthetically pleasing.

# PEP-8

class space:


I don't understand what's going on with that name. Call it Space, please.

# sgn

        if pos.x > 0:
scroll_surf.blit(self.space_image, pos - pygame.Vector2(screen_size.x, 0))
else:
scroll_surf.blit(self.space_image, pos + pygame.Vector2(screen_size.x, 0))


We have a ± there.

Using copysign(), it seems like we could collapse those lines down to one.

Similarly for the Y direction.

# handle_events

In the space class, it turns out that handle_events is Too Long. How do we know? Try to read the signature and also the last line of the method, without vertically scrolling. Yup, that's right, you can't. It's Too Long. There are several opportunities to Extract Helper.

Also, the Magic Number 5 is slightly worrying. It wants a MANIFEST_CONSTANT name, please.

Here is another troubling magic number:

        spaceships[player].level != 4


# getattr

                max_value = getattr(spaceships[player], ...
before = getattr(spaceships[player], ...


Not sure if this is quite a Code Smell. But it certainly does seem inconvenient.

There are so many solutions. Make those objects namedtuples, or dataclasses, or give them custom behavior.

Usually we resort to getattr() when there is a fixed Public API which we're not at liberty to change. But that's not your situation at all. Alter the underlying implementation, for the convenience of the caller. That's how excellent Public APIs are forged!

Similarly with the subsequent setattr( ... ).

# break

def run_game():
while True:
if handle_events():
break


That works, but I'm not crazy about it. Consider switching to

def run_game():
while not handle_events():
...


# imports up top

This troubles me:

if __name__ == "__main__":
...
import things


Move the import to top-of-file, please. We're not saving anything by conditionally importing it.

Also, the code within this __main__ guard is entirely Too Long, and should appear within def main():, hopefully with several helpers.

In addition to the previous thorough answer, in the things.py code, you could reduce repetition in creating the lists of file names using a loop in a function.

for image_name in [
"rocket_stage1.png",
"rocket_stage2.png",
"rocket_stage3.png",
"rocket_stage4.png",
"rocket_stage5.png",
]


Could be:

def get_files(name):
image_name = []
for i in range(1, 6):
image_name.append(f'{name}_stage{i}.png')
return image_name

# just for demo purposes
print(get_files('rocket'))
print(get_files('enemy'))


The space_shooter.py file has a couple groups of lines that look like commented-out code, such as:

    # if event.type == pygame.KEYDOWN:
# if event.key == pygame.K_q:
#    spaceships[1].health = 0
# if event.key == pygame.K_q:


Either add more comments to describe why they are there or just delete them to reduce clutter.

• Please don't recommend append of a simple expression in a loop, even though here it hardly can impact performance: return [f"{name}_stage{i}.png" for i in range(1, 6)] is just easier to comprehend. And a list called image_name (singular) sounds extremely weird to me. Commented Apr 4 at 23:11