Skip to content

A10: Text Adventure

Your goal in this project is to write a program that lets the user read and play a simple “choose-your-own-adventure” style book. (There is a long history of such interactive fiction or text adventure games. Perhaps my favorite short one is suveh nux. Enjoy, but do the project first.)

The first order of business is to write the story, and save it in a text file that your program can interpret. The basic structure of the story will be a graph. The data in each vertex will be the text of the story and the list of choices. You will also need to specify how the vertices in the graph are connected.

Here is a sample story. You will need to write your own, but may use this one for testing. (Your story may be less Grimm.)

START| LESS_TRAVELED, BRIDGE| You are walking through the woods on a snowy evening. You come to a fork in the road. Do you a) take the road less-traveled or b) take the road more-traveled?
LESS_TRAVELED|| You are eaten by a bear. The end.
BRIDGE|SLIPPED, EATEN_BY_TROLL| You walk along the road for a while. As you come to a bridge, a troll jumps out and demands a gold coin as a toll. Do you a) pay the troll's toll or b) refuse to pay?
SLIPPED|| The troll happily wanders off with his gold coin, but as you step onto the bridge,
your foot slips on the wet wood. You fall off of the bridge and into the icy water. The end.
EATEN_BY_TROLL|| The troll eats you. The end.

Let’s look at the first line of the file:

START| LESS_TRAVELED, BRIDGE| You are walking through the woods on a snowy evening. You come to a fork in the road. Do you a) take the road less-traveled or b) take the road more-traveled?

It’s a single line of text; the only invisible ‘\n’ carriage-return character occurs at the end of the line (in this example, after the question mark).

The first capital word, before the vertical bar (shift-colon on a US keyboard), is the name of the vertex. The comma-separated words between the vertical bars are a list of names of vertices that can be reached from this vertex. The order of this adjacency list matters: option a in the text leads to the first vertex in the list, option b to the second, and so on. In the example, choosing a leads to LESS_TRAVELED, and choosing b leads to BRIDGE, so when you write your own story, list the options in the text in the same order as the names in the adjacency list. The text after the second bar is the data for the vertex, a text description that you’d see on the page of a choose-your-own-adventure novel.

You’ll need to write a Vertex class. A Vertex contains reference to a Python list of strings describing the connected vertices, and some data. For the example vertex, the adjacency list would be [“LESS_TRAVELED”, “BRIDGE”], and the data would be “You are walking…more-traveled?”

Where are vertices stored? We’ll use a dictionary of vertices. The vertex names will be the keys into the dictionary. For our example, you’d like something like this: vertices["START"] = Vertex(...) (That’s just a hint. Your code won’t look exactly like this.)

Here is some nearly-complete code to get you started with loading the file in and parsing it. You’ll need to finish the load_story function by creating graph vertices and adding them to the dictionary.

def parse_line(line):
section_split = line.split("|")
vertex_name = section_split[0].strip()
adjacent_vertices = section_split[1].strip().split(",")
# add all except empty strings
adjacent = []
for a in adjacent_vertices:
if a:
adjacent.append(a.strip())
text = section_split[2].strip()
return vertex_name, adjacent, text
def load_story(filename):
vertex_dict = {}
# Read the lines in the file into a list of lines:
file = open(filename, "r")
for l in file:
# if this is a line in the correct format:
if len(l.split("|")) == 3:
vertex_name, adjacent_vertices, text = parse_line(l)
print("vertex " + vertex_name)
print(" adjacent: " + str(adjacent_vertices))
print(" text: " + text)
# YOU WRITE THIS PART
# create a graph vertex here and add it to the dictionary
file.close()
return vertex_dict
story_dict = load_story("story.txt")

Finally, write a function containing a while loop that allows you to play the game. Grab the vertex “START” from the dictionary, print its text, and allow the user to input a value like a, b, or c using the Python input function. Based on that choice, find the name of the next vertex in the current vertex’s adjacency list: a is index 0, b is index 1, and so on. (Remember that you can convert a character to a number using ord; for example, ord("b") - ord("a") is 1.) Then grab the next vertex from the dictionary, and repeat until you reach a vertex with no outgoing links.

Your code for adventure.py should call the function to start game play after loading the story data into the graph.

Long descriptions may run past the edge of the terminal window. If you would like them broken into shorter lines when displayed, the fill function in Python’s textwrap module will do this for you.

You will need to write your own story for the project; mine is too simple to adequately test your program.

Here are the ground rules for your story. It should have at least twelve vertices (mine only has five). At least one of the vertices should have three adjacent vertices, and at least three vertices should have two adjacent vertices. You will not be graded on the quality of the content of your story, but please keep it PG-rated!

Do not use Word to write your story. Word documents contain information about formatting (like whether a word is in bold or italics) in the file. Such formatting information would deeply confuse your program.

To create a plain text document, make a new file with the .txt extension, such as nanowrimo.txt, in the same editor you use for your Python code. Do not press return as you are typing the text line.

The file format above is one that we invented for this assignment, and so we had to write our own parser for it. In practice, programmers usually store structured data in a standard format for which a parser already exists. One common choice is JSON (JavaScript Object Notation). For this part of the assignment, add support for JSON as a second way of loading a story.

JSON is an easier way to set up the story, because the parser does most of the work. The purpose of Part 1 was to give you practice building the graph structure yourself from a file that no existing parser understands.

In JSON, the story might be written as a dictionary whose keys are vertex names. Each value is itself a dictionary that holds the list of adjacent vertices and the text of the page:

{
"START": {
"adjacent": ["LESS_TRAVELED", "BRIDGE"],
"text": "You are walking through the woods on a snowy evening. You come to a fork in the road. Do you a) take the road less-traveled or b) take the road more-traveled?"
},
"LESS_TRAVELED": {
"adjacent": [],
"text": "You are eaten by a bear. The end."
},
"BRIDGE": {
"adjacent": [],
"text": "You walk along the road for a while and arrive safely home. The end."
}
}

Python’s standard library includes the json module; the function json.load reads a JSON file and returns its contents as Python dictionaries and lists. This parser does the work that parse_line did for the original format. Your code should take the dictionary that the parser returns, create a Vertex for each entry, and add the vertices to the dictionary of vertices, so that the same game-play function works regardless of the format in which the story was stored.

The new format is an alternative to the original format, not a replacement for it. Your submission should load and play a story written in either format, and should include a copy of your story in each.

This section is not required, but it is a reasonable addition to the project, and completing it may earn a grade higher than 10.

In the game so far, the player can only choose among the lettered options on each page. Classic text adventures instead let the player type commands in a small subset of English, such as take the broom or sweep the floor. For the bonus, write a grammar for such a command language, use Lark to generate a parser from it, as in A9, and extend your game so that the player can carry objects and use them.

Add two optional fields to the vertices in your JSON story. The field items lists the objects lying in that location, and the field dirty says whether the location needs sweeping:

"CLOSET": {
"adjacent": ["START"],
"text": "You are in a cramped closet. Do you a) return to the hallway?",
"items": ["broom"]
},
"KITCHEN": {
"adjacent": ["START", "CELLAR"],
"text": "The kitchen floor is covered in dust. Do you a) return to the hallway or b) open the cellar door?",
"dirty": true
}

Your story should include at least one broom and at least one dirty location. The player’s inventory is the list of objects that the player is carrying. When the player takes an object, remove it from the vertex and add it to the inventory; when the player drops an object, move it the other way, so that it stays in that location until someone picks it up.

Your command language should include at least the following commands. The words in parentheses are optional.

CommandEffect
a, b, c, …Move to the corresponding vertex in the current vertex’s adjacency list, as before: a for the first, b for the second, and so on.
lookPrint the text of the current location, and list the objects in it.
inventoryList the objects that the player is carrying.
take (the) broom, get (the) broom, pick up (the) broomMove an object from the location into the inventory.
drop (the) broomMove an object from the inventory into the location.
sweep (the) floor (with (the) broom)Sweep the current location. The word room may be used in place of floor, and the whole phrase (the) floor may be left out, as in sweep with the broom.

Sweeping succeeds only if the player is carrying a broom and the location is dirty; it then marks the location as clean. Make the clean floor matter to the story in some way: for example, sweeping the kitchen might uncover a trapdoor, adding a new choice to that vertex.

Your grammar should treat the words for objects and places as terminals, so that sweep the broom and take the floor are rejected by the parser rather than by your game code. You may add other objects and verbs, such as a lamp that must be carried into a dark cellar, if you would like.

Before writing the parser, write and test the code that carries out the commands. Each command needs access to the state of the game: the dictionary of vertices, the current vertex, and the inventory. Store that state in a Game class, and write one method for each command. A method’s parameters are the parts of the command that can vary; for sweep, these are the place and the tool, either of which the player may leave out, in which case the method receives None:

class Game:
def __init__(self, vertices):
self.vertices = vertices
self.current = vertices["START"]
self.inventory = []
def choice(self, letter):
...
def take(self, item):
...
def sweep(self, place, tool):
...

You will need methods choice, look, inventory_list, take, drop, and sweep. (The method for inventory needs a name other than inventory, since that name is already used for the list.) The methods are the place for the checks that the grammar cannot make, such as whether the broom is actually in the current location, or whether the player is carrying it; in either case, print a message that explains why the command had no effect.

Test the methods by calling them directly, with no parser involved. For example, in a story whose START vertex is a hallway, from which choice a leads to the closet and choice b leads to the kitchen, the following calls take the broom and sweep the kitchen:

game = Game(story_dict)
game.choice("a") # walk to the closet
game.take("broom")
game.inventory_list() # should list the broom
game.choice("a") # return to the hallway
game.choice("b") # go to the kitchen
game.sweep("floor", None)
game.sweep("floor", None) # should report that the floor is already clean

Now write a grammar for the command language. First, write the grammar in BNF or EBNF, as you would on paper, in a comment in your file. Then write it in Lark. Here is the beginning of a Lark grammar that handles only the first three commands, to show the shape:

from lark import Lark
command_parser = Lark(r"""
?command: choice | look | inventory
choice: CHOICE
look: "look"
inventory: "inventory"
CHOICE: /[a-z]/
%import common.WS
%ignore WS
""", start="command")

Write a function parse_command(text) that parses the text and returns the command as a tuple, so that the rest of your game never needs to look at Lark’s trees. The first string in the tuple names the command, and the remaining entries are the arguments to the corresponding method of Game: take the broom becomes ("take", "broom"), b becomes ("choice", "b"), and look becomes ("look",). Each kind of command should always produce a tuple of the same length, with None for any part that the player left out, so that sweep the floor becomes ("sweep", "floor", None) and sweep with the broom becomes ("sweep", None, "broom").

Two details of Lark will help. An optional part written in square brackets, such as ["with" item], may be left out of the input; if that part would have produced a child in the tree, Lark puts None in its place, which is exactly the None that the tuple needs. If the text is not in the language, parse raises a lark.exceptions.UnexpectedInput exception; your game should catch it, print a message such as I don't understand that., and let the player try again. We have not covered how to catch exceptions, but this is a bonus part of the assignment, after all; the Python tutorial’s section on handling exceptions explains how.

To connect the parser to the game, use a dictionary that maps each command name to the method that carries it out, as the operator dictionary in A5 mapped each operator to a function:

game = Game(story_dict)
actions = {
"choice": game.choice,
"look": game.look,
"inventory": game.inventory_list,
"take": game.take,
"drop": game.drop,
"sweep": game.sweep,
}
command = parse_command(input("> "))
name = command[0]
arguments = command[1:]
actions[name](*arguments)

The expression game.take names the take method of that particular game object, without calling it; calling it later, as actions["take"]("broom"), has the same effect as game.take("broom"). The * in actions[name](*arguments) unpacks the tuple, passing its entries as separate arguments, so that the arguments ("floor", None) lead to the call game.sweep("floor", None).

With this design, the game loop does not change when you add a command: a new command needs a rule in the grammar, a case in parse_command, a method in Game, and an entry in the dictionary.

If you complete the bonus, submit a second story file, such as story_bonus.json, that uses the new fields, and a screenshot of a game in which the player takes the broom and sweeps a dirty location.

At the top of your main Python file, add a comment that answers these questions:

  • About how long did the assignment take you?
  • What was hard?
  • What was easy?
  • Was anything in the assignment unclear?

We use your answers to improve the assignment.

  • Your story has at least twelve vertices; at least one vertex has three adjacent vertices, and at least three have two. The options in each vertex’s text are listed in the same order as the names in its adjacency list.
  • A Vertex class holds a vertex’s adjacency list and text, and load_story creates a Vertex for each line of the file and stores it in a dictionary, keyed by the vertex’s name.
  • The game starts at START, prints each vertex’s text, follows the vertex in the adjacency list that corresponds to the player’s choice, and ends at a vertex with no adjacent vertices.
  • A second loading function builds the same dictionary of Vertex objects from the dictionary that json.load returns, and the same game-play function plays a story loaded from either format.
  • Your story is submitted in both formats.
  • For the bonus: the command grammar is written in BNF or EBNF in a comment and in Lark; the Game methods are tested by calling them directly; parse_command returns tuples of a fixed length for each command; commands are run through a dictionary that maps command names to methods; and input that does not parse produces a message rather than a crash.
  1. Your text file with the story. I called mine “story.txt”.
  2. adventure.py, containing the required functions for both file formats, and code that calls those functions to load and play the story.
  3. A screenshot of a story in progress.
  4. Your story in JSON format, such as “story.json”.