Zork web API using Python and Spaces

Zork web API using Python and Spaces

This details how I set up a Zork web API (see it running here) powered by the public domain C port of the game (https://github.com/devshane/zork) and Spaces, DigitalOcean's new object storage product, all in 50 lines of python.

Infocom released Zork I (Dungeons) source code to the public domain, and someone ported it from FORTRAN to C here. It's not too hard to build a web API on top of that code, especially if you're willing to completely disregard security best practices!

The Plan:

Zork is 37 years old, latency and overhead won't be a problem. Every time a player makes a move I:

  1. Make a new copy of Zork
  2. Load the player's save file (from Spaces!)
  3. Load the game, restore the save, execute the player's move, save the game.
  4. Save the player's file (to Spaces!)
  5. Return the output from the game, delete the copy of Zork

As ridiculous as that sounds, it means I dont have to mess with the Zork code, and it makes the server itself stateless.

API request syntax will look like this: http://zork.ruf.io/[OPTIONAL UUID]>[ZORK COMMAND HERE], it will return a JSON object {msg:"[ZORK OUTPUT HERE]"} and we will identify and save games either by a 36 char UUID in the URL or a cookie.

Note about using spaces: This isn't the ideal way to use Object Storage because I am overwriting and retrieving individual files very quickly. I haven't seen any issues so far but a speedy player might encounter situations where their last command hasn't saved.

The Server

A 512mb Ubuntu 16.04 DigitalOcean server will be ~16000 times more powerful than the first computer to run Zork, the TRS-80.

Here's what needs to be pre-installed:

# install nginx, python, pip and git
apt install nginx python-dev python-pip git
# web.py (web framework) boto3 (interface w/Spaces) uuid
pip install web.py boto3 uuid
# app will be run as user `zorkapi`
adduser zorkapi
# stackoverflow tells me the following makes `make` work for zork
apt install libncurses5-dev
apt build-dep nmon
reboot

The Game

Clone the github repo and use it as the "canonical" Zork source that gets copied for each run of the game.

cd /home/zorkapi
git clone https://github.com/devshane/zork

The App

The app handles the copy/load/run/save/return workflow mentioned above. It uses the Web.py framework because it is simple and easy. Here's the whole app, detailed walkthrough further down:

File: /home/zorkapi/app.py

import subprocess, web, boto3, botocore, shutil, json, uuid

#INITIALIZE SPACES CONNECTION W/BOTO
spaces_client = boto3.session.Session().client('s3',
      region_name='nyc3',
      endpoint_url='https://nyc3.digitaloceanspaces.com',
      aws_access_key_id='MY ID',
      aws_secret_access_key='MY SECRET KEY')

#INITIALIZE WEB.PY
web.config.debug = False
app = web.application(('/>([ a-zA-Z0-9]{0,100})', 'Play'), globals())

class Play:
  def GET(self, cmd_input):

    new_game = False

    #pid COOKIE ID'S PLAYER
    pid = web.cookies().get('pid')
    if pid == None:
      pid = str(uuid.uuid4())
      web.setcookie('pid', pid, 31536000)
      new_game = True

    cmd = cmd_input if len(cmd_input) > 0 else 'look'

    #MAKE NEW TEMP FOLDER WITH ZORK
    shutil.copytree('/home/zorkapi/zork/',  '/home/zorkapi/games/' + pid + '/')

    #CHECK FOR SAVED GAME
    if not new_game:
      try:
        spaces_client.download_file('zorkapi', pid + '.dat', '/home/zorkapi/games/' + pid + '/dsave.dat')
      except botocore.exceptions.ClientError as e:
        new_game = True
        pass

    #LOAD ZORK AND RUN COMMAND
    out = subprocess.check_output('cd /home/zorkapi/games/' + pid + '; make; (printf "' + ('restore\n' if not new_game else '') + cmd + '\nsave\nquit\nyes\n" | ./zork)', shell=True)

    #SAVE GAME AND EXIT
    spaces_client.upload_file('/home/zorkapi/games/' + pid + '/dsave.dat', 'zorkapi', pid + '.dat')

    #DELETE DIRECTORY
    shutil.rmtree('/home/zorkapi/games/' + pid + '/')

    #RETURN OUTPUT (STRIP OUT THE EXTRA TEXT CAUSED BY SAVING AND RESTORING)
    return json.dumps({'msg':out[out.find('>'):out.rfind('>Your score would be')].replace('>Restored.\n', '').replace('>Saved.\n', '')})

if __name__ == "__main__":
  app.run()

App Code Walkthrough

Boto Spaces Client

The boto python module makes uploading and downloading from S3 easy, since Spaces is S3 compatible I just need to change the endpoint and credentials! Hardcoding the access key and secret key is a no-no. Better to set it via environment variables.

spaces_client = boto3.session.Session().client('s3',
        region_name='nyc3',
        endpoint_url='https://nyc3.digitaloceanspaces.com',
        aws_access_key_id='MY ID',
        aws_secret_access_key='MY SECRET KEY')

Now the spaces_client object (type S3.Client) can download_file and upload_file from a bucket named zorkapi that I already created in the DigitalOcean Spaces interface.

Initialize Web.py

Initialize web.py by defining the acceptable route, and mapping it to a class (Play.) Web.py allows you to use regex in routes, so in a half-hearted attempt at security, />([ a-zA-Z0-9]{0,100}) only passes commands that consist of 0-100 alphanumeric characters to the Play class.

#INITIALIZE WEB.PY
web.config.debug = False
app = web.application(('/>([ a-zA-Z0-9]{0,100})', 'Play'), globals())

Receive Requests

In the Play class, listen for GET requests that include the cmd_input (the zork command entered by the user) and first look for a player id cookie pid, if none are found, generate a new one using the uuid module. Finally, default to look (which describes the scene in Zork if no command is given.)

class Play:
  def GET(self, cmd_input):

    new_game = False

    #pid COOKIE ID'S PLAYER
    pid = web.cookies().get('pid')
    if pid == None:
      pid = str(uuid.uuid4())
      web.setcookie('pid', pid, 31536000)
      new_game = True

    cmd = cmd_input if len(cmd_input) > 0 else 'look'

Copy Zork, Load Saved Game

Next, copy the canonical Zork directory to a temp directory using the pid to make it unique to current player, and check Spaces for a Zork save file with the same pid. (It's ok if there isn't a save file in Spaces, that just means it is a new game.)

#MAKE NEW TEMP FOLDER WITH ZORK
shutil.copytree('/home/zorkapi/zork/',  '/home/zorkapi/games/' + pid + '/')

#CHECK FOR SAVED GAME
if not new_game:
  try:
    spaces_client.download_file('zorkapi', pid + '.dat', '/home/zorkapi/games/' + pid + '/dsave.dat')
  except botocore.exceptions.ClientError as e:
    new_game = True
    pass

Run Zork

THE MOST IMPORTANT PART: A subprocess in a new shell compiles the game (seems to be required when save file is changed,) load Zork, and run the following commands: restore if saved game, then [whatever the user typed] then save, quit and yes to confirm the quit. Output from Zork is saved to the out variable.

    #LOAD ZORK AND RUN COMMAND
  out = subprocess.check_output('cd /home/zorkapi/games/' + pid + '; make; (printf "' + ('restore\n' if not new_game else '') + cmd + '\nsave\nquit\nyes\n" | ./zork)', shell=True)

Clean Up

Upload the save file to Spaces, delete the directory, and return the zork output to the user.

#SAVE GAME TO SPACES
spaces_client.upload_file('/home/zorkapi/games/' + pid + '/dsave.dat', 'zorkapi', pid + '.dat')

#DELETE DIRECTORY
shutil.rmtree('/home/zorkapi/games/' + pid + '/')

#RETURN OUTPUT (STRIP OUT THE EXTRA TEXT CAUSED BY SAVING AND RESTORING)
return json.dumps({'msg':out[out.find('>'):out.rfind('>Your score would be')].replace('>Restored.\n', '').replace('>Saved.\n', '')})

The lines at the end are required to initialize the web.py app:

if __name__ == "__main__":
    app.run()

At this point if you were to run python app.py from /home/zorkapi you'd be able to test things out by going to http://[YOUR SERVER's IP]:8080

The nginx Config

The web.py app runs behind an nginx proxy so nginx can handle serving of static assets.

This config tells nginx to first look in the directory: /home/zorkapi/static, and if it can't find a static file to match the url pass the request to the web.py app running locally on port 8080.

File: /etc/nginx/sites-enabled/zorkapi

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    root /home/zorkapi/static;

    index index.html;

    location / {
      # First attempt to serve request as file, then
      # as directory, then pass to web.py
      try_files $uri $uri/ @webpy;
    }

    location @webpy {
      proxy_pass http://127.0.0.1:8080;
      proxy_redirect off;
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Delete the default config and reload nginx using:

rm /etc/nginx/sites-enabled/default
systemctl reload nginx

And run the web.py app as a daemon (in the background)

/usr/bin/python app.py > log.txt 2>&1 &

The API is now accessible via http://[YOUR SERVER's IP]/>! You can enter commands by hitting URLs like http://[YOUR SERVER'S IP]/>go east

Final Steps

  1. I added an HTML front-end at /home/zorkapi/static/index.html to make things look nice. I went with a retro terminal look.
  2. I created a Cloud Firewall in DigitalOcean to block all ports except 80.
  3. Time to play Zork!