llm-cinema / claudcade_engine.py
ConductorAILabs's picture
Upload folder using huggingface_hub
e93d4ef verified
Raw
History Blame Contribute Delete
56.2 kB
"""
Claudcade Engine v2
───────────────────
A terminal game engine built on Python's curses library.
No dependencies beyond the standard library. macOS, Linux, WSL.
Quick start
-----------
from claudcade_engine import Engine, Scene, Input, Renderer
from claudcade_engine import PLAYER, ENEMY, GOOD, GOLD, NEUTRAL
class Game(Scene):
def on_enter(self):
self.score = 0
def update(self, inp: Input, tick: int, dt: float) -> str | None:
if inp.pause:
return "quit"
if inp.just_pressed(ord('f')): # single-frame fire
self.score += 1
def draw(self, r: Renderer, tick: int):
r.header("MY GAME", left=f"SCORE {self.score}")
r.center(r.H // 2, "Hello terminal!", PLAYER, bold=True)
Engine("My Game", fps=30).scene("game", Game()).run("game")
Color constants
---------------
Semantic aliases (preferred):
PLAYER ENEMY GOOD GOLD NEUTRAL SPECIAL SELECT WATER
Raw color names (same values):
CYAN RED GREEN YELLOW WHITE MAGENTA HIGHLIGHT BLUE
Drawing coordinates
-------------------
Renderer methods take (row, col) β€” row 0 is the TOP of the terminal.
Entity.x / Entity.y are (col, row) in screen space.
Use r.at(entity, sprite) to avoid manual conversion.
Delta time
----------
update() receives dt (seconds since last frame, typically 0.033 at 30 fps).
Write all movement as: entity.x += speed * dt * 30 (30 = "at 30fps" reference)
"""
from __future__ import annotations
import curses
import enum
import json
import math
import random
import time
from pathlib import Path
from typing import Callable, Literal, TypedDict
# ── TypedDicts ─────────────────────────────────────────────────────────────────
class StarDict(TypedDict):
x: float
y: float
spd: float
ch: str
cp: int
class _ParticleDict(TypedDict):
x: float
y: float
vx: float
vy: float
life: int
max_life: int
char: str
color: int
class BulletDict(TypedDict, total=False):
wx: float
y: float
vx: float
owner: str
dead: bool
class PlatformDict(TypedDict):
x: float
y: float
w: float
class ParticleDict(TypedDict):
x: float
y: float
life: int
type: str
class _FadeState(TypedDict):
to: str
ticks: int
t: int
phase: Literal['out', 'in']
# ── Color constants ────────────────────────────────────────────────────────────
class Color(enum.IntEnum):
"""Color-pair indices β€” match setup_colors() init order. Members are also
accessible by semantic name (PLAYER == CYAN, ENEMY == RED, etc.)."""
CYAN = 1
RED = 2
GREEN = 3
YELLOW = 4
WHITE = 5
MAGENTA = 6
HIGHLIGHT = 7
BLUE = 8
# Semantic aliases (same int values as the names above)
PLAYER = 1
ENEMY = 2
GOOD = 3
GOLD = 4
NEUTRAL = 5
SPECIAL = 6
SELECT = 7
WATER = 8
# Module-level shortcuts so callers can write CYAN, ENEMY, etc.
# IntEnum members are ints; existing `color: int` signatures still accept these.
CYAN = Color.CYAN
RED = Color.RED
GREEN = Color.GREEN
YELLOW = Color.YELLOW
WHITE = Color.WHITE
MAGENTA = Color.MAGENTA
HIGHLIGHT = Color.HIGHLIGHT
BLUE = Color.BLUE
PLAYER = Color.PLAYER
ENEMY = Color.ENEMY
GOOD = Color.GOOD
GOLD = Color.GOLD
NEUTRAL = Color.NEUTRAL
SPECIAL = Color.SPECIAL
SELECT = Color.SELECT
WATER = Color.WATER
# ── Input ─────────────────────────────────────────────────────────────────────
class Input:
"""
Keyboard and mouse state for the current frame.
Common aliases:
inp.up / down / left / right WASD or arrow keys
inp.fire J, F, or mouse click
inp.jump SPACE
inp.confirm ENTER or SPACE
inp.pause ESC
Single-frame detection:
inp.just_pressed(ord('j')) True only on first frame key is down
inp.just_released(ord('j')) True only on first frame key is up
Raw check:
inp.pressed(ord('x')) True every frame key is held
Mouse:
inp.mouse_click True on left-button press/click
inp.mouse_pressed set of buttons pressed this frame
(1=left, 2=middle, 3=right, 4=scroll)
inp.mouse_row, inp.mouse_col last reported mouse position (or -1)
"""
# Class-level state β€” Input owns its own previous-frame snapshot. _poll()
# captures and rotates it; callers don't manage it directly.
_last: Input | None = None
def __init__(self) -> None:
self.keys: set[int] = set()
self.mouse_pressed: set[int] = set()
self.mouse_row: int = -1
self.mouse_col: int = -1
self._prev: Input | None = None
@property
def mouse_click(self) -> bool:
"""True if the left mouse button was pressed this frame."""
return 1 in self.mouse_pressed
def pressed(self, *keys: int) -> bool:
"""True every frame any of the given keys are held."""
return any(k in self.keys for k in keys)
def just_pressed(self, *keys: int) -> bool:
"""True only on the first frame any of the given keys go down."""
if self._prev is None:
return self.pressed(*keys)
return any(k in self.keys and k not in self._prev.keys for k in keys)
def just_released(self, *keys: int) -> bool:
"""True only on the first frame any of the given keys come up."""
if self._prev is None:
return False
return any(k not in self.keys and k in self._prev.keys for k in keys)
@property
def up(self) -> bool: return self.pressed(curses.KEY_UP, ord('w'), ord('W'))
@property
def down(self) -> bool: return self.pressed(curses.KEY_DOWN, ord('s'), ord('S'))
@property
def left(self) -> bool: return self.pressed(curses.KEY_LEFT, ord('a'), ord('A'))
@property
def right(self) -> bool: return self.pressed(curses.KEY_RIGHT, ord('d'), ord('D'))
@property
def fire(self) -> bool: return self.pressed(ord('j'), ord('J'), ord('f'), ord('F')) or 1 in self.mouse_pressed
@property
def jump(self) -> bool: return self.pressed(ord(' '))
@property
def confirm(self) -> bool: return self.pressed(ord('\n'), 10, 13, ord(' '))
@property
def pause(self) -> bool: return self.pressed(27)
# Frames of "stickiness" applied to recently-seen keys. Terminal key
# auto-repeat is unreliable across emulators β€” macOS Terminal and iTerm
# default to ~500ms before the first repeat fires, then ~30ms between
# subsequent repeats. At 60 FPS that ~500ms gap is 30 frames of silence
# after the initial press. A grace window shorter than that produces a
# noticeable "press, move one cell, stall for half a second, then start
# moving" stutter. 20 frames (333ms) covers most terminals' initial
# delay without making single taps feel mushy.
_REPEAT_GRACE = 20
# Class-level age tracker: key -> frames since last seen.
_key_age: dict[int, int] = {}
@classmethod
def _poll(cls, scr: curses.window) -> Input:
"""Poll one frame of input. Rotates class-level _last so just_pressed /
just_released always have the prior frame to compare against. Recently-
seen keys remain in inp.keys for _REPEAT_GRACE frames to compensate
for unreliable terminal auto-repeat."""
inp = cls()
inp._prev = cls._last
seen_this_frame: set[int] = set()
while True:
k = scr.getch()
if k == -1:
break
if k == curses.KEY_MOUSE:
try:
_, mx, my, _, bst = curses.getmouse()
inp.mouse_row, inp.mouse_col = my, mx
if bst & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED):
inp.mouse_pressed.add(1)
if bst & (curses.BUTTON2_PRESSED | curses.BUTTON2_CLICKED):
inp.mouse_pressed.add(2)
if bst & (curses.BUTTON3_PRESSED | curses.BUTTON3_CLICKED):
inp.mouse_pressed.add(3)
except curses.error:
pass
else:
seen_this_frame.add(k)
# Age all tracked keys by one frame, drop expired ones.
cls._key_age = {k: a + 1 for k, a in cls._key_age.items()
if a + 1 < cls._REPEAT_GRACE}
# Reset age for keys actually seen this frame.
for k in seen_this_frame:
cls._key_age[k] = 0
# inp.keys = every key still within the grace window.
inp.keys = set(cls._key_age.keys())
cls._last = inp
return inp
# ── Bounds-safe primitive ──────────────────────────────────────────────────────
# Shared bounds-safe addstr wrapper used across games. Cheaper than each game
# defining a local `_p()` closure with the same try/except + bounds check.
def at_safe(scr: curses.window, H: int, W: int, row: int, col: int, s: str, attr: int = 0) -> None:
"""Bounds-safe addstr: silently no-ops outside the terminal."""
try:
if 0 <= row < H - 1 and 0 <= col < W:
scr.addstr(row, col, s[:max(0, W - col)], attr)
except curses.error:
pass
# ── Renderer ───────────────────────────────────────────────────────────────────
class Renderer:
"""
All drawing for one frame. Coordinates are (row, col), row 0 = top.
Every method is bounds-safe β€” drawing outside the terminal is silently ignored.
Color arguments accept module-level constants (PLAYER, ENEMY, GOOD, etc.)
or the raw pair integers 1-8.
"""
def __init__(self, scr: curses.window, H: int, W: int) -> None:
self._scr = scr
self.H = H
self.W = W
# ── Primitives ──────────────────────────────────────────────────────────────
def text(self, row: int, col: int, s: str,
color: int = NEUTRAL, bold: bool = False,
dim: bool = False, reverse: bool = False,
blink: bool = False) -> None:
"""Draw a string at (row, col). Silently clipped at terminal edges."""
attr = curses.color_pair(color)
if bold: attr |= curses.A_BOLD
if dim: attr |= curses.A_DIM
if reverse: attr |= curses.A_REVERSE
if blink: attr |= curses.A_BLINK
try:
if 0 <= row < self.H - 1 and 0 <= col < self.W:
self._scr.addstr(row, col, s[:max(0, self.W - col)], attr)
except curses.error:
pass
def center(self, row: int, s: str,
color: int = NEUTRAL, bold: bool = False,
dim: bool = False) -> None:
"""Draw s centered horizontally on row."""
self.text(row, max(0, (self.W - len(s)) // 2), s, color, bold, dim)
def fill(self, row: int, col: int, h: int, w: int,
char: str = ' ', color: int = NEUTRAL) -> None:
"""Fill a rectangle with a character β€” useful for clearing regions."""
line = (char * w)[:w]
for r in range(row, min(row + h, self.H - 1)):
self.text(r, col, line, color)
# ── Shapes ──────────────────────────────────────────────────────────────────
def box(self, row: int, col: int, h: int, w: int,
color: int = NEUTRAL, title: str = "",
double: bool = True) -> None:
"""Draw a border box, optionally with a centered title.
double=True ╔═══╗ (default)
double=False β”Œβ”€β”€β”€β”
"""
tl, tr, bl, br, hz, vt = (
('β•”', 'β•—', 'β•š', '╝', '═', 'β•‘') if double else
('β”Œ', '┐', 'β””', 'β”˜', '─', 'β”‚')
)
self.text(row, col, tl + hz * (w - 2) + tr, color, bold=True)
self.text(row + h-1, col, bl + hz * (w - 2) + br, color, bold=True)
for r in range(1, h - 1):
self.text(row + r, col, vt, color)
self.text(row + r, col + w - 1, vt, color)
if title:
t = f' {title} '
self.text(row, col + max(0, (w - len(t)) // 2), t, color, bold=True)
def outer_border(self, color: int = NEUTRAL) -> None:
"""Draw the full outer terminal border."""
self.box(0, 0, self.H - 1, self.W, color)
# ── Progress bars ────────────────────────────────────────────────────────────
def bar(self, row: int, col: int,
value: float, maximum: float, width: int,
fill_color: int = GOOD, empty_color: int = NEUTRAL,
label: str = "") -> None:
"""Horizontal progress bar. β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘ Optionally suffixed with label."""
ratio = max(0.0, min(1.0, value / maximum if maximum > 0 else 0.0))
filled = int(width * ratio)
self.text(row, col, 'β–ˆ' * filled, fill_color, bold=True)
self.text(row, col + filled, 'β–‘' * (width - filled), empty_color)
if label:
self.text(row, col + width + 1, label, NEUTRAL)
def vbar(self, top_row: int, col: int, height: int,
value: float, maximum: float,
fill_color: int = GOOD, empty_color: int = NEUTRAL) -> None:
"""Vertical progress bar β€” top=empty, bottom=full."""
ratio = max(0.0, min(1.0, value / maximum if maximum > 0 else 0.0))
filled = int(height * ratio)
empty = height - filled
for i in range(empty):
self.text(top_row + i, col, 'β–‘', empty_color)
for i in range(filled):
self.text(top_row + empty + i, col, 'β–ˆ', fill_color, bold=True)
# ── Sprites and menus ────────────────────────────────────────────────────────
def sprite(self, row: int, col: int, lines: list[str],
color: int = NEUTRAL, bold: bool = True) -> None:
"""Draw a multi-line ASCII sprite."""
for i, line in enumerate(lines):
self.text(row + i, col, line, color, bold)
def menu(self, row: int, col: int,
options: list[str], cursor: int,
color_selected: int = SELECT,
color_normal: int = NEUTRAL,
width: int = 20,
cursor_char: str = 'β–Έ') -> None:
"""Vertical menu with a cursor indicator.
Cursor wraps automatically. Selected item is highlighted.
"""
cursor = cursor % max(1, len(options))
for i, opt in enumerate(options):
prefix = f'{cursor_char} ' if i == cursor else ' '
label = (prefix + opt).ljust(width)[:width]
self.text(row + i, col, label,
color_selected if i == cursor else color_normal,
bold=(i == cursor))
# ── Text utilities ────────────────────────────────────────────────────────────
def wrapped_text(self, row: int, col: int, text: str, width: int,
color: int = NEUTRAL, bold: bool = False) -> int:
"""Word-wrap text into a fixed column width. Returns number of rows used."""
words = text.split()
lines: list[str] = []
current = ''
for word in words:
test = (current + ' ' + word).strip()
if len(test) <= width:
current = test
else:
if current:
lines.append(current)
current = word[:width]
if current:
lines.append(current)
for i, line in enumerate(lines):
self.text(row + i, col, line, color, bold)
return len(lines)
def dialog(self, row: int, col: int, width: int,
speaker: str, text: str,
tick: int = 0,
speaker_color: int = PLAYER,
portrait: list[str] | None = None,
prompt: str = 'β–Ό') -> None:
"""Standard RPG dialog box: speaker name tag, optional portrait, wrapped text.
The advance prompt blinks using tick. Prompt appears at bottom-right of box.
"""
port_w = max((len(l) for l in portrait), default=0) + 2 if portrait else 0
text_col = col + 2 + port_w
text_w = width - port_w - 4
lines = max(3, len(self._wrap(text, text_w)))
h = lines + 4
self.box(row, col, h, width, NEUTRAL, double=False)
tag = f' {speaker} '
self.text(row, col + 2, tag, speaker_color, bold=True)
if portrait:
for i, line in enumerate(portrait[:h - 2]):
self.text(row + 1 + i, col + 2, line, speaker_color)
self.wrapped_text(row + 1, text_col, text, text_w, NEUTRAL)
if (tick // 15) % 2 == 0:
self.text(row + h - 2, col + width - 4, prompt, GOLD, bold=True)
def _wrap(self, text: str, width: int) -> list[str]:
"""Internal word-wrap helper."""
words, lines, current = text.split(), [], ''
for word in words:
test = (current + ' ' + word).strip()
if len(test) <= width:
current = test
else:
if current:
lines.append(current)
current = word[:width]
if current:
lines.append(current)
return lines
# ── Standard screens ─────────────────────────────────────────────────────────
def header(self, game_name: str,
left: str = "", right: str = "",
color: int = GOLD) -> None:
"""Standard 3-row HUD header (rows 0–2)."""
self.text(0, 0, 'β•”' + '═' * (self.W - 2) + 'β•—', NEUTRAL, bold=True)
self.text(1, 0, 'β•‘', NEUTRAL, bold=True)
self.text(1, self.W - 1, 'β•‘', NEUTRAL, bold=True)
self.text(1, 1, 'β–“β–’β–‘', NEUTRAL, dim=True)
self.text(1, self.W - 4, 'β–‘β–’β–“', NEUTRAL, dim=True)
self.text(1, 5, f'β˜… {game_name} β˜…', color, bold=True)
if left:
self.text(1, 5 + len(game_name) + 5, left, ENEMY, bold=True)
if right:
self.text(1, self.W - len(right) - 4, right, GOOD)
self.text(2, 0, 'β• ' + '═' * (self.W - 2) + 'β•£', NEUTRAL, bold=True)
def footer(self, controls: str, color: int = NEUTRAL) -> None:
"""Controls hint in the bottom two rows (rows H-3 and H-2)."""
self.text(self.H - 3, 0, 'β• ' + '═' * (self.W - 2) + 'β•£', NEUTRAL, bold=True)
self.text(self.H - 2, 1, controls.center(self.W - 2)[:self.W - 2], color)
def pause_overlay(self, game_name: str, controls: list[str]) -> None:
"""Minimal pause panel β€” just `PAUSED` + Resume/Quit prompts.
Earlier versions painted a full-screen β–‘ scrim and rendered a
boxed CONTROLS table on every frame, which cost ~6000+ curses
writes per frame and read as overdesigned. The `controls`
argument is preserved for caller-compat but is no longer drawn.
"""
del controls # kept for API compatibility; intentionally unused
title = f' {game_name} Β· PAUSED '
prompt = '[ R ] Resume [ Q ] Quit'
bw = max(len(title), len(prompt)) + 6
bh = 5
by = max(1, (self.H - bh) // 2)
bx = max(1, (self.W - bw) // 2)
# Clear only the panel rectangle (no full-screen scrim) so the game
# stays visible behind the menu and we don't pay for an H*W write.
for r in range(bh):
self.text(by + r, bx, ' ' * bw, color=NEUTRAL)
# Single-line frame
self.box(by, bx, bh, bw, color=SELECT)
# Centered title row + prompt row
self.text(by + 1, bx + (bw - len(title)) // 2, title, color=GOLD, bold=True)
self.text(by + 3, bx + (bw - len(prompt)) // 2, prompt, color=NEUTRAL, bold=True)
def gameover_screen(self, title: str = "GAME OVER",
score_line: str = "", player_label: str = "",
rank: int | None = None, tick: int = 0,
prompt: str = "[ SPACE ] Play again [ ESC ] Quit",
title_color: int = ENEMY) -> None:
"""Standard game-over or victory screen."""
self.outer_border()
mr = self.H // 2
self.center(mr - 2, f' {title} ', title_color, bold=True)
if score_line:
self.center(mr, f' {score_line} ', GOLD, bold=True)
if player_label:
self.center(mr + 1, f' {player_label} ', PLAYER, bold=True)
if rank is not None:
self.center(mr + 2, f' Global rank: #{rank} ', GOOD, bold=True)
elif player_label:
self.center(mr + 2, ' Submitting score... ', NEUTRAL, dim=True)
if (tick // 15) % 2 == 0:
self.center(mr + 4, prompt, NEUTRAL)
def stars(self, star_list: list[StarDict]) -> None:
"""Draw a parallax star field generated by make_stars()."""
for s in star_list:
r, c = int(s['y']), int(s['x'])
if 0 < r < self.H - 1 and 0 < c < self.W - 1:
self.text(r, c, s['ch'], s['cp'], dim=True)
# ── Scene ─────────────────────────────────────────────────────────────────────
class Scene:
"""
One screen of a game (title, gameplay, pause, game over, …).
Subclass and implement:
on_enter(self) called once when scene activates;
self.payload holds any data passed
from the previous scene
update(self, inp, tick, dt) game logic; return either:
- None to stay
- "scene_name" to switch
- ("scene_name", payload) to switch
with data the next scene reads
via self.payload
- "quit" to exit
draw(self, r, tick) render via the Renderer
Access engine, size, and delta-time:
self.engine.H / self.engine.W terminal dimensions
self.engine.switch("gameover") programmatic scene switch
self.engine.fade_to("gameover") scene switch with fade transition
"""
engine: Engine
payload: object | None = None
def on_enter(self) -> None: pass
def update(self, inp: Input, tick: int, dt: float) -> str | tuple[str, object] | None:
return None
def draw(self, r: Renderer, tick: int) -> None: pass
# ── Engine ─────────────────────────────────────────────────────────────────────
_MIN_H = 18
_MIN_W = 50
class Engine:
"""
The game loop.
Example
-------
engine = (
Engine("Snake", fps=15)
.scene("menu", MenuScene())
.scene("game", GameScene())
.scene("gameover", GameOverScene())
)
engine.run("menu")
"""
def __init__(self, title: str, fps: int = 30, seed: int | None = None) -> None:
self.title = title
self.fps = fps
self.H = 0
self.W = 0
self.dt = 1.0 / fps # seconds per frame (updated each tick)
# Engine-owned RNG. Games should prefer self.engine.rng over the global
# `random` module so daily challenges and replays can be deterministic.
self.rng = random.Random(seed)
self._seed = seed
self._scenes: dict[str, Scene] = {}
self._current = ""
self._scr: curses.window | None = None
self._tick = 0
self._fade: _FadeState | None = None
# Profiling: when run(profile=True), accumulates per-frame timings.
self._profile_data: list[tuple[float, float]] | None = None
def seed(self, seed: int) -> None:
"""Reseed the engine RNG. Useful for daily challenges."""
self._seed = seed
self.rng = random.Random(seed)
def scene(self, name: str, s: Scene) -> Engine:
"""Register a scene. Returns self for chaining."""
s.engine = self
self._scenes[name] = s
return self
def switch(self, name: str, payload: object | None = None) -> None:
"""Switch to a named scene immediately. The next scene's `self.payload`
is set before on_enter() runs. Warns to stderr on unknown name."""
if name not in self._scenes:
import sys
print(
f'[Engine] Unknown scene {repr(name)}. '
f'Registered: {list(self._scenes)}',
file=sys.stderr,
)
return
self._current = name
self._scenes[name].payload = payload
self._scenes[name].on_enter()
def fade_to(self, name: str, ticks: int = 12) -> None:
"""Switch to a scene with a brief fade-out / fade-in transition."""
self._fade = {'to': name, 'ticks': ticks, 't': 0, 'phase': 'out'}
def run(self, initial: str, profile: bool = False) -> None:
"""Start the game loop, blocking until the game exits.
profile=True records per-frame update/draw timings and prints a
summary on exit. Useful for finding hot paths.
Any exception inside the loop tears down curses cleanly and prints the
traceback to stderr so the terminal isn't left in raw mode.
"""
import sys
import traceback
if profile:
self._profile_data = []
try:
curses.wrapper(self._loop, initial)
except Exception as exc:
try: curses.endwin()
except curses.error: pass
print(f'\n[{self.title} crashed] {exc}', file=sys.stderr)
traceback.print_exc(file=sys.stderr)
if self._profile_data:
self._print_profile()
print('\n [ back in Claude β€” type anything to chat ]\n')
def _print_profile(self) -> None:
"""Summarise recorded per-frame timings."""
data = self._profile_data or []
if not data:
return
n = len(data)
ups = sorted(u for u, _ in data)
drs = sorted(d for _, d in data)
def pct(a: list[float], p: float) -> float:
return a[min(len(a) - 1, int(len(a) * p))]
print(f'\n── {self.title} profile ({n} frames at {self.fps} fps) ──')
print(f' update avg={sum(ups)/n*1000:6.2f}ms '
f'p50={pct(ups,0.5)*1000:6.2f}ms '
f'p95={pct(ups,0.95)*1000:6.2f}ms '
f'max={max(ups)*1000:6.2f}ms')
print(f' draw avg={sum(drs)/n*1000:6.2f}ms '
f'p50={pct(drs,0.5)*1000:6.2f}ms '
f'p95={pct(drs,0.95)*1000:6.2f}ms '
f'max={max(drs)*1000:6.2f}ms')
budget = 1.0 / self.fps * 1000
over = sum(1 for u, d in data if (u + d) * 1000 > budget)
print(f' budget {budget:.1f}ms; {over} frame(s) over ({over * 100 // n}%)')
def _loop(self, scr: curses.window, initial: str) -> None:
self._scr = scr
_init_curses(scr)
# Read terminal size before calling on_enter so scenes can use it
self.H, self.W = scr.getmaxyx()
self._tick = 0
self.switch(initial)
nxt = time.perf_counter()
prev = time.perf_counter()
while True:
now = time.perf_counter()
# Fixed timestep β€” skip catch-up if we fall behind
if now < nxt:
time.sleep(max(0.0, nxt - now - 0.001))
continue
self.dt = now - prev
prev = now
nxt = max(nxt + 1.0 / self.fps, now) # no spiral-of-death
self._tick += 1
self.H, self.W = scr.getmaxyx()
# Minimum terminal size guard
if self.H < _MIN_H or self.W < _MIN_W:
scr.erase()
msg = f' Resize terminal: need {_MIN_W}x{_MIN_H}, have {self.W}x{self.H} '
try:
scr.addstr(0, 0, msg[:self.W])
except curses.error:
pass
scr.refresh()
continue
inp = Input._poll(scr)
# Handle fade transition
if self._fade:
self._tick_fade(scr, inp)
continue
current = self._scenes.get(self._current)
if current is None:
break
t0 = time.perf_counter()
result = current.update(inp, self._tick, self.dt)
t1 = time.perf_counter()
# Result may be: None, "name", ("name", payload), or "quit".
next_name: str | None = None
payload: object | None = None
if isinstance(result, tuple) and len(result) == 2:
next_name, payload = result
elif isinstance(result, str):
next_name = result
if next_name == 'quit':
break
if next_name and next_name != self._current:
self.switch(next_name, payload)
scr.erase()
self._scenes[self._current].draw(
Renderer(scr, self.H, self.W), self._tick
)
scr.refresh()
t2 = time.perf_counter()
if self._profile_data is not None:
self._profile_data.append((t1 - t0, t2 - t1))
def _tick_fade(self, scr: curses.window, inp: Input) -> None:
f = self._fade
if f is None:
return # _loop() only calls us when _fade is set, but narrow for type checker
frac = f['t'] / max(1, f['ticks'])
chars = 'β–ˆβ–“β–’β–‘ '
f['t'] += 1
current = self._scenes.get(self._current)
if f['phase'] == 'out':
# Draw scene underneath, then overlay fade
scr.erase()
if current:
current.draw(Renderer(scr, self.H, self.W), self._tick)
overlay = chars[min(4, int(frac * 5))]
for r in range(self.H - 1):
try:
scr.addstr(r, 0, overlay * self.W,
curses.color_pair(NEUTRAL) | curses.A_DIM)
except curses.error:
pass
if f['t'] >= f['ticks']:
self.switch(f['to'])
f['t'] = 0
f['phase'] = 'in'
else: # 'in'
frac2 = 1.0 - frac
scr.erase()
current2 = self._scenes.get(self._current)
if current2:
current2.draw(Renderer(scr, self.H, self.W), self._tick)
overlay = chars[min(4, int(frac2 * 5))]
for r in range(self.H - 1):
try:
scr.addstr(r, 0, overlay * self.W,
curses.color_pair(NEUTRAL) | curses.A_DIM)
except curses.error:
pass
if f['t'] >= f['ticks']:
self._fade = None
scr.refresh()
# ── AnimSprite ─────────────────────────────────────────────────────────────────
class AnimSprite:
"""
Frame-cycling sprite with named states.
Example
-------
states = {
'idle': [ ['(o)', '|=|', '/ \\'] ],
'run': [ ['(_o)', '/|\\', '/ \\'],
['(_o)', '\\|/', '/ \\'] ],
}
sprite = AnimSprite(states, ticks_per_frame=6)
sprite.set_state('run')
sprite.tick()
r.sprite(row, col, sprite.current())
Callbacks
---------
sprite.set_state('die', on_complete=lambda: self.remove())
"""
def __init__(self, states: dict[str, list[list[str]]], ticks_per_frame: int = 8) -> None:
self.states = states
self.ticks_per_frame = ticks_per_frame
self.state = next(iter(states))
self._frame = 0
self._timer = 0
self._on_complete: Callable[[], None] | None = None
def set_state(self, state: str,
on_complete: Callable[[], None] | None = None,
force: bool = False) -> None:
"""Switch animation state.
force=True restarts the animation even if already in this state.
on_complete fires once when the last frame plays.
"""
if (state != self.state or force) and state in self.states:
self.state = state
self._frame = 0
self._timer = 0
self._on_complete = on_complete
def tick(self) -> None:
self._timer += 1
if self._timer >= self.ticks_per_frame:
self._timer = 0
frames = self.states.get(self.state, [[]])
n = max(1, len(frames))
next_f = self._frame + 1
if next_f >= n:
if self._on_complete:
cb = self._on_complete
self._on_complete = None
cb()
self._frame = next_f % n
def current(self) -> list[str]:
frames = self.states.get(self.state, [[' ']])
return frames[self._frame % max(1, len(frames))]
# ── Camera ─────────────────────────────────────────────────────────────────────
class Camera:
"""
Smooth-follow camera for scrolling worlds.
Coordinate convention: x increases right, y increases DOWN (terminal default).
Set y_up=True in world_to_screen for platformer games where y=0 is the floor.
Example
-------
cam = Camera()
cam.set_bounds(0, world_width)
cam.follow(player.x, player.y) # each frame
row, col = cam.world_to_screen(e.x, e.y, W, H)
"""
def __init__(self) -> None:
self.x = 0.0
self.y = 0.0
self._min_x = -1e9
self._max_x = 1e9
self._min_y = -1e9
self._max_y = 1e9
def set_bounds(self, min_x: float, max_x: float,
min_y: float = -1e9, max_y: float = 1e9) -> None:
"""Prevent the camera from scrolling outside the world."""
self._min_x = min_x
self._max_x = max_x
self._min_y = min_y
self._max_y = max_y
def follow(self, tx: float, ty: float, lerp: float = 0.12) -> None:
"""Lerp toward target. lerp=1.0 is instant, 0.05 is very smooth."""
self.x = clamp(self.x + (tx - self.x) * lerp, self._min_x, self._max_x)
self.y = clamp(self.y + (ty - self.y) * lerp, self._min_y, self._max_y)
def world_to_screen(self, wx: float, wy: float, W: int, H: int,
y_up: bool = False) -> tuple[int, int]:
"""Convert world position to terminal (row, col).
y_up=False Y increases downward (default terminal β€” good for top-down games)
y_up=True Y increases upward (platformer convention β€” Y=0 is the floor)
"""
col = int(wx - self.x + W // 3)
if y_up:
row = int(H - 2 - (wy - self.y))
else:
row = int(wy - self.y)
return row, col
def on_screen(self, wx: float, wy: float, W: int, H: int,
margin: int = 4) -> bool:
"""True if the world position is currently visible."""
row, col = self.world_to_screen(wx, wy, W, H)
return -margin <= row < H + margin and -margin <= col < W + margin
# ── Particles ──────────────────────────────────────────────────────────────────
class Particles:
"""
Moving particle system for explosions, sparks, debris, and effects.
Example
-------
particles = Particles()
particles.explode(col, row, count=8, color=GOLD)
particles.spawn(col, row, vx=-0.5, vy=-1.0, char='β˜…', color=PLAYER)
# each frame:
particles.update()
particles.draw(r)
"""
def __init__(self) -> None:
self._active: list[_ParticleDict] = []
def spawn(self, col: float, row: float,
vx: float = 0.0, vy: float = 0.0,
char: str = '*', color: int = GOLD,
life: int = 8) -> None:
"""Spawn one particle with velocity and lifetime."""
self._active.append(_ParticleDict(
x=float(col), y=float(row),
vx=vx, vy=vy,
life=life, max_life=life,
char=char, color=color,
))
def explode(self, col: int, row: int,
count: int = 14, color: int = GOLD,
chars: str = 'βœ¦βœ§β˜…Β·βˆ™β—†β—‡*+') -> None:
"""Radial burst of particles in random directions."""
for _ in range(count):
angle = random.uniform(0, 2 * math.pi)
speed = random.uniform(0.3, 1.4)
self.spawn(
col, row,
vx=math.cos(angle) * speed * 2,
vy=math.sin(angle) * speed,
char=random.choice(chars),
color=color,
life=random.randint(4, 12),
)
def burst(self, col: int, row: int,
direction: tuple[float, float],
spread: float = 0.8, count: int = 5,
color: int = GOLD) -> None:
"""Directional burst (e.g., exhaust trail, hit sparks)."""
dx, dy = direction
angle = math.atan2(dy, dx)
for _ in range(count):
a = angle + random.uniform(-spread, spread)
s = random.uniform(0.4, 1.2)
self.spawn(col, row,
vx=math.cos(a) * s * 2,
vy=math.sin(a) * s,
char=random.choice('Β·βˆ™β—¦β€’'),
color=color,
life=random.randint(3, 8))
def update(self) -> None:
"""Advance all particles by one frame. Call once per frame."""
for p in self._active:
p['x'] += p['vx']
p['y'] += p['vy']
p['life'] -= 1
self._active = [p for p in self._active if p['life'] > 0]
def draw(self, r: Renderer) -> None:
"""Draw all live particles. Call after update()."""
for p in self._active:
fade = p['life'] / max(1, p['max_life'])
r.text(int(p['y']), int(p['x']),
p['char'], p['color'], dim=fade < 0.35)
@property
def count(self) -> int:
return len(self._active)
# ── Timer ─────────────────────────────────────────────────────────────────────
class Timer:
"""
Countdown timer for game events.
Example
-------
self.invincible = Timer(2.0) # 2-second invincibility
# in update():
if self.invincible.tick(dt):
self.can_take_damage = True
# check progress:
frac = self.invincible.frac # 0.0 β†’ 1.0
done = self.invincible.done
"""
def __init__(self, duration: float, auto_reset: bool = False) -> None:
self.duration = duration
self.auto_reset = auto_reset
self._elapsed = 0.0
self.done = False
def tick(self, dt: float) -> bool:
"""Advance the timer. Returns True the frame it completes."""
if self.done and not self.auto_reset:
return False
self._elapsed += dt
if self._elapsed >= self.duration:
self._elapsed = 0.0 if self.auto_reset else self.duration
self.done = not self.auto_reset
return True
return False
def reset(self, duration: float | None = None) -> None:
"""Restart the timer, optionally with a new duration."""
if duration is not None:
self.duration = duration
self._elapsed = 0.0
self.done = False
@property
def frac(self) -> float:
"""Progress 0.0 β†’ 1.0."""
return min(1.0, self._elapsed / max(1e-9, self.duration))
@property
def remaining(self) -> float:
return max(0.0, self.duration - self._elapsed)
# ── Audio ─────────────────────────────────────────────────────────────────────
class Audio:
"""
Minimal audio. Two surfaces:
beep() / flash() Always works (curses primitives).
play(path) Background a system sound command if available.
play() degrades gracefully β€” missing files or unsupported platforms are
no-ops, never raise. Currently looks for afplay (macOS) / aplay (ALSA) /
paplay (PulseAudio).
Example
-------
audio = Audio()
audio.beep() # short curses bell
audio.play('assets/hit.wav') # background; missing file is a no-op
"""
def __init__(self, enabled: bool = True) -> None:
import shutil
import subprocess
self.enabled = enabled
self._subprocess = subprocess
self._cmd: str | None = None
for candidate in ('afplay', 'paplay', 'aplay'):
if shutil.which(candidate):
self._cmd = candidate
break
def beep(self) -> None:
"""Short audible bell via curses."""
if not self.enabled:
return
try:
curses.beep()
except curses.error:
pass
def flash(self) -> None:
"""Visible screen flash via curses (silent alternative to beep)."""
if not self.enabled:
return
try:
curses.flash()
except curses.error:
pass
def play(self, path: str | Path) -> None:
"""Background-play an audio file. No-op if disabled, no command found,
or the file doesn't exist. Never blocks the game loop."""
if not self.enabled or self._cmd is None:
return
p = Path(path) if not isinstance(path, Path) else path
if not p.exists():
return
try:
self._subprocess.Popen(
[self._cmd, str(p)],
stdout=self._subprocess.DEVNULL,
stderr=self._subprocess.DEVNULL,
)
except OSError:
pass
# ── GameSave ───────────────────────────────────────────────────────────────────
class GameSave:
"""
Simple JSON save file stored in the user's home directory.
Example
-------
save = GameSave('claudemon')
save.save({'team': [...], 'gold': 500})
data = save.load() # returns dict or None
save.delete()
"""
def __init__(self, name: str) -> None:
self._path = Path.home() / f'.claudcade_{name}_save.json'
def save(self, data: dict[str, object]) -> None:
self._path.write_text(json.dumps(data, indent=2))
def load(self) -> dict[str, object] | None:
if not self._path.exists():
return None
try:
result = json.loads(self._path.read_text())
return result if isinstance(result, dict) else None
except (OSError, json.JSONDecodeError):
return None # missing / unreadable / corrupt save β†’ caller starts fresh
def delete(self) -> None:
self._path.unlink(missing_ok=True)
@property
def exists(self) -> bool:
return self._path.exists()
# ── Stars ─────────────────────────────────────────────────────────────────────
def make_stars(H: int, W: int, count: int = 80,
deep: int = 0) -> list[StarDict]:
"""Generate a parallax star field. Pass to Renderer.stars() and scroll_stars().
`deep` adds a small number of slow-scrolling far-background landmarks
(planets, moons) that give a stronger sense of depth.
"""
result: list[StarDict] = []
for _ in range(count):
spd = random.choice([0.25, 0.5, 1.0, 2.0])
ch = 'βˆ™' if spd < 0.5 else ('Β·' if spd < 1.0 else ('+' if spd < 1.8 else '✦'))
cp = NEUTRAL if spd < 1.0 else (PLAYER if spd < 1.5 else GOLD)
result.append(StarDict(
x=float(random.randint(1, max(1, W - 2))),
y=float(random.randint(1, max(1, H - 5))),
spd=spd, ch=ch, cp=cp,
))
# Deep-background landmarks β€” sparse, very slow, larger glyphs
for _ in range(deep):
spd = 0.08
ch = random.choice(['O', 'o', 'Β°', 'β—―'])
cp = random.choice([SPECIAL, WATER, NEUTRAL])
result.append(StarDict(
x=float(random.randint(1, max(1, W - 2))),
y=float(random.randint(1, max(1, H - 5))),
spd=spd, ch=ch, cp=cp,
))
return result
def scroll_stars(stars: list[StarDict], W: int, direction: float = -1.0) -> None:
"""Scroll stars horizontally. Call once per frame. direction=-1 scrolls left."""
for s in stars:
s['x'] += s['spd'] * direction
if s['x'] < 1:
s['x'] = float(W - 2)
elif s['x'] >= W - 1:
s['x'] = 1.0
# ── Math utilities ─────────────────────────────────────────────────────────────
def distance(ax: float, ay: float, bx: float, by: float) -> float:
return math.hypot(bx - ax, by - ay)
def clamp(value: float, lo: float, hi: float) -> float:
return max(lo, min(hi, value))
def lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def ease_in(t: float) -> float:
"""Quadratic ease-in. t in [0, 1]."""
return t * t
def ease_out(t: float) -> float:
"""Quadratic ease-out. t in [0, 1]."""
return 1.0 - (1.0 - t) ** 2
def ease_in_out(t: float) -> float:
"""Smooth step. t in [0, 1]."""
return t * t * (3.0 - 2.0 * t)
def sign(x: float) -> float:
return 0.0 if x == 0 else (1.0 if x > 0 else -1.0)
# ── Engine helpers ─────────────────────────────────────────────────────────────
def setup_colors() -> None:
"""Initialise the standard Claudecade color pairs. Call once inside curses.wrapper.
On 256-color terminals (Ghostty, iTerm2, modern xterm, etc.) we bind to
fixed indices in the 6x6x6 RGB cube so the arcade palette stays vibrant
regardless of the user's terminal theme. The named curses colors
(COLOR_CYAN etc.) are re-mapped by themes β€” Ghostty's default in
particular renders them pastel, which is what the arcade is *not*.
On 8-color terminals we fall back to the named pairs so legacy
setups still get something readable.
"""
curses.start_color()
curses.use_default_colors()
if curses.COLORS >= 256:
# 6x6x6 RGB cube: 16 + 36*r + 6*g + b where r,g,b in 0..5
# Picked for high saturation against a black background.
VIBRANT_CYAN = 51 # #00ffff
VIBRANT_RED = 196 # #ff0000
VIBRANT_GREEN = 46 # #00ff00
VIBRANT_YELLOW = 226 # #ffff00
VIBRANT_WHITE = 231 # #ffffff
VIBRANT_MAGENTA = 201 # #ff00ff
VIBRANT_BLUE = 33 # #0087ff (royal β€” pure 21 #0000ff is too dim)
VIBRANT_BLACK = 16 # #000000
curses.init_pair(CYAN, VIBRANT_CYAN, -1)
curses.init_pair(RED, VIBRANT_RED, -1)
curses.init_pair(GREEN, VIBRANT_GREEN, -1)
curses.init_pair(YELLOW, VIBRANT_YELLOW, -1)
curses.init_pair(WHITE, VIBRANT_WHITE, -1)
curses.init_pair(MAGENTA, VIBRANT_MAGENTA, -1)
curses.init_pair(HIGHLIGHT, VIBRANT_BLACK, VIBRANT_WHITE)
curses.init_pair(BLUE, VIBRANT_BLUE, -1)
else:
curses.init_pair(CYAN, curses.COLOR_CYAN, -1)
curses.init_pair(RED, curses.COLOR_RED, -1)
curses.init_pair(GREEN, curses.COLOR_GREEN, -1)
curses.init_pair(YELLOW, curses.COLOR_YELLOW, -1)
curses.init_pair(WHITE, curses.COLOR_WHITE, -1)
curses.init_pair(MAGENTA, curses.COLOR_MAGENTA, -1)
curses.init_pair(HIGHLIGHT, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(BLUE, curses.COLOR_BLUE, -1)
def _init_curses(scr: curses.window) -> None:
curses.cbreak()
curses.noecho()
scr.keypad(True)
scr.nodelay(True)
curses.curs_set(0)
setup_colors()
try:
curses.mousemask(curses.ALL_MOUSE_EVENTS)
except curses.error:
pass
def draw_how_to_play(scr: curses.window, H: int, W: int, tick: int,
goal: list[str],
tips: list[str],
controls: list[str] | None = None) -> None:
"""Standard HOW TO PLAY screen shared by all games.
Content is assembled into a single list, vertically centered inside the
panel, and horizontally centered within the box. The PRESS SPACE prompt
is reserved its own row at the bottom; the content block above it can
never overlap the prompt or run off the box (it scales down or clips
earlier sections if the terminal is too short).
"""
P = curses.color_pair
BW = min(70, W - 4)
lm = (W - BW) // 2
def _put(r: int, c: int, s: str, a: int = 0) -> None:
try:
if 0 <= r < H - 1 and 0 <= c < W:
scr.addstr(r, c, s[:max(0, W - c)], a)
except curses.error:
pass
# ── Frame ──────────────────────────────────────────────────────────────
scr.erase()
_put(0, lm, 'β•”' + '═' * (BW - 2) + 'β•—', P(NEUTRAL) | curses.A_BOLD)
_put(H-1, lm, 'β•š' + '═' * (BW - 2) + '╝', P(NEUTRAL) | curses.A_BOLD)
for r in range(1, H - 1):
_put(r, lm, 'β•‘', P(NEUTRAL) | curses.A_BOLD)
_put(r, lm + BW - 1, 'β•‘', P(NEUTRAL) | curses.A_BOLD)
title = 'H O W T O P L A Y'
hdr = 'β–“β–’β–‘' + title.center(BW - 8) + 'β–‘β–’β–“'
_put(1, lm + 1, hdr, P(GOLD) | curses.A_BOLD)
_put(2, lm, 'β• ' + '═' * (BW - 2) + 'β•£', P(NEUTRAL) | curses.A_BOLD)
# ── Build the content block as (text, color, bold) tuples ──────────────
Line = tuple[str, int, bool]
lines: list[Line] = []
lines.append(('GOAL', GOLD, True))
lines.append(('────', NEUTRAL, False))
for line in goal:
lines.append((line, NEUTRAL, False))
lines.append(('', NEUTRAL, False))
if controls is not None:
lines.append(('CONTROLS', GOLD, True))
lines.append(('────────', NEUTRAL, False))
for ctrl in controls:
lines.append((ctrl, NEUTRAL, False))
lines.append(('', NEUTRAL, False))
lines.append(('TIPS', GOLD, True))
lines.append(('────', NEUTRAL, False))
for line in tips:
lines.append((line, NEUTRAL, False))
# Strip trailing blank rows so vertical centering doesn't pad on both
# sides of empty space at the end.
while lines and lines[-1][0] == '':
lines.pop()
# ── Vertical centering ─────────────────────────────────────────────────
# Inner panel runs rows 3..H-4 inclusive; H-3 is reserved for the
# PRESS SPACE prompt. So the content has (H - 3) - 3 = H - 6 rows.
top_inner = 3
bot_inner = H - 4 # last writable row above the SPACE prompt
inner_h = max(0, bot_inner - top_inner + 1)
if len(lines) > inner_h:
# Too tall for this terminal β€” clip trailing lines so the prompt
# still has its row. Better than overrunning the box border.
lines = lines[:inner_h]
start = top_inner + max(0, (inner_h - len(lines)) // 2)
# ── Horizontal centering of the whole content column ───────────────────
# Compute the widest line so the column sits centered as a block, not
# so each line is independently centered (preserves CONTROLS alignment).
col_w = max((len(text) for text, _, _ in lines), default=0)
col_w = min(col_w, BW - 4)
col_x = lm + (BW - col_w) // 2
for i, (text, color, bold) in enumerate(lines):
attr = P(color) | (curses.A_BOLD if bold else 0)
_put(start + i, col_x, text[:col_w], attr)
# ── Blinking prompt (always on its reserved row) ───────────────────────
if (tick // 15) % 2 == 0:
msg = 'PRESS SPACE TO START'
_put(H - 3, lm + (BW - len(msg)) // 2, msg, P(GOLD) | curses.A_BOLD)
scr.refresh()
def run_game(main_fn: Callable[[curses.window], None], game_name: str = '') -> None:
"""Standard curses.wrapper + crash handler. Use at the bottom of every game file.
from claudcade_engine import run_game
run_game(main, 'C-TYPE')
"""
import sys
import traceback
try:
curses.wrapper(main_fn)
except Exception as e:
try:
curses.endwin()
except curses.error:
pass
label = f'[{game_name} crashed] ' if game_name else ''
print(f'\n{label}{e}', file=sys.stderr)
traceback.print_exc(file=sys.stderr)
print('\n [ back in Claude β€” type anything to chat ]\n')