Skip to content

Text-Based Adventure Game

Text adventures — Colossal Cave Adventure, Zork, Hitchhiker’s Guide — were the first true interactive fiction. They are also one of the best teaching projects in programming because they force you to think about state (what the player has, where they are, what they have done) and flow (which scene leads to which, under which conditions). In this project you build a fantasy adventure with a cave, a house, a sword, a wicked fairy, multiple endings, and a replay system. Then we refactor it from hard-coded scenes into a JSON-driven engine — the same architecture commercial interactive fiction tools use.

You will learn:

  • How to organize a game as a finite state machine of scenes.
  • How to manage inventory as a list of strings.
  • How to validate player input cleanly with a while not valid loop.
  • How to add save/load so a player can resume later.
  • How to extract the story from the code into a data file you can edit without programming.
  • Python 3.6 or above.
  • A text editor or IDE.
  • Comfort with functions, conditionals, and lists.
  • (Bonus) Familiarity with dictionaries and JSON for the engine refactor.
diagram how the pieces call each other mermaid
Derived from projects/beginners/textbasedadventuregame.py by parsing it, not by hand. Arrows are calls between the file's own functions and methods; library calls are left out, and only calls the parser could resolve with certainty are shown.
  1. Create a folder named text-adventure.
  2. Inside it, create textbasedadventuregame.py.
Text-Based Adventure pch.viewSource
Text-Based Adventure
# Text Based Adventure Game

# Importing Modules
import time
import random

# Defining Functions
def print_pause(message_to_print):
    print(message_to_print)
    time.sleep(2)
    
def intro():
    print_pause("You find yourself standing in an open field, filled with grass and yellow wildflowers.")
    print_pause("Rumor has it that a wicked fairie is somewhere around here, and has been terrifying the nearby village.")
    print_pause("In front of you is a house.")
    print_pause("To your right is a dark cave.")
    print_pause("In your hand you hold your trusty (but not very effective) dagger.\n")
    
def house(items):
    print_pause("You approach the door of the house.")
    print_pause("You are about to knock when the door opens and out steps a wicked fairie.")
    print_pause("Eep! This is the wicked fairie's house!")
    print_pause("The wicked fairie attacks you!")
    if "Sword of Ogoroth" not in items:
        print_pause("You feel a bit under-prepared for this, what with only having a tiny dagger.")
    while True:
        choice = input("Would you like to (1) fight or (2) run away?")
        if choice == "1":
            if "Sword of Ogoroth" in items:
                print_pause("As the wicked fairie moves to attack, you unsheath your new sword.")
                print_pause("The Sword of Ogoroth shines brightly in your hand as you brace yourself for the attack.")
                print_pause("But the wicked fairie takes one look at your shiny new toy and runs away!")
                print_pause("You have rid the town of the wicked fairie. You are victorious!")
            else:
                print_pause("You do your best...")
                print_pause("but your dagger is no match for the wicked fairie.")
                print_pause("You have been defeated!")
            play_again()
            break
        if choice == "2":
            print_pause("You run back into the field. Luckily, you don't seem to have been followed.")
            field(items)
            break
        
def cave(items):
    print_pause("You peer cautiously into the cave.")
    if "Sword of Ogoroth" in items:
        print_pause("You've been here before, and gotten all the good stuff. It's just an empty cave now.")
        print_pause("You walk back out to the field.")
        field(items)
    else:
        print_pause("It turns out to be only a very small cave.")
        print_pause("Your eye catches a glint of metal behind a rock.")
        print_pause("You have found the magical Sword of Ogoroth!")
        print_pause("You discard your silly old dagger and take the sword with you.")
        print_pause("You walk back out to the field.")
        items.append("Sword of Ogoroth")
        field(items)
        
def field(items):
    print_pause("Enter 1 to knock on the door of the house.")
    print_pause("Enter 2 to peer into the cave.")
    while True:
        choice = input("What would you like to do?")
        if choice == "1":
            house(items)
            break
        elif choice == "2":
            cave(items)
            break
        
def play_again():
    while True:
        choice = input("Would you like to play again? (y/n)")
        if choice == "y":
            print_pause("Excellent! Restarting the game ...")
            play_game()
            break
        elif choice == "n":
            print_pause("Thanks for playing! See you next time.")
            break
        
def play_game():
    items = []
    intro()
    field(items)
    
# Calling Functions
play_game()
run
python textbasedadventuregame.py
text
You find yourself standing in front of a cave.
In your hand you hold your trusty (but not very effective) dagger.
 
Enter 1 to knock on the door of the house.
Enter 2 to peer into the cave.
2
 
You have found the magical Sword of Ogoroth!
You are victorious!
 
Would you like to play again? (y/n)
textbasedadventuregame.py
import time
 
def print_pause(message, seconds=2):
    print(message)
    time.sleep(seconds)

Without delays, the game prints its entire story in milliseconds and loses all sense of pacing. A short sleep after each message lets the player actually read.

textbasedadventuregame.py
def intro():
    print_pause("You find yourself standing in an open field, filled with grass and yellow wildflowers.")
    print_pause("Rumor has it that a wicked fairie is somewhere around here...")
    print_pause("In your hand you hold your trusty (but not very effective) dagger.")

Plain-text scene-setting. The narrative is the gameplay in text adventures, so the writing matters.

textbasedadventuregame.py
def field(items):
    print_pause("Enter 1 to knock on the door of the house.")
    print_pause("Enter 2 to peer into the cave.")
    while True:
        choice = input("(Please enter 1 or 2.) ").strip()
        if choice == "1":
            house(items)
            break
        elif choice == "2":
            cave(items)
            break

The while True + if/elif/break pattern is the canonical way to handle constrained input. Each branch transitions to a different scene function — that is your state machine.

textbasedadventuregame.py
def cave(items):
    if "Sword of Ogoroth" in items:
        print_pause("You've been here before, and gotten all the good stuff.")
    else:
        print_pause("You have found the magical Sword of Ogoroth!")
        items.append("Sword of Ogoroth")
    field(items)            # back to the field

The items list is passed through every scene. Picking up an item is one append away. Branching on whether you have an item is one in check away.

textbasedadventuregame.py
def house(items):
    print_pause("You approach the door of the house.")
    print_pause("From within you hear a sinister cackle. The wicked fairie is inside.")
    print_pause("You must decide what to do.")
 
    while True:
        choice = input("(1) fight  (2) run\n> ")
        if choice == "1":
            if "Sword of Ogoroth" in items:
                print_pause("The Sword of Ogoroth shines brightly in your hand...")
                print_pause("You have defeated the fairie. Victory!")
            else:
                print_pause("Your dagger is no match for the fairie.")
                print_pause("You have been defeated.")
            play_again()
            break
        elif choice == "2":
            print_pause("You run back to the field.")
            field(items)
            break

The same scene has two endings — one rewarding preparation, one punishing recklessness.

textbasedadventuregame.py
def play_again():
    while True:
        ans = input("Play again? (y/n) ").strip().lower()
        if ans == "y":
            play_game()
            break
        if ans == "n":
            print_pause("Thanks for playing!")
            quit()

quit() terminates the program cleanly when the player is done.

textbasedadventuregame.py
def play_game():
    items = []
    intro()
    field(items)
 
play_game()

A fresh items = [] per game guarantees a clean slate.

Even this small game is a graph:

text
intro → field ──> cave ─→ field (with sword)

                 house ──> fight ─→ (win|lose)
                       └─> run  ─→ field

Every scene is a node. Every choice is an edge. As you add scenes, draw the graph on paper — it stops you writing dead-ends or unreachable scenes.

Players appreciate not losing progress.

save.py
import json
from pathlib import Path
 
def save_game(items, location):
    Path("save.json").write_text(json.dumps({"items": items, "location": location}))
 
def load_game():
    if not Path("save.json").exists():
        return None
    return json.loads(Path("save.json").read_text())

Each scene gets a name. The current scene name and the inventory are all that need persisting in a simple game.

Hard-coded scenes do not scale past 10 nodes. A real engine treats scenes as data:

scenes.json
{
  "field": {
    "text": "You stand in an open field. A house sits to your left and a cave to your right.",
    "choices": [
      { "label": "Knock on the house door", "go": "house" },
      { "label": "Peer into the cave",      "go": "cave"  }
    ]
  },
  "cave": {
    "text": "You find the magical Sword of Ogoroth!",
    "give": "sword",
    "choices": [{ "label": "Return to the field", "go": "field" }]
  },
  "house": {
    "text": "A wicked fairy lives here.",
    "choices": [
      { "label": "Fight", "go": "victory", "require": "sword" },
      { "label": "Fight", "go": "defeat",  "deny": "sword"    },
      { "label": "Run",   "go": "field" }
    ]
  },
  "victory": { "text": "You won!", "end": true },
  "defeat":  { "text": "You lost.", "end": true }
}

The engine becomes tiny:

engine.py
import json
data = json.load(open("scenes.json"))
state = {"location": "field", "items": []}
 
while True:
    scene = data[state["location"]]
    print(scene["text"])
    if scene.get("give"):
        state["items"].append(scene["give"])
    if scene.get("end"):
        break
    options = [c for c in scene["choices"]
               if c.get("require", None) in (None, *state["items"])
               and c.get("deny", "__none__") not in state["items"]]
    for i, c in enumerate(options, 1):
        print(f"  {i}. {c['label']}")
    n = int(input("> ")) - 1
    state["location"] = options[n]["go"]

You now write stories in JSON. Game designers (or you, at 2 AM) can iterate on the narrative without touching Python.

ProblemCauseFix
Loop never exitsForgot break after a choice handlerAlways break after dispatching
State leaks between runsGlobals reusedBuild state inside play_game()
Bad input crashes the gameint(input()) without tryValidate, or only accept letters/specific strings
Scenes call each other recursively foreverNo clear terminal sceneAdd explicit win/lose endings
Long scenes blur togetherNo pacingAdd time.sleep and section breaks

A health attribute that decreases on bad choices and ends the game at 0.

A scene where the player chooses what to say; the NPC responds based on prior choices. Track flags like met_wizard = True.

With a 25 % chance, an enemy intercepts the player on a path. Use random.random() < 0.25.

Instead of numbered choices, accept north, south, east, west. Store the map as a dict of rooms with exits.

Damage dice (1d6 + str), turn-based combat, escape attempts. See Dice Rolling Simulator for the dice mechanic.

Warrior, mage, rogue — each starts with different equipment and stats; some scenes only unlock for specific classes.

Multiple JSON files (save1.json, save2.json) plus a menu to pick one.

A separate Python script that lets a designer type scenes interactively and append them to scenes.json.

Pipe each print_pause line through pyttsx3 to read aloud.

Wrap the engine in Flask. Each scene becomes a page; choices become links. The state lives in the user’s session.

  • Onboarding flows — branching tutorials that adapt to user knowledge.
  • Chatbots with structured paths — customer-service trees.
  • Educational simulations — choose-your-own-adventure for ethics or training.
  • Decision-tree visualizations — the engine is a generic state machine.
  • Game prototypes — text mode for everything before art and engines.
  • State machines — scenes and transitions are an explicit FSM.
  • Inventory pattern — applicable to shopping carts, permissions, feature flags.
  • Branching logic — what most “config-driven” systems boil down to.
  • Refactoring from script to engine — extracting data from code is a fundamental engineering skill.
  • Narrative design — pacing, payoff, agency.
  • Add time.sleep pacing everywhere — it transforms the feel.
  • Extend with save/load.
  • Refactor to the JSON-driven engine above.
  • Write a longer story — 20+ scenes, 3 endings.
  • Try the textual library for a richer TUI with mouse and color support.
  • Port to Twine or Ink (industry-standard tools) once your story outgrows JSON.

You built a complete branching adventure, learned to manage state and inventory, and saw the refactor path from hard-coded scenes to a real data-driven engine. The same patterns underlie every interactive fiction system, chatbot, and onboarding flow. Full source on GitHub. Explore more game projects on Python Central Hub.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading