in General

Zork!

Zork is one of the very first interactive fiction games and was released in 1977 for the PDP-10. Interactive fiction is a gameplay style now (sadly) mostly lost to the sands of time where on-screen text describes your environment to you and you interact via basic text commands (e.g. go north, take sword, hit troll with sword).

Zork (or Zork I, to differentiate it from the subsequent releases), is a timeless classic with a rich and challenging story. Zork has been open sourced by its creator Infocom, so I set out to learn a bit about it and make some unique ways to interact with it.

Ultimately, I ended up setting up two mediums:

  • Text anything to (707) 505-XXXX to begin playing via SMS This got way too popular and expensive. Sorry!
  • Follow and mention @TweetZork

Technical Methods

Zork as an architecture is a good bit more complicated than it might sound — rather than being a standalone binary, Zork itself is actually a program written in the functional Zork Implementation Language, or ZIL. ZIL runs on a virtual machine engine called the Z-machine, creating a dual dependency — the game itself has its own code but is exclusively run on a ZIL interpreter. This makes it easy to use one interpreter for the whole family of Zork-esque games (of which there are many both official and fan creations).

This led to a bit of an implementation challenge — I wanted to get this running on Twitter so you could play via tweet, something many have tried (and failed) to do:

All these accounts and more ostensibly play Zork on Twitter. All are either inactive or never worked. As far as I know, mine is the only working, live bot (currently stymied by Twitter API weirdness but I’m working with support on that; the core works fine but mentions aren’t getting through to the API endpoint (but that’s a story for another time)).

Due to the complexity of the z-machine, I didn’t want to re-implement the game from scratch (which would have given me great programmatic and synchronous access, but would have been totally unrealistic in terms of practicality and my attention span — some people have ZIL interpreters as their “forever” project that they keep tinkering with and improving over the course of years).

What I ended up doing was pretty hacky — I found a great ZIL interpreter called frotz that included a build option for no text management (no NCURSES, etc.) and was simply ASCII via stdout. I decided to wrap this in a python subprocess and class-ify it with stdin/stdout pipes for gameplay cycles (i.e. load a save, send a move, capture the output, save again, exit). This ended up being pretty efficient, as far as that method could go — frotz is nice, tight code, so the spinup time for the binary is negligible. Most importantly, the input delay ended up being a non-issue as I learned some fun things about how stdout behaves in terms of its file descriptor roots.

Deep Magic

Some code (we’ll break this down)…

class ZorkInterface:
   def __init__(self, frotz_cli_opts=['-p', '-m', './frotz_compiled/zork1.z5']):
        if sys.platform == 'linux' or sys.platform == 'linux2':
            bin = './frotz_compiled/dfrotz-ubuntu'
        elif sys.platform == 'darwin':
            bin = './frotz_compiled/dfrotz-mac'
        else:
            print("NO BINARY FOUND")
            quit()

        self.zproc = subprocess.Popen([bin, *frotz_cli_opts],
                                      stdout=subprocess.PIPE,
                                      stdin=subprocess.PIPE,
                                      stderr=subprocess.PIPE)

 
        # http://eyalarubas.com/python-subproc-nonblock.html
        flags = fcntl.fcntl(self.zproc.stdout, fcntl.F_GETFL)
        fcntl.fcntl(self.zproc.stdout, fcntl.F_SETFL, flags | os.O_NONBLOCK)

    def get_output(self):
        time.sleep(self.SLEEP_TIME)  # wait for it...

        while True:
            try:
                input = os.read(self.zproc.stdout.fileno(), 4096)
            except OSError:
                # the os throws an exception if there is no data
                break

        decoded = input.decode('utf-8')
        return decoded

    def send_input(self, text):
        text_with_line_break = text + '\n'
        self.zproc.stdin.write(text_with_line_break.encode())
        self.zproc.stdin.flush()

The above code is the meat of the wrapper I constructed. For the most part, it’s fairly vanilla:

  • Detect the platform to choose the right binary
    • I was building on a Mac but deploying on Ubuntu
  • Popen a subprocess with assigned pipes
    • note that Python docs correctly tell you to use Popen.communicate() instead of the more dangerous pipes which are subject to blocking behavior if you fill the pipe, but communicate() is built for retrieving single-command output (i.e. an ls or cat) rather than mutiple read/write cycles)
  • Save the subprocess into the class

Lines 17 and 18, though are where we make calls to the deep magic of the operating system. Fcntl is the python library which wraps the low level unistd.h POSIX syscall and gives us direct access to file descriptor flags.

When a pipe is opened for a process (e.g. stdout for frotz), the default state is to block the thread reading the pipe until data comes through the pipe or all processes close the pipe (ref. unistd.h docs). This is a problem for us, as we want to capture the output of frotz until there is no more output to be captured and then move on with the game and our lives. Thankfully, the Unix pantheon of yore has blessed us with the O_NONBLOCK mask, a delightful flag which sets a non-blocking IO mode on a file descriptor — essentially, if the pipe has nothing in it, read() will return immediately (with an exception, in Python) rather than waiting.

Line 17 gets the currently set flags on the pipe file descriptor, then line 18 adds os.O_NONBLOCK to the flags via a bitwise OR operation and sets the pipe mode to nonblocking. Now, all we have to do in get_output() is let give the process a hot second to spin up (.1s is more than enough time but I felt like being cautious), then os.read until there is no more data and do what we will with the output. (NB: I used a 4KiB buffer for reading; Zork won’t feed us more than that but the number can be arbitrary.)

Sending input via send_input() is then as easy as writing to stdin and flushing it down the pipe. Once the base operations of reading stdout and writing to stdin are taken care of, we can simply use a bare-bones function to create a game loop (in this case, interactive for the user but passing through our wrapper):

def game_loop(self):
    while True:
        self.send_input(input(self.get_output()))

So, all in all, a very fun project and a great way to get hands on with some low-level pipe banging usually reserved for C/C++ gurus. Also, more Zork available to the world! Always excellent.

Below are the first few commands of a simple Zork session so you can get a feel for it:

ZORK I: The Great Underground Empire
Copyright (c) 1981, 1982, 1983 Infocom, Inc. All rights reserved.
Licensed to Tandy Corporation.
ZORK is a registered trademark of Infocom, Inc.
Revision 88 / Serial number 840726

West of House
You are standing in an open field west of a white house, with a boarded front door.
There is a small mailbox here.

>open mailbox
Opening the small mailbox reveals a leaflet.

>read leaflet
(Taken)
"WELCOME TO ZORK!

ZORK is a game of adventure, danger, and low cunning. In it you will explore some of the most amazing territory ever seen by mortals. No computer should be without one!"


>go south
South of House
You are facing the south side of a white house. There is no door here, and all the windows are boarded.

>go east
Behind House
You are behind the white house. A path leads into the forest to the east. In one corner of the house there is a small window which is slightly ajar.

>open window
With great effort, you open the window far enough to allow entry.

>enter house
Kitchen
You are in the kitchen of the white house. A table seems to have been used recently for the preparation of food. A passage leads to the west and a dark staircase can be seen leading upward. A dark chimney leads down and to the east is a small window which is open.
On the table is an elongated brown sack, smelling of hot peppers.
A bottle is sitting on the table.
The glass bottle contains:
  A quantity of water

>go west
Living Room
You are in the living room. There is a doorway to the east, a wooden door with strange gothic lettering to the west, which appears to be nailed shut, a trophy case, and a large oriental rug in the center of the room.
Above the trophy case hangs an elvish sword of great antiquity.
A battery-powered brass lantern is on the trophy case.

>take sword
Taken.

>take lantern
Taken.

>examine rug
There's nothing special about the carpet.

>move rug
With a great effort, the rug is moved to one side of the room, revealing the dusty cover of a closed trap door.

>open trap door
The door reluctantly opens to reveal a rickety staircase descending into darkness.

>go down stairs
You have moved into a dark place.
The trap door crashes shut, and you hear someone barring it.

It is pitch black. You are likely to be eaten by a grue.
Your sword is glowing with a faint blue glow.

>turn on lantern
The brass lantern is now on.

Cellar
You are in a dark and damp cellar with a narrow passageway leading north, and a crawlway to the south. On the west is the bottom of a steep metal ramp which is unclimbable.

>go north
The Troll Room
This is a small room with passages to the east and south and a forbidding hole leading west. Bloodstains and deep scratches (perhaps made by an axe) mar the walls.
A nasty-looking troll, brandishing a bloody axe, blocks all passages out of the room.
Your sword has begun to glow very brightly.
The troll hits you with a glancing blow, and you are momentarily stunned.

>hit troll with sword
You are still recovering from that last blow, so your attack is ineffective.
The troll's axe barely misses your ear.

>kill troll with sword
The fatal blow strikes the troll square in the heart:  He dies.
Almost as soon as the troll breathes his last breath, a cloud of sinister black fog envelops him, and when the fog lifts, the carcass has disappeared.
Your sword is no longer glowing.

Write a Comment

Comment