Introducing Promptly

Here's how I modified a point-and-click graphical game engine into being an old-school text adventure system.

Introducing Promptly

TL;DR - I used an existing game engine that depends on graphics and a mouse to instead work like a DOS-era Interactive Fiction prompt with just a keyboard.

Promptly
An Interactive Fiction framework for Adventure Game Studio!

Some time ago, my ears perked up in response to a question: "How do I make text scroll with a parser in Adventure Game Studio?"

The actual inquiry came up in an adventure game chat by The Space Quest Historian:

So, I had a bit of fun trying to build a DOS prompt environment in AGS, but I ran up against a brick wall.

My problem is that I can't get the output text window to "scroll up" however many lines the new output generates.

My brain tends to get enticed by questions like this, because the question is a puzzle. Adventure Game Studio is a 2D graphical engine that largely relies on mouse input to make things happen in the game. The engine is quirky and limited, and requires a great amount of creativity to work around them.

The editor window.

My main goal was to recreate an experience reminiscent of old Infocom text adventures. The challenge is that my engine of choice is mouse-driven as well as graphical. I had to come up with some pretty weird workarounds, but I'm really proud of how I got it all to work. Let's dig in.

The Hacks

The main things I needed to think about involved how the interface is supposed to work. I settled on these principles for how the system should behave:

  • Scrolling - The text interface should be scrollable, allowing users to see everything they've entered up to a point. If there's too much text, the screen should automatically scroll down to display the latest text response.
  • Branching Paths - A "Choose Your Own Adventure" style decision system allows users to decide which path to take, leading to different outcomes.
  • Screen Management - Inventory items will be displayed graphically when users look at them. The scrollback text can be hidden and restored at any given time.

Scrolling Interface

The first challenge involved creating a scrollable interface that could move up and down. This seems like an intuitive enough thing to make, right?

0:00
/0:03

Well, it's more complicated than you'd think. My friend was experimenting with what would be considered a more "semantically correct" approach: his GUI was basically a really big label that updated itself to simulate scrolling. It's all controlled by an integer value. Press the up key, the integer decreases, and the label is updated to represent a position. Hit the down key, the integer increases, and the inverse happens.

My friend's idea isn't a bad one, but the implementation is kind of messy to work with. It's harder to correct offset values for positions, and you're basically stuck using a text label as a makeshift canvas.

As my brain kept playing with the concept, I started thinking of a different approach. What if we...cheated? Used some feature of the engine to skip all of this, so we didn't have to write all that extra logic?

Using a ListBox instead of a Label

The GUI builder interface with its controls in the toolbar: Select, Add Button, Add Label, Add Text Box, Add List, Add Slider, Add Inventory Window

AGS comes with a very limited toolkit for interface development: Button, Text Label, Text Window, Text Box, Inventory Window, Sliders, and Lists. With a bit of creativity, you can still make some really cool game interfaces...but, this is all you really have to work with.

The ListBox element in particular caught my attention as a way to work around the first hurdle. In AGS, a ListBox is a set of text items. Normally, they're used to keep track of certain kinds of objects, such as save slots. But, it's actually just a box of strings with a couple of methods for updating their contents.

One of the most common uses for List Boxes is Save / Load dialogues.

From the outset, the API for doing stuff with a Listbox is pretty limited. Mostly, you can add something, remove something, check what's listed in the box, figure out which thing is selected, or scroll up and down. The more I thought about it, the more I realized that this element could actually work.

The good news: with some elbow grease, this component works really well!

The bad news: we have to subvert some very commonly-used things that almost every AGS game uses.

Some of the most common functions that AGS relies on include Display and Say, which are used to present messages to the user from either characters or the game itself. There's no way to force the engine to make those things work differently. So, I had to come up with an entirely new function and mechanism to adjust the ListBox contents, called Print().

Here's a quick comparison between the standard Display() function, and Print():

Display("Wow, you look really nice today!");
Print("Wow, you look really nice today!");

To the user, they look practically identical! However, Print has to do a few things: take a String input, add it to the ListBox, then make the whole interface scroll down.

function print(String Output) {
  // literally just a cheap "Say" function
  // The shell is really just a list of text
  // Where all output is displayed there
  listOutput.AddItem(Output);
  listOutput.ScrollDown();
}
0:00
/0:03

User Choice System

I'm a big fan of "Choose Your Own Adventure" style stories, where the reader / player is given a limited list of options that can take the entire story in a different direction. So, I decided to implement it in Promptly.

0:00
/0:08

The trick here is that we're actually using the native Dialog system AGS offers, and feeding it into an alternative interface using this function:

function decisionTime(int dialogID) {
  diaOpt = dialogID;
  make_choice = true;
  print("");
  print("What do you do?");
  Option1 = dialog[diaOpt].GetOptionText(1);
  Option2 = dialog[diaOpt].GetOptionText(2);
  String Num1 = String.Format("1. %s", Option1);
  String Num2 = String.Format("2. %s", Option2);
  print(Num1);
  print(Num2);
}

The obvious limitation here, for now, is that the function assumes that every Dialog tree only contains two options to choose from. This is kind of flaky, and I think I'll rewrite this in a for loop that iterates all possible options in the tree.

Blanking and Recall

There are a few edge-cases that specifically call for wiping the text off the screen, then bringing it all back later. Namely, that involves displaying inventory items and descriptions to the player.

0:00
/0:13

To make this work, we have to do a few ugly things.

  1. Show Inventory List
  2. When user looks at an item, wipe the screen.
  3. Show a graphic and description.
  4. When any new text is entered, wipe the screen again.
  5. Quickly put all of the old text back.

The Inventory List isn't too complicated. All we have to do is iterate through the different items a player has in their inventory.

function checkInv(){
  if (pocket.ItemCount == 0) {
    print("You are carrying nothing.");
    has_items = false;
  }
  else if (pocket.ItemCount == 1) {
    String itemName = pocket.ItemAtIndex[0].Name;
    String carrying = String.Format("You are carrying: %s.", itemName);
    print(carrying);
    has_items = true;
  }
  else {
    for (int i = 0; i < pocket.ItemCount; i++){
      // I kind of hate this,  but this is the best I can do right now.
      String itemName = pocket.ItemAtIndex[i].Name;
      print(itemName);
      has_items = true;
            }
        }
}

Wiping and Unwiping the screen are a little weirder. Prior to clearing all contents in the ListBox element, we need to quickly save everything that's in there.

Clearing the Screen:

function clearScreen() {
  // Clears the text output, but temporarily stores it for later
  // Useful for when you want to focus on items, or a character
  // but the player somehow still needs context from what was last said
  for (int i = 1; i < listOutput.ItemCount; i++)
  {
    String currentLine = listOutput.Items[listOutput.SelectedIndex];
    listOutput.SelectedIndex = i;
    backscroll[i] = currentLine;
    bs_count++;
    
  }
  stored_list = true;
  listOutput.Clear();
}

Basically, the above snippet saves all prior input in the ListBox to a String array, one line at a time, to be retrieved later. Once that's stored, we can wipe everything off, to draw other things on the screen.

Showing Inventory:

This is a weird little hack that I'm particularly proud of, but requires explanation. Text parsers are really weird, mostly because you have to establish a set of action verbs to tell the game to do things. The engine can be programmed to check for specific verbs, and determine what that verb is supposed to mean. Unfortunately, determining a course of action can get really messy.

function itemParse(String TextInput) {
  Parser.ParseText(TextInput);
  SplitInWords(TextInput);
  actionContext(Words[0]);
  int remainder = NumWords - 1;
    if (remainder == 1) {
      rest = String.Format("%s", Words[1]); // This is sloppy,  probably a better,  more dynamic way to do it.
      actInv(rest);
      resetContext();
    }
    else if (remainder == 2) {
      rest = String.Format("%s %s", Words[1],  Words[2]); // This is sloppy,  probably a better,  more dynamic way to do it.
      actInv(rest);
      resetContext();
    }
}

I work around this problem by setting a context value. This way, the game can know that "Look" means the player is using the Look action on a given object. "Touch" tells the engine to use the standard Interact action. What's cool about this is that AGS has built-in Look and Interact options available for pretty much every kind of in-game object: people, places, items, hotspots, you name it.

Here's a quick example of what the standard Look and Interact functions look like in standard AGS script:

function iKeys_Look()
{
  print("It's the car keys to your clunky 1994 Fiat Tempera.");
  print("Thinking about your clunky, rusted station wagon makes you sad.");
}

function iKeys_Interact()
{
  print("The keys are slightly warm.");
}

Promptly has a special function for handling inventory items specifically: check what context was set in relation to the inventory item, then do one of three things:

  1. Show the graphic and description for that item on-screen.
  2. Or: simply print out the appropriate interaction text.
  3. Or: if none of that works, fall back to a room script.
function actionContext(String interact) {
  Parser.ParseText(interact);
  if (Parser.Said("look")) {
    modeSwitch = 1;
//    print("look function");
  }
  else if (Parser.Said("touch")) {
    modeSwitch = 2;
//    print("touch function");
  }
  else{
    CallRoomScript(1);
  }
}

Restoring the Screen:

Once the player enters a brand-new command, the ListBox element undoes the wipe command by taking that String array we saved earlier, and putting it all back into the ListBox in the correct order.

function restoreScreen() {
  // Restores the screen contents
  // Due to a quirk, we make sure to start at an index of 1
  // And also cut off the top of the backscroll
  
    for (int i = 1; i < bs_count; i++)
  { 
    hide();
    listOutput.AddItem(backscroll[i]);
  }
  listOutput.RemoveItem(0);
  bs_count = 0;
  stored_list = false;
}

In Conclusion

The initial v0.1 release of this project happened a long time ago, and I think a refactor is in order. I've had some thoughts about what kinds of features I'd like to explore, like adding color customization, better support for synonyms in a dictionary, and better safeguards for interpreting the text parser. That being said, the main thing I'd like to do is clean up the code to be neater, less ugly, and more concise.

This was a really fun experiment, and I'm proud of what I managed to accomplish here. I hope to dust this off sometime soon, and produce a small, goofy text adventure on Itch.io. If you'd like to use my module in your own AGS project, check out the repo, read the documentation, and file some issues!