Skip to content

Instantly share code, notes, and snippets.

@JGalego
Created December 21, 2025 21:52
Show Gist options
  • Select an option

  • Save JGalego/0c21009d61a3317e679b873d28ab9b31 to your computer and use it in GitHub Desktop.

Select an option

Save JGalego/0c21009d61a3317e679b873d28ab9b31 to your computer and use it in GitHub Desktop.
Control a ๐Ÿญ with your cursor to collect ๐Ÿง€ while avoiding a rising herd of ๐Ÿ”ด enemies that hunt with flocking behavior! ๐ŸŽฎ
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "pygame >= 2.6.1",
# ]
# ///
"""
An escape game where the player avoids enemies with flocking behavior and collects items.
Enemies exhibit herd behavior (separation, alignment, cohesion).
"""
# Standard imports
import random
import math
import sys
# Library imports
import pygame
# Initialize Pygame
pygame.init()
# Constants
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
FPS = 60
# Colors
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 100, 255)
YELLOW = (255, 255, 0)
DARK_GRAY = (50, 50, 50)
# Game settings
STARTING_LIVES = 3
BASELINE_ENEMIES = 5 # User can modify this
ENEMY_SPAWN_INTERVAL = 3000 # milliseconds
ITEM_SPAWN_INTERVAL = 2000 # milliseconds
ENEMY_DEATH_INTERVAL = 1000 # milliseconds when mouse is off-screen
class Vector2D:
"""Simple 2D vector class for physics calculations"""
def __init__(self, x=0, y=0):
self.x = x
self.y = y
def __add__(self, other):
"""Add two vectors"""
return Vector2D(self.x + other.x, self.y + other.y)
def __sub__(self, other):
"""Subtract two vectors"""
return Vector2D(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
"""Multiply vector by scalar"""
return Vector2D(self.x * scalar, self.y * scalar)
def __truediv__(self, scalar):
"""Divide vector by scalar"""
if scalar != 0:
return Vector2D(self.x / scalar, self.y / scalar)
return Vector2D(0, 0)
def magnitude(self):
"""Calculate vector magnitude"""
return math.sqrt(self.x**2 + self.y**2)
def normalize(self):
"""Return normalized vector"""
mag = self.magnitude()
if mag > 0:
return self / mag
return Vector2D(0, 0)
def limit(self, max_value):
"""Limit vector magnitude to max_value"""
if self.magnitude() > max_value:
return self.normalize() * max_value
return self
def distance_to(self, other):
"""Calculate distance to another vector"""
return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2)
class Enemy:
"""Enemy with flocking behavior"""
def __init__(self, x, y):
self.pos = Vector2D(x, y)
self.vel = Vector2D(random.uniform(-1, 1), random.uniform(-1, 1))
self.acc = Vector2D(0, 0)
self.max_speed = 2.5
self.max_force = 0.1
self.size = 8
self.perception_radius = 50
def flock(self, enemies, player_pos, player_visible):
"""Apply flocking rules: separation, alignment, cohesion"""
separation = self.separate(enemies)
alignment = self.align(enemies)
cohesion = self.cohere(enemies)
# Only seek player if they're visible (on screen)
if player_visible:
seek = self.seek_target(player_pos)
# Weight the forces
separation = separation * 1.5
alignment = alignment * 1.0
cohesion = cohesion * 1.0
seek = seek * 0.8
self.acc = self.acc + separation + alignment + cohesion + seek
else:
# Random wandering when player is not visible
wander = Vector2D(random.uniform(-1, 1), random.uniform(-1, 1))
wander = wander.normalize() * 0.5
# Weight the forces differently for wandering
separation = separation * 1.0
alignment = alignment * 0.5
cohesion = cohesion * 0.5
self.acc = self.acc + separation + alignment + cohesion + wander
def separate(self, enemies):
"""Separation: steer to avoid crowding"""
steering = Vector2D(0, 0)
total = 0
for other in enemies:
distance = self.pos.distance_to(other.pos)
if other != self and distance < self.perception_radius / 2:
diff = self.pos - other.pos
diff = diff / (distance + 0.001) # Weight by distance
steering = steering + diff
total += 1
if total > 0:
steering = steering / total
steering = steering.normalize() * self.max_speed
steering = steering - self.vel
steering = steering.limit(self.max_force)
return steering
def align(self, enemies):
"""Alignment: steer towards average heading"""
steering = Vector2D(0, 0)
total = 0
for other in enemies:
distance = self.pos.distance_to(other.pos)
if other != self and distance < self.perception_radius:
steering = steering + other.vel
total += 1
if total > 0:
steering = steering / total
steering = steering.normalize() * self.max_speed
steering = steering - self.vel
steering = steering.limit(self.max_force)
return steering
def cohere(self, enemies):
"""Cohesion: steer towards average position"""
steering = Vector2D(0, 0)
total = 0
for other in enemies:
distance = self.pos.distance_to(other.pos)
if other != self and distance < self.perception_radius:
steering = steering + other.pos
total += 1
if total > 0:
steering = steering / total
return self.seek_position(steering)
return steering
def seek_target(self, target_pos):
"""Seek towards the player"""
desired = target_pos - self.pos
desired = desired.normalize() * self.max_speed
steering = desired - self.vel
steering = steering.limit(self.max_force)
return steering
def seek_position(self, target_pos):
"""Seek towards a position"""
desired = target_pos - self.pos
desired = desired.normalize() * self.max_speed
steering = desired - self.vel
steering = steering.limit(self.max_force)
return steering
def update(self):
"""Update position and velocity"""
self.vel = self.vel + self.acc
self.vel = self.vel.limit(self.max_speed)
self.pos = self.pos + self.vel
self.acc = self.acc * 0 # Reset acceleration
# Wrap around screen edges
if self.pos.x < 0:
self.pos.x = SCREEN_WIDTH
elif self.pos.x > SCREEN_WIDTH:
self.pos.x = 0
if self.pos.y < 0:
self.pos.y = SCREEN_HEIGHT
elif self.pos.y > SCREEN_HEIGHT:
self.pos.y = 0
def draw(self, screen):
"""Draw the enemy"""
pygame.draw.circle(screen, RED, (int(self.pos.x), int(self.pos.y)), self.size)
# Draw direction indicator
end_x = int(self.pos.x + self.vel.normalize().x * self.size)
end_y = int(self.pos.y + self.vel.normalize().y * self.size)
pygame.draw.line(screen, DARK_GRAY,
(int(self.pos.x), int(self.pos.y)),
(end_x, end_y), 2)
def collides_with(self, x, y, radius):
"""Check collision with a point"""
return self.pos.distance_to(Vector2D(x, y)) < (self.size + radius)
class Item:
"""Collectible item"""
def __init__(self):
self.x = random.randint(50, SCREEN_WIDTH - 50)
self.y = random.randint(50, SCREEN_HEIGHT - 50)
self.size = 10
self.pulse = 0
def update(self):
"""Update pulse animation"""
self.pulse = (self.pulse + 0.1) % (2 * math.pi)
def draw(self, screen):
"""Draw the item as cheese"""
pulse_size = int(math.sin(self.pulse) * 2)
# Cheese wedge (triangle)
cheese_points = [
(self.x - self.size, self.y + self.size), # Bottom left
(self.x + self.size, self.y + self.size), # Bottom right
(self.x, self.y - self.size) # Top
]
# Yellow cheese with pulse
offset = pulse_size
pulsed_points = [
(self.x - self.size - offset, self.y + self.size + offset),
(self.x + self.size + offset, self.y + self.size + offset),
(self.x, self.y - self.size - offset)
]
pygame.draw.polygon(screen, YELLOW, pulsed_points)
pygame.draw.polygon(screen, (255, 200, 0), cheese_points)
# Cheese holes
hole_positions = [
(self.x - 3, self.y + 2),
(self.x + 3, self.y + 2),
(self.x, self.y - 3)
]
for hole_x, hole_y in hole_positions:
pygame.draw.circle(screen, (200, 150, 0), (hole_x, hole_y), 2)
def collides_with(self, x, y):
"""Check if mouse collects the item"""
distance = math.sqrt((self.x - x)**2 + (self.y - y)**2)
return distance < self.size + 5
class Game:
"""Main game class"""
# pylint: disable=too-many-instance-attributes
def __init__(self):
self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Escape the Herd")
self.clock = pygame.time.Clock()
self.font = pygame.font.Font(None, 36)
self.small_font = pygame.font.Font(None, 24)
# Initialize all instance attributes
self.enemies = []
self.items = []
self.score = 0
self.lives = STARTING_LIVES
self.game_over = False
self.mouse_on_screen = False
self.baseline_enemies = BASELINE_ENEMIES
self.last_enemy_spawn = 0
self.last_item_spawn = 0
self.last_enemy_death = 0
self.last_score_penalty = 0
self.time_on_screen = 0
self.invincibility_timer = 0
self.reset_game()
def reset_game(self):
"""Reset game state"""
self.enemies = []
self.items = []
self.score = 0
self.lives = STARTING_LIVES
self.game_over = False
self.mouse_on_screen = False
self.baseline_enemies = BASELINE_ENEMIES
# Spawn initial enemies
for _ in range(self.baseline_enemies):
x = random.randint(0, SCREEN_WIDTH)
y = random.randint(0, SCREEN_HEIGHT)
self.enemies.append(Enemy(x, y))
# Spawn initial item
self.items.append(Item())
# Timers
self.last_enemy_spawn = pygame.time.get_ticks()
self.last_item_spawn = pygame.time.get_ticks()
self.last_enemy_death = pygame.time.get_ticks()
self.last_score_penalty = pygame.time.get_ticks()
self.time_on_screen = 0
self.invincibility_timer = 0
def spawn_enemy(self):
"""Spawn a new enemy at screen edge"""
side = random.choice(['top', 'bottom', 'left', 'right'])
if side == 'top':
x, y = random.randint(0, SCREEN_WIDTH), 0
elif side == 'bottom':
x, y = random.randint(0, SCREEN_WIDTH), SCREEN_HEIGHT
elif side == 'left':
x, y = 0, random.randint(0, SCREEN_HEIGHT)
else:
x, y = SCREEN_WIDTH, random.randint(0, SCREEN_HEIGHT)
self.enemies.append(Enemy(x, y))
def handle_events(self):
"""Handle pygame events"""
for event in pygame.event.get():
if event.type == pygame.QUIT:
return False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
return False
if event.key == pygame.K_r and self.game_over:
self.reset_game()
if event.key == pygame.K_UP:
# Allow user to increase baseline
self.baseline_enemies += 1
if event.key == pygame.K_DOWN:
# Allow user to decrease baseline
self.baseline_enemies = max(1, self.baseline_enemies - 1)
return True
def update(self):
"""Update game state"""
# pylint: disable=too-many-branches
if self.game_over:
return
# Get mouse position and check if on screen
mouse_x, mouse_y = pygame.mouse.get_pos()
window_rect = self.screen.get_rect()
mouse_in_bounds = window_rect.collidepoint(mouse_x, mouse_y)
window_focused = pygame.mouse.get_focused()
self.mouse_on_screen = mouse_in_bounds and window_focused
current_time = pygame.time.get_ticks()
# Update invincibility timer
if self.invincibility_timer > 0:
self.invincibility_timer -= 1
# Mouse on screen: spawn enemies and items
if self.mouse_on_screen:
self.time_on_screen += 1
# Spawn enemies based on time and items collected
score_factor = self.score * 100
time_factor = self.time_on_screen // 100
spawn_rate = max(1000, ENEMY_SPAWN_INTERVAL - score_factor - time_factor)
if current_time - self.last_enemy_spawn > spawn_rate:
self.spawn_enemy()
self.last_enemy_spawn = current_time
# Spawn items
item_spawn_time = current_time - self.last_item_spawn
if len(self.items) == 0 or item_spawn_time > ITEM_SPAWN_INTERVAL:
self.items.append(Item())
self.last_item_spawn = current_time
else:
# Mouse off screen: enemies die out
if len(self.enemies) > self.baseline_enemies:
if current_time - self.last_enemy_death > ENEMY_DEATH_INTERVAL:
self.enemies.pop()
self.last_enemy_death = current_time
# Deduct 1 point per second when off screen
if current_time - self.last_score_penalty >= 1000:
self.score = max(0, self.score - 1)
self.last_score_penalty = current_time
# Update enemies with flocking behavior
player_pos = Vector2D(mouse_x, mouse_y)
for enemy in self.enemies:
enemy.flock(self.enemies, player_pos, self.mouse_on_screen)
enemy.update()
# Check collision with player (only if not invincible AND mouse is on screen)
is_vulnerable = self.mouse_on_screen and self.invincibility_timer == 0
if is_vulnerable and enemy.collides_with(mouse_x, mouse_y, 5):
self.lives -= 1
self.invincibility_timer = 60 # 1 second at 60 FPS
if self.lives <= 0:
self.game_over = True
# Update and check items
for item in self.items[:]:
item.update()
if item.collides_with(mouse_x, mouse_y):
self.score += 10
self.items.remove(item)
def draw(self):
"""Draw everything"""
# pylint: disable=too-many-locals,too-many-statements
self.screen.fill(BLACK)
# Draw items
for item in self.items:
item.draw(self.screen)
# Draw enemies
for enemy in self.enemies:
enemy.draw(self.screen)
# Draw mouse cursor (custom) - only if mouse is on screen
if self.mouse_on_screen:
mouse_x, mouse_y = pygame.mouse.get_pos()
# Draw mouse (the animal)
if self.invincibility_timer > 0:
# Flashing colors when invincible
body_color = BLUE if (self.invincibility_timer // 10) % 2 == 0 else WHITE
else:
body_color = (150, 150, 150) # Gray mouse
# Mouse body (oval)
pygame.draw.ellipse(self.screen, body_color, (mouse_x - 8, mouse_y - 6, 16, 12))
# Mouse head (circle)
pygame.draw.circle(self.screen, body_color, (mouse_x, mouse_y - 3), 6)
# Mouse ears (circles)
pygame.draw.circle(
self.screen, body_color, (mouse_x - 5, mouse_y - 8), 3)
# Pink inner ear
pink = (255, 192, 203)
pygame.draw.circle(
self.screen, pink, (mouse_x - 5, mouse_y - 8), 2)
pygame.draw.circle(
self.screen, body_color, (mouse_x + 5, mouse_y - 8), 3)
# Pink inner ear
pygame.draw.circle(
self.screen, pink, (mouse_x + 5, mouse_y - 8), 2)
# Mouse eyes
pygame.draw.circle(self.screen, BLACK, (mouse_x - 2, mouse_y - 4), 1)
pygame.draw.circle(self.screen, BLACK, (mouse_x + 2, mouse_y - 4), 1)
# Mouse nose
pygame.draw.circle(self.screen, (255, 192, 203), (mouse_x, mouse_y - 1), 1)
# Mouse tail (curved line)
tail_points = [
(mouse_x + 8, mouse_y),
(mouse_x + 12, mouse_y + 2),
(mouse_x + 15, mouse_y + 1)
]
pygame.draw.lines(self.screen, body_color, False, tail_points, 2)
# Draw HUD
score_text = self.font.render(f"Score: {self.score}", True, WHITE)
lives_text = self.font.render(f"Lives: {self.lives}", True, WHITE)
enemy_count = len(self.enemies)
enemies_text = self.small_font.render(
f"Enemies: {enemy_count}", True, WHITE)
baseline_info = f"Baseline: {self.baseline_enemies} (Up/Down)"
baseline_text = self.small_font.render(baseline_info, True, WHITE)
self.screen.blit(score_text, (10, 10))
self.screen.blit(lives_text, (10, 50))
self.screen.blit(enemies_text, (SCREEN_WIDTH - 150, 10))
self.screen.blit(baseline_text, (SCREEN_WIDTH - 200, 40))
# Draw status message
if not self.mouse_on_screen:
status_msg = "Move mouse over screen to activate!"
status_text = self.small_font.render(status_msg, True, YELLOW)
center_x = SCREEN_WIDTH // 2
center_y = SCREEN_HEIGHT - 30
text_rect = status_text.get_rect(center=(center_x, center_y))
self.screen.blit(status_text, text_rect)
# Draw game over screen
if self.game_over:
overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
overlay.set_alpha(180)
overlay.fill(BLACK)
self.screen.blit(overlay, (0, 0))
game_over_text = self.font.render("GAME OVER", True, RED)
final_score_text = self.font.render(
f"Final Score: {self.score}", True, WHITE)
restart_msg = "Press R to Restart or ESC to Quit"
restart_text = self.small_font.render(restart_msg, True, WHITE)
center_x = SCREEN_WIDTH // 2
center_y = SCREEN_HEIGHT // 2
self.screen.blit(
game_over_text,
(center_x - game_over_text.get_width() // 2, center_y - 60))
self.screen.blit(
final_score_text,
(center_x - final_score_text.get_width() // 2, center_y))
self.screen.blit(
restart_text,
(center_x - restart_text.get_width() // 2, center_y + 60))
pygame.display.flip()
def run(self):
"""Main game loop"""
running = True
while running:
running = self.handle_events()
self.update()
self.draw()
self.clock.tick(FPS)
pygame.quit()
sys.exit()
if __name__ == "__main__":
game = Game()
game.run()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment