In CALL OF DUTY: BLACK OPS, if you stand up at the main menu, there’s a computer in the back of the room with several hidden features. One of them is a game called ZORK 1: THE GREAT UNDERGROUND EMPIRE (ZORK).
ZORK is an interactive text-based adventure game developed at MIT in the late 1970s. Players can explore hundreds of locations and interact with objects by entering natural language commands, which the game then processes. Serving as a narrator, the program describes the player's surroundings and the outcomes of their actions.
I spent more time than I'd like to admit playing ZORK here, yet I never managed to finish it. Part of that is because the Playstation 3 (PS3) version of CALL OF DUTY: BLACK OPS only allows you to enter one character at a time on the main menu computer. The other reason is that it actually is quite an extensive and difficult puzzle game.
Years passed, and then one day, I stumbled upon ZORK again. I thought, wouldn’t it be awesome to play this again? But then it hit me. You know what would be even more awesome? Playing it again, but with friends.
In the time between CALL OF DUTY: BLACK OPS and this project, I had learned to program, Skype had slid quietly into irrelevance, and now Discord was the main method of communication used by my friend group. Discord is pretty cool because they have a simple yet robust API where you can make bots.
For those who are unfamiliar with it, Discord allows users to make servers. Each server has a set of text or voice channels where people can chat. Usually, bots can interact with users in the text channels through various commands.
We have the perfect front-end environment for a co-op ZORK. Now we just need to run the game somewhere and tie the input/output (I/O) to Discord.
There are a few options here. In my first iteration of the project, I actually just web scraped from some site that was hosting the game. This is probably the easiest way, but I really didn’t like that it wasn’t entirely local. If the site shut down, or they blacklisted my IP, it would no longer work. Also, the way I was doing it wasn’t particularly sound. For some reason, I wasn’t able to just capture the most recent output. For every message, I would capture the entire playthrough text up until that point, and then filter out everything except the most recent output. This caused the output in Discord to become increasingly delayed the longer it was played… which was really annoying, to say the least.
There are a couple of more options. One of them is playing the game on Windows within an application called DOSBox. The other is playing the game on Linux with Frotz.
Usually, I find that doing a programming related thing on Linux is less of a headache than it is on Windows. Things on Linux will generally work out of the box. I could have tried to capture the I/O from DOSBox, but ultimately I decided to go with Frotz.
Linux has a handful of methods for capturing I/O within the command line. You can do stuff like:
command > output.txt
command | tee output.txt
script output.log
I ran into issues with all of these.
Some of them capture the output, but don’t have a way to input. The first one won’t enter Frotz at all. The second one will run Frotz but it wont capture anything within the text file. I think I eventually was able to get the program’s output into a text file, but it wouldn’t update until after the game was over and Frotz was exited.
Here’s what finally worked for me...
First of all, we need to download Frotz and ZORK:
sudo apt install frotz
The game can be downloaded for free from the official site:
http://www.infocom-if.org/downloads/downloads.html
Inside of the zip there is a folder called DATA and within that is the game, ZORK1.DAT
Within the os module, there is a function called openpty() that creates a pseudo-terminal pair. This allows us to essentially run Frotz within an emulated terminal where we can then control the I/O.
zork_path = "/home/drew/zork/ZORK1.DAT"
master, slave = os.openpty()
zork_process = subprocess.Popen(
["frotz", "-p", zork_path],
stdin=slave, stdout=slave,
stderr=subprocess.PIPE,
text=True, bufsize=1,
close_fds=True)
Now we have a pseudo-terminal pair with ZORK running within the slave terminal. I guess the first thing we should try to do is read from it to see if it’s working:
data = os.read(master, 1024)
print(data)
This gives us some output:
b'\x1b[?1049h\x1b[22;0;0t\x1b[1;24r\x1b(B\x1b[m\x1b[4l\x1b[?7h\x1b[?1h\x1b='
This is a byte string with a bunch of terminal formatting. We can make it more legible by decoding it to utf-8:
data = os.read(master, 1024).decode("utf-8")
print(data)
This gives us nothing:
The problem is that the ZORK program didn’t have enough time to produce all of its output before we captured it. We can fix this by waiting a tiny bit before reading the data:
zork_path = "/home/drew/zork/ZORK1.DAT"
master, slave = os.openpty()
zork_process = subprocess.Popen(
["frotz", "-p", zork_path],
stdin=slave, stdout=slave,
stderr=subprocess.PIPE,
text=True, bufsize=1,
close_fds=True)
sleep(0.1)
data = os.read(master, 1024).decode("utf-8")
print(data)
The output finally looks like ZORK:
ZORK I: The Great Underground Empire
Copyright (c) 1981, 1982, 1983 Infocom, Inc. All rights reserved.
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.
>
However, there are a few problems with this. It’s not good that we’re hard-coding both the amount of time before capturing the output and the total number of bytes of the response. The other issue is that if there is no output to be read, os.read() will hang. To fix this, we can use the select module to wait for I/O completion and throw it in a loop so that we’re guaranteed to capture it all and exit once it’s done:
output = ""
while True:
r, _, _ = select.select([master], [], [], 0.1)
if not r:
break
data = os.read(master, 1024).decode("utf-8")
if not data:
break
output += data
This will read in 1024-byte chunks until there’s no more output.
Let’s try to send some input:
zork_path = "/home/drew/zork/ZORK1.DAT"
master, slave = os.openpty()
zork_process = subprocess.Popen(
["frotz", "-p", zork_path],
stdin=slave, stdout=slave,
stderr=subprocess.PIPE,
text=True, bufsize=1,
close_fds=True)
sleep(0.1)
os.write(master, "open mailbox\n".encode("utf-8"))
output = ""
while True:
r, _, _ = select.select([master], [], [], 0.1)
if not r:
break
data = os.read(master, 1024).decode("utf-8")
if not data:
break
output += data
print(output)
This is what we get after sending input and reading the output:
ZORK I: The Great Underground Empire
Copyright (c) 1981, 1982, 1983 Infocom, Inc. All rights reserved.
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.
>
Yay, it worked.
Let’s put everything into its own function so it can be used later.
I also wrote a function that cleans up the output to make it look nice in Discord. It’s quite long and isn’t required for this to work, so I’ll exclude it in the interest of time.
def read_output(input_text=None):
output = ""
while True:
r, _, _ = select.select([master], [], [], 0.1)
if not r:
break
data = os.read(master, 1024).decode("utf-8")
if not data:
break
output += data
return cleaned(output, input_text)
def send_input(input_str):
os.write(master, (f"{input_str}\n").encode("utf-8"))
At this point we’re able to play ZORK entirely in Python. All that’s left to do is connect it to Discord.
To get a bot working, we need to go to the developer section on Discord’s site. The bot needs to be registered, we need to give it permissions to read and write in text channels, and then we can add it to our server.
Once that’s done, we can set up the client in Python:
with open("key.txt") as file:
DISCORD_ZORK = file.read()
intents = discord.Intents.all()
intents.messages = True
intents.message_content = True
client = commands.Bot(command_prefix="!", intents=intents)
client.run(DISCORD_ZORK)
I don’t like having to enter a command every time I message the ZORK bot since I have it in a dedicated channel, and I don’t want to have to summon it with a command either, so I’ll make it start the game as soon as the bot connects:
@client.event
async def on_ready():
print("zork is ready")
channel = client.get_channel(CHANNEL_ID)
output = read_output()
await channel.send(output)
Now we just need to grab any message sent in the channel and we can send it as input to ZORK. We can then send back the output as a Discord message:
@client.event
async def on_message(message):
if message.author == client.user:
return
channel = message.channel
if channel.id != CHANNEL_ID:
return
send_input(message.content)
output = read_output(input_text=message.content)
await channel.send(output)
Finally, we just need to run the script, and now we have ZORK in Discord:
It's pretty wild that an old game like ZORK can still feel so fresh, especially when you play it with friends. What started as a hidden terminal in a Call of Duty menu turned into a fun little project that hit all the right notes. A bit of nostalgia, some hands-on programming, and an excuse to mess around with friends on Discord. Teenage me would’ve thought this was the coolest thing ever, and honestly, current me agrees.