9
\$\begingroup\$

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 --> ")) - 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.

\$\endgroup\$
  • \$\begingroup\$ Please do not update the code in your question to incorporate feedback from answers, doing so goes against the Question + Answer style of Code Review. This is not a forum where you should keep the most updated version in your question. Please see what you may and may not do after receiving answers. \$\endgroup\$ – Sᴀᴍ Onᴇᴌᴀ Jan 18 '18 at 0:04
  • \$\begingroup\$ Yeah, my bad. I was just about to fix it. Thanks for doing it so quickly, however. \$\endgroup\$ – kingdom5500 Jan 18 '18 at 0:06
6
\$\begingroup\$

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:

  1. 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
    
  2. 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)
\$\endgroup\$
  • 1
    \$\begingroup\$ Thanks for the answer. I don't quite understand how the enhanced generator works for choices, could you please explain? I haven't heard of them before so I searched online, but I still don't get how it would work. \$\endgroup\$ – kingdom5500 Jan 16 '18 at 6:57
  • \$\begingroup\$ Just a comment @AustinHastings . I was very confused with the generator example you gave. I tested it writing the same code in the Python interpreter and gave me 1,5,9,13,17 as output \$\endgroup\$ – madtyn Jan 16 '18 at 16:36
  • \$\begingroup\$ Thanks for elaborating on your answer. I already briefly knew what generators were (I used one as part of the original branch class in the __iter__ method, which yielded the choices in a similar way that your approach does). Just out of curiosity, what would make your example preferred over mine? \$\endgroup\$ – kingdom5500 Jan 16 '18 at 17:23
  • \$\begingroup\$ Coherence. Your original example is a class that stringifies one way, but iterates another way, for example. When you try to describe the class, you wind up just listing language features - it's not really a class, it's an entire program accessed through various entry points, some of them hidden. \$\endgroup\$ – Austin Hastings Jan 16 '18 at 22:22
  • \$\begingroup\$ @madtyn Edited, thanks. It was last night, so I'm no longer sure, but I suspect I ran the generator without initializing it, or maybe after initializing it the wrong way, or something. What I get for using the same variable names more than once in a live REPL. \$\endgroup\$ – Austin Hastings Jan 16 '18 at 22:25
5
\$\begingroup\$

One thing that many branching stories I read during my childhood had in common is that branches can rejoin at some point.

This is not possible with your current code, because you create the Branch objects on-the-fly.

In order to implement this, you would have to separate your story elements and the actual story. The story elements could be saved in a simple JSON file, something like this:

{"Start Room": {"text": "You find yourself in a room with three doors, which one do you choose?",
                 "choices": ["Left door", "Middle door", "Right door"]},
 "Hole": {"text": "You fell in a hole and died",
          "choices": []},
"Dragon Room": {"text": "You find yourself in a room with a dragon, what do you do?",
           "choices": ["Fight!", "Run!"],
"Fiery Death": {"text": "You are burnt to a crisp!",
          "choices": []}
...
}

And then the actual story becomes just a dictionary of strings:

{"Start Room": ["Hole", "Hole", "Dragon Room"],
 "Dragon Room": ["Fiery Death", "Start Room"]}

This way you can encode an arbitrary story topology (circular, branching, rejoining).

Your story telling code could then be something like this:

import json

ELEMENTS = "dragon_elements.json"
STORY = "dragon_story.json"

def get_choice(choices):
    for i, choice in enumerate(choices, 1):
        print("\t{}) {}".format(i, choice))
    c = -1
    while not (0 <= c < len(choices)):
        try:
            c = int(input("Select --> ")) - 1
        except ValueError:
            print("Please enter a number")
    return c


if __name__ == "__main__":    
    with open(ELEMENTS) as f:
        elements = json.load(f)
    with open(STORY) as f:
        story = json.load(f)

    room = elements["Start Room"]
    next_rooms = story["Start Room"]
    print(room["text"])

    while room["choices"] and next_rooms is not None:
        assert len(room["choices"]) == len(next_rooms)
        choice = next_rooms[get_choice(room["choices"])]
        room = elements[choice]
        print(room["text"])
        next_rooms = story.get(choice)

Alternatively, for some additional fun, you could encode the story in a graph language, like the dot language:

digraph dragon_story {
    graph[bgcolor=white, margin=0];
    node[shape=box, style=rounded, fontname=sans, fontsize=10, penwidth=2];
    edge[penwidth=2, color=grey];
    0[label = "Start Room", color = "0.32 0.6 0.85"];
    1[label = "Hole", color = "0.23 0.6 0.85"];
    2[label = "Dragon Room", color = "0.03 0.6 0.85"];
    3[label = "Fiery Death", color = "0.38 0.6 0.85"];
    0 -> 1, 1, 2
    2 -> 3, 0
}

This makes parsing this a bit harder, but it allows you to visualize your story:

dot dragon_story.dot | display

enter image description here

\$\endgroup\$
  • 1
    \$\begingroup\$ My code does actually allow for rejoining branches. If you save a branch as a separate variable (ie story_main_part2 or something) you could make all the current branches point towards that new one. However, I still really like your ideas ^^ Thanks for answering. \$\endgroup\$ – kingdom5500 Jan 16 '18 at 17:14
  • 1
    \$\begingroup\$ Holy cow! That dot idea is brilliant! And it wouldn't be too hard to automatically generate the graph from the JSON. Nice! \$\endgroup\$ – Austin Hastings Jan 18 '18 at 0:00

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.