Creating branched narratives Recently, I was asked to develop a way to easily create a branched narrative, where the user can select options to progress the story in their own path. After a couple hours of fiddling around with different options I managed to produce this simple code.

### STORY BRANCH MANAGEMENT

class Branch:

    def __init__(self, dialogue, options={}):
        self.dialogue = dialogue
        self.options = options
        self.keys = list(options.keys())
        self.canbranch = options != {}

    def __iter__(self):
        for x in self.keys:
            yield x

    def __str__(self):
        return self.dialogue

    def choose(self, choice):
        return self.options[self.keys[choice]]

story_main = Branch("You find yourself in a room with three doors, which one do you choose?", {
    "Left door":
        Branch("You fell in a hole and died"),
    "Middle door":
        Branch("Oh hey, you win a free Parker pen"),
    "Right door":
        Branch("You tripped over the door frame", {
            "Get up and walk away":
                Branch("Oh well, at least you lived"),
            "Die on the spot":
                Branch("Nevermind...") } ),
    "All of the above":
        Branch("Well I guess that works") } )

### PLAY THE STORY

def play():
    br = story_main

    while br.canbranch:
        # Output the current branch's dialogue
        print(br)

        # Create blank line, then displays the sub-branch choices as an enumerated list
        print("")
        for i,x in enumerate(br):
            print("\t{}) {}".format(i + 1, x))

        # Have the user to select an option
        print("")
        c = -1
        while c not in range(len(br.keys)):
            c = int(input("Select --&gt; ")) - 1

        # Update the current branch to the sub-branch that the user just chose
        br = br.choose(c)
        print("---")

    # If the user reaches a 'final' branch (i.e, no choices), output it's dialogue and close the program after enter is hit.
    input(str(br) + "\n---")

play()


The code itself makes sense I hope, but the main concern I have is with the part in the middle where we can create a story: story_main = Branch( ... ). It seems a bit... strange. I've been told that it is readable, but it doesn't seem very 'pythonic'. I assume this is because of the way I've used dictionaries to store a choice and its corresponding sub-branch as the key and value respectively.

If you can't understand how the story is written, I'll brief over what it does so that it may be easier to grasp. When the program is opened, you're told "You find yourself in a room ... " Then, you are given a list of options, "Left door", "Right door", and so on. If you chose to enter the right door, the program would output "You tripped over the doorframe" and give you some more options, "Get up and walk away" and "Die on the spot". If you chose to die on the spot, it would output "Nevermind..." and the story would end.

This is what the program looks like when run:



So as you can see it works, but is it acceptable to use this method?

EDIT: The answers so far have both recommended to save the story as an external file in some way, and then access it from within the code. I imagine this is for organisation, and it was actually what I originally had in mind. The program from which the code I provided is from did do something like this (although likely less convenient than suggested methods in most respects). If anyone has more suggestions on how storing the narrative could be optimised, that would also be appreciated.
I started reading at play. At first, I thought you had posted non-working code, because your code couldn't possibly work. Then I got up to the definition of Branch and realized that yes, it could work. But it violates so many principles that I don't know what the "real" type of a Branch is.

Let me suggest a slightly different model:


You have an outer loop. The loop ends when the narrative ends. You could express this as either an iteration stop, or as a boolean condition. I'm going to call each step in the narrative a step. You might prefer page or part or section, whatever helps you tell this story:

# Iteration stop:
for step in narrative:
    pass

# Boolean condition:
while not narrative.has_ended:
    step = narrative.current_step

Once you have a step, you can display the narrative text, and then present the user with a collection of options to choose from. Because users are users, there probably needs to be an inner loop that keeps asking for a valid option until one is entered. I would make a function for that:

    # Display the narrative text
    print(step.text)

    # Present the user with options, force a choice:
    choice = get_user_choice(step.options)



Finally, you have interacted with the user and received a choice, so update the narrative. Maybe your iterator has a .select method, or maybe your narrative does:

        step.select(choice)

        # or maybe

        narrative.branch(choice)


After looking at it, I personally prefer the idea of invoking methods on the Narrative. It shows more clearly that the narrative is an object, maintaining and updating its state. However, I would make the menu selection a "simple" cough! enhanced generator (see PEP-342):

from os import PathLike
from typing import Generator, Iterable, None, TextIO

def tell_a_story(datafile: PathLike, output: TextIO,
                 chooser: Generator[str, Iterable[str], None]):
    """
    Tell a branched-narrative story. Configure the narrative using
    "datafile", write the result on "output," interact with "chooser"
    (an enhanced generator) to select branches to follow.

    """
    narrative = Narrative(datafile)

    while not narrative.has_ended():
        step = narrative.current_step
        output.write(step.text)

        # send options to the generator, get a choice back
        choice = chooser.send(step.options)

        narrative.branch(choice)


I chose to use a Writable and an Extended Generator to help with automated testing. You should be able to supply a StringIO and a generator that ignores the options in favor of just iterating over a list of choices, and compare the generated text against what you predict for your test case.

If there is an epilog, you'll want to handle it specially. In particular, your generator should deal with an options parameter that is None. Then you could print the epilog, give the user no options, probably get None back from the generator, select None as your branch, and mark the narrative as ended.

Alternatively, you could create Narrative with some sort of .introduction text, print that outside the loop, and then loop in a different order:

output.write(narrative.introduction)

while not narrative.has_ended():
    choice = chooser(narrative.options)
    narrative.branch(choice)
    output.write(narrative.new_text)


This may tend to make your last loop iteration cleaner, and impose fewer constraints on your generator.

How Enhanced Generators Work

Generators are functions in Python that make use of the yield or yield from statements. We'll focus on yield because that's all we need.

You can create a generator like this:

def g():
    yield 1
    yield 2


You know it's a generator because you see yield. Twice!

Generators don't return a value when you "call" them. Instead, they return a generator object. It's like an iterator - it's an object that you can call next() on and it might return multiple values:

for x in g():
    print(x)


This does what you might expect: it prints

1 
2


So you can see that the yield keyword is used to pass data out of a generator, and suspend execution. When the caller invokes next(generator_object) or generator_object.__next__(), execution resumes where it left off - at the yield statement, and more code is executed. 

In a first attempt at supporting coroutines, python added generator.send() methods. This allows the caller to push data into the generator, and it works like this:

def eg():
    x = 0
    while True:
        x = yield x + 1

egen = eg()
p = egen.send(None)

for _ in range(5):
    print("P is:", p)
    p = egen.send(p + 3)


The output looks like:

P is: 1
P is: 5
P is: 9
P is: 13
P is: 17


As you can see, this lets you send data into the generator, then pull data back out. You just have to start off by sending a None to "prime the generator". 

This is relevant to you because you need to have a 2-way conversation. You want to send out some options, and get back a selection. You could do it like this:

def get_selection(choices):
    print("\n".join(choices))
    return input("What will it be?")


But then how would you test? You'd have to use global variables or something, and where's the fun in that? 

But if you built a generator, you could ignore the options and just pre-program a path through the data:

def test_selections(test_data):
    for choice in test_data:
        options = yield choice

chooser = test_selections(["a", "b", "a", "b", "a", "c"])
outfile = io.StringIO()
datafile = "story.txt"

tell_a_story(datafile, outfile, chooser)


Or, you could talk to the user:

def user_selections():
    choice = None
    while True:
        options = yield choice
        print("\n".join(options))
        choice = input("What will it be?")


Depending on whether you want to test or really use it, you just pass a different generator.

Edit (responding to changes in the original question):

First, note that etiquette on CodeReview is to append your updates, not edit them in-place. When you make in-place changes, that invalidates the answers people posted and makes other readers confused.

That said, this version looks considerably better. My main suggestion would be to remove the enumerate from getoptions (let the display code handle the enumeration), and move getoptions into Branch as a method rather than a free function. When you do that, of course, you realize that you could just let the caller access Branch.options directly, which is fine. ;-)

One other suggestion is to make the story an incoming parameter for play(), like:

if __name__ == '__main__':
    play(Story_main)