How to Write a Text Adventure in Python Part 3: Player Action

This is an abbreviated version of the book Make Your Own Python Text Adventure.

So far we’ve created a world and filled it with lots of interesting things. Now we’re going to create our player and provide ways for the player to interact with the world. This will probably be the most conceptually challenging part of the game, so you may want to re-read this section a few times.

The Player

Time for a new module! Create player.py and include this class:

import items

class Player():
    def __init__(self):
        self.inventory = [items.Gold(15), items.Rock()]
        self.hp = 100
        self.location_x, self.location_y = world.starting_position
        self.victory = False

    def is_alive(self):
        return self.hp > 0

    def print_inventory(self):
        for item in self.inventory:
            print(item, '\n')

Now you can see some of the concepts that we previously templated have been made into reality. The player starts out with a few basic items and 100 hit points. We also load the starting location that was saved before and create a victory flag that will notify us if the player has won the game. The methods is_alive and print_inventory should be self-explanatory.

Adding Actions

Now that we have a player, we can start to give them actions. We’ll start with moving around first.

    def move(self, dx, dy):
        self.location_x += dx
        self.location_y += dy
        print(world.tile_exists(self.location_x, self.location_y).intro_text())

    def move_north(self):
        self.move(dx=0, dy=-1)

    def move_south(self):
        self.move(dx=0, dy=1)

    def move_east(self):
        self.move(dx=1, dy=0)

    def move_west(self):
        self.move(dx=-1, dy=0)

The player can move in four directions: north, south, east, and west. To avoid repeating ourselves, we have a basic move method that takes care of actually changing the player’s position and then we have four convenience methods that use the common move method. Now we can simply refer to move_south without specifically trying to remember if y should be positive or negative, for example.

The next action the player should have is attack.

    def attack(self, enemy):
        best_weapon = None
        max_dmg = 0
        for i in self.inventory:
            if isinstance(i, items.Weapon):
                if i.damage > max_dmg:
                    max_dmg = i.damage
                    best_weapon = i

        print("You use {} against {}!".format(best_weapon.name, enemy.name))
        enemy.hp -= best_weapon.damage
        if not enemy.is_alive():
            print("You killed {}!".format(enemy.name))
        else:
            print("{} HP is {}.".format(enemy.name, enemy.hp))

In order to find the most powerful weapon in the player’s inventory, we loop through all the items and use isinstance (a built-in function) to see if the item is a Weapon. This is another feature we gain by having all of our weapons share a common class. If we didn’t do this, we would need to do something messy like if item.name=="dagger" or item.name=="rock" or item.name=="sword".... The rest of the method actually attacks the enemy and reports the result back to the user.

We now have behavior defined for certain actions. But within the game, we need some additional information. First, we need to bind keyboard keys to these actions. It would also be nice if we had a “pretty” name for each action that could be displayed to the player. Because of this additional “meta” information, we are going to wrap these behavior methods inside of classes. It’s time for a new module called actions.py

import Player

class Action():
    def __init__(self, method, name, hotkey, **kwargs):
        self.method = method
        self.hotkey = hotkey
        self.name = name
        self.kwargs = kwargs

    def __str__(self):
        return "{}: {}".format(self.hotkey, self.name)

We’re going to use the now-comfortable design of a base class with specific subclasses. For starters, the Action class will have a method assigned to it. This method will correspond directly to one of the action methods in the player class, which you will see shortly. Additionally, each Action will have a hotkey, the “pretty” name, and a slot for additional parameters. These additional parameters are specified by the special ** operator and are named kwargs by convention. Using **kwargs allows us to make the Action class extremely flexible. We know all actions will require certain parameters, but there may be additional parameters that are different for certain actions. For example, we’ve already seen the attack method that requires an enemy parameter.

The following classes are our first wrappers:

class MoveNorth(Action):
    def __init__(self):
        super().__init__(method=Player.move_north, name='Move north', hotkey='n')

class MoveSouth(Action):
    def __init__(self):
        super().__init__(method=Player.move_south, name='Move south', hotkey='s')

class MoveEast(Action):
    def __init__(self):
        super().__init__(method=Player.move_east, name='Move east', hotkey='e')

class MoveWest(Action):
    def __init__(self):
        super().__init__(method=Player.move_west, name='Move west', hotkey='w')

class ViewInventory(Action):
    """Prints the player's inventory"""
    def __init__(self):
        super().__init__(method=Player.print_inventory, name='View inventory', hotkey='i')

Notice how the method parameter actually points to a specific method in the Player class. Referring to methods as objects in a feature in Python and other languages with “first class” methods. Be sure that you do not include () after the method name. The code Player.some_method() will execute the method whereas Player.some_method is just a reference to the method as an object.

The attack method wrapper is very similar with one small difference:

class Attack(Action):
    def __init__(self, enemy):
        super().__init__(method=Player.attack, name="Attack", hotkey='a', enemy=enemy)

Here we have included the “enemy” parameter as previously discussed. Since enemy is not a named parameter in the base Action class constructor, it will get bundled up into the **kwargs parameter.

Now that we have some actions defined, we need to consider how they will be used in the game. For example, the player should not be able to attack when no enemy is present. Conversely, they shouldn’t be able to calmly leave a room that has an enemy! Actions should be available or unavailable based on the context of the situation. To handle this, we need to flip back to our tiles module.

Change your import statement to include the actions and world modules:

import items, enemies, actions, world

Next add the following methods to MapTile:

    def adjacent_moves(self):
        """Returns all move actions for adjacent tiles."""
        moves = []
        if world.tile_exists(self.x + 1, self.y):
            moves.append(actions.MoveEast())
        if world.tile_exists(self.x - 1, self.y):
            moves.append(actions.MoveWest())
        if world.tile_exists(self.x, self.y - 1):
            moves.append(actions.MoveNorth())
        if world.tile_exists(self.x, self.y + 1):
            moves.append(actions.MoveSouth())
        return moves

    def available_actions(self):
        """Returns all of the available actions in this room."""
        moves = self.adjacent_moves()
        moves.append(actions.ViewInventory())

        return moves

These methods provide some default behavior for a tile. The default actions that a player should have are: move to any adjacent tile and view inventory. The method adjacent_moves determines which moves are possible in the map. For each available action, we append an instance of one of our wrapper classes to the list. Since we used the wrapper classes, we will later have easy access to the names and hotkeys of the actions.

Now we need to allow the Player class to take an Action and run the action’s internally-bound method. Add this method to the Player class:

    def do_action(self, action, **kwargs):
        action_method = getattr(self, action.method.__name__)
        if action_method:
            action_method(**kwargs)

That getattr rears its head again! We have a similar concept to what we did to create tiles, but this time instead of looking for a class in a module, we’re looking for a method in a class. For example, if action is a MoveNorth action, then we know that its internal method is Player.move_north. The __name__ of that method is “move_north”. Then getattr finds the move_north method inside the Player class and stores that method as the object action_method. If getattr was successful, we execute the found method and we include the **kwargs in case that method needs additional objects (like the attack method).

Looking for something a little simpler? My book Make Your Own Python Text Adventure has a different approach to player actions that avoids the sometimes confusing getattr and **kwargs.

At this point, I decided to add one more action: flee. As an alternative to battle, the player can flee which causes them to a random adjacent tile. Here’s the behavior for the Player in the players module:

import random #Note the new import!
import items, world

class Player:
    # Existing code omitted for brevity

    def flee(self, tile):
        """Moves the player randomly to an adjacent tile"""
        available_moves = tile.adjacent_moves()
        r = random.randint(0, len(available_moves) - 1)
        self.do_action(available_moves[r])

And here’s our wrapper in the actions module:

class Flee(Action):
    def __init__(self, tile):
        super().__init__(method=Player.flee, name="Flee", hotkey='f', tile=tile)

Similar to the attack action, the flee action requires an additional parameter. This time, it’s the tile from which the player needs to flee.

Most of the tiles we have created so far can use the default available moves. However, the enemy tiles need to provide the attack and flee actions. To do this, we will override the default behavior of the MapTile class with our own version of the method in the EnemyRoom class.

class EnemyRoom(MapTile):
    def __init__(self, x, y, enemy):
        self.enemy = enemy
        super().__init__(x, y)

    def modify_player(self, the_player):
        if self.enemy.is_alive():
            the_player.hp = the_player.hp - self.enemy.damage
            print("Enemy does {} damage. You have {} HP remaining.".format(self.enemy.damage, the_player.hp))

    def available_actions(self):
        if self.enemy.is_alive():
            return [actions.Flee(tile=self), actions.Attack(enemy=self.enemy)]
        else:
            return self.adjacent_moves()

If the enemy is still alive then the player’s only options are attack or flee. If the enemy is dead, then this room works like all other rooms.

Whew! That was a lot of new code. We’re almost finished! All we need to do to wrap up is create and interface for the human player.

Click here for part 4

Tagged on: , , , ,

54 thoughts on “How to Write a Text Adventure in Python Part 3: Player Action

    1. Osyche

      First off thank you for this wonderful instruction, as it was very helpful and informative and allowed me to learn more about the language. I am, however, wondering as to why i keep getting this error regarding the best_weapon.

       Traceback (most recent call last):
        File "game.py", line 2, in 
          from player import Player
        File "C:\Users\Michael\Desktop\adventuretutorial\player.py", line 5, in 
          class Player():
        File "C:\Users\Michael\Desktop\adventuretutorial\player.py", line 55, in Player
          print("You use {} against {}!".format(best_weapon.name, enemy.name))
      NameError: name 'best_weapon' is not defined
      

      Im new to coding so apologies if this is a simple thing, but any help would be greatly appreciated. Thanks!

      1. Phillip Johnson Post author

        I can’t be certain without seeing the code, but is it possible that you missed the line best_weapon = None? The error is telling you that Python doesn’t yet know what best_weapon refers to, but your code expects Python to know about it.

  1. Rush Montgomery III

    I can’t make this work at all. I’ve got all my modules and I’ve declared the path to all of them but when I compile I get

    AttributeError: ‘NoneType’ object has no attribute ‘modify_player’.

    what is room.modify_player(player) referencing, because it looks like ‘room’ is defined as world.tile_exists and nowhere in the ‘world,py’ is there a reference to modify_player. Also in your ’tiles.py’ is the reference to modify_player, which has an input called the_player which doesn’t seem to be defined or referenced anywhere else in code. Am I missing something?

    1. Phillip

      Take a look at the documentation for tile_exists: “Returns the tile at the given coordinates or None if there is no tile”.

      My guess is that your map is not formatted/defined properly. Therefore when you call tile_exists, None is returned. You cannot call a method from a None reference.

  2. Bart

    For any future issues that some people may have regarding
    “AttributeError: ‘NoneType’ object has no attribute ‘modify_player’” :
    You have to change the players (location_x, location_y) position to match the positon of ‘StartingRoom’ or whatever room you want him to start in.
    when I got finished making my map.txt:
    location_x, location_y = (2, 4) was a blank tile.
    I did not know this. I thought the player spawned in ‘StartingRoom’

    1. Phillip

      Nice find! I agree it would be better to not have those positions hard coded. If you want a make a pull request to make that more dynamic, I’d definitely take a look.

  3. Mika

    I have some problems with def attack. When I am trying to run it it says “‘NoneType’ object has no attribute ‘name'” So as I understood, it cannot find name of the enemy or it thinks that there is no name. I checked my class and it was fine. Then I tried to check if it works by using print, I did “print(enemy.Cerberus.name)” and there was error: ‘NoneType’ object has no attribute ‘name’. What should I do now?

    1. Phillip

      You need an actual object. There is a big difference between enemy.Cerberus and enemy.Cerberus(). The former just refers to the class, the latter actually creates an instance of the class. Happy programming!

  4. Chad Wilson

    Why doesn’t the player class have a constructor, and why are all the player attributes defined outside of a constructor?

    I tried to add a map to the player and found that it was not getting initialized unless I created a constructor method and added the map initialization to that method.

  5. Ryan

    I’m getting a Type Error message on the self.inventory line saying there’s two positional arguments. I’ve looked at the coding in github and it matches up with the only difference is that I have two more items in my players inventory.

    class Player():
    def __init__(self):
    self.inventory= [items.Potion(2), items.AgedSword(), items.LeatherArmor(), items.Buckler()]

  6. Derek

    So I would imagine, if you wanted to impliment northeast, southeast, northwest, and southwest. All you would have to do is create a function to define them, and add or subtract x and y accordingly:
    Northeast would be x += 1 and y += -1
    Southeast would be x += 1 and y += 1
    Southwest would be x += -1 and y += 1
    Northwest would be x += -1 and y += -1

    Right?

  7. Pratik Patil

    Hi,
    I am a beginner in Python. I was doing some exercise in learn python the hard way book. In one exercise it says to build your own game with while, if and for loops. I did it and it was simple. I then found your site for text-based games in python. Compared to simple loop based, object-oriented approach is much more complex. (Though I suppose it would pay-off when games are complex)
    Anyway, I was able to run this game on Python3.3, excercised all possibilities and it ran as intended. Sometimes it printed tiles object description like this:

    which is undesirable. I will look for a way to remove it in my code.
    Debugging and running this code was fun and I think I learned some more about python. I would follow your blog for python exercises now.
    Thank you!

    1. Phillip Johnson Post author

      Thanks for the kind words, glad you enjoyed the tutorial. Unfortunately, nothing showed up in your comment showing how the tile was printing so I can’t offer specific advice. However, if you add a __str__() method to any class that returns a string, that method will be used by Python when printing an object. I hope that helps.

  8. Jake Stokes

    Hi Philip,
    Any ideas about this?

    —> 96 from player import Player
    ImportError: cannot import name ‘Player’

    My root folder contains player.py, which I have replaced with copy & pasted code from you tutorial to make sure that it is accurate. It also does indeed start with:

    import random
    import items, world

    class Player():

    All other modules so far have imported fine. The error message and traceback don’t give me much to work with, I can’t work it out. What would you suggest?

      1. Jake Stokes

        After restarting my session, the error appears to have vanished… so no problem there.

        Quick FYI – I noticed that on this page, the fourth chunk of example code you provide, for actions.py starts with `import Player`, but the file is player.py.

        I have another issue though:

        AttributeError Traceback (most recent call last)
        in ()
        21
        22 if __name__ == “__main__”:
        —> 23 play()

        in play()
        1 def play():
        —-> 2 world.load_tiles()
        3 player = Player()
        4 #These lines load the starting room and display the text
        5 room = world.tile_exists(player.location_x, player.location_y)

        /projects/621b7834-1c5c-438d-8124-605053844f32/world.ipynb in load_tiles()

        AttributeError: ‘module’ object has no attribute ‘{‘

        What does that mean? Where has ‘{‘ come from?? I’ve restarted the kernel and run it again several times. I don’t understand it.

        1. Phillip Johnson Post author

          The load_tiles method is the one that does all of the looking up of your tiles, so it is probably a wrong character in your map.txt file. Some other people have ran into this problem too, so you might take a look at the comments in sections 2 and 4.

  9. Cash Rion

    Maybe I’m missing something, but is it necessary for North to be y = -1 and South to be y = 1? It seems counter-intuitive when looking at an x-y graph, with y going up as values increase.

    1. Phillip Johnson Post author

      You’re right, it would be counter-intuitive for an X-Y graph, but the data structure we’re using isn’t an X-Y graph–it’s a list of lists:

                Col 0   Col 1  Col 2          
      Row 0 -->   A       B      C
      Row 1 -->   D       E      F
      Row 2 -->   G       H      I
      

      If we are in cell D and we want to move north to cell A, we have to subtract 1 since we are moving from row 1 to row 0. Hope that helps!

  10. Kelvin

    Howdy again haha. Question, im having the issue with being on a tile for more than one turn. It keeps presenting me with the intro text. Can’t figure a work around for this.

  11. EthanKelly

    I am having trouble with the movement. Whenever my player gets to an area were they can move east or west, the option is not given. I am confused as to why this is. I am not sure where to look for an issue, so maybe you can help me?
    Thanks,
    Ethan

    1. Phillip Johnson Post author

      It’s hard to guess without seeing code, but try adding some print statements in the adjacent_moves method. Make sure that it is actually finding the rooms that you expect it to find based on your map. If it’s not, there could be a problem with the map. Or it could be something small like replacing a plus sign with a minus sign.

  12. Ryan

    Thank you for making this site. I have been learning python but had not found a way to apply it to anything. I enjoy RPGs and learning it this way really puts what I have been learning into perspective. I wish I got into this earlier!

  13. David Van Meter

    Hello,
    I am stuck on the fact that the online tutorial and the book do not really follow the same path in code development. With this, I have followed the book very closely and am on page 81 and everything was working just fine and I was learning ton, but now there seems to be a missed step in the book. It references and then calls a function ” action() ” but there is no such function in any module or class so far. I see there is a class action() in the online tutorial for the actions of the player in part 3, but this code is quite different than what is in the book. I have pasted the areas I am referring to below.

    Book code:

    def choose_action(area, player):
        action = None
        while not action:
            available_actions = get_available_actions(area, player)
            action_input = input("Action: ")
            action = available_actions.get(action_input)
            if action:
                action()
            else:
                print("Invalid action!")
    

    Online tutorial code:

    class Action():
        def __init__(self, method, name, hotkey, **kwargs):
            self.method = method
            self.hotkey = hotkey
            self.name = name
            self.kwargs = kwargs
     
        def __str__(self):
            return "{}: {}".format(self.hotkey, self.name)
    
    1. Phillip Johnson Post author

      Hi David,

      You’re right the tutorial and book are not the same code base, which is intentional. The book goes into a lot more depth and is better designed for someone new to programming.

      The action variable only exists inside of choose_action because that’s all it is: a variable. I go into some explanation about this in the paragraphs following the code that you pasted. Here’s another way to think about it: do_dishes is the name of a function that contains the instructions for your robot to wash your dishes, do_dishes() tells the robot to actually do the work.

      action = available_actions.get(action_input) ## Go find the action that the user wants to run
          if action: ## Shorthand for "if an action exists"
              action() ## Execute the action
      

      Happy coding!

      1. David Van Meter

        Thank you! I only ask about this because as the code and game stand now, I get the following error whenever I try to move in any direction. If I enter ‘i’ for inventory, it will display player inventory, but then the same error comes up again.

        Traceback (most recent call last):
          File "C:\Users\DM\Desktop\PythonScripts\Arisen\game.py", line 69, in 
            play()
          File "C:\Users\DM\Desktop\PythonScripts\Arisen\game.py", line 14, in play
            if action_input in ['n','N']:
        NameError: name 'action_input' is not defined
        
        1. Phillip Johnson Post author

          Hard to say without seeing the code, but do you still have the action_input = get_player_command() line? The error message you are getting means that Python does not know what the variable action_input refers to. That usually happens when you try to reference a variable before creating it. If you still have trouble, try taking a look at the example code linked to in the book and compare your code against the book.

  14. Ian McCaffery

    When I attempt to run the module player.py, which I’ve been doing in order to check for typos etc, I receive an “invalid syntax” bug, for all of the def move_north(self), move_south, etc lines. I have it exactly as you do. I’ve pasted my code below:

        def move_north(self):
            self.move(dx=0, dy=-1)
    
        def move_south(self):
            self.move(dx=0, dy=1)
    
        def move_east(self):
            self.move(dx=1, dy=0)
    
        def move_west(self):
            self.move(dx=-1, dy=0)
    
  15. Colton Nolan
    TypeError: __init__() takes exactly 5 arguments (4 given)
    
    class Player():
        def __init__(self):
            self.inventory = [items.Gold(15), items.Rock()]
            self.hp = 100
            self.location_x, self.location_y = world.starting_position
            self.victory = False
    

    I cannot figure oout why this is happening.

    1. Phillip Johnson Post author

      The error should also include a file and line number where your error is. I can’t say for certain what your problem is because you gave very little information to go off of, but in general, that error means you are not passing in enough values when creating an object. Check to make sure you aren’t missing any required parameters.

  16. Albin

    Hey, and thanks a lot for a great tutorial! This is surely a great way to learn and practice Python.
    Now to the issue I was hoping you could help me with:

     File "/Users/user/Desktop/python_programming/adventure_game/adventuretutorial/actions.py", line 40, in __init__
        super().__init__(method=Player.move_south, name="Move east", hotkey = "e")
    TypeError: object.__init__() takes no parameters
    

    – I am using IDLE with Python 3.6.4
    – I have googled a lot but find the super().__init__ function is rather difficult to understand.
    – From the way I see it, the error is because there are arguments passed to the super() function…?

    I would much appreciate a hint on what could be the issue! Thanks a lot and Merry Christmas.

    My code looks like yours, as seen below:

    class action():
        def _init__(self, method, name, hotkey, **kwargs):
            self.method = method
            self.name = name
            self.hotkey = hotkey
            self.kwargs = kwargs
            
        def __str__(self):
            return "{}: {}".format(self.hotkey, self.name)
            
            
    class moveNorth(action):
        def __init__(self):
            super().__init__(method=Player.move_north, name="Move north", hotkey = "n")
            
    class moveSouth(action):
        def __init__(self):
            super().__init__(method=Player.move_south, name="Move south", hotkey = "s")
            
    class moveEast(action):
        def __init__(self):
            super().__init__(method=Player.move_south, name="Move east", hotkey = "e")
            
    class moveWest(action):
        def __init__(self):
            super().__init__(method=Player.move_south, name="Move west", hotkey = "w")
    
    1. Albin

      Nevermind! I saw its a Typo under class action(): where i missed an underscore before the “init” statement. Graah! Happy it was simple though.

      Thanks!

  17. Auraking

    Hello. im trying to get the attack command to work. when we get to the spider boss room it says none and then deals damage to my character. it doesn’t give me the option to attack. do you have any idea why that’s happening? i’ve used the codes you have above yet nothing works

  18. Pingback: Cost-Benefit Analysis – Desmond's Coding Voyage

  19. Adam Fairweather

    Do you have a save feature in this game? That is what I am really struggling with because i want my game to be very long. This means you can’t complete it in one sitting. This means, to make sure the player does not have to restart every time they load up the game, i need to implement a save feature. If you could give any advice on this i would be all ears.

    1. Phillip Johnson Post author

      Hi Adam — No save feature is in this tutorial, but adding one wouldn’t be too difficult. The pickle module built into Python is used to save objects in memory to a file and load objects from a file into memory. (This is generally called serialization and deserialization.) You could also choose to use a different file format like JSON or your own custom format to save the game state. The good news is there isn’t much to save: just the player and the state of each room.

  20. George

    Hi, Thanks for the great tutorial. When I run game.py I get this syntax error and for the life of me cant fix it.

    Traceback (most recent call last):
      File "C:\Users\Doddy\Desktop\adventuretutorial\game.py", line 2, in 
        from player import Player
      File "C:\Users\Doddy\Desktop\adventuretutorial\player.py", line 44
        if i.damage > max_dmg:
                                    ^
     SyntaxError: invalid syntax
    
    1. Phillip Johnson Post author

      Do you maybe have some extra whitespace after the colon? I’m just looking at the position of that carat. If you’re still not sure, send me a link to the whole file (gist, GitHub, pastebin, etc.) and I’ll take a closer look.

  21. Nathaniel

    Hello. I’ve started my game and everything runs until I try to execute an action (view inventory) at the starting room. The error is:
    “Exception Unhandled: ‘Player’ object has no attribute ‘do_action'”

    What I get from this is “It thinks that Player doesn’t have a method for doing the action, let me check”. I check and sure enough, nowhere in my code is the a do_action method. Am I missing something? Sorry if it’s a blind mistake and the answer is easy.

  22. Pingback: How to Write a Text Adventure in Python Appendix A: Saving A Game – Let's Talk Data

  23. Daniel Schwan

    There is also a good and clean way to catch inputerrors of lower and upper cases. I always implement something like this:

    def play():
        print("Entkomme der Höhle des Terrors!")
        action_input = get_player_command().lower()
        if action_input[0] == 'n':
            print('Du gehst nach Norden')
        if action_input[0] == 'o':
            print('Du gehst nach Osten')
        if action_input[0] == 's':
            print('du gehst nach Süden')
        if action_input[0] == 'w':
            print('Du gehst nach Westen')
        else:
            print('Fehlerhafte Eingabe')
    
    def get_player_command():
        return input('Aktion: ')
    
    play()
    

    The .lower() function will convert the players input into lower cases, no matter what the input is. The Index [0] is looking for the very first Character in players input, so the player is able to enter anything like “W”, “w”, “West”, “west” or “w3$t ffs”. The if Statement will always look for the very first character, no matter what the next Character is. This makes a clean and readable Code.

    By the way: I bought and downloaded your book and i really love it. I’m about to write my own Textadventure with variable dice functions and GUI. Thanks for helping me with that 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *