A world of objects#

An object orientated text adventure game#

Back in the late 80s and early 90s a popular type of game on micro-computers, such as the Commodore 64 and ZX Spectrum, was the text adventure. These were games where a player had to navigate and solve puzzles in a game world with only text descriptions of locations as their guide!

These types of games lend themselves nicely to abstraction offered in object orientated methods of design (and python command line interfaces). For example, in a very simple implementation we could have a class of type Game that accepts a limited number of commands (for example, “n” to represent ‘go north’ or “look” to represent ‘look and describe the room’.). The Game would encapsulate one or more Room objects that describes a location and contain exits to other Room objects.

A text hospital adventure#

Before we look at creating instances of objects, let’s first take a look at a basic game. In this game we are limited to just exploring. We can move around a small hospital setting by issuing commands to move.

Imports#

The function get_hospital_textworld returns a TextWorld object. It is setup for the hospital text adventure we can play.

from text_adventure.basic_game import get_hospital_textworld
adventure = get_hospital_textworld()
print(adventure)
TextWorld(name='text hospital world', n_rooms=4, legal_exits=['n', 'e', 's', 'w'],
	legal_commands=['look', 'quit'],
	current_room=Room(name='reception', description='You are stood in the', n_exits=1),
	max_moves=5)

A function to play the game#

def play_text_adventure(adventure):
    '''
    Play your text adventure!
    '''
    print('********************************************')
    print(adventure.opening, end='\n\n')
    print(adventure.current_room.describe())
    
    while adventure.active:
        user_input = input("\nWhat do you want to do? >>> ")
        response = adventure.take_action(user_input)    
        print(response)
    print('Game over!')
#uncomment to play
#play_text_adventure(adventure)

Text Hospital - let’s build it using Room objects#

We will start by creating a text based hospital world. The game will be comprised by network of four Room objects: a reception, a corridor, a ward and the operating theatre.

Imports#

from text_adventure.basic_game import TextWorld, Room

Setting and getting attributes and methods#

Each object we will instantiate has its own attribute and methods. You have come across these before in a different context.

An attribute represents a variable that is local to the object. For example, each Room has a description attribute. You can access the attribute by following the object name with a ‘.’ and then name of the attribute.

A method is the same as a function, but it is again attached to the object. For example, objects of type Room have a add_exit(room, direction) method that allows you to pass in another Room object and a direction of travel from the current room (these are stored in the attribute exit - a dict).

# Let's instantiate some Room objects to represent our network of rooms

#start fo the game = reception
reception = Room(name="reception")
reception.description = """You are stood in the busy hospital reception.
To the south, east and west are wards with COVID19 restricted areas. 
To the north is a corridor."""

corridor = Room(name='corridor')
corridor.description = """A long corridor branching in three directions. 
To the north is signposted 'WARD'.  
The south is  signposted 'RECEPTION'.
The east is signposted 'THEATRE'"""

ward = Room(name="ward")
ward.description = """You are on the general medical ward. There are 10 beds
and all seem to be full today.  There is a smell of disinfectant. 
The exit is to the south"""

theatre = Room(name="theatre")
theatre.description = """You are in the operating theatre.  Its empty today as
all of the elective operations have been cancelled.
An exit is to the west."""

#add the exits by calling the add_exit() method  
reception.add_exit(corridor, 'n')
corridor.add_exit(reception, 's')
corridor.add_exit(ward, 'n')
corridor.add_exit(theatre, 'e')
ward.add_exit(corridor, 's')
theatre.add_exit(corridor, 'w')

rooms_collection = [reception, corridor, ward, theatre]
print(reception)
Room(name='reception', description='You are stood in the', n_exits=1)
#let's take a look at the description of reception via its attribute
print(reception.description)
You are stood in the busy hospital reception.
To the south, east and west are wards with COVID19 restricted areas. 
To the north is a corridor.
print(reception.describe())
You are stood in the busy hospital reception.
To the south, east and west are wards with COVID19 restricted areas. 
To the north is a corridor.
#reception only has a single exit
reception.exits
{'n': Room(name='corridor', description='A long corridor bran', n_exits=3)}
#corridor has three exits
corridor.exits
{'s': Room(name='reception', description='You are stood in the', n_exits=1),
 'n': Room(name='ward', description='You are on the gener', n_exits=1),
 'e': Room(name='theatre', description='You are in the opera', n_exits=1)}
#create the game room
adventure = TextWorld(name='text hospital world', rooms=rooms_collection, 
                      start_index=0)

#set the legal commands for the game
#directions a player can move and command they can issue.
adventure.legal_commands = ['look', 'quit']
adventure.legal_exits = ['n', 'e', 's', 'w']

adventure.opening = """Welcome to your local hospital! Unfortunatly due to the 
pandemic most of the hospital is under restrictions today. But there are a few
areas where it is safe to visit.
"""
print(adventure)
TextWorld(name='text hospital world', n_rooms=4, legal_exits=['n', 'e', 's', 'w'],
	legal_commands=['look', 'quit'],
	current_room=Room(name='reception', description='You are stood in the', n_exits=1),
	max_moves=5)
#play_text_adventure(adventure)

How to build a class in python#

Now that we have learnt how to instantiate objects and use frameworks of python classes we need to learn how to code a class.

We will start with a very simple example and then take a look at the Room class from our text adventure framework.

The world’s most simple python class#

We declare a class in python using the class keyword.

#the world's most simple `Patient` class!
class Patient: 
    pass
#create an object of type `Patient`
new_patient = Patient()

#in python we can dynamically add attributes (and methods)
new_patient.name = 'Tom'
new_patient.occupation = 'data scientist'

print(new_patient.name)
Tom

Most classes have a constructor __init__() method#

In most classes I code, I include an __init__() method. It is the method called when you create an instance of the class. This is sometimes called a contructor method, as it is used when an object is constructed. A simple example is below.

Note the use of the argument self. This is a special method parameter that must be included as the first parameter in all methods in your class. self is the way an object internally references itself. If you need your class to have an attribute called name then you refer to it as self.name. This means that any method in the class can access the attribute.

class Patient:
    def __init__(self):
        self.name = 'joe bloggs'
        self.occupation = 'coder'
patient2 = Patient()
print(patient2.name)
print(patient2.occupation)
joe bloggs
coder

including parameters in the constructor method#

class Patient:
    def __init__(self, name, occupation, age):
        self.name = name
        self.occupation = occupation
        self.age = age 
        
        #example of an attribute that is not set by the constructor
        #but still needs to be initialised.
        self.triage_band = None
patient3 = Patient('Joe Bloggs', 'ex-coder', 87)
print(patient3.name)
print(patient3.occupation)
print(patient3.age)
print(patient3.triage_band)
Joe Bloggs
ex-coder
87
None

The Room class from the Hospital Basic Text Adventure#

class Room:
    '''
    Encapsulates a location/room within a TextWorld.

    A `Room` has a number of exits to other `Room` objects
    '''
    def __init__(self, name):
        self.name = name
        self.description = ""
        self.exits = {}             

    def add_exit(self, room, direction):
        '''
        Add an exit to the room

        Params:
        ------
        room: Room
            a Room object to link 

        direction: str
            The str command to access the room
        '''
        self.exits[direction] = room

    def exit(self, direction):
        '''
        Exit the room in the specified direction

        Params:
        ------
        direction: str
            A command string representing the direction.
        '''
        if direction in self.exits:
            return self.exits[direction]
        else:
            raise ValueError()

    def describe(self):
        '''
        Describe the room to a player
        '''
        return self.description

The TextWorld class#

class TextWorld:
    '''
    A TextWorld encapsulate the logic and Room objects that comprise the game.
    '''
    def __init__(self, name, rooms, start_index=0, max_moves=5):
        '''
        Constructor method for World

        Parameters:
        ----------
        rooms: list
            A list of rooms in the world.

        start_index: int, optional (default=0)
            The index of the room where the player begins their adventure.

        '''
        self.name = name
        self.rooms = rooms
        self.current_room = self.rooms[start_index]
        self.legal_exits = ['n', 'e', 's', 'w']
        self.legal_commands =['look', 'quit']
        self.n_actions = 0
        
        #true while the game is active.
        self.active = True
        
        #limit the number of move before the game ends.
        self.max_moves = max_moves

    def take_action(self, command):
        '''
        Take an action in the TextWorld

        Parameters:
        -----------
        command: str
            A command to parse and execute as a game action

        Returns:
        --------
        str: a string message to display to the player.
        '''

        #no. of actions taken
        self.n_actions += 1
        if self.n_actions == self.max_moves:
            self.active = False

        #handle action to move room
        if command in self.legal_exits:
            msg = ''
            try:
                self.current_room = self.current_room.exit(command)
                msg = self.current_room.description
            except ValueError:
                msg = 'You cannot go that way.'
            finally:
                return msg

        #split into array
        parsed_command = command.split()

        if parsed_command[0] in self.legal_commands:
            #handle command
            if parsed_command[0] == 'look':
                return self.current_room.describe()
            elif parsed_command[0] == 'quit':
                self.active = False
                return 'You have quit the game.'
        
        else:
            #handle command error
            return f"I don't know how to {command}"

More complex OO frameworks#

Classes are customised by Inheritance#

A note of caution: Over time I’ve learnt to be somewhat wary of complex multiple inheritance structures in any programming language. Inheritance brings huge benefits in terms of code reuse, but you also need to learn good OO design principals in order to avoid unexpected dependencies in your code and avoid major rework due to small changes in a projects requirements.

Let’s work with a simple patient based example first. In the code below we create a class called Patient. The class StrokePatient inherits from Patient. In python’s OO terminology we called Patient a super class and StrokePatient a subclass. StrokePatient inherits all of the super class methods and attributes. In effect StrokePatient is-a specialisation of Patient. The code below illustrates how this works.

import random

#`Patient` is refered to as a 'super class'
class Patient:
    def __init__(self, name, occupation, age):
        self.name = name
        self.occupation = occupation
        self.age = age 
        self.triage_band = None
        
    def set_random_triage_band(self):
        '''set a random triage band 1 - 5'''
        self.triage_band = random.randint(1, 5)
#subclass `StrokePatient`
class StrokePatient(Patient):
    def __init__(self, name, occupation, age, stroke_type=1):
        #call the constructor of the superclass
        super().__init__(name, occupation, age)
        self.stroke_type = stroke_type
        
#create an instance of a `StrokePatient` and use inherited methods
random.seed(42)

new_patient = StrokePatient('Joe Blogs', 'Teacher', 45)
new_patient.set_random_triage_band()
print(new_patient.name)
print(new_patient.triage_band)
Joe Blogs
1

Has-a: An alternative OO design.#

In the previous example StrokePatient is-a specialisation of Patient. An alternative way to frame this design problem as one of object composition where a StrokeCase has-a Patient.

This approach provides slightly more flexibility than direct inheritance. For example you can pass in a different type of object as long as it implements the same interface. It does, however, require a bit more code to setup.

The code below introduces a bit of OOP syntax called a getter property for accessing StrokeCase.triage_band. Externally to the object triage_band appears and behaves very much like a class attribute. However in the implementation we can see that it isn’t and we can use code and logic if we want. Here we are simply calling the Patient.triage_band.

#this time patient is a parameter instead of a superclass
class StrokeCase:
    def __init__(self, patient, stroke_type=1):
        self.patient = patient
        self.stroke_type = stroke_type
        
    @property
    def triage_band(self):
        return self.patient.triage_band
        
    def set_random_triage_band(self):
        self.patient.set_random_triage_band()
random.seed(101)
new_patient = Patient('Joe Bloggs', 'Teacher', 45)
stroke = StrokeCase(new_patient)
stroke.set_random_triage_band()
print(stroke.triage_band)
5

Using inheritance to allow a Room and a game player to hold inventory#

Now that we understand inheritance and object composition let’s take look at a modified text adventure hospital. In this version of the code we introduce the concept of inventory. Have a go at playing the game. You will find items of inventory scattered around the rooms in the game. You can interact with these items by issuing the get, drop, and ex (examine) commands. To view what inventory you are holding issue the inv command.

from text_adventure.advanced_game import hospital_with_inventory
game = hospital_with_inventory()
game
TextWorld(name='text hospital world', n_rooms=4, legal_exits=['n', 'e', 's', 'w'],
	legal_commands=['look', 'inv', 'get', 'drop', 'ex', 'quit'],
	current_room=Room(name='reception', description='You are stood in the', n_exits=1, n_items=1))
#play_text_adventure(game)

OOP Implementation#

The game is now a little bit more fun than before (although that isn’t saying much). Let’s take a look at how the code is implemented.

  • An item of inventory is represented by a class called InventoryItem.

  • A Room and a TextWorld is-a InventoryHolder

  • An object that is-a InventoryHolder holds references to InventoryItem

In other words we have used inheritance to add some abilities to Room and TextWorld to hold multiple instances of InventoryItem. First we define two new classes: InventoryItem and InventoryItemHolder.

class InventoryItem:
    '''
    An item found in a text adventure world that can be picked up
    or dropped.
    '''
    def __init__(self, short_description):
        self.name = short_description
        self.long_description = ''
        self.aliases = []


    def add_alias(self, new_alias):
        '''
        Add an alias (alternative name) to the InventoryItem.
        For example if an inventory item has the short description
        'credit card' then a useful alias is 'card'.

        Parameters:
        -----------
        new_alias: str
            The alias to add.  For example, 'card'

        '''
        self.aliases.append(new_alias)
class InventoryHolder:
    '''
    Encapsulates the logic for adding and removing an InventoryItem
    This simulates "picking up" and "dropping" items in a TextWorld 
    '''
    def __init__(self):
        #inventory just held in a list interally
        self.inventory = []

    def list_inventory(self):
        '''
        Return a string representation of InventoryItems held.
        '''
        msg = ''
        for item in self.inventory:
            msg += f'{item.name}\n'

        return msg

    def add_inventory(self, item):
        '''
        Add an InventoryItem
        '''
        self.inventory.append(item)

    def get_inventory(self, item_name):
        '''
        Returns an InventoryItem from Room.
        Removes the item from the Room's inventory

        Params:
        ------
        item_name: str
            Key identifying item.

        Returns
        -------
        InventoryItem

        '''
        selected_item, selected_index = self.find_inventory(item_name)
         
        #remove at index and return
        del self.inventory[selected_index]
        return selected_item

    def find_inventory(self, item_name):
        '''
        Find an inventory item and return it and its index 
        in the collection.
        
        Returns:
        -------
        Tuple: InventoryItem, int
        
        Raises:
        ------
        KeyError
            Raised when an InventoryItem without a matching alias.
        '''
        selected_item = None
        selected_index = -1
        for index, item in zip(range(len(self.inventory)), self.inventory):
            if item_name in item.aliases:
                selected_item = item
                selected_index = index
                break
    
        if selected_item == None:
            raise KeyError('You cannot do that.')  

        return selected_item, selected_index

In summary, InventoryItem is just a textual description of an item plus a collection of aliases. This allows a game player to interact with the item through a number of names. For example, a “banana” might be aliased as ‘fruit’ or ‘banana’.

InventoryHolder provides all the functionality to manage a collection of InventoryItem objects. So we can add, remove, and list all, items from a InventoryHolder object’s internal collection.

Any class that inherits from InventoryHolder can now manage a collection of items that they ‘own’. Neat! In our enhanced game both Room and TextWorld inherit this functionality. The TextWorld object here used as a surrogate for a player. The modified code listing for Room is provided below.

class Room(InventoryHolder):
    '''
    Encapsulates a location/room within a TextWorld.

    A `Room` has a number of exits to other `Room` objects

    A `Room` is-a type of `InventoryHolder`
    '''
    def __init__(self, name):
        '''
        Room constructor
        '''
        self.name = name
        self.description = ""
        self.exits = {}
        #MODIFICATION
        super().__init__()
                

    def add_exit(self, room, direction):
        '''
        Add an exit to the room

        Params:
        ------
        room: Room
            a Room object to link 

        direction: str
            The str command to access the room
        '''
        self.exits[direction] = room

    
    def exit(self, direction):
        '''
        Exit the room in the specified direction

        Params:
        ------
        direction: str
            A command string representing the direction.
        '''
        if direction in self.exits:
            return self.exits[direction]
        else:
            raise ValueError()

    def describe(self):
        msg = self.description
        ### MODIFICATION
        if len(self.inventory) > 0:
            msg += '\nYou can also see:\n'
            msg += self.list_inventory()
        return msg

End#