#!/usr/bin/env python3 """dino: Chrome-style endless runner for the terminal. SPACE / UP = jump. Press any key to start or restart. Q / Ctrl-C = quit. No external dependencies -- pure ANSI escape codes, no Curses. """ import os, sys, tty, termios, fcntl, signal, time, random, shutil, argparse from typing import List, Optional # ── Terminal control ────────────────────────────────────────────────────────── RESET = "\033[0m" BOLD = "\033[1m" HIDE_CUR = "\033[?25l" SHOW_CUR = "\033[?25h" ALT_ON = "\033[?1049h" ALT_OFF = "\033[?1049l" def _fg(r, g, b): return f"\033[38;2;{r};{g};{b}m" def _go(r, c): return f"\033[{r+1};{c+1}H" def term_size(): s = shutil.get_terminal_size(); return s.lines, s.columns def _write_all(fd: int, data: bytes) -> None: mv = memoryview(data) off = 0 while off < len(mv): try: off += os.write(fd, mv[off:]) except BlockingIOError: time.sleep(0.001) # ── Colors ──────────────────────────────────────────────────────────────────── C_DINO = _fg(200, 100, 55) # Claude terracotta orange C_DEAD = _fg(220, 60, 60) C_CACTUS = _fg( 50, 170, 60) C_BIRD = _fg( 80, 130, 220) C_GROUND = _fg(160, 140, 90) C_CLOUD = _fg(180, 200, 220) C_SCORE = _fg(200, 200, 200) C_HI = _fg(255, 220, 80) C_TITLE = _fg(255, 255, 255) C_SUB = _fg(150, 150, 150) # ── Sprites ─────────────────────────────────────────────────────────────────── # Each sprite: list of strings. Spaces are transparent. # Claude mascot (6 wide, 4 tall) # Body: solid block, two eye-holes (spaces = transparent = dark bg) # Legs: two stumps, alternating spread / together DINO_RUN = [ # frame 0 – legs spread ["██████", "█ ██ █", # eyes as transparent gaps "██████", " █ █ "], # frame 1 – legs together ["██████", "█ ██ █", "██████", " ██ "], ] DINO_JUMP = [ "██████", "█ ██ █", "██████", " ", ] DINO_DEAD = [ "██████", "█x██x█", # x eyes "██████", " ", ] DINO_W = 6 DINO_H = 4 # Cacti (variable width, 4-5 tall) CACTI = [ # small single (3 wide, 4 tall) [" ^ ", "###", " # ", " # "], # tall single with left arm (4 wide, 5 tall) [" ^ ", "/## ", " ## ", " ## ", " ## "], # double (6 wide, 5 tall) [" ^ ^ ", "## ## ", " #### ", " ## ", " ## "], # triple (5 wide, 4 tall) ["^ ^ ^", "#####", " ### ", " # "], ] # Birds / pterodactyls (5 wide, 2 tall, 2 animation frames) BIRDS = [ # frame 0: wings up ["\\-/", " "], # frame 1: wings level [" v ", "/ \\"], ] BIRD_W = 3 BIRD_H = 2 # Clouds (8 wide, 2 tall) CLOUD = [ " .--. ", " / \\", ] CLOUD_W = 8 CLOUD_H = 2 # ── Canvas ──────────────────────────────────────────────────────────────────── class Canvas: def __init__(self, rows: int, cols: int): self.rows, self.cols = rows, cols self._ch: List[List[str]] = [[' '] * cols for _ in range(rows)] self._ck: List[List[Optional[str]]] = [[None] * cols for _ in range(rows)] def resize(self, rows: int, cols: int): self.rows, self.cols = rows, cols self._ch = [[' '] * cols for _ in range(rows)] self._ck = [[None] * cols for _ in range(rows)] def clear(self): for r in range(self.rows): self._ch[r] = [' '] * self.cols self._ck[r] = [None] * self.cols def put(self, row: int, col: int, ch: str, ck: Optional[str]): if 0 <= row < self.rows and 0 <= col < self.cols: self._ch[row][col] = ch self._ck[row][col] = ck def put_str(self, row: int, col: int, s: str, ck: Optional[str]): for i, ch in enumerate(s): self.put(row, col + i, ch, ck) def put_sprite(self, y: int, x: int, lines: List[str], ck: Optional[str]): for dy, line in enumerate(lines): for dx, ch in enumerate(line): if ch != ' ': self.put(y + dy, x + dx, ch, ck) def render(self) -> str: col_limit = self.cols - 1 parts = ["\033[H", RESET] sentinel = object() last_ck: object = sentinel for r in range(self.rows): parts.append(_go(r, 0)) for c in range(col_limit): ck = self._ck[r][c] if ck is not last_ck: parts.append(RESET if ck is None else ck) last_ck = ck parts.append(self._ch[r][c]) parts.append(RESET + "\033[K") last_ck = sentinel parts.append(RESET) return ''.join(parts) # ── Input ───────────────────────────────────────────────────────────────────── class Input: def __init__(self): self.fd = sys.stdin.fileno() self._old = termios.tcgetattr(self.fd) tty.setraw(self.fd) fl = fcntl.fcntl(self.fd, fcntl.F_GETFL) fcntl.fcntl(self.fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) def read(self) -> bytes: try: return os.read(self.fd, 32) except (BlockingIOError, OSError): return b'' def restore(self): termios.tcsetattr(self.fd, termios.TCSADRAIN, self._old) # ── Obstacle ────────────────────────────────────────────────────────────────── GRAVITY = 0.55 # acceleration (chars/tick²) — tuned for ~20 fps JUMP_VEL = -3.0 # initial upward velocity (negative = up) DINO_COL = 6 # dino's fixed horizontal position class Obstacle: def __init__(self, x: float, ground_row: int, rows: int): self.x = x if random.random() < 0.25: # Bird – flies at a random height self.kind = 'bird' self.sprite = BIRDS self.w = BIRD_W self.h = BIRD_H height = random.choice([1, 2, 4]) self.row = ground_row - DINO_H + 1 - height self.color = C_BIRD else: # Cactus self.kind = 'cactus' idx = random.randint(0, len(CACTI) - 1) sp = CACTI[idx] self.sprite = [sp] # wrap in list so index works like birds self.w = max(len(l) for l in sp) self.h = len(sp) self.row = ground_row - self.h + 1 self.color = C_CACTUS self._frame = 0.0 def current_frame(self) -> List[str]: if self.kind == 'bird': return self.sprite[int(self._frame) % len(self.sprite)] return self.sprite[0] def update(self, speed: float): self.x -= speed fadv = 0.12 if self.kind == 'bird' else 0 self._frame = (self._frame + fadv) % max(len(self.sprite), 1) # ── Game ────────────────────────────────────────────────────────────────────── class DinoGame: def __init__(self): rows, cols = term_size() self.rows, self.cols = rows, cols self.canvas = Canvas(rows, cols) self.highscore = 0.0 self.state = 'title' # 'title' | 'running' | 'dead' self._setup() def _setup(self): rows, cols = self.rows, self.cols self.ground_row = rows - 3 self.dino_y = float(self.ground_row - DINO_H + 1) self.dino_vy = 0.0 self.on_ground = True self.score = 0.0 self.speed = 0.35 self.obstacles: List[Obstacle] = [] self.clouds: List[List[float]] = [] # [x, y] self._frame = 0.0 self._next_obs = random.randint(30, 55) self._death_anim = 0 # frames of death flash # Seed some clouds for _ in range(3): cx = float(random.randint(10, cols - CLOUD_W - 2)) cy = float(random.randint(1, max(2, self.ground_row - DINO_H - 5))) self.clouds.append([cx, cy]) def resize(self, rows: int, cols: int): self.rows, self.cols = rows, cols self.canvas.resize(rows, cols) was_running = self.state == 'running' self._setup() if was_running: self.state = 'running' def jump(self): if self.on_ground and self.state == 'running': self.dino_vy = JUMP_VEL self.on_ground = False def step(self): if self.state != 'running': return # Score & speed ramp self.score += 0.12 self.speed = 0.35 + self.score * 0.00035 # Dino physics if not self.on_ground: self.dino_vy += GRAVITY * 0.28 self.dino_y += self.dino_vy * 0.28 floor = float(self.ground_row - DINO_H + 1) if self.dino_y >= floor: self.dino_y = floor self.dino_vy = 0.0 self.on_ground = True # Leg animation (only while running on ground) if self.on_ground: self._frame = (self._frame + 0.18) % 2 # Spawn obstacles self._next_obs -= 1 if self._next_obs <= 0: self.obstacles.append( Obstacle(float(self.cols - 2), self.ground_row, self.rows)) gap = max(18, int(50 - self.score / 60)) self._next_obs = random.randint(gap, gap + 22) # Move obstacles for obs in self.obstacles: obs.update(self.speed) self.obstacles = [o for o in self.obstacles if o.x + o.w > 0] # Move clouds (slow parallax) for cl in self.clouds: cl[0] -= 0.08 self.clouds = [c for c in self.clouds if c[0] + CLOUD_W > 0] if len(self.clouds) < 4 and random.random() < 0.015: cy = float(random.randint(1, max(2, self.ground_row - DINO_H - 5))) self.clouds.append([float(self.cols - CLOUD_W - 1), cy]) # Collision detection (AABB with 1-char margin) dx = DINO_COL dy = int(self.dino_y) for obs in self.obstacles: ox = int(obs.x) oy = obs.row if (dx + DINO_W - 1 > ox + 1 and dx + 1 < ox + obs.w - 1 and dy + DINO_H - 1 > oy + 1 and dy + 1 < oy + obs.h - 1): self.state = 'dead' if self.score > self.highscore: self.highscore = self.score def render(self) -> str: rows, cols = self.rows, self.cols canvas = self.canvas canvas.clear() gr = self.ground_row # Ground line ground_str = ('_.-' * ((cols // 3) + 2))[:cols - 1] canvas.put_str(gr, 0, ground_str, C_GROUND) # Clouds for (cx, cy) in self.clouds: canvas.put_sprite(int(cy), int(cx), CLOUD, C_CLOUD) # ── Title screen ────────────────────────────────────────────────────── if self.state == 'title': # Draw a static dino on the ground canvas.put_sprite(gr - DINO_H, cols // 2 - 2, DINO_RUN[0], C_DINO) title = "DINO RUNNER" sub = "SPACE / UP to start Q to quit" canvas.put_str(rows // 2 - 2, (cols - len(title)) // 2, title, C_TITLE) canvas.put_str(rows // 2, (cols - len(sub)) // 2, sub, C_SUB) # ── Running / Dead ──────────────────────────────────────────────────── else: # Obstacles for obs in self.obstacles: canvas.put_sprite(obs.row, int(obs.x), obs.current_frame(), obs.color) # Dino if self.state == 'dead': dino_sp = DINO_DEAD dino_c = C_DEAD elif not self.on_ground: dino_sp = DINO_JUMP dino_c = C_DINO else: dino_sp = DINO_RUN[int(self._frame) % 2] dino_c = C_DINO canvas.put_sprite(int(self.dino_y), DINO_COL, dino_sp, dino_c) # Score row score_str = f"SCORE {int(self.score):05d}" hi_str = f"BEST {int(self.highscore):05d}" canvas.put_str(0, cols - len(score_str) - 2, score_str, C_SCORE) canvas.put_str(0, cols - len(score_str) - len(hi_str) - 4, hi_str, C_HI) if self.state == 'dead': msg = "GAME OVER" sub = "SPACE to restart Q to quit" canvas.put_str(rows // 2 - 1, (cols - len(msg)) // 2, msg, C_DEAD) canvas.put_str(rows // 2 + 1, (cols - len(sub)) // 2, sub, C_SUB) return canvas.render() # ── Entry point ─────────────────────────────────────────────────────────────── def main(): ap = argparse.ArgumentParser(description='Terminal dino runner game') ap.add_argument('--speed', type=float, default=1.0, help='Starting speed multiplier (default 1.0)') args = ap.parse_args() TICK = 0.05 # 20 fps fd_out = sys.stdout.fileno() inp = Input() game = DinoGame() game.speed *= args.speed resize_flag = False # Key bytes that mean "jump / start / restart" ACTION_KEYS = {b' ', b'\r', b'\x1b[A', b'\x1b[B'} # space, enter, up, down def on_resize(sig, frame): nonlocal resize_flag resize_flag = True signal.signal(signal.SIGWINCH, on_resize) _write_all(fd_out, (ALT_ON + HIDE_CUR).encode()) try: while True: t0 = time.monotonic() if resize_flag: resize_flag = False r, c = term_size() game.resize(r, c) raw = inp.read() if raw: # Quit on Q, q, Ctrl-C, Escape if any(b in raw for b in (b'q', b'Q', b'\x03', b'\x1b\x1b')): break # Action: start / jump / restart acted = (b' ' in raw or b'\r' in raw or b'\x1b[A' in raw or b'\x00' in raw) # Arrow up is ESC [ A (3 bytes); detect as subsequence if b'\x1b' in raw and b'A' in raw: acted = True if acted: if game.state == 'title': game.state = 'running' elif game.state == 'dead': game._setup() game.state = 'running' else: game.jump() game.step() _write_all(fd_out, game.render().encode()) elapsed = time.monotonic() - t0 rem = TICK - elapsed if rem > 0: time.sleep(rem) finally: _write_all(fd_out, (ALT_OFF + SHOW_CUR + RESET).encode()) inp.restore() if game.score > 0: print(f"\nScore: {int(game.score)} Best: {int(game.highscore)}") if __name__ == '__main__': main()