Interacting With Text Adventures Through Perl
One of my side projects at the moment is getting llms to play text adventures.
I have seen some previous attempts, but I think I can do better. At its core,
this ought to be fairly simple: pipe the output of the text adventure to Simon
Willison’s cli llm
utility, and pipe its output back to the text adventure.
This won’t work, however, because (a) intuition tells me llms need a little hand-holding to be good at tasks, and (b) people have tried just that with not great results. We may, for example, want to give the llm context that includes that it is playing a text adventure, what the conventions of such a game is, etc. The easiest way to do this might be to plug a Perl script in between the text adventure and the llm.
Text adventures are normally distributed in story files, which are bytecode for a text adventure interpreter. Since Perl can handle pipes, this seems set up for success: all we need is a text adventure interpreter that has a dumb terminal mode, where it uses plain stdin/stdout for interaction.
The community has informed me that there are a couple of popular alternatives for this: one is called dumbfrotz, and the other is using Bocfel with the cheapglk implementation. These are used by game authors and compiler writers in automated test suites. Just before I heard of those, I had come across fweep, which warns that it “does not require any special terminal mode or similar, therefore many features are unavailable.”
Perfect! In a single C file to boot; couldn’t be easier!
Oh, was I mistaken.
Patching up fweep to work better
One quickly discovers that fweep doesn’t actually compile on a modern system. Not afraid of a little C code, I found three problems.
- Some functions are implicitly declared, and need to be forward
declared.1 Someone has suggested that maybe it is sufficient to make
inline
functionsstatic inline
– I don’t know enough C to understand the details here. - There is a bare
return;
in anint
function – this leads to undefined behaviour, so it needs an arbitrary return value specified. - When saving and restoring games, the code calls
gets()
to read the filename. That function is no longer part of the standard C library, so these calls need to be upgraded tofgets()
(making sure to strip the final newline in the result2 Yes, I forgot this at first and got weird filenames as a result. While not afraid of C, I’m also not very good at it..)
These are all easily fixed.
However, another problem is that although fweep does not assume any special
terminal modes, it does assume there is a terminal with a limited size. This
means that it refuses to print more than around 24 lines of output before it
stops at a [MORE]
prompt. There is a command-line parameter to set the size of
the terminal, with which we could pretend our terminal has 999 rows, but this
parameter is not implemented.3 Trying to use it causes fweep to think the
terminal is zero-by-zero big, which means it asks for a keypress between every
single printed word. Fortunately, most of this flag is implemented – the only
thing that remains is to parse the numbers given by the user into the right
variables. We can use sscanf()
for this. We also need to move the block that
processes this argument slightly higher up in the processing chain, because the
variables we set in it are used in processing other arguments.4 I think this
is a place where my experience shows. As a beginner, I might not have thought to
check for usages of the variables that were already invalid, but hidden by the
unimplementedness of them.
After this, there is only one problem remaining. Our reads from pipes in Perl
use the readline
function, which makes the pipe line buffered. But fweep –
like any text adventure interpreter – does not print a newline after the prompt
character when it expects input. There are some ways we could deal with this,
but the easiest I could think of was to add another command-line flag to fweep
that forces it to print a newline any time it asks for user input. This was
fortunately also an easy fix, because there is a single function that asks for
user input that is used almost everywhere user input is needed.
The changes to fweep made thus are indicated in this diff.
--- fweep.c 2013-07-14 02:52:30.000000000 +0200 +++ fweep_patched.c 2025-07-18 12:24:13.303644055 +0200 @@ -90,6 +90,7 @@ U16 stream3addr[16]; int stream3ptr=-1; +boolean prompt_newline=0; boolean texting=1; boolean window=0; boolean buffering=1; @@ -127,6 +128,12 @@ void debugger(void); +// These need to be forward declared. +void char_print(U8 zscii); +void zch_print(int z); +void parse_options(int argc,char**argv); +void insert_object(U16 obj,U16 dest); + S16 get_random(S16 max) { int k,v,m; if(predictable_max) { @@ -742,7 +749,7 @@ oldscore=(S16)fetch(17); } res=system_input(&ptr); - if(lastdebug) return; + if(lastdebug) return 0; } if(logging && outlog) { fprintf(outlog,"%s",ptr); @@ -840,7 +847,10 @@ } printf("\n*** Save? "); fflush(stdout); - gets(filename); + // Read filename and strip terminating newline. + fgets(filename, sizeof(filename), stdin); + if(strlen(filename)>0 && filename[strlen(filename)-1]=='\n') + filename[strlen(filename)-1]='\0'; if(*filename=='.' && !filename[1]) sprintf(filename,"%s.sav",story_name); cur_column=0; if(!*filename) { @@ -901,7 +911,10 @@ if(from_log) return; printf("\n*** Restore? "); fflush(stdout); - gets(filename); + // Read filename and strip terminating newline. + fgets(filename, sizeof(filename), stdin); + if(strlen(filename)>0 && filename[strlen(filename)-1]=='\n') + filename[strlen(filename)-1]='\0'; if(*filename='.' && !filename[1]) sprintf(filename,"%s.sav",story_name); cur_column=0; if(!*filename) return; @@ -1485,6 +1498,7 @@ if(cur_prop_size==1) memory[u]=inst_args[2]; else write16(u,inst_args[2]); break; case 0xE4: // Read line of input + if(prompt_newline) char_print('\n'); n=line_input(); if(version>4 && !lastdebug) storei(n); break; @@ -1672,6 +1686,7 @@ " -g * = Set screen geometry by rows,columns.\n" " -i * = Set command log file for input.\n" " -n = Enable score notification.\n" + " -N = Print newline before reading input.\n" " -o * = Set command log file for output.\n" " -p = Assume game disc is not original.\n" " -q = Convert question marks to spaces before lexical analysis.\n" @@ -1728,6 +1743,7 @@ } if(opts['s'] && !transcript) transcript=fopen(opts['s'],"wb"); if(opts['i'] && !inlog) inlog=fopen(opts['i'],"rb"); + if(opts['N']) prompt_newline=1; if(opts['o'] && !outlog) outlog=fopen(opts['o'],"wb"); rewind(story); fread(memory,64,1,story); @@ -1783,6 +1799,10 @@ exit(1); break; } + if(opts['g']) { // Must happen before version check. + char*p=opts['g']; + sscanf(p, "%d,%d", &sc_rows, &sc_columns); + } restart_address=read16(0x06)<<address_shift; dictionary_table=read16(0x08)<<address_shift; object_table=read16(0x0A)<<address_shift; @@ -1810,10 +1830,6 @@ if(opts['e'][0]>='0' && opts['e'][0]<='9') escape_code=strtol(opts['e'],0,0); else escape_code=opts['e'][0]; } - if(opts['g']) { - char*p=opts['g']; - sc_rows=sc_columns=0; - } if(opts['p']) original=0; if(opts['u']) { allow_undo=0;
This worked great for the first two games I tried. It was easy to write the Perl code needed to read from and write to a pipe connected to fweep.
Unfortunately, only after writing quite a lot of code did I discover that fweep does not appear to play all text adventures I would expect it to. Of four games I tested with, it plays two. The other two quit at startup. This could be related to my patches, but I don’t know, and I don’t care enough to find out.
Building Bocfel against cheapglk instead
Thus, after having fixed up fweep to work just right, we’ll drop it. Instead, let’s try the community suggestion of Bocfel linked with the cheapglk implementation. Bocfel is a text adventure interpreter that speaks an i/o protocol designed for text adventures, called Glk. The cheapglk implementation is “an extremely minimal Glk library using only stdio.h” – no terminal escape codes, nothing. This is just what we want.
I didn’t manage to get Bocfel linked with cheapglk from their git repositories5 Most likely a silly mistake I made along the way and not a real problem., but the following sequence of commands did work.
curl -O https://cspiegel.github.io/bocfel/downloads/bocfel-2.1.2.tar.gz tar -zxfv bocfel-2.1.2.tar.gz cd bocfel-2.1.2 curl -O https://github.com/erkyrath/cheapglk/archive/refs/tags/cheapglk-1.0.6.tar.gz tar -zxfv cheapglk-1.0.6.tar.gz mv cheapglk-cheapglk-1.0.6 cheapglk cd cheapglk make cd .. make GLK=cheapglk
This produces a binary bocfel
that runs all the test games I throw at it.
Another benefit of Bocfel is that it can be configured to autosave. To get
corresponding functionality in fweep, we’d have to manually save the game at
each point where it is valid to create a save, and this runs the risk of
introducing a lot of bugs.6 I know because I tried and I never figured out
all of them.
Reading text adventures through pipe is complicated
However, as I was going through these test games manually, I realised that the simplifications I made to fweep are not going to cut it. The problem is that a text adventure does not reliably signal when it is expecting user input.7 At least not when played through text as the interface. There is a different implementation of Glk that might make this judgment easier: RemGlk uses json data between text adventure and consumer. However, it also includes more advanced features of Glk that would be overkill for this project. Most of the time, a prompt character precedes input – but seeing a prompt character is no guarantee that input is expected, and not all input is preceded by a prompt character. In the end, I think the best we can do is a very rough heuristic: if the interpreter has not produced output for some time, it must be waiting for input.8 This approach is incompatible with advanced functionality like timed input. Fortunately, none of my test games use timed input.
Our first instinct might be to try to use Perl’s IO::Select
with a timeout on
the game output handle to figure out whether more output is available or if
enough time has passed that we should try to produce a command. Somehow this
didn’t work as I expected, so I asked in #perl on Freenode – er, I mean Libera
Chat – and had the following confusing interaction.
I described the problem. Had a generous but ultimately unfruitful attempt at help by someone. Then LeoNerd breaks in.
<LeoNerd> The only reason to use multiplexed i/o is if you want to multiplex multiple things. There’s literally no point if you are doing just one thing. What else are you doing while waiting for input?
<kqr> The output is consumed by the Perl script (well, actually another process controlled from the Perl script) and then the next command is produced.
<LeoNerd> Yes, that’s what you do once you receive input. What else are you doing while also waiting for input?
<kqr> Oh, nothing. Just waiting for input.
<LeoNerd> Ok, so you don’t need to
select()
.<kqr> How else will I know when the program has stopped producing input for me and awaits my next command?
<LeoNerd> … Ok so you are doing other things: waiting for input or a timeout.
<LeoNerd> That’s two things. Two is more than one.
<LeoNerd> So yes some sort of event system. I’d suggest one or several but folks might accuse me of bias since I wrote most of the good ones.
This made me vaguely remember that I have a little previous experience with
IO::Async
from before, although when I used it previously it was not to
multiplex i/o but for concurrent execution through the functions and futures
compatible with the IO::Async
event loop.
And sure, IO::Async
seems like the right tool for this job too. It’s time to
look at code.
The Perl script
Given that I am personally on Perl 5.34 on my oldest machines, I should probably start to require higher Perl versions in my scripts. But I really like the property that Perl scripts can be dumped onto any system and Just Work™, so I have tried to figure out a minimal acceptable version of Perl I can use in all my scripts. I somewhat arbitrarily landed on 5.16 when making this decision ages ago, so that’s why my Perl scripts start with that.
We should also always enable strict mode, warnings, and autodie. These discover so many problems that could otherwise take a while to find.
use v5.16; use strict; use warnings; use autodie qw( :all );
Then some imports we’ll need: IPC::Open3
to run Bocfel and create the pipe to
it. The IO::Async
libraries to detect when the text adventure has stopped
producing output, and Getopt::Long
to parse command-line arguments. Of these,
only IO::Async
is a third-party library, the rest are core modules that ship
with Perl.
use IPC::Open3; use IO::Async::Stream; use IO::Async::Timer::Countdown; use IO::Async::Loop; use Getopt::Long;
The first thing the script does is parse command-line arguments. Since we’ll ask Bocfel to autosave, we need a way to reset the game. This will be a flag that defaults to false.
GetOptions("reset" => \my $reset, "help" => \my $help); $reset //= 0; $help //= 0; my $storyfile = shift @ARGV; sub usage { say "Usage: loop.pl [--reset] <story file>"; say ""; say "To clear out existing session and start from scratch, --reset."; die; } unless ($storyfile) { say 'Missing argument story file.'; say ''; usage; } usage if $help;
Bocfel with cheapglk produces very long lines of output. We’ll want to hard wrap these, mainly because the user experience in the final product will be better that way. We can write up a couple of primitive utilities for this.
# Hard break a buffer at existing newlines, spaces, and in the worst case in the # middle of words. Do this for just one line, which is returned, and remove # what was consumed from the buffer. sub hard_break { my ($cols, $buf) = @_; my $nextlf = index $$buf, "\n", 0; my $prevspace = rindex $$buf, ' ', $cols; if ($nextlf != -1 && $nextlf < $cols) { return break_line $buf, $nextlf+1; } elsif ($prevspace > $cols - 20) { return break_line $buf, $prevspace+1; } else { return break_line $buf, $cols; } } # Break a large buffer apart at a specific index. Return what is left of the # index, and remove up to and including the index from the referencing buffer. sub break_line { my ($buf, $i) = @_; $i = length $$buf if $i > length $$buf; my $line = substr $$buf, 0, $i-1; $$buf = substr $$buf, $i; return $line; }
With that out of the way, we’ll get into the real purpose of this script. We’ll want to take whatever output has been produced by the text adventure and convert it into the next command. Since this is the first article in a series, we won’t shell out to the llm yet – we’ll simply ask the user for the next command.
# Variable that holds the output of the previous command. It is meant to be # used to derive what the next command should be. my $result = ''; # Consume $result and produce a command. sub get_command { # In this example, we'll just ask the user for the command. chomp(my $command = <STDIN>); $result = ''; return $command; }
We will want to discover when no output has been produced for a while. This
happens through an IO::Async
countdown timer, which triggers after just a
second.
# A countdown that waits until the text adventure has stopped producing output # for a second. It understands this as the game waiting for input. To produce # input, it takes whatever output has been produced so far and either performs # an automatic action, or calls get_command to figure out what the next command # should be. # # This countdown will be added to the event loop as a child of the main game # stream, which means it can write to the game with $self->parent->write. my $output_wait = IO::Async::Timer::Countdown->new ( delay => 1, on_expire => sub { my ($self) = @_; if ($reset) { $reset = 0; $result = ''; $self->parent->write("restart\n"); } elsif ($result =~ /^Are you sure you want to .*?$/) { $self->parent->write("yes\n"); } elsif ($result =~ /Press any key/) { $self->parent->write("\n"); } else { my $command = get_command; say "> $command"; $self->parent->write("$command\n"); } $self->start; }, );
Then we set up the child process running the text adventure by opening pipes
to a a new invocation of Bocfel and converting the file handles to an
IO::Async
stream.
my $game_process = open3(my $gin, my $gout, undef, "./bocfel", $storyfile); my $game_stream = IO::Async::Stream->new ( read_handle => $gout, write_handle => $gin, on_read => sub { my ($self, $buf, $eof) = @_; # Read one hard-broken line at a time, printing it to the terminal and # appending it to the result buffer. my $line = hard_break 65, $buf; say "< $line"; $result .= "$line\n"; $output_wait->reset; return ($$buf ? 1 : 0); } );
This hard breaks a line out of the game output buffer, adds it to the $result
variable, and resets the countdown timer to indicate that the game is still
producing output. If there is stuff remaining in the buffer, we ask IO::Async
to run the method again to get the next line immediately. (This happens through
the return value – true-ish for “please invoke me immediately again” and
false-ish for “don’t invoke me until there is new input”.)
Finally, we tie all this together by setting up a new event loop, starting the output timer, adding our objects, and running the loop.
my $loop = IO::Async::Loop->new; $output_wait->start; $game_stream->add_child($output_wait); $loop->add($game_stream); $loop->run;
That’s it! This is a very convoluted way of letting the user play a text adventure. Since it’s asking the user for the next command, this is literally the experience we would get if we would run Bocfel directly, except with some extra machinery around it.
The reason we do this will become clearer in the next article, where we hook this up to an llm.