Using Nested Rooms as Staging Locations

by Eric Eve

A fairly common type of puzzle in Interactive Fiction is where the player character needs to stand on one object (such as a chair or table) in order to reach another (such a window high up on a wall) in order to leave via a particular exit (in this case, the window). In the TADS 3 library (known as "adv3"), objects like tables and chairs that can stood on, sat on or lain on are generally implemented as NestedRooms, and a location that's used to reach a particular exit is a staging location. Using the two together can, however, be a little tricky; this article suggests some ways round the most common problems.

An erroneous example

To illustrate the problems, we'll start by coding an example that doesn't work, but instead exemplifies the kinds of problem a TADS 3 programmer faced with this situation could easily come up against. For the sake of illustration, we'll assume a simple two-room one-puzzle game. The player character starts in a bare room with nothing but a plain wooden chair for company. There's a window high up on one wall, but no other exit. The player character needs to leave the room via the window, but can only do that if he's standing on the chair.

The basic code we might use to set this up could be something like this:

startRoom: Room 'Start Room'
    "This is the starting room. There's a window high up on the north wall. "
    north = window
;

+ me: Actor
;

+ chair: Chair 'sturdy wooden chair' 'chair'
  "It's a sturdy wooden chair. "
;


+ window: OutOfReach, Door 'window' 'window'
  "Too high to reach from the floor, but big enough to squeeze through. "
  initNominalRoomPartLocation = defaultNorthWall
  canObjReachSelf(obj) { return obj.isIn(chair) && obj.posture == standing; }
;

outside: OutdoorRoom 'Outside'
  "You are now outside the start room. You could get back in through the window. "
;

+ window2: Door -> window 'window' 'window'
;

Here the most notable thing we've done is to make the window an OutOfReach and to define its canObjReachSelf method so that it can only be reached if the object trying to reach it (normally, the player character) is standing on the chair. If you try to compile and run the above example (together with the other code you need, such as the gameMain and versionInfo objects), you'll find it works after a fashion, but fails at the critical point. It won't let you open the window if you're just standing in the room or sitting on the chair, but it will let you open the window if you're standing on the chair. This is just as it should be. But if you attempt to go through the window while standing on the chair, the game will frustrate your efforts:

>go through window
(first getting off of the chair)
The window is too far away.

The problem here is that by default the library assumes that a traveler must be standing in the outermost room to be ready for travel, which immediately negates the player's attempts to stand the player character on the chair so he can go through the window. In the next section, we'll see how to fix this.

Fixing the problems — connectorStagingLocation and Platform

Trying to trace the routines that are applying the offending travel preconditions through the various class definitions through which they are directed may make your head spin, but in fact we can cut this gordian knot by simply adding the following to the definition of the window object:

connectorStagingLocation = chair

This tells the game that the actor needs to be in the chair rather than the enclosing room before attempting to travel; indeed it does allow the player character to stand on the chair and exit through the window:

>stand on chair
Okay, you're now standing on the chair.

>go through window
(first opening the window)

Outside
You are now outside the start room. You could get back in through the window.

But the code behaves rather less elegantly if the player character isn't already in the chair when the player types n or go through window:

>go through window
(first sitting on the chair)
The window is too far away.

Opinions may vary on whether the above response is acceptable, but the following is definitely perverse:

>sit on chair
Okay, you're now sitting on the chair.

>go through window
(first standing up, then sitting on the chair)
The window is too far away.

To cut a long story short, the problem here is that now that the chair is a staging location for the window, the game not only tries to move the player character into the chair prior to travel, but also applies the chair's default posture (which is sitting). It might seem that changing the chair's defaultPosture to standing would fix the problem, but unfortunately it doesn't, since Chair inherits BasicLocation's tryMakingTravelReady method, which, by default, performs an implicit STAND action. Rather than trying to fix a bunch of things on Chair to try to make it work as we need, it becomes easier to redefine the chair as a Platform and then restrict the postures that can be used with it:

+ chair: Platform 'sturdy wooden chair' 'chair'
  "It's a sturdy wooden chair. "
  allowedPostures = [sitting, standing]  
  obviousPostures = [sitting, standing]
  dobjFor(Board) asDobjFor(SitOn)
;
This now works fine in all cases; the only problem is that it might now work a bit too well, a problem we'll address in the next section.

Stopping it all being too helpful

The one problem with the game as we now have it is that it may be a bit too helpful to the player, since it will now take the player character through the window whether the player works out that s/he needs to use the window or not:

Start Room
This is the starting room. There's a window high up on the north wall.

You see a chair here.

>n
(first standing on the chair, then opening the window)

Outside
You are now outside the start room. You could get back in through the window.

Much the same happens if the player character first sits on the chair:

>sit on chair
Okay, you're now sitting on the chair.

>n
(first standing up, then opening the window)

Outside
You are now outside the start room. You could get back in through the window.

This doesn't exactly leave the player with much to work out. Of course, you may feel that the puzzle here is so much of a no-brainer that solving it implicity is just fine. It's more likely, though, that you'd want the player to figure out that the player character needs to stand on the chair at least for the first time, even if it's reasonable for the necessary actions to be carried out implicitly thereafter.

One way to achieve this might be to have an object not counted as a staging location until it's been stood on. This means that the player character will have to stand on the chair via an explicit command at least once before it will function as a staging location for the window. We can achieve that by modifying the connectorStagingLocation property of the window so that the chair only becomes the staging location for the window once it has been stood on:

 connectorStagingLocation = (chair.hasBeenStoodOn ? chair : nil)

At the same time, we need to ensure that the chair keeps track of when it has been stood on:

modify Platform
  hasBeenStoodOn = nil
  dobjFor(StandOn)
  {
    action()
    {
        inherited;
        hasBeenStoodOn = true;
    }
  }
;

The previous modification could have been made on the chair object rather than the Platform class, but by putting it on the class we automatically cater for the possibility that more than one object might be used as a platform to reach the window (a possibility to which we shall return in the next section). At the moment, the problem we're left with is that the program is still a little too obliging if the player character first sits on the chair:

Start Room
This is the starting room. There's a window high up on the north wall.

You see a chair here.

>sit on chair
Okay, you're now sitting on the chair.

>n
(first standing up, then opening the window)

Outside
You are now outside the start room. You could get back in through the window.

One way to fix this is to override the actorTravelPreCond on the window so that it only returns a travel precondition if the staging location has first been stood on. That way, if the player character sits on the chair without having first stood on it, no implicit actions will be triggered and the window will remain out of reach. Once again, we may as well write this code so that it works for the most general case, rather than with one specific staging location object:

  actorTravelPreCond(actor)
  {
     local loc = connectorStagingLocation;
     if(loc && loc.hasBeenStoodOn)
       return inherited(actor);
     else
       return [];
  }

This now works fine, apart from one small detail. If the player character sits on the chair and the player issues a stand command it will be taken as "stand on chair" rather than "get out of chair", so that the player character will then be able to go through the window with a n or go through window command even if the player hadn't in fact intended standing on the chair as a means of reaching the window. One way of fixing this might be to make the chair reinterpret a stand command as a get out command until the chair has been stood upon. We could do this in the chair's roomBeforeAction method:

 roomBeforeAction()
  {
     if(gActionIs(Stand) && !hasBeenStoodOn)
       replaceAction(GetOutOf, self);
  }

Now everything should behave as we want it.

Multiple staging locations

So far we have dealt with the case where an exit can be reached by one and only one staging location, in this case a chair. But in a real game there may be several objects the player character could use to reach the window. For example, we might have a table in the room that would work just as well:

+ table: Platform, Heavy 'heavy table' 'heavy table'
  "It's against the wall; sturdy enough to stand on and too heavy to move. "
  initSpecialDesc = "A heavy table rests against the north wall. "
;

There are basically two ways we could have the window identify which objects were suitable for use as staging locations when stood on. Either the window could keep a list of such objects (in this case, the table and chair) or the objects could identify themselves by means of some custom property, such as isWindowStage, which would need to be set to true on all objects that can be used to reach the window (here, the table and chair). Either method should be perfectly workable, but the second may be slightly easier because:

  • The resulting coding pattern is slightly simpler.
  • When adding a new object that could be used as a staging location for the window (e.g. a step-ladder found in a different location) it's probably easier to be able to set a property on that new object there and then rather than having to look elsewhere in the code for the window object and add the new object name to the window's list of possible staging locations.

We shall accordingly use the second method here. The first thing we need to do is to allow any object that has isWindowStage set to true to be used as a connectorStagingLocation for the window. The problem here is that TravelConnector.connectorStagingLocation is ordinarily a property that contains a single value; how, then, can it be made to work with multiple staging locations? The trick is to turn window.connectorStagingLocation into a method that returns the actor's current roomLocation if and only if that roomLocation is a valid staging location for the window:

  connectorStagingLocation
  {
     local loc = gActor.roomLocation;
     return loc.isWindowStage  && loc.hasBeenStoodOn ? loc : nil;
  }

The other change needed is to make the window reachable from any valid object on which the actor is standing. To achieve this we need to tweak the window's canObjReachSelf method:

  canObjReachSelf (obj) 
  { 
     return obj.roomLocation.isWindowStage && gActor.posture == standing; 
  }  

Note that though it might be tempting simply to test for gActor.roomLocation.ofKind(Platform), rather than adding a custom property for the purpose, in the general case this may not be such a good idea. Not every Platform is necessarily high enough to enable the window to be reached; for example, you might later add a rubber mat object to the game, and implement the mat as a Platform so it can be stood on, but standing on the mat shouldn't enable the player character to reach the window. You're much more likely to avoid the unrealistic use of mat-like objects if you use a custom property from the outset, even if you don't have any mat-like objects in mind when you're coding your NestedRoom as a staging location puzzle.

Finally, one side-effect of making our window allow either the chair or the table as a staging location is that it won't choose either by default, so even if the player character has used one or the other of them as a route to the window before, a subsequent n or go through window command will result in the response "The window is too far away" unless the player character is already on either the chair or the table. In practice this may not matter much, since should the player character return to this situation, the player would know well enough second time round what to do to get him through the window, and having to type an additional command (stand on chair or stand on table) at that point would not be that much of a bother. However, unlike the chair (which could be carried away), in this instance we can be sure that the table will always be present to be used as a staging location for the window (since it can't be moved), so if we wanted to automate the player character's use of the table once it had been used the first time we could add the following code to the start of window.connectorStagingLocation:

  if(table.hasBeenStoodOn && gActor.roomLocation == getOutermostRoom)
       return table;  

Alternatively, a more general test would be, if the player character is in the outermost room, to look through all the room contents for an object that's been stood on and that's a window staging location. The connectorStagingLocation for the window then becomes:

  connectorStagingLocation
  {
     local loc;
     if(gActor.roomLocation == getOutermostRoom)
         foreach(loc in getOutermostRoom.allContents)
         {
            if(loc.hasBeenStoodOn && loc.isWindowStage)
              return loc;
         }
     loc = gActor.roomLocation;
     return loc.isWindowStage  && loc.hasBeenStoodOn ? loc : nil;
  }

The nice thing about this is that if the player character stands on either the chair or the table and then goes through the window, the next time the player issues a go through window command when the player character is directly in the room, the game will send the player character through the window by whatever route was used before. If the player has previously used both the chair and the window, however, the game will arbitrarily choose one or the other (depending which one it comes across first).

Summary and final remarks

It may seem that setting up the relatively straightforward and common puzzle of using a platform or chair-like object to reach an otherwise out-of-reach exit is something quite complicated. How complicated it needs to be depends on precisely what you want to achieve; not every game will require all the steps suggested here.

In summary, the steps you may need to take are:

  1. Define your out-of-reach exit (normally some kind of TravelConnector such as a Door or ThroughPassage) as an OutOfReach.
  2. Define this exit's canObjReachSelf method to return true only if the actor (the obj parameter of this method) is in an object than can be used as a staging location (such as a table, chair, or ladder). The easiest way to do this may be to test a custom property (such as isWindowStage) on the player character's current roomLocation. You may also want to enforce the condition that the actor is standing.
  3. Define the exit's connectorStagingLocation so that:
    • If there is only one object in the game that could ever be used to reach the exit, connectorStagingLocation is simply a property containing the identifier for this object.
    • If there are several objects that could potentially be used as staging locations for this exit, connectorStagingLocation is a method that returns the actor's current roomLocation if this is a valid staging location for this exit, or else returns nil.
    • Optionally, you may want to make this more sophisticated by testing for whether the actor's current location has (say) been stood on before, or whether there's a platform anywhere in the current room that's been stood on before and could be used as a staging location. See above for examples of how to do this.
  4. Add a custom property, such as isWindowStage, to any objects you want to be usable as staging locations for this exit, and set its value to true for these objects.
  5. Use Platform rather than Chair for any chair-like option you want to use as a staging location in this way, and tweak properties such as allowedPostures to make the Platform behave in a more chair-like way (but see further on this below).

It may be useful to illustrate this by listing the finished form of the code we developed above:

startRoom: Room 'Start Room'
    "This is the starting room. There's a window high up on the north wall. "     
     north = window
;

+ me: Actor
;

+ table: Platform, Heavy 'heavy table' 'heavy table'
  "It's against the wall; sturdy enough to stand on and too heavy to move. "
  initSpecialDesc = "A heavy table rests against the north wall. "
  isWindowStage = true
;

+ chair: Platform 'sturdy wooden chair' 'chair'
  "It's a sturdy wooden chair. "
  isWindowStage = true  
  allowedPostures = [sitting, standing]  
  obviousPostures = [sitting, standing]
  dobjFor(Board) asDobjFor(SitOn)
  roomBeforeAction()
  {
     if(gActionIs(Stand) && !hasBeenStoodOn)
       replaceAction(GetOutOf, self);
  }
;


+ window: OutOfReach, Door 'window' 'window'
  "Too high to reach from the floor, but big enough to squeeze through. "
  initNominalRoomPartLocation = defaultNorthWall
  
  canObjReachSelf (obj) 
  { 
     return obj.roomLocation.isWindowStage && gActor.posture == standing; 
  }  
  
  connectorStagingLocation
  {
     local loc;
     if(gActor.roomLocation == getOutermostRoom)
         foreach(loc in getOutermostRoom.allContents)
         {
            if(loc.hasBeenStoodOn && loc.isWindowStage)
              return loc;
         }
     loc = gActor.roomLocation;
     return loc.isWindowStage  && loc.hasBeenStoodOn ? loc : nil;
  }

  
  actorTravelPreCond(actor)
  {
     local loc = connectorStagingLocation;
     if(loc && loc.hasBeenStoodOn)
       return inherited(actor);
     else
       return [];
  }
;

outside: OutdoorRoom 'Outside'
  "You are now outside the start room. You could get back in through the window. "
;

+ window2: Door -> window 'window' 'window'
;

modify Platform
  hasBeenStoodOn = nil
  dobjFor(StandOn)
  {
    action()
    {
        inherited;
        hasBeenStoodOn = true;
    }
  }
;

There's just a final couple of points to consider. The first is that the approach taken here with the chair is to start with a Platform and make it more Chair-like. In some cases it may work better to start with a Chair and make it more Platform-like; either way when a chair is used as a staging location in the type of case we've been exploring it seems necessary to override quite a bit of the default behaviour it inherits from its class. If, however, you do decide to experiment by starting with a Chair and modifying its behaviour you'll need to remember to apply the hasBeenStoodOn modification not to the Platform class (as above) but to BasicChair or NestedRoom.

The second is that the test we applied to see if a player had already worked out how to use an object as a staging location was simply whether or not it had already been stood on. This worked reasonably well in our sample game, but it was not strictly perfect: it's possible that a player character could stand on the table and then get off again without going through the window, so that a subsequent north or go through window command would then work without the player expecting it to. This is admittedly unlikely, since it's unlikely that it would occur to players to stand on the chair or table without it also occurring to them that this is the way to reach the window. It's also difficult to think of a better test, since if we tested for the player character actually going through the window after standing on the chair or table we'd create a Catch-22 situation in which neither the chair nor the table could be used as a staging location until it had been used as one previously. There may be a way round this, but it may also be more trouble than it's worth to find it. However, depending on your game, you may need to adjust the stood-on condition if, for example, your staging locations can be stood on by other actors, or if you use a NestedRoom on which the player character can sit or lie to reach the exit. In particular, if, as may be well be the case in your own game, the platform object that's to be used as a staging location first needs to be brought to the OutOfReach TravelConnector that the player's trying to get through, you'd probably want to apply a stricter test before setting its hasBeenStoodOn flag (or its equivalent in your game); for example, you might want to set it only if the player character stands on the staging object when the staging object is in the same location as the otherwise inaccessible exit.