4
\$\begingroup\$

As a fun project with my wife, I decided to make a small text adventure game à la Zork. Instead of using available engines, I decided to roll my own. It won't be that hard since there are no graphics.

The piece of code I submit for review is a class representing a room in the game. The processInput method takes in the words typed by the player and determines the appropriate response.

I am concerned with the heavy use of nested conditionals and am wondering if there is a design pattern, or perhaps a different data structure, that could make the code cleaner.

import { Room, Output } from "../room";
import { GameState } from "../game-controller";

export class CenterRoom extends Room {
    roomName = 'centerRoom';

    roomState = {
        openedBox: false,
        keyTaken: false,
        northDoorUnlocked: false
    }

    processInput(terms: string[], gameState: GameState): Output {
        let output: Output = {
            text: '',
            moveTo: '',
            inventory: {
                add: [],
                remove: []
            }
        }

        // LOOK
        if (terms.includes('look')) {
            if (terms.includes('around')) {
                if (this.roomState.northDoorUnlocked) {
                    output.text = 'You are standing in an empty square room. Each of the four walls has a door.\n' +
                        'You can see a small box on the floor in the middle of the room.\n' +
                        'The door on the North wall is opened and leads to another room steeped in darkness.';
                    return output;
                }
                output.text = 'You are standing in an empty square room. Each of the four walls has a door.\n' +
                    'You can see a small box on the floor in the middle of the room.';
                return output;
            }
            if (terms.includes('box')) {
                if (this.roomState.openedBox && !this.roomState.keyTaken)
                    output.text = 'It is a plain wooden box with a lid. Inside, you see a small metal key.';
                else
                    output.text = 'It is a plain wooden box with a lid.'
                return output;
            }
            output.text = `Sorry, you can't look at that.`;
            return output;
        }

        // OPEN
        if (terms.includes('open')) {
            if (terms.includes('box')) {
                this.roomState.openedBox = true;
                output.text = 'You open the box. Inside, you see a small metal key.'
                return output;
            }
            output.text = `Sorry, you can't open that.`;
            return output;
        }

        // TAKE
        if (terms.includes('take')) {
            if (terms.includes('key') && this.roomState.openedBox) {
                if (this.roomState.keyTaken)
                    output.text = `You have already taken the key`;
                else {
                    this.roomState.keyTaken = true;
                    output.text = 'You take the key.';
                    output.inventory.add = ['a small metal key']
                }
                return output;
            }
            output.text = `Sorry, you can't take that.`
            return output;
        }

        // UNLOCK
        if (terms.includes('unlock')) {
            if (terms.includes('door') && this.roomState.keyTaken) {
                if (terms.includes('north')) {
                    if (this.roomState.northDoorUnlocked)
                        output.text = 'You have already unlocked the North door';
                    else
                        output.text = 'You unlock and open the north door.\n' +
                            'The room on the other side is steeped in darkness.'
                    this.roomState.northDoorUnlocked = true;
                    return output;
                }
                output.text = `Please identify the door you wish to unlock?`
                return output;
            } else {
                output.text = `Sorry, you can't unlock that.`
                return output;
            }
        }

        // WALK
        if (terms.includes('walk')) {
            if (terms.includes('north') && this.roomState.northDoorUnlocked) {
                output.moveTo = 'northRoom';
                return output;
            }
        }

        return super.processInput(terms, gameState);
    }
}

\$\endgroup\$
  • 2
    \$\begingroup\$ I agree with your assessment that the nested if statements are a problem. I would probably separate the command code from the room and just leave lists of "lookables" / "takeables" / "unlockables" / etc. with their accompanying texts and side-effects in the room class. \$\endgroup\$ – Jonathan Jan 17 '18 at 12:48
  • \$\begingroup\$ @Jonathan, can you describe what you identify as the command code that could be separated? \$\endgroup\$ – snowfrogdev Jan 17 '18 at 15:39
2
\$\begingroup\$

Flexible, but not scalable

Having to write input handling for each room like this lets you do anything you want, but it doesn't seem very scalable:

  • Some text is duplicated several times. What if you need to fix a spelling error, or want to adjust some description? It's easy to fix it in one place and forget about another.
  • Likewise, verbs will likely be duplicated across multiple rooms, which increases the possibility of ending up with different verbs in different rooms, and that can make the game more difficult (and frustrating) to control.
  • What if you want to support aliases such as inspect box or go north?
  • It's difficult to ensure that all possible (meaningful) interactions are accounted for. What about look at north door or open north door?

These problems will likely get worse for more complicated rooms.

Alternative: a data-driven approach

As Jonathan already mentioned, a data-driven approach, if properly designed, should be easier to manage and result in a more robust and consistent experience. For example, your room could (roughly) be described by the following data:

new Room(
    name = 'center room',
    description = 'empty square room. Each of the four walls has a door.'
    items = [
        new Item(
            name = 'box',
            description = 'plain wooden box with a lid',
            canBePickedUp = false,
            isContainer = true,
            isClosed = true,
            items = [
                new Item(
                    name = 'metal key',
                    description = 'small metal key',
                    unlocks = 'north door'),
            ]),
    ],
    exits = [
        new Exit(
            name = 'north door',
            description = 'the door leads to another room steeped in darkness'
            isLocked = true,
            destination = 'dark room'),
    ]);

With this, all common verbs can be handled by a single function. Most verbs involve a few common steps, after all:

  • a name lookup: does the current room contain an item or exit with this name?
    • if none, display a failure message.
    • if multiple, let the player choose from a list of matching things.
  • some verb-specific checks:
    • take is only valid for items, and only those that are pick-uppable.
    • look displays the description of a thing, and possibly their status (open/closed) and what they contain (container items) or what they lead to (exits) if applicable.
    • unlock only works if the player holds an item that can unlock the specified exit.
    • and so on.

One benefit is that, as soon as you add an item to a room, all possible interactions with it will 'just work' - it can be looked at, taken, opened, and so on, without any additional code. This even works for items that are dropped by the player, regardless of where they originally came from. It's now also relatively easy to add other creatures that can interact with items and navigate the world.

\$\endgroup\$
  • \$\begingroup\$ I love it. This approach also has the benefit of making scripting a room much easier... I could probably get my wife, a non programmer, to chip in with that work. One thing I'm wondering though, is how to deal with state dependent actions and descriptions. As an example, how would you handle a room's description being different depending on whether the player has a certain item in his inventory, or weather he has done a certain action previously in another room. \$\endgroup\$ – snowfrogdev Jan 18 '18 at 23:36
  • 1
    \$\begingroup\$ It depends. Maybe you can introduce a common mechanism, such as only being able to look around in dark rooms when there's a light-emitting item (either in the room or held by the player). Or introduce usable things that can affect other things (possibly in other rooms). You could also allow functions inside your data: description = (gameState) => { ...return description... }. \$\endgroup\$ – Pieter Witvoet Jan 19 '18 at 0:16
  • 1
    \$\begingroup\$ I think that is a good suggestion. You can introduce a standardized way of describing room features, like exits, objects, NPCs and their states. You can get these from the entities in the room then \$\endgroup\$ – Jonathan Jan 20 '18 at 14:48

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.