Starfall Arena
Three-hour Python restart · lists · functions · classes · a playable terminal battle game
Mission
Teacher provides
24-bit gradients, action animations, terminal renderer, command loop, enemy turns, replay, and build checks.
Student builds
Fighter state, damage, healing, random attacks, inventory, and monster selection.
Not a TODO
Students do not write the game loop or ANSI layout from scratch.
Three-Hour Route
00:00-00:10
Restart diagnostic
Predict tiny outputs aloud. Do not teach yet; listen for what has been forgotten.
00:10-00:45
Six guided warm-ups
Lists, conditions, loops, functions, self, and a list of objects.
00:45-01:05
Run the finished game
Play one battle first, then map each visible feature to data or a method.
01:05-01:50
Build Fighter
Finish TODO 1-5 and run each checkpoint before moving on.
01:50-02:00
Break
Leave the last successful test visible.
02:00-02:35
Inventory and monster queue
Finish TODO 6-9. This reconnects classes with Python lists.
02:35-02:50
Play and debug
Clear all three rooms and deliberately test potion, inspect, and an invalid command.
02:50-03:00
One upgrade and demo
Choose one extension, explain the state it needs, and demo the result.
Warm-Up: Reconnect Python
Use hints in order. Try for two minutes before opening Hint 1. Expected output is hidden with the full solution at the top of the page.
Trace two lists
Predict both printed lines without running the code. Then run it and check.
scores = [4, 7, 2]backup = scores.copy()scores[1] += 3backup.append(9)print(scores)print(backup)Hint 1
Hint 2
Finish clamp
Return minimum when value is too small, maximum when it is too large, and value otherwise.
def clamp(value, minimum, maximum): # TODO passprint(clamp(-4, 0, 10))print(clamp(7, 0, 10))print(clamp(99, 0, 10))Hint 1
Hint 2
Count living fighters
Write the loop yourself. A value greater than 0 means that fighter is alive.
def count_alive(hp_values): # TODO passprint(count_alive([30, 0, 12, 0, 5]))Hint 1
Hint 2
Trace an object
Predict the final hp. Say which object changes after each method call.
class TrainingBot: def __init__(self, name, hp): self.name = name self.hp = hp def take_damage(self, amount): self.hp -= amountbot = TrainingBot("Bolt", 20)bot.take_damage(5)bot.take_damage(8)print(bot.hp)Hint 1
Hint 2
Repair two self bugs
Fix the code so hero.heal(6) changes hero.hp and prints 26.
class Fighter: def __init__(self, name, hp): self.name = name hp = hp def heal(amount): self.hp += amounthero = Fighter("Nova", 20)hero.heal(6)print(hero.hp)Hint 1
Hint 2
Find the first living object
Return the first living fighter. Return None if every fighter is defeated.
class TrainingBot: def __init__(self, name, hp): self.name = name self.hp = hpdef first_living(fighters): # TODO passteam = [ TrainingBot("Moss Slime", 0), TrainingBot("Iron Warden", 14), TrainingBot("Void Core", 22),]enemy = first_living(team)print(enemy.name)Hint 1
Hint 2
Play It Before Building It
Run the finished version once. Ask the student to identify what must be a value, what must be a list, and what belongs inside a Fighter object.
+------------------------------------------------------------------------------------+| STARFALL ARENA |+------------------------------------------------------------------------------------+| Room 2/3 Turn 8 Score 164 || || NOVA || Hero [##################......] 44/58 HP || Focus [================........] 70/100 || Bag potion x1 || || ENEMY SIGNAL || .------. || .--| [==] |--. || | | () | | || '--| /__\ |--' || /| |\ || /_|__|_\ || IRON WARDEN || Enemy [##########..............] 14/34 HP || || COMBAT LOG || > Nova hits Iron Warden for 8 damage. || > Iron Warden's attack deals 6 damage. || || [A] Attack [S] Star strike [P] Potion [I] Inspect [Q] Quit || Star strike needs 100 focus. Normal attacks give 35 focus. |+------------------------------------------------------------------------------------+Data to notice
hp changes, focus changes, potions disappear, monsters stay in order, and logs keep only recent messages.
Methods to notice
take_damage changes hp, heal changes hp, roll_attack produces damage, and use removes one item.
TODO Contract
Read the contract card before the code. Every function states its purpose, input, return value, state change, and smallest useful test. Complete one TODO at a time.
Fighter.__init__
Purpose
Create a fighter that remembers all of its starting data.
Input
name: str, max_hp: int, attack_min: int, attack_max: int
Output / Return
No return value.
State change
Create five attributes from the parameters. hp starts at max_hp. The template already provides self.focus = 0.
Checkpoint
hero = Fighter("Nova", 58, 7, 11)print(hero.name, hero.hp, hero.focus)# Nova 58 0Fighter.is_alive
Purpose
Answer one question: does this fighter still have hp?
Input
self
Output / Return
True when hp > 0, otherwise False.
State change
Do not change the object.
Checkpoint
hero.hp = 1print(hero.is_alive()) # Truehero.hp = 0print(hero.is_alive()) # FalseFighter.take_damage
Purpose
Apply damage without allowing hp to become negative.
Input
amount: int, for example 8
Output / Return
The hp actually removed.
State change
Decrease hp and stop at 0.
Checkpoint
hero.hp = 5print(hero.take_damage(99)) # 5print(hero.hp) # 0Fighter.heal
Purpose
Restore hp without going above max_hp.
Input
amount: int, for example 14
Output / Return
The hp actually restored.
State change
Increase hp and stop at max_hp.
Checkpoint
hero.hp = 52print(hero.heal(99)) # 6print(hero.hp) # 58Fighter.roll_attack
Purpose
Generate one attack value inside this fighter's attack range.
Input
rng: Random object
Output / Return
A random integer inside the attack range.
State change
Do not change hp. Use rng.randint.
Checkpoint
rng = Random(7)hero = Fighter("Nova", 58, 7, 11)print(hero.roll_attack(rng)) # 9Inventory.__init__
Purpose
Create a bag whose item list is independent from the input list.
Input
starting_items: list[str]
Output / Return
No return value.
State change
Store a copy as self.items.
Checkpoint
source = ["potion"]bag = Inventory(source)source.append("key")print(bag.items) # ["potion"]Inventory.count
Purpose
Count one kind of item without changing the bag.
Input
item: str, for example "potion"
Output / Return
How many matching items are in the list.
State change
Do not change the item list. Write the counting loop yourself.
Checkpoint
bag = Inventory(["potion", "key", "potion"])print(bag.count("potion")) # 2Inventory.use
Purpose
Remove exactly one requested item when it exists.
Input
item: str, for example "potion"
Output / Return
True when one item was removed; otherwise False.
State change
Remove exactly one matching item.
Checkpoint
bag = Inventory(["potion", "potion"])print(bag.use("potion")) # Trueprint(bag.items) # ["potion"]print(bag.use("key")) # Falsefirst_living_monster
Purpose
Find which monster the game should fight next.
Input
monsters: a list of Fighter objects
Output / Return
The first living Fighter, or None.
State change
Do not change the list or any monster.
Checkpoint
a = Fighter("A", 1, 1, 1)b = Fighter("B", 1, 1, 1)a.take_damage(1)print(first_living_monster([a, b]).name) # BStudent Work Area
student_work.py
class Fighter: def __init__(self, name, max_hp, attack_min, attack_max): # Provided game rule: every fighter starts with 0 focus. self.focus = 0 # TODO 1 # Input: name, max_hp, attack_min, attack_max # Return: nothing # Change: create five attributes; hp starts at max_hp pass def is_alive(self): # TODO 2 # Input: self # Return: True when hp > 0, otherwise False # Change: none return False def take_damage(self, amount): # TODO 3 # Input: amount, an integer damage value # Return: the hp actually removed # Change: reduce hp, but never below 0 return 0 def heal(self, amount): # TODO 4 # Input: amount, an integer healing value # Return: the hp actually restored # Change: increase hp, but never above max_hp return 0 def roll_attack(self, rng): # TODO 5 # Input: rng, a Random object # Return: one random integer from attack_min through attack_max # Change: none return 0class Inventory: def __init__(self, starting_items): # TODO 6 # Input: starting_items, a list of strings # Return: nothing # Change: store a COPY as self.items pass def add(self, item): self.items.append(item) def count(self, item): # TODO 7 # Input: item, a string # Return: how many times item appears # Change: none return 0 def use(self, item): # TODO 8 # Input: item, a string # Return: True if one item was removed, otherwise False # Change: remove exactly one matching item when possible return Falsedef first_living_monster(monsters): # TODO 9 # Input: monsters, a list of Fighter objects # Return: the first living Fighter, or None # Change: none return NoneProject Template
Copy this full file once so the game can run. During class, return to the Student Work Area and search by TODO number instead of reading the engine.
starfall_arena.py
import reimport sysfrom random import Randomfrom time import sleepESC = chr(27) + "["RESET = f"{ESC}0m"USE_COLOR = sys.stdout.isatty()ANIMATE = sys.stdout.isatty()ANSI_RE = re.compile(re.escape(ESC) + r"[0-9;]*m")SCREEN_WIDTH = 86STYLES = { "title": "1;96", "hero": "1;92", "enemy": "1;91", "gold": "1;93", "dim": "2", "good": "92", "bad": "91", "focus": "95",}def paint(text, style): if not USE_COLOR: return str(text) return f"{ESC}{style}m{text}{RESET}"def style(text, name): return paint(text, STYLES[name])def rgb(text, red, green, blue): if not USE_COLOR: return str(text) return f"{ESC}38;2;{red};{green};{blue}m{text}{RESET}"def gradient_text(text, start=(20, 220, 255), end=(255, 70, 180)): if not USE_COLOR or len(text) <= 1: return str(text) result = "" steps = len(text) - 1 for index, character in enumerate(text): ratio = index / steps red = round(start[0] + (end[0] - start[0]) * ratio) green = round(start[1] + (end[1] - start[1]) * ratio) blue = round(start[2] + (end[2] - start[2]) * ratio) result += rgb(character, red, green, blue) return resultdef visible_len(text): return len(ANSI_RE.sub("", str(text)))def pad_visible(text, width): return str(text) + " " * max(0, width - visible_len(text))def center_visible(text, width): spaces = max(0, width - visible_len(text)) left = spaces // 2 right = spaces - left return " " * left + str(text) + " " * rightdef clear_screen(): if sys.stdout.isatty(): print(f"{ESC}2J{ESC}H", end="")def print_panel(title, lines, width=SCREEN_WIDTH): inside = width - 4 print("+" + "-" * (width - 2) + "+") print("| " + center_visible(title, inside) + " |") print("+" + "-" * (width - 2) + "+") for line in lines: print("| " + pad_visible(line, inside) + " |") print("+" + "-" * (width - 2) + "+")def bar(current, maximum, width=24, fill="#", empty="."): if maximum <= 0: filled = 0 else: ratio = max(0, min(1, current / maximum)) filled = round(ratio * width) return "[" + fill * filled + empty * (width - filled) + "]"def gradient_bar(current, maximum, width=24): if maximum <= 0: filled = 0 else: ratio = max(0, min(1, current / maximum)) filled = round(ratio * width) if not USE_COLOR: return "[" + "=" * filled + "." * (width - filled) + "]" colored = "" for index in range(filled): ratio = index / max(1, width - 1) red = round(30 + (255 - 30) * ratio) green = round(225 + (80 - 225) * ratio) blue = round(255 + (190 - 255) * ratio) colored += rgb("=", red, green, blue) return "[" + colored + style("." * (width - filled), "dim") + "]"def animate_line(frames, delay=0.055, start=(20, 220, 255), end=(255, 70, 180)): if not ANIMATE: return width = SCREEN_WIDTH - 2 for frame in frames: line = center_visible(frame, width) print("\r" + gradient_text(line, start, end), end="", flush=True) sleep(delay) print("\r" + " " * width + "\r", end="", flush=True)def animate_intro(hero_name): if not ANIMATE: return for filled in range(0, 25, 3): clear_screen() scan = "[" + "=" * filled + "." * (24 - filled) + "]" print_panel( gradient_text("STARFALL ARENA"), [ "", center_visible("SCANNING MONSTER SIGNALS", SCREEN_WIDTH - 4), center_visible( gradient_text(scan, (40, 255, 170), (255, 80, 200)), SCREEN_WIDTH - 4, ), "", center_visible(f"FIGHTER LINK: {hero_name.upper()}", SCREEN_WIDTH - 4), ], ) sleep(0.045)def animate_attack(attacker, target, special=False, enemy_attack=False): if special: frames = [ f"{attacker} [..........] {target}", f"{attacker} [===.......] {target}", f"{attacker} [======....] {target}", f"{attacker} [==========] {target}", f"{attacker} >>> STAR STRIKE >>> {target}", f"{attacker} >>>>> IMPACT >>>>> {target}", ] animate_line(frames, 0.07, (80, 220, 255), (255, 60, 210)) return arrows = [">", " >", " >", " >", " >>>"] frames = [f"{attacker} {arrow} {target}" for arrow in arrows] if enemy_attack: animate_line(frames, 0.045, (255, 80, 90), (255, 200, 60)) else: animate_line(frames, 0.045, (40, 255, 180), (50, 170, 255))def animate_potion(): animate_line( ["POTION [....]", "POTION [=...]", "POTION [==..]", "POTION [===.]", "POTION [====] +HP"], 0.055, (60, 255, 160), (255, 230, 70), )def animate_defeat(name): animate_line( [ f"{name} SIGNAL: 100%", f"{name} SIGNAL: 62%", f"{name} SIGNAL: 21%", f"{name} SIGNAL: OFFLINE", ], 0.065, (255, 210, 60), (255, 60, 110), )def hp_line(label, fighter): if fighter.hp > fighter.max_hp * 0.5: bar_style = "good" elif fighter.hp > fighter.max_hp * 0.25: bar_style = "gold" else: bar_style = "bad" hp_bar = style(bar(fighter.hp, fighter.max_hp), bar_style) return f"{label:<8} {hp_bar} {fighter.hp:>3}/{fighter.max_hp:<3} HP"MONSTER_DATA = [ { "name": "Moss Slime", "hp": 22, "attack_min": 3, "attack_max": 6, "art": [ " _______ ", " .-' '-. ", " / o o \\ ", " | ^ | ", " \\ '-----' / ", " '--------- ' ", ], }, { "name": "Iron Warden", "hp": 34, "attack_min": 5, "attack_max": 8, "art": [ " .------. ", " .--| [==] |--. ", " | | () | | ", " '--| /__\\ |--' ", " /| |\\ ", " /_|__|_\\ ", ], }, { "name": "Void Core", "hp": 48, "attack_min": 7, "attack_max": 11, "art": [ " .------------. ", " .-' .------. '-. ", " / / /\\ \\ \\ ", " | | < > | | ", " \\ \\ \\/ / / ", " '-. '----' .-' ", " '----------' ", ], },]class Fighter: def __init__(self, name, max_hp, attack_min, attack_max): # Provided game rule: every fighter starts with 0 focus. self.focus = 0 # TODO 1 # Input: name, max_hp, attack_min, attack_max # Return: nothing # Change: create five attributes; hp starts at max_hp pass def is_alive(self): # TODO 2 # Input: self # Return: True when hp > 0, otherwise False # Change: none return False def take_damage(self, amount): # TODO 3 # Input: amount, an integer damage value # Return: the hp actually removed # Change: reduce hp, but never below 0 return 0 def heal(self, amount): # TODO 4 # Input: amount, an integer healing value # Return: the hp actually restored # Change: increase hp, but never above max_hp return 0 def roll_attack(self, rng): # TODO 5 # Input: rng, a Random object # Return: one random integer from attack_min through attack_max # Change: none return 0class Inventory: def __init__(self, starting_items): # TODO 6 # Input: starting_items, a list of strings # Return: nothing # Change: store a COPY as self.items pass def add(self, item): self.items.append(item) def count(self, item): # TODO 7 # Input: item, a string # Return: how many times item appears # Change: none return 0 def use(self, item): # TODO 8 # Input: item, a string # Return: True if one item was removed, otherwise False # Change: remove exactly one matching item when possible return Falsedef first_living_monster(monsters): # TODO 9 # Input: monsters, a list of Fighter objects # Return: the first living Fighter, or None # Change: none return Nonedef create_monsters(): monsters = [] for data in MONSTER_DATA: monster = Fighter( data["name"], data["hp"], data["attack_min"], data["attack_max"], ) monsters.append(monster) return monstersdef monster_art(name): for data in MONSTER_DATA: if data["name"] == name: return data["art"] return ["", f" {name}", ""]class BattleGame: def __init__(self, hero, monsters, inventory, seed=None): self.hero = hero self.monsters = monsters self.inventory = inventory self.rng = Random(seed) self.turn = 1 self.defeated = 0 self.score = 0 self.logs = ["The arena gate opens. Three signals are moving inside."] def add_log(self, message): self.logs.append(message) self.logs = self.logs[-5:] def current_enemy(self): return first_living_monster(self.monsters) def render(self): clear_screen() enemy = self.current_enemy() title = gradient_text("STARFALL ARENA") room = min(self.defeated + 1, len(self.monsters)) lines = [ f"Room {room}/{len(self.monsters)} Turn {self.turn} Score {style(self.score, 'gold')}", "", style(self.hero.name.upper(), "hero"), hp_line("Hero", self.hero), f"Focus {gradient_bar(self.hero.focus, 100)} {self.hero.focus:>3}/100", f"Bag potion x{self.inventory.count('potion')}", "", ] if enemy is not None: lines.append(style("ENEMY SIGNAL", "enemy")) lines.extend(monster_art(enemy.name)) lines.append(style(enemy.name.upper(), "enemy")) lines.append(hp_line("Enemy", enemy)) lines.extend([ "", style("COMBAT LOG", "gold"), ]) for message in self.logs: lines.append(f"> {message}") lines.extend([ "", "[A] Attack [S] Star strike [P] Potion [I] Inspect [Q] Quit", style("Star strike needs 100 focus. Normal attacks give 35 focus.", "dim"), ]) print_panel(title, lines) def normal_attack(self, enemy): animate_attack(self.hero.name, enemy.name) damage = self.hero.roll_attack(self.rng) actual = enemy.take_damage(damage) self.hero.focus = min(100, self.hero.focus + 35) self.score += actual self.add_log(f"{self.hero.name} hits {enemy.name} for {actual} damage.") def star_strike(self, enemy): if self.hero.focus < 100: self.add_log("Star strike is not ready yet.") return False animate_attack(self.hero.name, enemy.name, special=True) damage = self.hero.roll_attack(self.rng) + 10 actual = enemy.take_damage(damage) self.hero.focus = 0 self.score += actual * 2 self.add_log(style(f"STAR STRIKE deals {actual} damage!", "focus")) return True def drink_potion(self): if self.hero.hp == self.hero.max_hp: self.add_log("HP is already full. The potion was not used.") return False if not self.inventory.use("potion"): self.add_log("No potion remains in the bag.") return False animate_potion() healed = self.hero.heal(14) self.add_log(style(f"Potion restores {healed} HP.", "good")) return True def inspect_enemy(self, enemy): self.add_log( f"{enemy.name}: attack {enemy.attack_min}-{enemy.attack_max}, " f"HP {enemy.hp}/{enemy.max_hp}." ) def enemy_turn(self, enemy): animate_attack(enemy.name, self.hero.name, enemy_attack=True) damage = enemy.roll_attack(self.rng) if self.rng.random() < 0.18: damage += 3 attack_name = "charged attack" else: attack_name = "attack" actual = self.hero.take_damage(damage) self.add_log( style(f"{enemy.name}'s {attack_name} deals {actual} damage.", "bad") ) def defeat_enemy(self, enemy): animate_defeat(enemy.name) self.defeated += 1 self.score += 100 self.add_log(style(f"{enemy.name} is defeated. +100 score.", "gold")) if self.defeated == 2: self.inventory.add("potion") self.add_log("The Iron Warden dropped one potion.") def handle_command(self, command, enemy): if command in ("a", "attack"): self.normal_attack(enemy) return True, False if command in ("s", "star"): return self.star_strike(enemy), False if command in ("p", "potion", "heal"): return self.drink_potion(), False if command in ("i", "inspect"): self.inspect_enemy(enemy) return False, False if command in ("q", "quit", "exit"): return False, True self.add_log("Unknown command. Use A, S, P, I, or Q.") return False, False def run(self): animate_intro(self.hero.name) while self.hero.is_alive(): enemy = self.current_enemy() if enemy is None: return "win" self.render() try: command = input("Command: ").strip().lower() except EOFError: command = "q" used_turn, should_quit = self.handle_command(command, enemy) if should_quit: return "quit" if not used_turn: continue if enemy.is_alive(): self.enemy_turn(enemy) else: self.defeat_enemy(enemy) self.turn += 1 return "lose" def show_result(self, outcome): clear_screen() if outcome == "win": heading = style("ARENA CLEARED", "good") message = "All three monster signals are silent." elif outcome == "lose": heading = style("MISSION FAILED", "bad") message = "Nova was defeated. Rebuild and try a new strategy." else: heading = "RUN ENDED" message = "You left the arena safely." print_panel( heading, [ message, "", f"Score: {self.score}", f"Monsters defeated: {self.defeated}/{len(self.monsters)}", f"Rank: {'S' if self.score >= 430 else 'A' if self.score >= 360 else 'B'}", ], )def run_build_checks(): rng = Random(7) test = Fighter("Test", 20, 3, 5) assert ( getattr(test, "name", None) == "Test" and getattr(test, "max_hp", None) == 20 and getattr(test, "hp", None) == 20 and getattr(test, "attack_min", None) == 3 and getattr(test, "attack_max", None) == 5 ), "TODO 1 failed: check Fighter.__init__ attributes." assert test.is_alive() is True, "TODO 2 failed: a fighter with hp should be alive." assert ( test.take_damage(50) == 20 and test.hp == 0 ), "TODO 3 failed: damage must stop hp at 0 and return actual damage." assert test.is_alive() is False, "TODO 2 failed: a fighter at 0 hp is not alive." assert ( test.heal(7) == 7 and test.hp == 7 ), "TODO 4 failed: heal must return actual healing." assert ( 3 <= test.roll_attack(rng) <= 5 ), "TODO 5 failed: attack must stay inside the range." source = ["potion", "key", "potion"] bag = Inventory(source) source.append("coin") assert ( getattr(bag, "items", None) == ["potion", "key", "potion"] ), "TODO 6 failed: Inventory must store an independent copy." assert ( bag.count("potion") == 2 and bag.count("coin") == 0 ), "TODO 7 failed: count returned the wrong number." assert ( bag.use("potion") is True and bag.count("potion") == 1 and bag.use("missing") is False ), "TODO 8 failed: use must remove exactly one matching item." first = Fighter("First", 1, 1, 1) second = Fighter("Second", 1, 1, 1) first.take_damage(1) assert ( first_living_monster([first, second]) is second ), "TODO 9 failed: return the first living monster." second.take_damage(1) assert ( first_living_monster([first, second]) is None ), "TODO 9 failed: return None when every monster is defeated."def main(): try: run_build_checks() except (AssertionError, AttributeError, TypeError) as error: detail = str(error) if str(error) else "A required result was incorrect." print_panel( "BUILD CHECK FAILED", [ "Finish TODO 1-9 before entering the arena.", detail, "", "Run the file again after fixing one TODO.", ], ) return name = input("Fighter name [Nova]: ").strip() if not name: name = "Nova" while True: hero = Fighter(name, 58, 7, 11) inventory = Inventory(["potion", "potion"]) game = BattleGame(hero, create_monsters(), inventory) outcome = game.run() game.show_result(outcome) if outcome == "quit": return again = input("Play again? [y/N]: ").strip().lower() if again not in ("y", "yes"): returnif __name__ == "__main__": main()Run Checklist
Start the game
python3 starfall_arena.py# Build check passes, then:Fighter name [Nova]:# Useful commands during play:# a = attack# s = star strike when focus reaches 100# p = use one potion# i = inspect the current monster# q = quitOptional Upgrades
Choose exactly one. Before coding, name the new attribute or list entry that the feature needs.
Shield command
Add D. The next enemy attack deals half damage.
Critical hit
Give normal attacks a 15% chance to deal double damage.
New item
Add one bomb to the inventory. It deals 12 damage without using focus.
Fourth monster
Add a balanced monster data dictionary and its ASCII art.
Teacher Checkpoints
After TODO 1: ask the student to point to the difference between max_hp and hp.
After TODO 3 and 4: ask why the method returns actual damage or healing instead of the requested amount.
After TODO 6: change the original starting_items list and prove that the bag does not change.
At the end: ask the student to explain the whole program as objects, lists, and method calls, without reading code line by line.