This is a physics game that simulates projectile motion affected by gravity and wind.
One user can play, or two can take turns. There are sound effects, explosions, debris fields, and craters. You can demolish obstacles.
You can use the mouse to set the elevation angle and the amount of powder, or you can use the arrow keys.
The game displays the number of misses and the accuracy percentage.
For such a simple game, a surprising amount is going on in the code.
Here is that code:
import pygame
import math
import random
import numpy as np
# --- Constants ---
WIDTH, HEIGHT = 1200, 750
FPS = 60
GROUND_COLOR = (80, 50, 20)
SKY_COLOR = (135, 206, 235)
CANNON_COLOR = (40, 40, 40)
WHEEL_COLOR = (20, 20, 20)
EXPLOSION_COLOR = (255, 100, 0)
# --- Audio Engine ---
class SoundGenerator:
@staticmethod
def create_beep(freq, duration, volume=0.3):
sample_rate = 44100
n_samples = int(sample_rate * duration)
t = np.linspace(0, duration, n_samples, False)
wave = np.sin(2 * np.pi * freq * t) * volume
fade = np.linspace(1, 0, n_samples)
wave = (wave * fade * 32767).astype(np.int16)
stereo_wave = np.repeat(wave.reshape(n_samples, 1), 2, axis=1)
return pygame.sndarray.make_sound(stereo_wave)
@staticmethod
def create_explosion_sound():
sample_rate = 44100
duration = 0.6
n_samples = int(sample_rate * duration)
noise = np.random.uniform(-1, 1, n_samples)
fade = np.linspace(1, 0, n_samples) ** 2
wave = (noise * fade * 32767).astype(np.int16)
stereo_wave = np.repeat(wave.reshape(n_samples, 1), 2, axis=1)
return pygame.sndarray.make_sound(stereo_wave)
class Slider:
def __init__(self, x, y, w, h, label, min_val, max_val, initial):
self.rect = pygame.Rect(x, y, w, h)
self.label, self.min, self.max, self.val = label, min_val, max_val, initial
self.grabbed = False
def draw(self, screen, font):
pygame.draw.rect(screen, (100, 100, 100), self.rect, border_radius=5)
pos = self.rect.x + (self.val - self.min) / (self.max - self.min) * self.rect.w
pygame.draw.circle(screen, (255, 255, 255), (int(pos), self.rect.centery), 10)
txt = font.render(f"{self.label}: {int(self.val)}", True, (0, 0, 0))
screen.blit(txt, (self.rect.x, self.rect.y - 25))
def handle_event(self, event):
if event.type == pygame.MOUSEBUTTONDOWN and self.rect.inflate(0, 20).collidepoint(event.pos): self.grabbed = True
elif event.type == pygame.MOUSEBUTTONUP: self.grabbed = False
elif event.type == pygame.MOUSEMOTION and self.grabbed:
rel_x = max(0, min(event.pos[0] - self.rect.x, self.rect.w))
self.val = self.min + (rel_x / self.rect.w) * (self.max - self.min)
class Protractor:
def __init__(self, x, y, radius):
self.x, self.y, self.radius, self.angle, self.grabbed = x, y, radius, 45, False
def draw(self, screen, font):
pygame.draw.arc(screen, (50, 50, 50), (self.x-self.radius, self.y-self.radius, self.radius*2, self.radius*2), 0, math.pi, 3)
rad = math.radians(self.angle)
pygame.draw.line(screen, (200, 0, 0), (self.x, self.y), (self.x + math.cos(rad)*self.radius, self.y - math.sin(rad)*self.radius), 4)
txt = font.render(f"Elevation: {int(self.angle)}°", True, (0, 0, 0))
screen.blit(txt, (self.x - 50, self.y + 15))
def handle_event(self, event):
if event.type == pygame.MOUSEBUTTONDOWN and math.hypot(event.pos[0] - self.x, event.pos[1] - self.y) < self.radius + 10: self.grabbed = True
elif event.type == pygame.MOUSEBUTTONUP: self.grabbed = False
elif event.type == pygame.MOUSEMOTION and self.grabbed:
self.angle = max(0, min(180, math.degrees(math.atan2(self.y - event.pos[1], event.pos[0] - self.x))))
class ParticleEffect:
def __init__(self, x, y, color):
self.particles = [[x, y, random.uniform(-5, 5), random.uniform(-5, 5), random.randint(20, 50)] for _ in range(30)]
self.color = color
def update(self):
for p in self.particles:
p[0] += p[2]; p[1] += p[3]; p[4] -= 1
self.particles = [p for p in self.particles if p[4] > 0]
class Game:
def __init__(self):
pygame.init()
pygame.mixer.init()
self.screen = pygame.display.set_mode((WIDTH, HEIGHT))
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("Arial", 20); self.banner_font = pygame.font.SysFont("Arial", 80, bold=True)
self.audio = SoundGenerator()
self.exp_snd = self.audio.create_explosion_sound()
self.fire_snd = self.audio.create_beep(150, 0.1, 0.5)
self.running, self.mode, self.state = True, None, "IDLE"
self.wind, self.gravity = random.uniform(-0.15, 0.15), 0.2
self.terrain = self.generate_terrain()
self.p1_pos, self.p2_pos = [100, 0], [WIDTH - 100, 0]
self.p1_destroyed = self.p2_destroyed = False
self.turn, self.stats = 1, {1: {"hits": 0, "shots": 0}, 2: {"hits": 0, "shots": 0}}
self.projectile, self.explosions, self.debris = None, [], []
self.protractor = Protractor(WIDTH//2, HEIGHT - 100, 80)
self.powder_slider = Slider(WIDTH//2 + 120, HEIGHT - 80, 200, 15, "Powder", 10, 120, 60)
self.update_cannon_heights()
def generate_terrain(self):
terrain, base = [], HEIGHT * 0.75
f1, f2, amp1, amp2 = random.uniform(0.004, 0.008), random.uniform(0.01, 0.02), random.randint(60, 130), random.randint(20, 50)
return [base + amp1 * math.sin(x * f1) + amp2 * math.cos(x * f2) for x in range(WIDTH)]
def update_cannon_heights(self):
if not self.p1_destroyed: self.p1_pos[1] = self.terrain[self.p1_pos[0]]
if not self.p2_destroyed: self.p2_pos[1] = self.terrain[self.p2_pos[0]]
def draw_cannon(self, pos, angle, is_p2=False, destroyed=False):
if destroyed: return
rad = math.radians(angle) if not is_p2 else math.radians(180 - angle)
end_x, end_y = pos[0] + math.cos(rad) * 35, pos[1] - math.sin(rad) * 35
pygame.draw.line(self.screen, CANNON_COLOR, pos, (end_x, end_y), 10)
pygame.draw.circle(self.screen, WHEEL_COLOR, (pos[0], pos[1]), 14)
def draw_building(self, pos, destroyed=False):
if destroyed: return
rect = pygame.Rect(pos[0]-25, pos[1]-50, 50, 50)
pygame.draw.rect(self.screen, (120, 120, 120), rect)
for i in range(2):
for j in range(2): pygame.draw.rect(self.screen, (255, 255, 100), (rect.x+10+i*20, rect.y+10+j*20, 10, 10))
def create_debris(self, x, y):
for _ in range(15):
self.debris.append([x + random.randint(-25, 25), y + random.randint(-15, 5),
random.randint(6, 18), random.randint(4, 10), random.uniform(-1, 1)])
def fire(self):
if self.state != "IDLE": return
self.state = "FIRING"
self.stats[self.turn]["shots"] += 1
self.fire_snd.play()
angle = self.protractor.angle if self.turn == 1 else 180 - self.protractor.angle
power = self.powder_slider.val / 5.5
start = self.p1_pos if self.turn == 1 else self.p2_pos
self.projectile = {"x": start[0], "y": start[1], "vx": math.cos(math.radians(angle)) * power, "vy": -math.sin(math.radians(angle)) * power}
def update(self):
if self.state == "FIRING" and self.projectile:
self.projectile["vx"] += self.wind
self.projectile["vy"] += self.gravity
self.projectile["x"] += self.projectile["vx"]
self.projectile["y"] += self.projectile["vy"]
px, py = int(self.projectile["x"]), int(self.projectile["y"])
# Pitch shift based on height
pitch = 300 + (HEIGHT - py) * 2.5
self.audio.create_beep(pitch, 0.05, 0.1).play()
if px < 0 or px >= WIDTH or py > HEIGHT:
self.state, self.projectile = "IDLE", None
self.next_turn()
elif py >= self.terrain[px]:
self.exp_snd.play()
self.explosions.append(ParticleEffect(px, py, EXPLOSION_COLOR))
d1 = math.hypot(px - self.p1_pos[0], py - self.p1_pos[1])
d2 = math.hypot(px - self.p2_pos[0], py - self.p2_pos[1])
hit_detected = False
if d1 < 40:
self.p1_destroyed = True
self.create_debris(self.p1_pos[0], self.p1_pos[1])
hit_detected = True
if d2 < 40:
self.p2_destroyed = True
self.create_debris(self.p2_pos[0], self.p2_pos[1])
hit_detected = True
if hit_detected:
self.state, self.state_timer = "HIT_WAIT", pygame.time.get_ticks()
if self.turn == 1 and d2 < 40: self.stats[1]["hits"] += 1
if self.turn == 2 and d1 < 40: self.stats[2]["hits"] += 1
else:
for i in range(px-35, px+35):
if 0 <= i < WIDTH: self.terrain[i] += math.sqrt(max(0, 35**2 - abs(i-px)**2))
self.state, self.state_timer = "EXPLODING", pygame.time.get_ticks()
self.projectile = None
elif self.state == "EXPLODING" and pygame.time.get_ticks() - self.state_timer > 1000:
self.state = "IDLE"; self.next_turn()
elif self.state == "HIT_WAIT" and pygame.time.get_ticks() - self.state_timer > 2000:
self.state = "RESET_WAIT"
for e in self.explosions: e.update()
self.explosions = [e for e in self.explosions if e.particles]
def next_turn(self):
if self.mode == '2P': self.turn = 2 if self.turn == 1 else 1
self.update_cannon_heights()
def reset_after_hit(self):
self.wind, self.gravity = random.uniform(-0.15, 0.15), random.uniform(0.12, 0.28)
self.terrain = self.generate_terrain()
self.debris = []
self.p1_destroyed = self.p2_destroyed = False
self.state = "IDLE"
self.update_cannon_heights()
self.next_turn()
def draw(self):
self.screen.fill(SKY_COLOR)
for x, y in enumerate(self.terrain): pygame.draw.line(self.screen, GROUND_COLOR, (x, HEIGHT), (x, y))
for d in self.debris:
surf = pygame.Surface((d[2], d[3]), pygame.SRCALPHA)
surf.fill((50, 50, 50))
self.screen.blit(pygame.transform.rotate(surf, d[4]*90), (d[0], d[1]))
self.draw_cannon(self.p1_pos, self.protractor.angle if self.turn == 1 else 45, destroyed=self.p1_destroyed)
if self.mode == '2P': self.draw_cannon(self.p2_pos, self.protractor.angle if self.turn == 2 else 45, True, destroyed=self.p2_destroyed)
else: self.draw_building(self.p2_pos, destroyed=self.p2_destroyed)
if self.projectile: pygame.draw.circle(self.screen, (0,0,0), (int(self.projectile["x"]), int(self.projectile["y"])), 5)
for e in self.explosions:
for p in e.particles: pygame.draw.circle(self.screen, EXPLOSION_COLOR, (int(p[0]), int(p[1])), 4)
if self.state == "IDLE":
self.protractor.draw(self.screen, self.font); self.powder_slider.draw(self.screen, self.font)
hint = self.font.render("Arrows: Elev/Powder | Space: Fire", True, (0,0,0))
self.screen.blit(hint, (WIDTH//2 - 150, HEIGHT - 30))
elif self.state in ["HIT_WAIT", "RESET_WAIT"]:
banner_text = "Got them!" if (self.p2_destroyed and self.turn == 1) or (self.p1_destroyed and self.turn == 2) else "Oops!"
banner = self.banner_font.render(banner_text, True, (200, 0, 0))
self.screen.blit(banner, (WIDTH//2 - banner.get_width()//2, HEIGHT//3))
if self.state == "RESET_WAIT":
sub = self.font.render("Press SPACE for next terrain", True, (0,0,0))
self.screen.blit(sub, (WIDTH//2 - sub.get_width()//2, HEIGHT//3 + 100))
def acc(p): return (self.stats[p]["hits"]/self.stats[p]["shots"]*100) if self.stats[p]["shots"] > 0 else 0
self.screen.blit(self.font.render(f"P1: {self.stats[1]['hits']} ({acc(1):.1f}%)", True, (0,0,0)), (20, 20))
if self.mode == '2P':
self.screen.blit(self.font.render(f"P2: {self.stats[2]['hits']} ({acc(2):.1f}%)", True, (0,0,0)), (WIDTH-200, 20))
self.screen.blit(self.font.render(f"Wind: {abs(self.wind*100):.1f} {'R' if self.wind > 0 else 'L'} | G: {self.gravity:.2f}", True, (0,0,150)), (WIDTH//2 - 120, 20))
pygame.display.flip()
def run(self):
while self.running:
# 1. Handle keyboard polling for smooth movement
keys = pygame.key.get_pressed()
if self.state == "IDLE" and self.mode:
if keys[pygame.K_UP]: self.protractor.angle = min(180, self.protractor.angle + 1)
if keys[pygame.K_DOWN]: self.protractor.angle = max(0, self.protractor.angle - 1)
if keys[pygame.K_RIGHT]: self.powder_slider.val = min(self.powder_slider.max, self.powder_slider.val + 0.5)
if keys[pygame.K_LEFT]: self.powder_slider.val = max(self.powder_slider.min, self.powder_slider.val - 0.5)
# 2. Handle discrete events
for event in pygame.event.get():
if event.type == pygame.QUIT: self.running = False
if self.mode:
if self.state == "IDLE":
self.protractor.handle_event(event)
self.powder_slider.handle_event(event)
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: self.fire()
elif self.state == "RESET_WAIT" and event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: self.reset_after_hit()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_1: self.mode = '1P'
if event.key == pygame.K_2: self.mode = '2P'
if self.mode: self.update(); self.draw()
else:
self.screen.fill(SKY_COLOR)
self.screen.blit(self.font.render("Press 1 for Single Player, 2 for Two Player Duel", True, (0,0,0)), (WIDTH//2 - 200, HEIGHT//2))
pygame.display.flip()
self.clock.tick(FPS)
pygame.quit()
if __name__ == "__main__": Game().run()We use PyGame to draw the game and the sliders, and to handle the sound effects and animation.
When there is a hit, we create the explosion using the ParticleEffect class.
The game starts with the creation of a height map for the terrain. This lets us carve craters in the ground when the projectile lands. When something blows up, we create a debris field.
There is a cannon-firing sound, followed by the sound of the projectile that rises and falls as the projectile does.
We check for collisions with the ground, the enemy, and with our own cannon, which we are allowed to blow up.
The result is a fun and quite playable game, either as a solitary activity or with a friend (or in this case, enemy).
What to Learn From Reading This Code
- How to use PyGame for game elements and animation.
- How to add sound effects to a game.
- How to calculate the physics of gravity and wind.
- How to simulate explosions and dig craters.
- How to use both the mouse and the keyboard for input.