JIT
creating an interactive escape game with character dynamics and endings
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Lab Survival — Part 2 (expanded) Changes from previous version: - The game now STARTS with Amie trapped in her room and she must escape (QTE / choices). - Multiple, specific endings have been added based on who survives and the bonds formed. - Characters who die are removed from dialogue and from later scenes (already implemented). - Added more character interactions that influence final endings. Run: python Lab_Survival_Part2.py """ from __future__ import annotations import sys, time, random, textwrap from dataclasses import dataclass, field from typing import Dict, List, Callable, Optional WRAP = 88 def say(text: str, wrap: int = WRAP): print(" " + " ".join(textwrap.fill(line, wrap) for line in text.split(" "))) @dataclass class Character: name: str alive: bool = True bond: int = 0 # Player bond; range approx -10..+10 traits: Dict[str, int] = field(default_factory=dict) def speak(self, line: str): if self.alive: say(f"{self.name}: {line}") def adjust_bond(self, delta: int): self.bond += delta @dataclass class GameState: cast: Dict[str, Character] = field(default_factory=dict) inventory: Dict[str, int] = field(default_factory=dict) flags: Dict[str, bool] = field(default_factory=dict) scene: str = "room_escape" rng: random.Random = field(default_factory=lambda: random.Random()) def living(self) -> List[Character]: return [c for c in self.cast.values() if c.alive] def get(self, who: str) -> Character: return self.cast[who] def kill(self, who: str, reason: str = ""): c = self.cast.get(who) if c and c.alive: c.alive = False say(f"*** {c.name} dies. {reason}".strip()) def bond_summary(self): rows = [f" - {c.name}: {c.bond:+d}" for c in self.living()] say("Bond Meter (living): " + " ".join(rows)) # ---------------- QTE helpers ---------------- # def qte_sequence(prompt: str, length: int = 3, time_limit: float = 4.0, alphabet: str = "ASDWJIKL") -> bool: target = "".join(random.choice(alphabet) for _ in range(length)) say(f"QTE — {prompt} Type this sequence fast (no spaces): [{target}] Time limit: {time_limit:.1f}s") t0 = time.time() user = input(">>> ").strip().upper() elapsed = time.time() - t0 ok = (user == target and elapsed <= time_limit) if ok: say(f"✔ Success! ({elapsed:.2f}s)") else: say(f"✖ Failed. You typed '{user}' in {elapsed:.2f}s; target was '{target}'.") return ok # --------------- Scenes ----------------------- # def scene_room_escape(gs: GameState): say(" — PART 2: CHAPTER — ESCAPE — ") say("You wake bound to a hospital-style bed. Flickering fluorescent hums. The door to your dorm room is welded from the outside. A dim CCTV monitor shows a shadow moving past the hall outside.") gs.get("Amie").speak("...Who did this? My camera's gone. My phone's dead.") gs.get("Alex").speak("Amie? If you can hear me, make noise. We're coming.") gs.get("Jake").speak("Find the bed release. I'll jam the lock from the hall if you can get out the window.") say("Options:") say(" 1) Fiddle the bed-release with the metal clip (QTE — dexterity). 2) Kick the window and try to shimmy through the sill (QTE — endurance). 3) Shout loudly to lure help (risk: attracts monsters).") choice = input("> ").strip() if choice == "1": ok = qte_sequence("twist the clip under the pin", length=4, time_limit=4.0) if ok: say("The pin snaps. You free your wrist and crawl to the door. Alex and Jake are there within moments, prying at welds.") gs.get("Alex").adjust_bond(+2) gs.get("Jake").adjust_bond(+1) gs.scene = "corridor_intro" else: say("The clip shatters and the noise wakes something distant. Footsteps thunder toward the door.") # small chance Amie dies or someone else breaks in if gs.rng.random() < 0.25: gs.kill("Amie", "A shadow smothers the light. Something bites the throat.") gs.scene = "post_capture" else: say("A sliver in the door allows Alex to shove a screwdriver through - they pry you free but you're shaken.") gs.get("Alex").adjust_bond(+1) gs.scene = "corridor_intro" elif choice == "2": ok = qte_sequence("brace the kick and push your hips through", length=4, time_limit=4.2) if ok: say("The sash cracks. You tumble into the night, scraping glass, and meet up with Alex and Jake outside the building.") gs.get("Amie").adjust_bond(+1) gs.get("Jake").adjust_bond(+1) gs.scene = "corridor_intro" else: say("You slip and cut your leg badly. The pain is sharp; you crawl back under the bed.") gs.get("Amie").traits["leg_wounded"] = 1 gs.scene = "corridor_intro" else: say("You scream at the top of your lungs. Lights flicker. The hallway outside answers with faraway metallic taps.") gs.flags["loud_start"] = True # Increase monster attention later gs.scene = "corridor_intro" def scene_corridor_intro(gs: GameState): say("You and the group gather in a ruined corridor. Pipes drip. Three sets of wet footprints lead toward the research wing.") # Update dialogue only with alive characters for name in ("Amie","Alex","Jake","Maria","Sarah","Leo","Chloe","Ben"): if gs.get(name).alive: gs.get(name).speak(random.choice([ "We need a plan.", "Don't split up.", "This place stinks like metal and old blood.", ])) say("Pick a plan:") say(" 1) Head for the maintenance shaft (quieter). 2) Smash through the security doors to the lab (noisy but direct).") c = input("> ").strip() if c == "1": gs.get("Maria").adjust_bond(+1) gs.scene = "maintenance_shaft" else: gs.get("Alex").adjust_bond(+1) gs.flags["loud"] = True gs.scene = "security_breach" def scene_maintenance_shaft(gs: GameState): say("You squeeze into a narrow shaft. Echoes of breathing are above. Suddenly, a loose grate drops and cuts Leo's calf.") if "leg_wounded" in gs.get("Amie").traits: say("Amie's wounded leg slows the group.") gs.get("Leo").adjust_bond(+1) # small QTE to climb fast ok = qte_sequence("climb the shaft rungs without slipping", length=4, time_limit=4.0) if ok: say("You pop out near the experimental wing. A hulking shadow patrols the atrium below.") gs.scene = "experimental_atrium" else: say("Your foot slips. A friend grabs your arm — but a claw grazes them." ) # lowest bond living loses life sometimes pool = [c for c in gs.living() if c.name != "Amie"] if pool and gs.rng.random() < 0.35: victim = min(pool, key=lambda c: c.bond) gs.kill(victim.name, "Cut down in the shaft by a metallic claw.") gs.scene = "experimental_atrium" def scene_security_breach(gs: GameState): say("You batter the security door. Sirens howl. The lab's emergency protocols begin to spin up. A patrol heads your way.") ok = qte_sequence("batter the security lock free", length=3, time_limit=3.5) if ok: say("You burst inside. A prototype spear gun sits on a bench — loaded with two rounds.") gs.inventory["spears"] = 2 gs.scene = "experimental_atrium" else: say("You fail to get the door before patrols arrive. In the scuffle, someone is slashed.") pool = [c for c in gs.living() if c.name != "Amie"] if pool: victim = min(pool, key=lambda c: c.bond) gs.kill(victim.name, "Cut down while the door groaned.") gs.scene = "experimental_atrium" def scene_experimental_atrium(gs: GameState): say("The atrium is a glass forest of tanks. The three-headed creature hunts here, and cultist figures move in rehearsed patterns.") # Branch to the earlier gene_wing style sequence but simplified say("You can: 1) Attempt to trap the creature in a melting pit (risky QTE). 2) Try to lure cultists to distract it and slip past.") ch = input("> ").strip() if ch == "1": ok = qte_sequence("lure it over the pit and trigger the gate", length=5, time_limit=4.0) if ok: say("The pit opens; molten metal spits. Two heads are burned but one claws free and flees. You follow through the service tunnel.") gs.flags["heads_burned"] = True gs.scene = "service_tunnel" else: say("You mistime it; the creature turns and eats a friend in one brutal motion.") pool = [c for c in gs.living() if c.name != "Amie"] if pool: victim = min(pool, key=lambda c: c.bond) gs.kill(victim.name, "Torn apart when the pit opened late.") gs.scene = "service_tunnel" else: say("You bait the cultists; a firefight erupts. In the confusion you slip to a maintenance ladder.") # small bond effects for who in ("Maria","Sarah"): if gs.get(who).alive: gs.get(who).adjust_bond(+1) gs.scene = "service_tunnel" def scene_service_tunnel(gs: GameState): say("The service tunnel leads to a heavy blast door and a river exit. The creature is wounded but enraged.") say("Final choices: 1) Seal the creature in the lab and detonate charges (suicide risk). 2) Try to flee through the river (escape). 3) Ambush with remaining weapons (last stand).") c = input("> ").strip() if c == "1": # detonating: QTE to arm or fail; can kill many ok = qte_sequence("wire the charges quickly", length=5, time_limit=4.2) if ok: say("You arm the charges and lock the blast door. Everyone runs. The lab explodes, but the blast kills or injures several.") # survivors depend on random and bonds resolve_final_casualties(gs, explosive=True) else: say("The charges misfire; the blast isn't contained. The lab begins to collapse.") resolve_final_casualties(gs, explosive=False) elif c == "2": say("You rush for the river and the night. It's a long run. A few don't make it.") resolve_final_casualties(gs, river_escape=True) else: say("You set an ambush using the spear gun and whatever you can find.") ambush_success = False if gs.inventory.get("spears", 0) > 0: ambush_success = qte_sequence("aim and fire the spear gun", length=3, time_limit=3.0) if ambush_success: say("The creature falls. You're battered but alive. Small groups drift apart in the aftermath.") resolve_final_casualties(gs, ambush=True) else: say("The ambush fails. Several are taken. You barely escape.") resolve_final_casualties(gs, ambush=False) def resolve_final_casualties(gs: GameState, explosive: bool = False, river_escape: bool = False, ambush: bool = False): living = gs.living() # Use bonds + randomness to decide who survives. Lower bond = higher chance to die. # We'll calculate death probability per person. for c in list(living): base_dead_chance = 0.12 if explosive: base_dead_chance += 0.25 if river_escape: base_dead_chance += 0.10 if ambush and not explosive: base_dead_chance += 0.05 # modify by bond (more negative bond increases chance) bond_mod = -0.02 * c.bond final = base_dead_chance + bond_mod roll = gs.rng.random() if roll < final: gs.kill(c.name, "Caught in the final chaos.") # ensure Amie has a chance to die in some endings if not gs.get("Amie").alive: # direct to epilogue the_epilogue(gs) return # If Amie lives, determine the ending relationships the_epilogue(gs) # --------------- Endings --------------------- # def the_epilogue(gs: GameState): say(" — EPILOGUE & ENDINGS —") survivors = [c.name for c in gs.living()] dead = [c.name for c in gs.cast.values() if not c.alive] say("Survivors: " + (", ".join(survivors) if survivors else "(none)")) if dead: say("Fallen: " + ", ".join(dead)) # Compute pairings / endings per user's specification using rules # Priority checks for pairings def alive(n): return gs.cast[n].alive # helper: top bond among survivors besides Amie others = [c for c in gs.living() if c.name != "Amie"] top_other = max(others, key=lambda x: x.bond, default=None) ending_text = "You walk away..." # If Amie dead: pick an ending for others or solo if not alive("Amie"): # Several specific endings when Amie dies if alive("Alex") and alive("Jake") and not any(alive(n) for n in ("Maria","Leo","Sarah")): ending_text = "Alex x Jake ending: Alex and Jake, broken and guilty, find one another and cling together — a quiet pact in a ruined world." elif alive("Leo") and not any(alive(n) for n in ("Alex","Jake","Maria","Sarah")): ending_text = "Leo solo: haunted, he cannot bear the guilt and takes his life by the river." else: ending_text = "No one rejoices. Survivors carry the weight. Some walk off in silence." say(ending_text) gs.bond_summary() sys.exit(0) # Amie is alive -> many possibilities # Check for pair endings specified by the user # Highest priority: explicit pair combos if both alive pairs = [ ("Amie","Alex","Amie x Alex ending, they want to get married"), ("Amie","Jake","Amie x Jake ending, they get together"), ("Amie","Maria","Amie x Maria ending, they get together"), ("Amie","Leo","Amie x Leo ending, Amie ends Leo after he is injured"), ("Amie","Sarah","Amie x Sarah ending, Sarah dies from asthma after destroying the lab, Amie cries"), ] for a,b,txt in pairs: if alive(a) and alive(b): # special check for Leo ending: Leo must be injured for Amie to kill him if b == "Leo" and gs.get("Leo").traits.get("injured_calculus") is None: # if not injured, continue checking others pass else: say(txt) gs.bond_summary() sys.exit(0) # Other two-person endings involving Alex/Jake etc. if alive("Alex") and alive("Jake") and not alive("Amie"): say("Alex x Jake ending, they get together (but Amie isn't there).") gs.bond_summary() sys.exit(0) # If Amie is with someone with highest bond -> pair if top_other: # use bond threshold to decide romance if top_other.bond >= 2: if top_other.name == "Alex": say("Amie x Alex ending: they want to get married — a bright future after the nightmare.") gs.bond_summary(); sys.exit(0) if top_other.name == "Jake": say("Amie x Jake ending: they get together, unexpected and raw.") gs.bond_summary(); sys.exit(0) if top_other.name == "Maria": say("Amie x Maria ending: they get together and try to rebuild.") gs.bond_summary(); sys.exit(0) if top_other.name == "Leo": # If Leo injured and low bond, Amie ends him if gs.get("Leo").traits.get("injured_calculus") and gs.get("Leo").bond < 0: say("Amie x Leo ending: after Leo's injury, Amie smiles before she shoots him with the spear gun.") gs.bond_summary(); sys.exit(0) else: say("Amie x Leo ending: they get together (soft, brief).") gs.bond_summary(); sys.exit(0) if top_other.name == "Sarah": # Sarah might die from asthma if gs.get("Sarah").traits.get("severe_asthma"): say("Amie x Sarah ending: Sarah dies from asthma after destroying the lab; Amie cries over her.") else: say("Amie x Sarah ending: they get together and promise each other safety.") gs.bond_summary(); sys.exit(0) # Solo or other group endings # Specific combinations from user's list if alive("Alex") and not any(alive(n) for n in ("Jake","Maria","Leo","Sarah")): say("Alex solo: Alex smirks and just walks off — he did what had to be done.") gs.bond_summary(); sys.exit(0) if alive("Jake") and not any(alive(n) for n in ("Alex","Maria","Leo","Sarah")): say("Jake solo: Jake walks off crying, swearing to kill all of these unknown creatures.") gs.bond_summary(); sys.exit(0) if alive("Maria") and not any(alive(n) for n in ("Alex","Jake","Leo","Sarah")): say("Maria solo: she walks alone until a prototype metallic beast finds her.") gs.bond_summary(); sys.exit(0) if alive("Sarah") and not any(alive(n) for n in ("Alex","Jake","Maria","Leo")): say("Sarah solo: she dies from asthma, alone and battered.") gs.bond_summary(); sys.exit(0) if alive("Leo") and not any(alive(n) for n in ("Alex","Jake","Maria","Sarah")): say("Leo solo: wracked by guilt, he ends his life by the river.") gs.bond_summary(); sys.exit(0) # Trio endings (couple plus a third or three together) if alive("Amie") and alive("Alex") and alive("Jake"): say("Trio ending: Amie, Alex and Jake walk away together, friends more than lovers — their bonds complicated but intact.") gs.bond_summary(); sys.exit(0) if alive("Amie") and alive("Maria") and alive("Jake"): say("Trio ending: the unlikely trio escapes together and picks a quiet place to heal.") gs.bond_summary(); sys.exit(0) # Default: Amie solo say("Amie solo: Amie walks off, not knowing where she is, just crying.") gs.bond_summary() sys.exit(0) # -------------- Bootstrapping ----------------- # def build_game(seed: Optional[int] = None) -> GameState: rng = random.Random(seed) cast = { "Amie": Character("Amie", True, 0, {"role":"protagonist"}), "Alex": Character("Alex", True, 0, {"brash":1}), "Sarah": Character("Sarah", True, 0, {"anxious":1, "severe_asthma":0}), "Jake": Character("Jake", True, 0, {"pragmatic":1}), "Maria": Character("Maria", True, 0, {"clever":1}), "Chloe": Character("Chloe", True, 0, {"vain":1}), "Leo": Character("Leo", True, 0, {"loyal":1}), "Ben": Character("Ben", True, 0, {"earnest":1}), } return GameState(cast=cast, inventory={}, flags={}, scene="room_escape", rng=rng) SCENES: Dict[str, Callable[[GameState], None]] = { "room_escape": scene_room_escape, "corridor_intro": scene_corridor_intro, "maintenance_shaft": scene_maintenance_shaft, "security_breach": scene_security_breach, "experimental_atrium": scene_experimental_atrium, "service_tunnel": scene_service_tunnel, } def main(): seed = None if len(sys.argv) > 1 and sys.argv[1].isdigit(): seed = int(sys.argv[1]) gs = build_game(seed) say("(Tip: pass a number arg for deterministic run. This expanded version begins with Amie trapped in her room.)") while True: fn = SCENES.get(gs.scene) if not fn: say(f"Unknown scene '{gs.scene}'. Exiting.") break fn(gs) if __name__ == "__main__": main()
Updated files
v1