Created
August 11, 2026 08:55
-
-
Save float64co/63d16f5d8c92da570e9aec607a23be9f to your computer and use it in GitHub Desktop.
Nethack-inspired pure-python RPG.
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
| #!/usr/bin/env python3 | |
| """ | |
| NEON WARD - A cyberpunk-themed ncurses RPG. | |
| You are a freelance operative in the rain-soaked megacity of Neo-Kowloon. | |
| Roam the streets, loot weapons off fallen enemies and vending kiosks, and | |
| decide the fate of every hostile you encounter: gun them down for good, | |
| or take them down non-lethally and place them under arrest for a bounty. | |
| Controls: | |
| Arrow keys / WASD - move | |
| F - fire/attack equipped weapon at nearest enemy | |
| R - attempt to arrest a subdued/nearby enemy | |
| E - pick up item on the current tile | |
| TAB - cycle equipped weapon | |
| Q - quit the game | |
| """ | |
| import curses | |
| import random | |
| import time | |
| from dataclasses import dataclass, field | |
| from enum import Enum, auto | |
| # -------------------------------------------------------------------------- | |
| # Map & world configuration | |
| # -------------------------------------------------------------------------- | |
| MAP_WIDTH = 60 | |
| MAP_HEIGHT = 20 | |
| MAX_ENEMIES = 8 | |
| MAX_ITEMS = 6 | |
| FLOOR = "." | |
| WALL = "#" | |
| class TileType(Enum): | |
| """Kinds of static tiles the map can contain.""" | |
| FLOOR = auto() | |
| WALL = auto() | |
| # -------------------------------------------------------------------------- | |
| # Weapons | |
| # -------------------------------------------------------------------------- | |
| @dataclass | |
| class Weapon: | |
| """A single weapon archetype the player can wield.""" | |
| name: str | |
| symbol: str # single character used to draw it on the map | |
| damage: int # damage dealt per hit | |
| lethal: bool # True = kills enemies, False = only stuns them | |
| range_tiles: int # how far the weapon can reach | |
| accuracy: float # chance (0-1) that an attack actually connects | |
| # The full arsenal available in the game world. Non-lethal weapons (the | |
| # taser and stun baton) knock enemies out instead of killing them, which | |
| # lets the player arrest them afterwards for a reward instead of a kill. | |
| WEAPON_CATALOG = [ | |
| Weapon("Stun Baton", "b", damage=8, lethal=False, range_tiles=1, accuracy=0.9), | |
| Weapon("Taser", "t", damage=12, lethal=False, range_tiles=2, accuracy=0.8), | |
| Weapon("Pistol", "p", damage=18, lethal=True, range_tiles=4, accuracy=0.85), | |
| Weapon("SMG", "m", damage=14, lethal=True, range_tiles=5, accuracy=0.7), | |
| Weapon("Shotgun", "s", damage=35, lethal=True, range_tiles=2, accuracy=0.75), | |
| Weapon("Assault Rifle", "a", damage=25, lethal=True, range_tiles=6, accuracy=0.8), | |
| Weapon("Sniper Rifle", "r", damage=50, lethal=True, range_tiles=10, accuracy=0.65), | |
| Weapon("Monokatana", "k", damage=30, lethal=True, range_tiles=1, accuracy=0.95), | |
| Weapon("Plasma Cannon", "c", damage=60, lethal=True, range_tiles=5, accuracy=0.6), | |
| ] | |
| # Bare fists are what the player starts with before finding real gear. | |
| FISTS = Weapon("Fists", "-", damage=4, lethal=False, range_tiles=1, accuracy=0.95) | |
| # -------------------------------------------------------------------------- | |
| # Enemies | |
| # -------------------------------------------------------------------------- | |
| class EnemyKind(Enum): | |
| """The different factions/roles enemies can belong to.""" | |
| STREET_THUG = auto() | |
| CORP_MERC = auto() | |
| GANG_BOSS = auto() | |
| RIOT_COP = auto() | |
| # Base stats per enemy kind: (max_hp, symbol, bounty_for_arrest, bounty_for_kill) | |
| ENEMY_STATS = { | |
| EnemyKind.STREET_THUG: (30, "t", 25, 15), | |
| EnemyKind.CORP_MERC: (50, "m", 50, 30), | |
| EnemyKind.GANG_BOSS: (90, "B", 150, 90), | |
| EnemyKind.RIOT_COP: (60, "c", 80, 20), # arresting cops pays better than killing them | |
| } | |
| class Enemy: | |
| """A hostile NPC that can be fought, subdued, or arrested.""" | |
| def __init__(self, kind, x, y): | |
| self.kind = kind | |
| self.x = x | |
| self.y = y | |
| max_hp, symbol, arrest_bounty, kill_bounty = ENEMY_STATS[kind] | |
| self.max_hp = max_hp | |
| self.hp = max_hp | |
| self.symbol = symbol | |
| self.arrest_bounty = arrest_bounty | |
| self.kill_bounty = kill_bounty | |
| self.alive = True | |
| self.subdued = False # True once knocked out by a non-lethal weapon | |
| self.arrested = False # True once the player has cuffed them | |
| def take_damage(self, amount, lethal): | |
| """Apply damage. Lethal hits can kill; non-lethal hits only subdue.""" | |
| self.hp -= amount | |
| if self.hp <= 0: | |
| if lethal: | |
| self.alive = False | |
| else: | |
| # Non-lethal weapons never kill; they just knock the target out. | |
| self.hp = 0 | |
| self.subdued = True | |
| # -------------------------------------------------------------------------- | |
| # Items that can be picked up off the ground | |
| # -------------------------------------------------------------------------- | |
| @dataclass | |
| class GroundItem: | |
| """A weapon lying on the map, waiting to be picked up.""" | |
| weapon: Weapon | |
| x: int | |
| y: int | |
| # -------------------------------------------------------------------------- | |
| # Player | |
| # -------------------------------------------------------------------------- | |
| class Player: | |
| """The player-controlled operative.""" | |
| def __init__(self, x, y): | |
| self.x = x | |
| self.y = y | |
| self.max_hp = 100 | |
| self.hp = 100 | |
| self.inventory = [FISTS] # every weapon the player has picked up | |
| self.equipped_index = 0 # index into inventory of the active weapon | |
| self.credits = 0 # in-game currency earned from bounties | |
| self.kills = 0 | |
| self.arrests = 0 | |
| @property | |
| def weapon(self): | |
| """The weapon currently equipped.""" | |
| return self.inventory[self.equipped_index] | |
| def cycle_weapon(self): | |
| """Switch to the next weapon in the inventory.""" | |
| self.equipped_index = (self.equipped_index + 1) % len(self.inventory) | |
| def add_weapon(self, weapon): | |
| """Add a newly picked-up weapon to the inventory if not already held.""" | |
| for held in self.inventory: | |
| if held.name == weapon.name: | |
| return # already own this weapon type | |
| self.inventory.append(weapon) | |
| # -------------------------------------------------------------------------- | |
| # Map generation | |
| # -------------------------------------------------------------------------- | |
| def generate_map(width, height): | |
| """Build a simple bordered map with a scattering of interior walls.""" | |
| grid = [[FLOOR for _ in range(width)] for _ in range(height)] | |
| # Border walls around the whole play area. | |
| for x in range(width): | |
| grid[0][x] = WALL | |
| grid[height - 1][x] = WALL | |
| for y in range(height): | |
| grid[y][0] = WALL | |
| grid[y][width - 1] = WALL | |
| # A handful of random interior obstacles (broken-down cyberpunk clutter). | |
| for _ in range(width * height // 40): | |
| x = random.randint(2, width - 3) | |
| y = random.randint(2, height - 3) | |
| grid[y][x] = WALL | |
| return grid | |
| def random_open_tile(grid, occupied): | |
| """Find a random floor tile that isn't a wall and isn't already occupied.""" | |
| while True: | |
| x = random.randint(1, MAP_WIDTH - 2) | |
| y = random.randint(1, MAP_HEIGHT - 2) | |
| if grid[y][x] == FLOOR and (x, y) not in occupied: | |
| return x, y | |
| # -------------------------------------------------------------------------- | |
| # Main game state / engine | |
| # -------------------------------------------------------------------------- | |
| class Game: | |
| """Owns the map, player, enemies, and items, and drives the game loop.""" | |
| def __init__(self, stdscr): | |
| self.stdscr = stdscr | |
| self.grid = generate_map(MAP_WIDTH, MAP_HEIGHT) | |
| self.player = Player(*random_open_tile(self.grid, set())) | |
| self.enemies = [] | |
| self.items = [] | |
| self.messages = [] # scrolling log shown at the bottom of the screen | |
| self.running = True | |
| self._spawn_enemies(MAX_ENEMIES) | |
| self._spawn_items(MAX_ITEMS) | |
| self._init_colors() | |
| # ---------------------- setup helpers ---------------------- | |
| def _init_colors(self): | |
| """Set up a neon-inspired curses color palette.""" | |
| curses.start_color() | |
| curses.use_default_colors() | |
| curses.init_pair(1, curses.COLOR_CYAN, -1) # player | |
| curses.init_pair(2, curses.COLOR_MAGENTA, -1) # enemies | |
| curses.init_pair(3, curses.COLOR_YELLOW, -1) # items | |
| curses.init_pair(4, curses.COLOR_GREEN, -1) # HUD text | |
| curses.init_pair(5, curses.COLOR_RED, -1) # warnings/damage | |
| curses.init_pair(6, curses.COLOR_WHITE, -1) # walls | |
| def _spawn_enemies(self, count): | |
| occupied = {(self.player.x, self.player.y)} | |
| for _ in range(count): | |
| kind = random.choice(list(EnemyKind)) | |
| x, y = random_open_tile(self.grid, occupied) | |
| occupied.add((x, y)) | |
| self.enemies.append(Enemy(kind, x, y)) | |
| def _spawn_items(self, count): | |
| occupied = {(self.player.x, self.player.y)} | |
| occupied.update((e.x, e.y) for e in self.enemies) | |
| for _ in range(count): | |
| weapon = random.choice(WEAPON_CATALOG) | |
| x, y = random_open_tile(self.grid, occupied) | |
| occupied.add((x, y)) | |
| self.items.append(GroundItem(weapon, x, y)) | |
| def log(self, text): | |
| """Push a message onto the log, keeping only the most recent lines.""" | |
| self.messages.append(text) | |
| self.messages = self.messages[-4:] | |
| # ---------------------- movement ---------------------- | |
| def try_move_player(self, dx, dy): | |
| """Move the player if the destination tile is open, else do nothing.""" | |
| nx, ny = self.player.x + dx, self.player.y + dy | |
| if self.grid[ny][nx] == WALL: | |
| return | |
| if any(e.alive and not e.arrested and e.x == nx and e.y == ny for e in self.enemies): | |
| return # can't walk through a hostile that's still standing | |
| self.player.x, self.player.y = nx, ny | |
| # ---------------------- combat ---------------------- | |
| def nearest_enemy(self, max_range): | |
| """Return the closest live, un-arrested enemy within range, or None.""" | |
| best = None | |
| best_dist = None | |
| for e in self.enemies: | |
| if not e.alive or e.arrested: | |
| continue | |
| dist = abs(e.x - self.player.x) + abs(e.y - self.player.y) | |
| if dist <= max_range and (best_dist is None or dist < best_dist): | |
| best = e | |
| best_dist = dist | |
| return best | |
| def attack(self): | |
| """Fire the equipped weapon at the nearest enemy in range.""" | |
| weapon = self.player.weapon | |
| target = self.nearest_enemy(weapon.range_tiles) | |
| if target is None: | |
| self.log("No target in range.") | |
| return | |
| if random.random() > weapon.accuracy: | |
| self.log(f"{weapon.name} missed!") | |
| return | |
| target.take_damage(weapon.damage, weapon.lethal) | |
| if not target.alive: | |
| self.player.kills += 1 | |
| self.player.credits += target.kill_bounty | |
| self.log(f"Killed {target.kind.name} with {weapon.name}. +{target.kill_bounty}cr") | |
| elif target.subdued: | |
| self.log(f"{target.kind.name} is stunned! Press R nearby to arrest.") | |
| else: | |
| self.log(f"Hit {target.kind.name} for {weapon.damage} damage.") | |
| def arrest(self): | |
| """Attempt to arrest an adjacent, subdued (non-lethally downed) enemy.""" | |
| for e in self.enemies: | |
| if e.subdued and not e.arrested: | |
| dist = abs(e.x - self.player.x) + abs(e.y - self.player.y) | |
| if dist <= 1: | |
| e.arrested = True | |
| self.player.arrests += 1 | |
| self.player.credits += e.arrest_bounty | |
| self.log(f"Arrested {e.kind.name}. +{e.arrest_bounty}cr bounty.") | |
| return | |
| self.log("No subdued enemy nearby to arrest.") | |
| def pickup(self): | |
| """Pick up a weapon lying on the player's current tile, if any.""" | |
| for item in self.items: | |
| if item.x == self.player.x and item.y == self.player.y: | |
| self.player.add_weapon(item.weapon) | |
| self.items.remove(item) | |
| self.log(f"Picked up {item.weapon.name}.") | |
| return | |
| self.log("Nothing here to pick up.") | |
| # ---------------------- enemy AI ---------------------- | |
| def update_enemies(self): | |
| """Very simple AI: living, un-arrested enemies drift toward the player.""" | |
| for e in self.enemies: | |
| if not e.alive or e.arrested or e.subdued: | |
| continue | |
| dist = abs(e.x - self.player.x) + abs(e.y - self.player.y) | |
| if dist > 8: | |
| continue # too far away to notice the player yet | |
| dx = (self.player.x > e.x) - (self.player.x < e.x) | |
| dy = (self.player.y > e.y) - (self.player.y < e.y) | |
| # Move one step toward the player, preferring whichever axis is farther. | |
| if random.random() < 0.5 and dx != 0: | |
| nx, ny = e.x + dx, e.y | |
| elif dy != 0: | |
| nx, ny = e.x, e.y + dy | |
| else: | |
| nx, ny = e.x + dx, e.y | |
| if self.grid[ny][nx] != WALL and (nx, ny) != (self.player.x, self.player.y): | |
| e.x, e.y = nx, ny | |
| # If adjacent, the enemy attacks the player. | |
| if abs(e.x - self.player.x) + abs(e.y - self.player.y) <= 1: | |
| dmg = random.randint(3, 10) | |
| self.player.hp -= dmg | |
| self.log(f"{e.kind.name} hits you for {dmg} damage!") | |
| # ---------------------- rendering ---------------------- | |
| def draw(self): | |
| """Redraw the entire screen: map, entities, and HUD.""" | |
| self.stdscr.erase() | |
| # Draw the static map tiles. | |
| for y, row in enumerate(self.grid): | |
| for x, tile in enumerate(row): | |
| color = curses.color_pair(6) if tile == WALL else curses.color_pair(0) | |
| self.stdscr.addch(y, x, tile, color) | |
| # Draw ground items (weapons waiting to be looted). | |
| for item in self.items: | |
| self.stdscr.addch(item.y, item.x, item.weapon.symbol, curses.color_pair(3)) | |
| # Draw enemies, using a different glyph for arrested/subdued ones. | |
| for e in self.enemies: | |
| if not e.alive: | |
| continue | |
| glyph = e.symbol.upper() if not (e.subdued or e.arrested) else e.symbol.lower() | |
| self.stdscr.addch(e.y, e.x, glyph, curses.color_pair(2)) | |
| # Draw the player last so it's always on top. | |
| self.stdscr.addch(self.player.y, self.player.x, "@", curses.color_pair(1) | curses.A_BOLD) | |
| self._draw_hud() | |
| self.stdscr.refresh() | |
| def _draw_hud(self): | |
| """Draw the heads-up display below the map: stats, weapon, messages.""" | |
| hud_y = MAP_HEIGHT | |
| p = self.player | |
| status = (f"HP:{p.hp}/{p.max_hp} Weapon:{p.weapon.name} " | |
| f"Credits:{p.credits} Kills:{p.kills} Arrests:{p.arrests}") | |
| self.stdscr.addstr(hud_y, 0, status[:MAP_WIDTH], curses.color_pair(4)) | |
| controls = "Move:WASD/Arrows F:Fire R:Arrest E:Pickup TAB:Switch Q:Quit" | |
| self.stdscr.addstr(hud_y + 1, 0, controls[:MAP_WIDTH], curses.color_pair(4)) | |
| for i, msg in enumerate(self.messages): | |
| self.stdscr.addstr(hud_y + 2 + i, 0, msg[:MAP_WIDTH], curses.color_pair(5)) | |
| # ---------------------- main loop ---------------------- | |
| def handle_input(self, key): | |
| """Translate a single keypress into a game action.""" | |
| if key in (ord('w'), curses.KEY_UP): | |
| self.try_move_player(0, -1) | |
| elif key in (ord('s'), curses.KEY_DOWN): | |
| self.try_move_player(0, 1) | |
| elif key in (ord('a'), curses.KEY_LEFT): | |
| self.try_move_player(-1, 0) | |
| elif key in (ord('d'), curses.KEY_RIGHT): | |
| self.try_move_player(1, 0) | |
| elif key in (ord('f'), ord('F')): | |
| self.attack() | |
| elif key in (ord('r'), ord('R')): | |
| self.arrest() | |
| elif key in (ord('e'), ord('E')): | |
| self.pickup() | |
| elif key == ord('\t'): | |
| self.player.cycle_weapon() | |
| elif key in (ord('q'), ord('Q')): | |
| self.running = False | |
| def is_over(self): | |
| """The game ends if the player dies or every enemy is dealt with.""" | |
| if self.player.hp <= 0: | |
| return True | |
| if all((not e.alive) or e.arrested for e in self.enemies): | |
| return True | |
| return False | |
| def run(self): | |
| """The main game loop: draw, wait for input, update, repeat.""" | |
| self.stdscr.nodelay(False) | |
| while self.running: | |
| self.draw() | |
| key = self.stdscr.getch() | |
| self.handle_input(key) | |
| self.update_enemies() | |
| if self.is_over(): | |
| break | |
| self._show_end_screen() | |
| def _show_end_screen(self): | |
| """Display a final summary once the game loop exits.""" | |
| self.stdscr.erase() | |
| if self.player.hp <= 0: | |
| headline = "YOU DIED IN THE GUTTER." | |
| else: | |
| headline = "DISTRICT CLEARED." | |
| lines = [ | |
| headline, | |
| f"Kills: {self.player.kills} Arrests: {self.player.arrests}", | |
| f"Credits earned: {self.player.credits}", | |
| "Press any key to exit...", | |
| ] | |
| for i, line in enumerate(lines): | |
| self.stdscr.addstr(i, 0, line, curses.color_pair(4) | curses.A_BOLD) | |
| self.stdscr.refresh() | |
| self.stdscr.nodelay(False) | |
| self.stdscr.getch() | |
| # -------------------------------------------------------------------------- | |
| # Entry point | |
| # -------------------------------------------------------------------------- | |
| def main(stdscr): | |
| """curses.wrapper entry point: configures the terminal and starts play.""" | |
| curses.curs_set(0) # hide the terminal cursor | |
| stdscr.keypad(True) # let curses translate arrow keys into KEY_* constants | |
| game = Game(stdscr) | |
| game.run() | |
| if __name__ == "__main__": | |
| curses.wrapper(main) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment