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.