TADS 3 is the latest version of “The Adventure Development System.” It is a powerful programming language for the development of text adventures. Out of the box, TADS comes with a base programming language (which is geared towards text adventures but could well be used for other purposes) and an adventure library. It is the library that provides lots of classes that make text adventure development possible. There are classes for rooms, items, non-player characters, buttons, levers, even smells and sounds.

The TADS Adv3 library, as it is called, is quite big and provides numerous classes for almost every conceivable adventure implementation detail. Because of this level of detail, it can be difficult to get started with TADS 3, and this is one of the reasons that there is also a “light” version of adv3 available (called Adv3Lite) – though this doesn’t mean that it’s that much smaller, but the authors have removed some of the less used classes.

While spending time learning to use Adv3, it occurred to me that I would like to focus on developing complex environment components and try to isolate them from the rest of the code. This way, I would be able to encapsulate code, removing it from the main body of code to a separate file, and initialize my components with a few parameters. This way, I would be able to reuse polished components in a game (or various games). Still, it’s unlikely that I would want to reuse a component over and over, because every game is and should be different. Nevertheless, encapsulating code improves code readability and is an interesting exercise it itself.

This article will go on to describe the development of a sample complex component: an elevator that moves between floors (rooms) and is equipped with buttons, doors and floor indicators.

The Elevator

The elevator is added declaratively to the game by saying:

	
elevator: Elevator
    floors = [startRoom, topRoom]
    exitDir = &south
;

Here, the elevator stops at startRoom (floor 0) and topRoom (floor 1). More rooms can be added to the list. It is not necessary to make changes to startRoom or topRoom other than adding text to the rooms describing the elevator doors, call button and floor indicator. These objects will be added automatically to the rooms. exitDir indicates in what direction the exit of the elevator lies. In this example, the elevator exit is to the south, thus the exits from the rooms to the elevator lie to the north.

Other design requirements are:

  • The elevator and all its related objects are generated from the declaration above.
  • A call button summons the elevator from outside (if the elevator is not present).
  • The elevator doors open automatically.
  • The elevator contains buttons for all floors.
  • The player can press the buttons to make the elevator move, or go UP or DOWN to keep interaction less wordy.
  • The floor indicator shows the floor the elevator is on, both inside and outside of the elevator.

Elevator base class

The first code for the elevator base class is this:

	
class Elevator: Room
    name = 'Elevator'
    destName = 'the elevator'
    desc() {
        "This elevator is a rickety contraption. There is a floor indicator above the
        doors (currently reading <q><<currentFloor>></q>), and a panel on the wall";
        if(floors.length == 0) ". ";
        if(floors.length == 1) " with a single button. ";
        if(floors.length == 2) " with buttons marked zero and one. ";
        if(floors.length >  2) " with buttons marked zero through <<spellInt(floors.length-1)>>. ";
        "A set of open double doors leads out of the elevator. ";
    }
    roomParts = [elevatorFloor, elevatorCeiling, elevatorNorthWall,
                 elevatorSouthWall, elevatorEastWall, elevatorWestWall]
 
    // The following must be overridden by the code that creates the elevator instance.
    // Floors that elevator can go to, e.g. [room1, room2, room3]
    floors = []
    // Direction that elevator opens into. The direction is the same for all floors.
    exitDir = nil // e.g. (&north)
    // Floor that elevator is currently on. By default this is
    // the first floor in the list of floors.
    currentFloor = 0
;

This defines the elevator room name, destination name and overrides default walls, ceiling and floor for a better look. It also implements the properties floors (the list of floors that the elevator can visit, in order from bottom to top) and exitDir (the direction that the elevator opens into). Finally, it keeps track of where the elevator currently is. Note that we ask the Adv3 library to spell out a digit for us (e.g. 1 becomes ‘one’, 2 becomes ‘two’ etc.) using the spellInt function. There are also functions for spelling out ordinals, which we’ll need later.

Connecting the exits

The exitDir is actually a reference to a property, which is how we store our exit direction. TADS allows us to use an ampersand (&) to get a reference to a property. We’ll still need to actually set a TravelConnector in that direction. We’ll add a method to the class for this:

	
setExit(index) {
    if(floors.length == 0) {
        self.(exitDir) = nil;
        out = nil;
    } else {
        self.(exitDir) = floors[index + 1];
        out = floors[index + 1];
        currentFloor = index;
    }
}

When you call setExit(0), then the property that exitDir refers to is set to point to the room stored in floors[0]. For the sample code at the top of this article, exitDir = &south, so that means that elevator.south is set to startRoom (which is the first floor in the floors list). We use a reference to a property to make sure that the code also works if the elevator exit direction were to lie to the north, or to the southeast, or even up, although that would make no sense.

We’ll need a place to actually call the setExit method, which we’ll get to shortly.

Describing the exits

It would be nice if we were able to say which direction the doors lead:

A set of open double doors leads north out of the elevator.

We do have this information (it’s in exitDir), but we need to find a way to convert that direction to a string. I found that this was possible by adding a method to the Elevator class:

/*
 *   Find direction name of exitDir. Returns 'north', or 'south', etc.
 */
getExitName() {
     foreach (local dir in Direction.allDirections) {
         local conn = self.getTravelConnector(dir, nil);
         if(conn == self.(exitDir)) return dir.name;
     }
     return 'none';
}

Here, we query the Adv3 library for all existing directions (north, south, etc. as well as up, down, port…). For each direction, we then ask the Elevator for the TravelConnector that exists for this direction (in fact, it’s the one that is created by the setExit method). For the TravelConnector that matches self.(exitDir), we return the direction’s name. This is an interesting bit: self.(exitDir) actually dereferences our reference to a property against the Elevator instance (self), thus returning the actual property value. It turns out that property references are valuable tools. It’s actually possible to dereference this reference against a different object, as well.

Initializing the elevator

So far, we only have the elevator base class and a couple of methods. Where and when should these methods be called? How can we create additional objects, like the elevator doors, buttons and floor indicator? We’ll need some sort of entry point to the elevator code that gets called when the elevator is first constructed.

It turns out there is a special method that gets called by Adv3 at precisely this time: initializeThing. But where does it come from? The Elevator class is derived from Adv3’s Room class, which is in turn derived from the Thing class. It is the Thing class that provides the initializeThing method, which is intended to be overridden. We can thus initialize the Elevator like so:

class Elevator: Room
    // ... code we saw earlier ...

    initializeThing() {
        // Call default initializer first.
        inherited;

        setExit(0);
    }
}

Whenever a new Elevator is instantiated, the initializeThing method calls its base code using inherited (the Thing class does other initialization work that we must allow to happen). After that, we are free to call our own methods, so we call setExit to point the exit to the first room in the floors list.

The initialization object is also a good place to create other objects associated with the elevator.

Creating associated objects

Our elevator still needs buttons, a panel to group them on, a set of doors, and a floor indicator. And that’s just inside. On each floor that the elevator visits, we’ll also need to automatically create a set of doors, a call button, and a floor indicator.

Since our Elevator is a class and not an object, we cannot just say:

class Elevator {
    ....
}

+ floorIndicator: Fixture
    ...
;

Suppose we had more than one elevator in our game, which elevator would our floorIndicator belong to? To get this to work, we need to create classes for our other objects as well. Here, for instance, is the elevator floor indicator:

/*
 *   Elevator readout. When examined, it shows the floor the elevator
 *   is currently on, spelled out in ordinal form.
 */
class ElevatorReadout: CustomFixture
    name = 'floor indicator'
    vocabWords = '(elevator) (floor) indicator/readout'
    location = (_elevator)
    desc = "The readout shows that the elevator is currently on the
           <<_elevator.currentFloor == 0 ? 'ground' : spellIntOrdinal(_elevator.currentFloor)>> floor. "
    cannotTakeMsg = 'The floor indicator is firmly set into the elevator wall. '

    construct(elevator, room) {
        inherited;
        _elevator = elevator;
        location = room;
    }
;

Some of this code is standard Adv3 fare: defining the readout’s name, vocabulary words, and long description. It’s a CustomFixture, so we can provide a custom cannotTakeMsg as well. However, there are some new things here as well.

The readout class has a construct method. This method gets called when we create an instance of the indicator using the new keyword. This allows us to pass in some parameters as well: the elevator that the indicator belongs to, and the location it’s in (we will have an indicator inside the elevator, as well as indicators outside, one on each floor). We’re storing a reference to the owning Elevator instance in order to be able to store the floor number the elevator’s on in the indicator’s description.

Creating an instance of the indicator class is done so:

local indicator = new ElevatorReadout(self, floors[0]);

This will create an instance connected to self (assuming this code lives in our Elevator class), and places it in the startRoom (floor 0).

We can now add this to our Elevator initialization method to create a readout inside the elevator:

initializeThing() {
    // Call default initializer first.
    inherited;

    new ElevatorReadout(self, self);
    setExit(0);
}

Creating buttons

We’ll need to add a button inside the elevator for each floor that it can visit. We’ll make a button class first:

/*
 *   Elevator button. When clicked, calls moveToFloor on parent Elevator object.
 */
class ElevatorButton: Button, CustomFixture
    name = ('button marked ' + spellInt(_index))
    vocabWords = '(marked) button*buttons'
    location = (_elevator)
    desc = "The digit <<spellInt(_index)>> is backlit on the surface of the round button. "
    cannotTakeMsg = 'That\'s an integral part of the panel. '

    brightness = 1  // self-illuminating
    _elevator = nil // parent elevator
    _index = 0      // button index
    
    construct(elevator, index) {
        inherited;
        _elevator = elevator;
        _index = index;
 
        // Add button index as noun and adjective to this button's vocabulary (as words and as digits):
        cmdDict.addWord(self, spellInt(_index), &noun);
        cmdDict.addWord(self, '' + _index, &noun);
        cmdDict.addWord(self, spellInt(_index), &adjective);
        cmdDict.addWord(self, '' + _index, &adjective);
    }
 
    dobjFor(Push) {
        action() {
            _elevator.moveToFloor(_index);
        }
    }
;

This class has a construct method, just like the ElevatorReadout we’ve seen before. It stores a reference to the owning Elevator instance, as well as the 0-based index of the floor that the elevator goes to when the button is pressed. We also use the index to add the floor number to the button description, so that we don’t need to create many different button classes. Finally, we’ll use the index to add vocabulary words to the button. That way, it’s possible to refer to them as “button one” or “button marked two”.

We’ll need to add a button for each floor, so we add a loop to the Elevator initialization code (note index – 1, since TADS lists indices start at 1).

floors.forEachAssoc(function(index, value) {
    new ElevatorButton(self, index - 1);
});

When a button is pressed, we call the method moveToFloor on the elevator to actually move the elevator:

moveToFloor(index) {
    // Nothing happens if elevator is already on destination floor.
    if(index == currentFloor) {
        "Click. Nothing appears to happen. ";
    } else {
        "With a sudden shake, the elevator begins to <<currentFloor < index ? 'ascend' : 'descend'>> while faint music starts to play.
        Moments later, it slows down and stops. The music fades and the doors slide open with a friendly <q>ping</q>. The floor indicator
        now reads <q><<index>></q>. ";
    }
    setExit(index);
}

This method causes the elevator to move to the floor indicated by index. If the elevator is already on the destination floor, nothing happens. This is a good place to add some flavor text describing the elevator’s movement.

Adding floor indicators and a call button on each floor is done in a similar way.

Elevator doors

It remains to add doors to the elevator. It’s currently possible to leave the elevator and arrive on the correct floor, but it’s not possible to enter it. We could use exitDir to create the right connectors, but we’d like to go one step further and add doors to each floor. The doors are open when the elevator is present, and closed when it’s on a different floor.

First, we’ll add a base class for the doors. It’s not actually possible to open or close the doors (they are automated) so we’ll use a ThroughPassage. More importantly, our doors are special in the sense that while doors leading into the elevator have only once associated facet object, which is the internal doors. However, the internal doors have multiple associated facts, i.e. all external doors. The Adv3 Door implementation can’t handle this. The following code implements some basic door behaviour:

class ElevatorDoorBase: ThroughPassage
    name = 'doors'
    vocabWords = '(elevator) door*doors'
    isPlural = true

    construct(elevator) {
      inherited;
      _elevator = elevator;
    }

    cannotTakeMsg = 'The elevator doors are very decidedly a fixed part of the elevator. '
    cannotMoveMsg = 'The elevator doors can\'t be moved. '
    
    dobjFor(Open) {
      verify() {
        illogical('The elevator doors open automatically. ');
      }
    }
    dobjFor(Close) {
      verify() {
        illogical('The elevator doors close automatically when it begins to move. ');
      }
    }
;

The internal doors are simple to implement. From inside the elevator, they are always open. They merely set the Elevator instance as the passage’s destination:

class ElevatorInternalDoors: ElevatorDoorBase
    desc = "A pair of steel doors (currently open) is set in the <<getExitName()>> wall of the elevator. There's a panel next to the doors. "
    location = (_elevator)
    destination = (_elevator.getCurrentRoom())

    construct(elevator) {
      inherited(elevator);
    }
;

The external doors are different. These doors are open or closed depending on whether the elevator is present. We implement this behaviour by overriding the canTravelerPass and explainTravelBarrier methods which our class derives from ThroughPassage:

class ElevatorExternalDoors: ElevatorDoorBase
    desc = "A pair of steel elevator doors (currently <<isDoorOpen ? 'open' : 'closed'>>) is set in the wall. There's a button next to the doors. "
    isDoorOpen = (_elevator.getCurrentRoom() == self.location)
    destination = (_elevator)
    canTravelerPass (traveler) { return isDoorOpen; }
    explainTravelBarrier (traveler) { "The elevator doors are closed. "; }

    construct(elevator, room, conn) {
      inherited(elevator);
      location = room;
      room.(conn) = self;
    }
;

This class’s construction method takes a room and a connection argument. The room is the location that the external doors are placed in, while the connection is the direction that they open into. This must be the direction that is opposite the exitDir, so we’ll need a method to get opposite directions:

/*
 *   Returns connector property for opposite exit to elevator in specified
 *   room. That is, if the elevator opens to the south, it would return
 *   the 'north' connector for the room.
 */
getOppositeExit(room) {
    if(exitDir == &north) return &south;
    if(exitDir == &south) return &north;
    if(exitDir == &east) return &west;
    if(exitDir == &west) return &east;
    if(exitDir == &northeast) return &southwest;
    if(exitDir == &southwest) return &northeast;
    if(exitDir == &northwest) return &southeast;
    if(exitDir == &southeast) return &northwest;
    return nil;
}

Supporting UP and DOWN

It would be nice to be able to move the elevator by simply typing UP or DOWN, rather than PRESS BUTTON X every time. What I’d like to do is override the UP and DOWN commands to actually remap to pressing a button. The way I’ve found to do this is:

class Elevator
    ...
    initilializeThing() {
        ...
        up = new ElevatorTravelUp(self);
    }
;

ElevatorTravelUp:  NoTravelMessage
    travelDesc() {
         _elevator.moveUp();
    }
    construct(elevator) {
         _elevator = elevator;
    }
;

The Elevator room’s Up connector is now a NoTravelMessage . The connector’s travelDesc method is overridden to instruct the elevator to move up.

Please note that I am sure a more elegant method to do this must exist, but I haven’t found it yet.

Getting the code

The complete code for the Elevator component discussed in this article is available for download here.