import json import math import random from pathlib import Path import pygame WIDTH, HEIGHT = 540, 760 GROUND_Y = HEIGHT - 80 FPS = 60 BIRD_X = 145 BIRD_RADIUS = 15 GRAVITY = 1500.0 FLAP_VELOCITY = -435.0 PIPE_WIDTH = 78 HIGH_SCORE_FILE = Path.cwd() / "high_scores.json" LATEST_SCORE_FILE = Path("/tmp/alpaca_score.json") SKY = (18, 25, 43) WHITE = (239, 244, 250) MUTED = (157, 177, 199) YELLOW = (255, 208, 65) GREEN = (48, 191, 121) DARK_GREEN = (24, 113, 76) RED = (255, 95, 105) def load_high_scores(): try: data = json.loads(HIGH_SCORE_FILE.read_text(encoding="utf-8")) if not isinstance(data, list): return [] valid = [] for entry in data: if not isinstance(entry, dict): continue name = entry.get("initials") score = entry.get("score") if ( isinstance(name, str) and name.strip() and type(score) is int and score >= 0 ): valid.append({"initials": name.strip()[:12], "score": score}) return sorted(valid, key=lambda entry: entry["score"], reverse=True)[:5] except (OSError, ValueError): return [] def write_json(path, data): temporary = path.with_name(path.name + ".tmp") temporary.write_text(json.dumps(data, indent=2), encoding="utf-8") temporary.replace(path) def circle_hits_rect(cx, cy, radius, rect): closest_x = max(rect.left, min(cx, rect.right)) closest_y = max(rect.top, min(cy, rect.bottom)) return (cx - closest_x) ** 2 + (cy - closest_y) ** 2 <= radius ** 2 class FlappyBird: def __init__(self): pygame.init() self.screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("Flappy Bird — One Tap, No Mercy") self.clock = pygame.time.Clock() self.font_small = pygame.font.Font(None, 24) self.font_medium = pygame.font.Font(None, 32) self.font_large = pygame.font.Font(None, 52) self.font_score = pygame.font.Font(None, 72) self.high_scores = load_high_scores() self.state = "menu" self.running = True self.score = 0 self.bird_y = HEIGHT * 0.40 self.velocity = 0.0 self.pipes = [] self.spawn_timer = 0.0 self.initials = "" self.last_name = "" self.saved = False self.save_error = "" self.ground_offset = 0.0 def text(self, message, y, font=None, color=WHITE, x=None): font = font or self.font_medium surface = font.render(message, True, color) rect = surface.get_rect() if x is None: rect.midtop = (WIDTH // 2, y) else: rect.topleft = (x, y) self.screen.blit(surface, rect) def start_game(self): self.score = 0 self.bird_y = HEIGHT * 0.40 self.velocity = FLAP_VELOCITY self.pipes = [] self.spawn_timer = 0.65 self.saved = False self.save_error = "" self.initials = "" self.state = "playing" def finish_game(self): if self.state != "playing": return self.state = "name" self.initials = "" pygame.key.start_text_input() pygame.key.set_text_input_rect(pygame.Rect(120, 216, 300, 44)) def save_run(self): if self.saved: return name = self.initials.strip() or self.last_name or "ANON" name = name[:12] self.last_name = name entry = {"initials": name, "score": int(self.score)} self.high_scores.append(entry) self.high_scores.sort(key=lambda item: item["score"], reverse=True) self.high_scores = self.high_scores[:5] errors = [] for path, payload in ( (HIGH_SCORE_FILE, self.high_scores), (LATEST_SCORE_FILE, entry), ): try: write_json(path, payload) except OSError as error: errors.append(path.name) print(f"Could not save {path}: {error}") self.save_error = "Save failed: " + ", ".join(errors) if errors else "" self.saved = True pygame.key.stop_text_input() self.state = "gameover" def spawn_pipe(self): gap = max(132, 178 - self.score * 2) center = random.randint(125 + gap // 2, GROUND_Y - 100 - gap // 2) self.pipes.append( { "x": float(WIDTH + 20), "top": center - gap // 2, "bottom": center + gap // 2, "scored": False, } ) def pipe_rectangles(self, pipe): x = int(pipe["x"]) return ( pygame.Rect(x, 0, PIPE_WIDTH, pipe["top"]), pygame.Rect(x, pipe["bottom"], PIPE_WIDTH, GROUND_Y - pipe["bottom"]), pygame.Rect(x - 5, pipe["top"] - 22, PIPE_WIDTH + 10, 22), pygame.Rect(x - 5, pipe["bottom"], PIPE_WIDTH + 10, 22), ) def handle_events(self): for event in pygame.event.get(): if event.type == pygame.QUIT: if self.state == "name": self.save_run() self.running = False continue if event.type == pygame.TEXTINPUT and self.state == "name": for character in event.text: if ( len(self.initials) < 12 and character.isascii() and (character.isalnum() or character in " _-") ): self.initials += character.upper() if event.type != pygame.KEYDOWN: continue if self.state == "menu": if event.key in (pygame.K_SPACE, pygame.K_RETURN): self.start_game() elif event.key == pygame.K_ESCAPE: self.running = False elif self.state == "playing": if event.key == pygame.K_SPACE: self.velocity = FLAP_VELOCITY elif event.key == pygame.K_ESCAPE: self.finish_game() elif self.state == "name": if event.key == pygame.K_BACKSPACE: self.initials = self.initials[:-1] elif event.key == pygame.K_RETURN: self.save_run() elif self.state == "gameover": if event.key in (pygame.K_SPACE, pygame.K_RETURN): self.start_game() elif event.key == pygame.K_ESCAPE: self.running = False def update(self, dt): if self.state != "playing": return speed = min(335.0, 225.0 + self.score * 5.0) self.ground_offset = (self.ground_offset + speed * dt) % 32 self.velocity += GRAVITY * dt self.bird_y += self.velocity * dt self.spawn_timer -= dt if self.spawn_timer <= 0: self.spawn_pipe() self.spawn_timer += max(1.08, 1.48 - self.score * 0.014) for pipe in self.pipes: pipe["x"] -= speed * dt if self.bird_y + BIRD_RADIUS >= GROUND_Y: self.bird_y = GROUND_Y - BIRD_RADIUS self.finish_game() return if self.bird_y - BIRD_RADIUS <= 0: self.bird_y = BIRD_RADIUS self.finish_game() return for pipe in self.pipes: for rect in self.pipe_rectangles(pipe): if circle_hits_rect(BIRD_X, self.bird_y, BIRD_RADIUS, rect): self.finish_game() return for pipe in self.pipes: if not pipe["scored"] and pipe["x"] + PIPE_WIDTH + 5 < BIRD_X - BIRD_RADIUS: pipe["scored"] = True self.score += 1 self.pipes = [pipe for pipe in self.pipes if pipe["x"] > -PIPE_WIDTH - 10] def draw_world(self): self.screen.fill(SKY) for i in range(30): x = (i * 137 + 25) % WIDTH y = (i * 89 + 30) % (GROUND_Y - 30) pygame.draw.circle(self.screen, (43, 57, 77), (x, y), 2) for pipe in self.pipes: rectangles = self.pipe_rectangles(pipe) for rect in rectangles: pygame.draw.rect(self.screen, GREEN, rect) pygame.draw.rect(self.screen, DARK_GREEN, rect, 3) x = int(pipe["x"]) pygame.draw.rect( self.screen, (99, 222, 148), (x + 10, 0, 8, max(0, pipe["top"] - 24)) ) pygame.draw.rect( self.screen, (99, 222, 148), (x + 10, pipe["bottom"] + 24, 8, GROUND_Y - pipe["bottom"] - 24), ) pygame.draw.rect(self.screen, (73, 65, 60), (0, GROUND_Y, WIDTH, HEIGHT)) pygame.draw.rect(self.screen, GREEN, (0, GROUND_Y, WIDTH, 7)) for x in range(-32, WIDTH + 32, 32): offset = int(self.ground_offset) pygame.draw.line( self.screen, (106, 92, 75), (x - offset, GROUND_Y + 18), (x + 18 - offset, GROUND_Y + 36), 4, ) y = int(self.bird_y) if self.state == "menu": y = 180 + int(math.sin(pygame.time.get_ticks() / 220) * 7) pygame.draw.circle(self.screen, (113, 74, 27), (BIRD_X, y), BIRD_RADIUS + 2) pygame.draw.circle(self.screen, YELLOW, (BIRD_X, y), BIRD_RADIUS) pygame.draw.ellipse(self.screen, (241, 155, 43), (BIRD_X - 15, y, 18, 10)) pygame.draw.polygon( self.screen, (255, 125, 47), [(BIRD_X + 11, y + 1), (BIRD_X + 23, y + 5), (BIRD_X + 11, y + 8)], ) pygame.draw.circle(self.screen, WHITE, (BIRD_X + 7, y - 5), 6) pygame.draw.circle(self.screen, SKY, (BIRD_X + 9, y - 5), 3) def draw_board(self, y): self.text("TOP 5 — LOCAL HIGH SCORES", y, self.font_medium, YELLOW) for index in range(5): row_y = y + 43 + index * 36 if index < len(self.high_scores): entry = self.high_scores[index] name, score = entry["initials"], str(entry["score"]) else: name, score = "---", "-" self.text(f"{index + 1}. {name}", row_y, self.font_small, x=115) surface = self.font_small.render(score, True, WHITE) self.screen.blit(surface, surface.get_rect(topright=(425, row_y))) def draw(self): self.draw_world() if self.state == "playing": self.text(str(self.score), 38, self.font_score) self.text("SPACE: flap +1 per pipe", HEIGHT - forty(), self.font_small) elif self.state == "menu": self.text("ONE TAP. NO MERCY.", sixty(), self.font_large, YELLOW) self.text("FLAPPY BIRD", 118, self.font_medium) self.text("SPACE / ENTER to start", 230) self.text("SPACE flaps. Every pipe earns 1 point.", 270, self.font_small, MUTED) self.text("Avoid pipes, ceiling, and ground.", 297, self.font_small, MUTED) self.draw_board(365) self.text("Gaps tighten. Pipes accelerate.", 622, self.font_small, RED) else: veil = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA) veil.fill((8, 12, 23, 220)) self.screen.blit(veil, (0, 0)) self.text("RUN OVER", 65, self.font_large, RED) self.text(f"SCORE {self.score}", 127, self.font_large, YELLOW) if self.state == "name": self.text("Enter name / initials (12 characters)", 190, self.font_small) box = pygame.Rect(110, 221, 320, 47) pygame.draw.rect(self.screen, (30, 43, 64), box, border_radius=6) pygame.draw.rect(self.screen, YELLOW, box, 2, border_radius=6) cursor = "_" if pygame.time.get_ticks() % 1000 < 500 else "" self.text(self.initials + cursor, 232) self.text("ENTER saves your run (blank = ANON)", 284, self.font_small, MUTED) else: self.text(f"Recorded for {self.last_name}", 211) self.text("SPACE / ENTER to try again", 265, self.font_medium, YELLOW) self.text("ESC to quit", 304, self.font_small, MUTED) self.draw_board(367) if self.save_error: self.text("Storage error — see terminal.", 622, self.font_small, RED) elif self.state == "gameover": self.text("Scores saved. Every new run starts at 0.", 622, self.font_small, MUTED) pygame.display.flip() def run(self): try: while self.running: dt = min(self.clock.tick(FPS) / 1000.0, 0.05) self.handle_events() if not self.running: break remaining = dt while remaining > 0: step = min(remaining, 1 / 120) self.update(step) remaining -= step self.draw() finally: pygame.quit() def forty(): return 40 def sixty(): return 60 if __name__ == "__main__": FlappyBird().run()