Created
February 6, 2025 03:29
-
-
Save streichsbaer/7341497ad5b3ab1c564cb6f4da9a869b to your computer and use it in GitHub Desktop.
"100-Snake Battle Royale Game" by o3 deep research
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import pygame | |
import random | |
import colorsys | |
# -------------------- Configuration Constants -------------------- # | |
CELL_SIZE = 10 # pixel size of each grid cell (snake segment size) | |
GRID_WIDTH = 80 # number of cells horizontally | |
GRID_HEIGHT = 60 # number of cells vertically | |
NUM_SNAKES = 100 # number of snakes in the battle royale | |
INITIAL_LENGTH = 3 # initial length of each snake (in segments) | |
INITIAL_FOOD = 20 # initial number of food items on the board | |
FPS = 15 # frames per second (speed of the game) | |
# Colors | |
BACKGROUND_COLOR = (0, 0, 0) # black background | |
FOOD_COLOR = (255, 255, 255) # white food | |
# Generate distinct colors for snakes by varying hue | |
SNAKE_COLORS = [ | |
tuple(int(c * 255) for c in colorsys.hsv_to_rgb(i / NUM_SNAKES, 1.0, 1.0)) | |
for i in range(NUM_SNAKES) | |
] | |
# -------------------- Snake Class (AI and state) -------------------- # | |
class Snake: | |
def __init__(self, snake_id, start_pos, start_dir, color): | |
"""Initialize a snake with an ID, starting position, direction, and color.""" | |
self.id = snake_id | |
self.body = [start_pos] # list of (x,y) positions; body[0] is head | |
self.direction = start_dir # current moving direction (dx, dy) | |
self.alive = True | |
self.color = color | |
def get_head(self): | |
return self.body[0] | |
def choose_direction(self, food_positions, occupancy): | |
""" | |
Decide the next movement direction based on nearest food, | |
while avoiding immediate collisions. Returns a (dx, dy) direction. | |
""" | |
if not self.alive: | |
return self.direction # no change if snake is dead | |
hx, hy = self.get_head() | |
# Find the closest food (Manhattan distance) | |
target = None | |
min_dist = float("inf") | |
for fx, fy in food_positions: | |
dist = abs(fx - hx) + abs(fy - hy) | |
if dist < min_dist: | |
min_dist = dist | |
target = (fx, fy) | |
if target is None: | |
# No food available (shouldn't happen since we maintain some food) | |
# Just keep moving in the same direction | |
target = (hx + self.direction[0], hy + self.direction[1]) | |
tx, ty = target | |
# Determine the preferred direction to move towards the target food | |
dx, dy = 0, 0 | |
if abs(tx - hx) > abs(ty - hy): | |
dx = 1 if tx > hx else -1 if tx < hx else 0 | |
dy = 0 | |
else: | |
dx = 0 | |
dy = 1 if ty > hy else -1 if ty < hy else 0 | |
desired_dir = (dx, dy) | |
if desired_dir == (0, 0): | |
desired_dir = self.direction # target is on current cell (shouldn't happen) | |
# Avoid reversing into itself (no 180Β° turn if length > 1) | |
if len(self.body) > 1 and ( | |
desired_dir[0] == -self.direction[0] | |
and desired_dir[1] == -self.direction[1] | |
): | |
# If the desired direction is directly opposite, choose a different direction (left/right turn) | |
# Determine perpendicular directions: | |
if self.direction[0] != 0: # current moving horizontally | |
# perpendicular moves: up or down | |
candidates = [(0, 1), (0, -1)] | |
else: # current moving vertically | |
# perpendicular moves: left or right | |
candidates = [(1, 0), (-1, 0)] | |
# Pick the candidate that brings closer to the target (if any) | |
candidates.sort(key=lambda d: abs((hx + d[0]) - tx) + abs((hy + d[1]) - ty)) | |
if candidates: | |
desired_dir = candidates[0] | |
# Avoid immediate collision: if desired direction leads to a wall or snake body, try another | |
def is_safe(dir_tuple): | |
nx, ny = hx + dir_tuple[0], hy + dir_tuple[1] | |
# Check wall boundaries | |
if nx < 0 or nx >= GRID_WIDTH or ny < 0 or ny >= GRID_HEIGHT: | |
return False | |
occ = occupancy.get((nx, ny)) | |
# Safe if the next cell is empty or just has food (not a snake body) | |
return occ is None or occ == "food" | |
if not is_safe(desired_dir): | |
# Try other directions (excluding the reverse of current direction) | |
possible_dirs = [(0, -1), (0, 1), (-1, 0), (1, 0)] | |
# Remove the reverse direction from possible moves | |
rev_dir = (-self.direction[0], -self.direction[1]) | |
possible_dirs = [d for d in possible_dirs if d != rev_dir] | |
# Sort by closeness to target food to choose a reasonable alternative | |
possible_dirs.sort( | |
key=lambda d: abs((hx + d[0]) - tx) + abs((hy + d[1]) - ty) | |
) | |
for d in possible_dirs: | |
if is_safe(d): | |
desired_dir = d | |
break | |
# If none are safe, the snake will end up colliding (dead end scenario) | |
# Update the snake's direction | |
self.direction = desired_dir | |
return desired_dir | |
# -------------------- Game Class (Game state and logic) -------------------- # | |
class Game: | |
def __init__(self): | |
"""Initialize the game: create snakes, place initial food, set up grid.""" | |
# Grid occupancy dictionary: maps (x,y) -> 'food' or snake_id | |
self.occupancy = {} | |
# List of snakes | |
self.snakes = [] | |
# List of food positions (tuples) | |
self.food_positions = [] | |
# Spawn snakes at random positions | |
for i in range(NUM_SNAKES): | |
# Find an empty start position for the snake's head | |
while True: | |
start_x = random.randrange(GRID_WIDTH) | |
start_y = random.randrange(GRID_HEIGHT) | |
if (start_x, start_y) not in self.occupancy: | |
break | |
# Random initial direction for variety | |
start_dir = random.choice([(1, 0), (-1, 0), (0, 1), (0, -1)]) | |
# Initialize snake's body (starting length). Place segments in opposite direction of movement to avoid immediate self-collision. | |
body = [(start_x, start_y)] | |
self.occupancy[(start_x, start_y)] = ( | |
i # mark head position as occupied by this snake | |
) | |
bx, by = start_x, start_y | |
for seg in range(1, INITIAL_LENGTH): | |
bx -= start_dir[0] | |
by -= start_dir[1] | |
# Stop if out of bounds or hitting another snake (shouldn't normally happen with careful placement) | |
if ( | |
bx < 0 | |
or bx >= GRID_WIDTH | |
or by < 0 | |
or by >= GRID_HEIGHT | |
or (bx, by) in self.occupancy | |
): | |
break | |
body.append((bx, by)) | |
self.occupancy[(bx, by)] = i # mark this segment on grid | |
# Create Snake object | |
snake = Snake( | |
snake_id=i, | |
start_pos=(start_x, start_y), | |
start_dir=start_dir, | |
color=SNAKE_COLORS[i], | |
) | |
snake.body = body # override body if we added segments | |
self.snakes.append(snake) | |
# Spawn initial food items | |
for f in range(INITIAL_FOOD): | |
self.spawn_food() | |
def spawn_food(self): | |
"""Place a new food item on a random empty cell.""" | |
# Try random positions until an empty cell is found | |
for attempt in range(1000): | |
fx = random.randrange(GRID_WIDTH) | |
fy = random.randrange(GRID_HEIGHT) | |
if (fx, fy) not in self.occupancy: # empty spot | |
self.occupancy[(fx, fy)] = "food" | |
self.food_positions.append((fx, fy)) | |
return (fx, fy) | |
# Fallback: do a linear scan for empty cell (in case the grid is very full) | |
for x in range(GRID_WIDTH): | |
for y in range(GRID_HEIGHT): | |
if (x, y) not in self.occupancy: | |
self.occupancy[(x, y)] = "food" | |
self.food_positions.append((x, y)) | |
return (x, y) | |
return None # no space (game board full) | |
def update(self): | |
""" | |
Perform one update step: move all snakes (AI decisions), handle collisions and eating, | |
and spawn new food if needed. | |
Returns a tuple: (alive_count, cells_cleared, cells_filled). | |
- alive_count: number of snakes still alive after the update. | |
- cells_cleared: list of grid cells that were emptied this turn (to redraw background). | |
- cells_filled: list of (cell, color) for new snake segments or food that need drawing. | |
""" | |
if self.get_alive_count() <= 1: | |
# If game is already over (should be handled outside before calling update) | |
return self.get_alive_count(), [], [] | |
alive_snakes = [s for s in self.snakes if s.alive] | |
cells_cleared = [] # cells to clear (became empty) | |
cells_filled = [] # cells to draw (became occupied by snake or food) | |
food_set = set(self.food_positions) # for quick lookup | |
# **1. Determine moves for each snake (their intended new head position)** | |
moves = [] # list of (snake, new_head_x, new_head_y, will_eat) | |
for snake in alive_snakes: | |
dir_x, dir_y = snake.choose_direction(self.food_positions, self.occupancy) | |
hx, hy = snake.get_head() | |
new_x = hx + dir_x | |
new_y = hy + dir_y | |
will_eat = ( | |
new_x, | |
new_y, | |
) in food_set # True if there's food at the target cell | |
moves.append((snake, new_x, new_y, will_eat)) | |
# **2. Remove snake tails from occupancy (before moving) for those that will move forward** | |
# (If a snake is going to eat, its tail stays, because it grows.) | |
for snake, nx, ny, will_eat in moves: | |
if not snake.alive: | |
continue | |
if not will_eat: | |
# Snake is not eating, so it will move forward normally: remove its tail segment | |
tail_x, tail_y = snake.body[-1] | |
if (tail_x, tail_y) in self.occupancy and self.occupancy[ | |
(tail_x, tail_y) | |
] == snake.id: | |
del self.occupancy[(tail_x, tail_y)] | |
# Mark this cell for clearing (it becomes empty) | |
cells_cleared.append((tail_x, tail_y)) | |
# Remove tail from the snake's body list | |
snake.body.pop() | |
# **3. Handle collisions (and determine which snakes survive to move)** | |
head_targets = {} # maps target cell -> snake that intends to move there | |
for snake, nx, ny, will_eat in moves: | |
if not snake.alive: | |
continue | |
# Check wall collision | |
if nx < 0 or nx >= GRID_WIDTH or ny < 0 or ny >= GRID_HEIGHT: | |
snake.alive = False | |
continue | |
# Check collision with any existing snake body in that cell | |
occ = self.occupancy.get((nx, ny)) | |
if occ is not None and occ != "food": | |
# The cell is occupied by a snake segment (since it's not 'food') | |
if occ == snake.id: | |
# It's this snake's own body (should only happen if it tried to go into its tail that wasn't removed due to eating) | |
snake.alive = False | |
else: | |
# It's another snake's body | |
snake.alive = False | |
continue | |
# Check for head-on collision: two snakes aiming for the same cell | |
if (nx, ny) in head_targets: | |
# Another snake is also moving into this cell | |
other_snake = head_targets[(nx, ny)] | |
if other_snake is not None: | |
other_snake.alive = False | |
snake.alive = False | |
# Mark the cell with None to indicate a head-on crash (no snake gets it) | |
head_targets[(nx, ny)] = None | |
else: | |
head_targets[(nx, ny)] = snake | |
# **4. Apply movements for surviving snakes and handle food consumption** | |
new_food_needed = 0 | |
for snake, nx, ny, will_eat in moves: | |
if not snake.alive: | |
# Snake died this turn: clear all its body from the grid | |
for bx, by in snake.body: | |
if (bx, by) in self.occupancy and self.occupancy[ | |
(bx, by) | |
] == snake.id: | |
del self.occupancy[(bx, by)] | |
cells_cleared.append((bx, by)) | |
snake.body.clear() | |
continue | |
# Snake is alive and will move into (nx, ny) | |
# If there's food at the new cell, consume it | |
if will_eat: | |
# Remove the food from game state | |
if (nx, ny) in self.food_positions: | |
self.food_positions.remove((nx, ny)) | |
# We don't immediately remove 'food' from occupancy here, because we're about to override it with snake.id below. | |
new_food_needed += 1 # we will spawn a new food elsewhere later | |
# Add new head position to snake's body | |
snake.body.insert(0, (nx, ny)) | |
# Mark the new head position in the occupancy grid with the snake's id | |
self.occupancy[(nx, ny)] = snake.id | |
# Mark this cell to draw (snake's head moved here) | |
cells_filled.append(((nx, ny), snake.color)) | |
# **5. Spawn new food for each eaten piece** | |
for _ in range(new_food_needed): | |
pos = self.spawn_food() | |
if pos: | |
# Mark new food cell to draw | |
cells_filled.append((pos, FOOD_COLOR)) | |
# Return number of alive snakes and lists of cells to redraw | |
return self.get_alive_count(), cells_cleared, cells_filled | |
def get_alive_count(self): | |
"""Utility to get current count of alive snakes.""" | |
return sum(1 for s in self.snakes if s.alive) | |
# -------------------- Main Game Loop -------------------- # | |
pygame.init() | |
# Create game window | |
window_width = GRID_WIDTH * CELL_SIZE | |
window_height = GRID_HEIGHT * CELL_SIZE | |
screen = pygame.display.set_mode((window_width, window_height)) | |
pygame.display.set_caption("100-Snake Battle Royale") | |
# Font for scoreboard | |
font = pygame.font.Font(None, 24) # default font, size 24 | |
clock = pygame.time.Clock() | |
# Initialize game state | |
game = Game() | |
# Fill background once at start | |
screen.fill(BACKGROUND_COLOR) | |
# Draw initial snakes and food | |
for (x, y), occ in game.occupancy.items(): | |
if occ == "food": | |
# draw food | |
rect = pygame.Rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE) | |
pygame.draw.rect(screen, FOOD_COLOR, rect) | |
else: | |
# occ is a snake id -> draw snake segment | |
snake_id = occ | |
rect = pygame.Rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE) | |
pygame.draw.rect(screen, SNAKE_COLORS[snake_id], rect) | |
# Initial scoreboard | |
alive = game.get_alive_count() | |
score_text = f"Snakes remaining: {alive}" | |
score_surf = font.render(score_text, True, (255, 255, 255)) | |
# We draw scoreboard text with a background fill for clarity | |
text_rect = score_surf.get_rect(topleft=(5, 5)) | |
# Fill a rectangle behind text (to cover any snake segments underneath) | |
screen.fill(BACKGROUND_COLOR, text_rect.inflate(5, 5)) | |
screen.blit(score_surf, (5, 5)) | |
pygame.display.update() # update entire screen for initial frame | |
# Game loop | |
running = True | |
winner_shown = False | |
while running: | |
# Cap the frame rate | |
clock.tick( | |
FPS | |
) # limit to FPS frames per second​:contentReference[oaicite:4]{index=4} | |
# Handle quit events | |
for event in pygame.event.get(): | |
if event.type == pygame.QUIT: | |
running = False | |
# Update game state (move snakes, handle collisions/eating) | |
alive_count, cells_cleared, cells_filled = game.update() | |
# Redraw the parts of the screen that changed | |
dirty_rects = [] # list of pygame.Rect to update | |
# 1. Clear emptied cells (draw background) | |
for cx, cy in cells_cleared: | |
rect = pygame.Rect(cx * CELL_SIZE, cy * CELL_SIZE, CELL_SIZE, CELL_SIZE) | |
pygame.draw.rect(screen, BACKGROUND_COLOR, rect) | |
dirty_rects.append(rect) | |
# 2. Draw new snake segments or food in cells that became occupied | |
for (cx, cy), color in cells_filled: | |
rect = pygame.Rect(cx * CELL_SIZE, cy * CELL_SIZE, CELL_SIZE, CELL_SIZE) | |
pygame.draw.rect(screen, color, rect) | |
dirty_rects.append(rect) | |
# 3. Update scoreboard text if number of snakes changed or game ended | |
if alive_count != alive or alive_count <= 1: | |
alive = alive_count | |
if alive_count > 1: | |
score_text = f"Snakes remaining: {alive_count}" | |
elif alive_count == 1: | |
# One snake left β declare the winner | |
# Find the winner's ID (the one snake still alive) | |
winner_id = next(s.id for s in game.snakes if s.alive) | |
score_text = f"π Snake {winner_id} wins! π" | |
else: # alive_count == 0 | |
score_text = "All snakes died - No winners!" | |
score_surf = font.render(score_text, True, (255, 255, 255)) | |
text_rect = score_surf.get_rect(topleft=(5, 5)) | |
# Clear background behind text and draw new text | |
screen.fill(BACKGROUND_COLOR, text_rect.inflate(5, 5)) | |
screen.blit(score_surf, (5, 5)) | |
dirty_rects.append(text_rect.copy()) | |
# Refresh just the changed areas on screen | |
if dirty_rects: | |
pygame.display.update(dirty_rects) | |
# End condition: if 0 or 1 snake remains, end the game loop | |
if alive_count <= 1: | |
# Keep the final winner/game-over message on screen for a moment | |
winner_shown = True | |
pygame.time.wait(2000) # wait 2 seconds so the player can see the result | |
running = False | |
# Exit Pygame | |
pygame.quit() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment