Skip to content

Instantly share code, notes, and snippets.

@float64co
Created August 11, 2026 08:55
Show Gist options
  • Select an option

  • Save float64co/63d16f5d8c92da570e9aec607a23be9f to your computer and use it in GitHub Desktop.

Select an option

Save float64co/63d16f5d8c92da570e9aec607a23be9f to your computer and use it in GitHub Desktop.
Nethack-inspired pure-python RPG.
#!/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