homework.wenqian.dev
< Back to index
Tutorial 32026-07-30

Starfall Arena

Three-hour Python restart · lists · functions · classes · a playable terminal battle game

Mission

Finish line: Build a complete command-line battle game with three monsters, animated-looking terminal panels, HP bars, focus, potions, combat logs, score, rank, and replay.

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.

Warm-Up 15 min

Trace two lists

Predict both printed lines without running the code. Then run it and check.

python
scores = [4, 7, 2]backup = scores.copy()scores[1] += 3backup.append(9)print(scores)print(backup)
Hint 1
scores.copy() creates a second list.
Hint 2
Draw two boxes. Apply each change to only one box.
Warm-Up 26 min

Finish clamp

Return minimum when value is too small, maximum when it is too large, and value otherwise.

python
def clamp(value, minimum, maximum):    # TODO    passprint(clamp(-4, 0, 10))print(clamp(7, 0, 10))print(clamp(99, 0, 10))
Hint 1
There are three cases, so start with if, then elif, then return.
Hint 2
First compare value < minimum. Then compare value > maximum.
Warm-Up 36 min

Count living fighters

Write the loop yourself. A value greater than 0 means that fighter is alive.

python
def count_alive(hp_values):    # TODO    passprint(count_alive([30, 0, 12, 0, 5]))
Hint 1
Start with count = 0.
Hint 2
Loop over each hp. Increase count only when hp > 0.
Warm-Up 46 min

Trace an object

Predict the final hp. Say which object changes after each method call.

python
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
After __init__, bot.hp is 20.
Hint 2
self means bot for both method calls.
Warm-Up 56 min

Repair two self bugs

Fix the code so hero.heal(6) changes hero.hp and prints 26.

python
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
A value that must stay inside the object needs self.
Hint 2
Fix hp = hp and add the current object as the first heal parameter.
Warm-Up 66 min

Find the first living object

Return the first living fighter. Return None if every fighter is defeated.

python
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
Loop over the objects, not over indexes.
Hint 2
Return immediately when fighter.hp > 0. Put return None after the loop.

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.

terminal
+------------------------------------------------------------------------------------+|                                  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.

TODO 1Phase 1 - Fighter basics

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

python
hero = Fighter("Nova", 58, 7, 11)print(hero.name, hero.hp, hero.focus)# Nova 58 0
TODO 2Phase 1 - Fighter basics

Fighter.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

python
hero.hp = 1print(hero.is_alive())  # Truehero.hp = 0print(hero.is_alive())  # False
TODO 3Phase 1 - Fighter basics

Fighter.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

python
hero.hp = 5print(hero.take_damage(99))  # 5print(hero.hp)               # 0
TODO 4Phase 1 - Fighter basics

Fighter.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

python
hero.hp = 52print(hero.heal(99))  # 6print(hero.hp)        # 58
TODO 5Phase 1 - Fighter basics

Fighter.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

python
rng = Random(7)hero = Fighter("Nova", 58, 7, 11)print(hero.roll_attack(rng))  # 9
TODO 6Phase 2 - Inventory and lists

Inventory.__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

python
source = ["potion"]bag = Inventory(source)source.append("key")print(bag.items)  # ["potion"]
TODO 7Phase 2 - Inventory and lists

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

python
bag = Inventory(["potion", "key", "potion"])print(bag.count("potion"))  # 2
TODO 8Phase 2 - Inventory and lists

Inventory.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

python
bag = Inventory(["potion", "potion"])print(bag.use("potion"))    # Trueprint(bag.items)            # ["potion"]print(bag.use("key"))       # False
TODO 9Phase 2 - Inventory and lists

first_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

python
a = Fighter("A", 1, 1, 1)b = Fighter("B", 1, 1, 1)a.take_damage(1)print(first_living_monster([a, b]).name)  # B

Student Work Area

Only edit this part: All nine TODOs are collected below. Students can understand and test these functions without reading the terminal renderer or game loop.

student_work.py

python
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 None

Project 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

python
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()
Build rule: The file enters the arena only after all nine build checks pass. A failed check is not a crash; it identifies unfinished behavior before the game starts.

Run Checklist

Start the game

bash
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 = quit
Run it in a real terminal to see 24-bit gradients and animation. Captured output and some IDE consoles automatically use the plain, non-animated fallback.
01Attack until focus reaches 100, then use Star strike.
02Use a potion while injured and confirm exactly one disappears.
03Try a potion at full HP. It must not be wasted.
04Inspect an enemy. Inspect must not give the enemy a turn.
05Enter an invalid command. The game must explain the valid commands.
06Defeat all three monsters and confirm the score and rank screen.

Optional 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.