โ
Retro Maelstrom โ
๐ฆ Game code, download & prompt info
๐ฅ๏ธ Desktop app (python) โ play it live above, right in your browser, or download it below
Original prompt
Write a complete, runnable Python program using `pygame` and `PyOpenGL` that renders a 3D Asteroids scene. Create the window with `pygame.display.set_mode((800,600), pygame.OPENGL | pygame.DOUBLEBUF)`, set up a perspective projection, and draw several rotating 3D asteroids (use `glBegin(GL_TRIANGLES)` / `glVertex3f` from `from OpenGL.GL import *`) drifting and rotating in 3D space, plus a small 3D ship. Rotate the camera/scene slightly each frame. Handle the QUIT event. Use only primitive geometry - no external models. Design note (interpret freely): Maelstrom was a 3D-rendered Asteroids upgrade - thrust and rotate a ship in open space, blast asteroids that split into fragments, and collect power-up 'goodies' (triple/long shots) while dodging an indestructible steel asteroid and enemy saucers. It proved fast arcade action could run on a desktop computer. SCORING & PERSISTENT HIGH-SCORE BOARD (REQUIRED): The game MUST award points for gameplay actions and maintain a persistent high-score table. When a run finishes, prompt the player to enter their name/initials and record it with the score. Save the top 5 scores (name + score) to a local JSON file in the working directory (e.g. high_scores.json), and also write the latest finished run as {"initials": name, "score": number} to /tmp/alpaca_score.json (overwrite it every run) so the arcade scoreboard can pick it up automatically, load and display them on startup and on the game-over screen, and write a new entry whenever a run finishes. Scores must survive restarting the program. Every new game MUST start with the score reset to 0 - never carry over the previous game's score into the next session. The program must run top-to-bottom and open a visible 3D window. OUTPUT REQUIREMENT: Respond with ONLY the raw, runnable source code in a single fenced code block, with the first line starting at column 0 (no leading indentation). Do not include any thinking, explanation, summary, or prose before or after the code, and keep comments inside the code minimal.import json
import math
import os
import random
import pygame
from OpenGL.GL import *
from OpenGL.GLU import gluPerspective
WIDTH, HEIGHT = 800, 600
FIELD_X, FIELD_Y = 23.0, 16.0
HIGH_SCORE_FILE = "high_scores.json"
LATEST_SCORE_FILE = "/tmp/alpaca_score.json"
def load_scores(path):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
return []
result = []
for item in data:
if not isinstance(item, dict):
continue
name = item.get("initials", item.get("name", ""))
score = item.get("score")
if isinstance(name, str) and isinstance(score, int) and score >= 0:
result.append({"initials": name[:12], "score": score})
return sorted(result, key=lambda item: item["score"], reverse=True)[:5]
except (OSError, ValueError, TypeError):
return []
def load_latest():
try:
with open(LATEST_SCORE_FILE, "r", encoding="utf-8") as f:
item = json.load(f)
if (isinstance(item, dict)
and isinstance(item.get("initials"), str)
and isinstance(item.get("score"), int)):
return item
except (OSError, ValueError, TypeError):
pass
return None
def write_json(path, data):
temporary = path + ".tmp"
with open(temporary, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(temporary, path)
def normalize(v):
length = math.sqrt(sum(x * x for x in v))
return tuple(x / length for x in v) if length else (0, 0, 1)
def triangle(a, b, c):
u = tuple(b[i] - a[i] for i in range(3))
v = tuple(c[i] - a[i] for i in range(3))
n = normalize((u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0]))
glNormal3f(*n)
glVertex3f(*a)
glVertex3f(*b)
glVertex3f(*c)
def make_mesh(rough=False):
top, bottom = (0, 0, 1), (0, 0, -1)
ring = [(1, 0, 0), (0, 1, 0), (-1, 0, 0), (0, -1, 0)]
faces = []
for i in range(4):
a, b = ring[i], ring[(i + 1) % 4]
faces.extend([(top, a, b), (bottom, b, a)])
refined = []
for a, b, c in faces:
ab = normalize(tuple(a[i] + b[i] for i in range(3)))
bc = normalize(tuple(b[i] + c[i] for i in range(3)))
ca = normalize(tuple(c[i] + a[i] for i in range(3)))
refined.extend([(a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)])
vertices = {}
mesh = []
for face in refined:
result = []
for vertex in face:
if vertex not in vertices:
factor = random.uniform(0.78, 1.18) if rough else 1.0
vertices[vertex] = tuple(x * factor for x in vertex)
result.append(vertices[vertex])
mesh.append(tuple(result))
return mesh
def draw_mesh(mesh):
glBegin(GL_TRIANGLES)
for face in mesh:
triangle(*face)
glEnd()
def wrap(value, bound):
return (value + bound) % (2 * bound) - bound
def distance_squared(a, b):
dx = wrap(a[0] - b[0], FIELD_X)
dy = wrap(a[1] - b[1], FIELD_Y)
return dx * dx + dy * dy
class Asteroid:
def __init__(self, size=3, position=None, velocity=None):
self.size = size
self.radius = {1: 0.62, 2: 1.12, 3: 1.95}[size]
self.position = list(position or (
random.uniform(-FIELD_X, FIELD_X),
random.uniform(-FIELD_Y, FIELD_Y),
random.uniform(-0.9, 0.9)))
speed = random.uniform(1.3, 2.6) * (1.5 - size * 0.15)
angle = random.uniform(0, math.tau)
self.velocity = list(velocity or (
math.cos(angle) * speed,
math.sin(angle) * speed,
random.uniform(-0.22, 0.22)))
self.rotation = [random.uniform(0, 360) for _ in range(3)]
self.spin = [random.uniform(-38, 38) for _ in range(3)]
self.mesh = make_mesh(True)
shade = random.uniform(0.5, 0.78)
self.color = (shade, shade * 0.82, shade * 0.64)
def update(self, dt):
for i in range(3):
self.position[i] += self.velocity[i] * dt
self.rotation[i] += self.spin[i] * dt
self.position[0] = wrap(self.position[0], FIELD_X)
self.position[1] = wrap(self.position[1], FIELD_Y)
if abs(self.position[2]) > 1.0:
self.velocity[2] = -math.copysign(abs(self.velocity[2]), self.position[2])
def draw(self):
glPushMatrix()
glTranslatef(*self.position)
for angle, axis in zip(self.rotation, ((1, 0, 0), (0, 1, 0), (0, 0, 1))):
glRotatef(angle, *axis)
glScalef(self.radius, self.radius, self.radius)
glColor3f(*self.color)
draw_mesh(self.mesh)
glPopMatrix()
class Game:
def __init__(self):
pygame.init()
pygame.display.set_mode((800, 600), pygame.OPENGL | pygame.DOUBLEBUF)
pygame.display.set_caption("Maelstrom Drift - 3D Asteroids")
self.clock = pygame.time.Clock()
self.fonts = {
"small": pygame.font.Font(None, 23),
"normal": pygame.font.Font(None, 29),
"large": pygame.font.Font(None, 58),
}
self.text_cache = {}
self.scores = load_scores(HIGH_SCORE_FILE)
self.latest = load_latest()
self.state = "title"
self.running = True
self.quit_after_entry = False
self.initials = ""
self.save_message = ""
self.score = 0
self.lives = 3
self.wave = 0
self.time = 0
self.ship_position = [0.0, 0.0, 0.0]
self.ship_velocity = [0.0, 0.0]
self.ship_angle = 0.0
self.invulnerable = 0
self.cooldown = 0
self.thrusting = False
self.bullets = []
self.asteroids = [Asteroid() for _ in range(9)]
self.bullet_mesh = make_mesh()
self.stars = [
(random.uniform(-65, 65), random.uniform(-45, 45),
random.uniform(-35, -9), random.uniform(0.35, 1))
for _ in range(260)
]
glClearColor(0.008, 0.012, 0.03, 1)
glEnable(GL_DEPTH_TEST)
glEnable(GL_NORMALIZE)
glEnable(GL_LIGHTING)
glEnable(GL_LIGHT0)
glEnable(GL_COLOR_MATERIAL)
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.95, 0.95, 1.0, 1))
glLightModelfv(GL_LIGHT_MODEL_AMBIENT, (0.24, 0.25, 0.32, 1))
glShadeModel(GL_FLAT)
glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
def new_game(self):
self.score = 0
self.lives = 3
self.wave = 0
self.bullets = []
self.asteroids = []
self.initials = ""
self.save_message = ""
self.quit_after_entry = False
self.reset_ship()
self.next_wave()
self.state = "playing"
def reset_ship(self):
self.ship_position = [0.0, 0.0, 0.0]
self.ship_velocity = [0.0, 0.0]
self.ship_angle = 0.0
self.invulnerable = 3.0
self.cooldown = 0.25
self.thrusting = False
def next_wave(self):
self.wave += 1
for _ in range(min(4 + self.wave, 15)):
asteroid = Asteroid()
while distance_squared(asteroid.position, self.ship_position) < 64:
asteroid.position[0] = random.uniform(-FIELD_X, FIELD_X)
asteroid.position[1] = random.uniform(-FIELD_Y, FIELD_Y)
self.asteroids.append(asteroid)
def finish_run(self):
if self.state != "playing":
return
self.state = "entry"
self.thrusting = False
self.initials = ""
pygame.key.start_text_input()
def record_run(self):
if self.state != "entry":
return
name = self.initials.strip() or "AAA"
entry = {"initials": name, "score": int(self.score)}
self.latest = entry
self.scores.append(entry)
self.scores = sorted(
self.scores, key=lambda item: item["score"], reverse=True)[:5]
errors = []
for path, data in ((HIGH_SCORE_FILE, self.scores),
(LATEST_SCORE_FILE, entry)):
try:
write_json(path, data)
except OSError as exc:
errors.append(os.path.basename(path))
print("Could not save", path, ":", exc)
self.save_message = (
"Save failed: " + ", ".join(errors) if errors else "Score saved.")
pygame.key.stop_text_input()
self.state = "gameover"
if self.quit_after_entry:
self.running = False
def events(self):
for event in pygame.event.get():
if event.type == pygame.QUIT:
if self.state == "playing":
self.quit_after_entry = True
self.finish_run()
elif self.state == "entry":
self.quit_after_entry = True
self.record_run()
else:
self.running = False
elif event.type == pygame.TEXTINPUT and self.state == "entry":
clean = "".join(c for c in event.text.upper()
if c.isascii() and (c.isalnum() or c in "_-"))
self.initials = (self.initials + clean)[:12]
elif event.type == pygame.KEYDOWN:
if self.state == "entry":
if event.key == pygame.K_BACKSPACE:
self.initials = self.initials[:-1]
elif event.key in (pygame.K_RETURN, pygame.K_KP_ENTER):
self.record_run()
elif event.key == pygame.K_ESCAPE:
self.record_run()
elif self.state in ("title", "gameover"):
if event.key in (pygame.K_RETURN, pygame.K_KP_ENTER):
self.new_game()
elif event.key == pygame.K_ESCAPE:
self.running = False
elif event.key == pygame.K_ESCAPE:
self.finish_run()
def shoot(self):
angle = math.radians(self.ship_angle)
direction = (-math.sin(angle), math.cos(angle))
self.bullets.append({
"position": [
self.ship_position[0] + direction[0] * 1.25,
self.ship_position[1] + direction[1] * 1.25,
0.0],
"velocity": [
self.ship_velocity[0] + direction[0] * 28,
self.ship_velocity[1] + direction[1] * 28],
"life": 1.35,
})
self.cooldown = 0.16
def update(self, dt):
self.time += dt
if self.state in ("entry", "gameover"):
return
for asteroid in self.asteroids:
asteroid.update(dt)
if self.state != "playing":
return
keys = pygame.key.get_pressed()
turn = int(keys[pygame.K_LEFT] or keys[pygame.K_a])
turn -= int(keys[pygame.K_RIGHT] or keys[pygame.K_d])
self.ship_angle += turn * 205 * dt
self.thrusting = bool(keys[pygame.K_UP] or keys[pygame.K_w])
if self.thrusting:
angle = math.radians(self.ship_angle)
self.ship_velocity[0] -= math.sin(angle) * 13 * dt
self.ship_velocity[1] += math.cos(angle) * 13 * dt
speed = math.hypot(*self.ship_velocity)
if speed > 15:
self.ship_velocity = [v * 15 / speed for v in self.ship_velocity]
for i, bound in enumerate((FIELD_X, FIELD_Y)):
self.ship_velocity[i] *= math.exp(-0.16 * dt)
self.ship_position[i] = wrap(
self.ship_position[i] + self.ship_velocity[i] * dt, bound)
self.invulnerable = max(0, self.invulnerable - dt)
self.cooldown -= dt
if keys[pygame.K_SPACE] and self.cooldown <= 0:
self.shoot()
surviving_bullets = []
for bullet in self.bullets:
bullet["life"] -= dt
if bullet["life"] <= 0:
continue
for i, bound in enumerate((FIELD_X, FIELD_Y)):
bullet["position"][i] = wrap(
bullet["position"][i] + bullet["velocity"][i] * dt, bound)
hit = next((a for a in self.asteroids
if distance_squared(a.position, bullet["position"])
< (a.radius + 0.18) ** 2), None)
if hit is None:
surviving_bullets.append(bullet)
continue
self.score += {3: 20, 2: 50, 1: 100}[hit.size]
self.asteroids.remove(hit)
if hit.size > 1:
angle = random.uniform(0, math.tau)
for sign in (-1, 1):
dx, dy = math.cos(angle) * sign, math.sin(angle) * sign
position = [hit.position[0] + dx * 0.4,
hit.position[1] + dy * 0.4, hit.position[2]]
velocity = [hit.velocity[0] + dx * 2.8,
hit.velocity[1] + dy * 2.8,
random.uniform(-0.2, 0.2)]
self.asteroids.append(Asteroid(hit.size - 1, position, velocity))
self.bullets = surviving_bullets
if self.invulnerable <= 0:
for asteroid in self.asteroids:
if distance_squared(asteroid.position, self.ship_position) < (
asteroid.radius + 0.55) ** 2:
self.lives -= 1
if self.lives <= 0:
self.finish_run()
else:
self.reset_ship()
break
if self.state == "playing" and not self.asteroids:
self.score += 250 * self.wave
self.next_wave()
def draw_ship(self):
if self.state == "playing" and self.invulnerable > 0:
if int(self.time * 12) % 2:
return
glPushMatrix()
glTranslatef(*self.ship_position)
glRotatef(self.ship_angle, 0, 0, 1)
nose = (0, 1.15, 0)
left = (-0.78, -0.72, -0.18)
right = (0.78, -0.72, -0.18)
ridge = (0, -0.25, 0.42)
underside = (0, -0.4, -0.32)
glBegin(GL_TRIANGLES)
glColor3f(0.3, 0.8, 1)
triangle(nose, left, ridge)
glColor3f(0.8, 0.9, 1)
triangle(nose, ridge, right)
glColor3f(0.16, 0.35, 0.55)
triangle(nose, underside, left)
triangle(nose, right, underside)
triangle(left, right, ridge)
triangle(left, underside, right)
if self.thrusting:
flame = (0, -1.6 - 0.35 * math.sin(self.time * 55), 0)
glColor3f(1, 0.4, 0.04)
triangle((-0.3, -0.65, 0), flame, (0, -0.65, 0.25))
glColor3f(1, 0.85, 0.1)
triangle((0, -0.65, 0.25), flame, (0.3, -0.65, 0))
glEnd()
glPopMatrix()
def text(self, value, x, y, size="normal", color=(225, 237, 255), center=False):
key = (value, size, color)
if key not in self.text_cache:
if len(self.text_cache) > 256:
self.text_cache.clear()
surface = self.fonts[size].render(value, True, color)
self.text_cache[key] = (
surface.get_width(), surface.get_height(),
pygame.image.tostring(surface, "RGBA", True))
width, height, pixels = self.text_cache[key]
if center:
x -= width // 2
glRasterPos2f(x, HEIGHT - y - height)
glDrawPixels(width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels)
def panel(self, bottom=545):
glColor4f(0.015, 0.025, 0.06, 0.9)
glBegin(GL_QUADS)
glVertex2f(95, HEIGHT - 65)
glVertex2f(705, HEIGHT - 65)
glVertex2f(705, HEIGHT - bottom)
glVertex2f(95, HEIGHT - bottom)
glEnd()
def score_table(self, y):
self.text("PERSISTENT TOP 5", 400, y, color=(255, 208, 105), center=True)
for i in range(5):
if i < len(self.scores):
item = self.scores[i]
name, score = item["initials"], str(item["score"])
else:
name, score = "---", "---"
self.text(f"{i + 1}. {name}", 220, y + 37 + i * 29)
self.text(score, 520, y + 37 + i * 29)
def draw_ui(self):
glDisable(GL_LIGHTING)
glDisable(GL_DEPTH_TEST)
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glMatrixMode(GL_PROJECTION)
glPushMatrix()
glLoadIdentity()
glOrtho(0, WIDTH, 0, HEIGHT, -1, 1)
glMatrixMode(GL_MODELVIEW)
glPushMatrix()
glLoadIdentity()
if self.state == "playing":
self.text(f"SCORE {self.score}", 18, 15)
self.text(f"LIVES {self.lives}", 325, 15)
self.text(f"WAVE {self.wave}", 660, 15)
self.text("Arrows/WAD: fly SPACE: fire ESC: finish run",
400, 573, "small", center=True)
elif self.state == "entry":
self.panel(470)
self.text("RUN FINISHED", 400, 96, "large", center=True)
self.text(f"Final score: {self.score}", 400, 175, center=True)
self.text("Enter your name / initials (12 characters)", 400, 239,
center=True)
cursor = "_" if int(self.time * 2) % 2 == 0 else " "
self.text(self.initials + cursor, 400, 290, "large",
(255, 208, 105), True)
self.text("ENTER saves Backspace edits Blank name becomes AAA",
400, 375, "small", center=True)
if self.quit_after_entry:
self.text("Your score will be saved before closing.",
400, 415, "small", center=True)
else:
self.panel()
title = "MAELSTROM DRIFT" if self.state == "title" else "GAME OVER"
self.text(title, 400, 86, "large", center=True)
subtitle = ("Primitive geometry. Endless space. Three lives."
if self.state == "title" else
f"Final score: {self.score} {self.save_message}")
self.text(subtitle, 400, 148, "small", center=True)
self.score_table(198)
if self.latest:
self.text(
f"Latest run: {self.latest['initials'][:12]} "
f"{self.latest['score']}",
400, 399, "small", center=True)
self.text("ENTER: new game ESC: quit", 400, 440,
color=(120, 230, 255), center=True)
self.text("Left/Right or A/D: rotate Up/W: thrust Space: fire",
400, 484, "small", center=True)
self.text("Rocks: 20 / 50 / 100 pts Clear wave: 250 x wave",
400, 510, "small", center=True)
glPopMatrix()
glMatrixMode(GL_PROJECTION)
glPopMatrix()
glMatrixMode(GL_MODELVIEW)
glDisable(GL_BLEND)
glEnable(GL_DEPTH_TEST)
glEnable(GL_LIGHTING)
def draw(self):
glViewport(0, 0, WIDTH, HEIGHT)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glMatrixMode(GL_PROJECTION)
glLoadIdentity()
gluPerspective(50, WIDTH / HEIGHT, 0.1, 160)
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()
glTranslatef(0, 0, -49)
glRotatef(8 + math.sin(self.time * 0.23) * 3, 1, 0, 0)
glRotatef(math.sin(self.time * 0.19) * 5, 0, 1, 0)
glRotatef(math.sin(self.time * 0.13) * 3, 0, 0, 1)
glLightfv(GL_LIGHT0, GL_POSITION, (-12, 18, 24, 1))
glDisable(GL_LIGHTING)
glPointSize(2)
glBegin(GL_POINTS)
for x, y, z, brightness in self.stars:
glColor3f(brightness * 0.8, brightness * 0.88, brightness)
glVertex3f(x, y, z)
glEnd()
glEnable(GL_LIGHTING)
for asteroid in self.asteroids:
asteroid.draw()
if self.lives > 0 or self.state == "title":
self.draw_ship()
glDisable(GL_LIGHTING)
glColor3f(0.5, 1, 0.85)
for bullet in self.bullets:
glPushMatrix()
glTranslatef(*bullet["position"])
glScalef(0.15, 0.15, 0.3)
draw_mesh(self.bullet_mesh)
glPopMatrix()
glEnable(GL_LIGHTING)
self.draw_ui()
pygame.display.flip()
def run(self):
try:
while self.running:
dt = min(self.clock.tick(60) / 1000.0, 0.033)
self.events()
if not self.running:
break
self.update(dt)
self.draw()
finally:
pygame.quit()
if __name__ == "__main__":
Game().run()
๐ฎ Press โถ Play above to run it live in your browser, or download it below and run locally (pip install pygame), then enter your score by hand