Creating a dice game in Python (part 2)

Iteration 2

In the second iteration of the development of Seven Out we add an outer loop so that the game will continue until the player’s total score equals or exceeds the target score – in this case 200. In the pseudocode shown below the outer loop is modelled as a while loop with a condition to check whether the target score has been met. The algorithm used inside of the outer loop is the same as that used in iteration 1 except a new variable is used to keep track of the score for the current round.


set target to 200
set score to 0
while score < target
   set round_score to 0
   loop
      set total to sum of two random integers between 1 and 6
      if total = 7
      then
         exit loop
      else
         add total to round_score
      end if
    end loop
    output round_score
end while
output score

The code written for this iteration is similar to that from iteration 1. New lines of code are highlighted in the listing below. Line 6 contains a while loop with a condition that checks that the score is less than the target. The while loop will continue while
this condition is true. In particular this while loop will always run at least once. Line 7 creates a new variable to represent the score for the current round - the value for this variable is reset to zero each time the outer loop is repeated. Line 17 updates the round score by adding the current dice total. Note that when a total of 7 is rolled this is not added to the round score.

import random

target = 200
score = 0

while score < target:
    round_score = 0
    valid = True
    while valid:
        roll1 = random.randint(1, 6)
        roll2 = random.randint(1, 6)
        total = roll1 + roll2
        if total == 7:
            valid = False
            print(total)
        else:
            round_score = round_score + total
            print(total, end=", ")
    score = score + round_score
    print("You scored", round_score, "this round")
    print("Total score is", score)

print("Well done. You got to", target)

Line 18 of the code uses an option within the print statement to alter its behaviour. The end option will by default print a newline character at the end of the output, meaning that subsequent output will appear on a new line. In this case we replace the default behaviour and instead end with a comma followed by a space. This means all of the totals for a round will be printed out on one line.

Output generated by running the game is shown below. For each round a list of comma-separated dice totals are shown, followed by the total for that round and then the total score. At the end of the game the player's total is shown.

8, 2, 3, 8, 2, 4, 9, 7
You scored 36 this round
Total score is 36
9, 5, 8, 10, 7
You scored 32 this round
Total score is 68
7
You scored 0 this round
Total score is 68
2, 9, 5, 11, 4, 4, 10, 6, 11, 11, 3, 4, 7
You scored 80 this round
Total score is 148
3, 6, 9, 7
You scored 18 this round
Total score is 166
6, 5, 5, 4, 7
You scored 20 this round
Total score is 186
8, 6, 4, 3, 6, 6, 7
You scored 33 this round
Total score is 219
Well done. You got to 200

Developing a text-based adventure (part 5)

Dictionaries

Dictionaries are a data structure that allow us to store related information in our game. For example we might use a dictionary to represent the attributes of the player character. Or a dictionary might be used to store the different monsters that could be encountered in the game.

Character creation

To demonstrate the use of dictionaries in a text-based adventure game we will do a walkthrough of character creation code.

The first step in character creation is to give the character a name. This name is entered with an input statement. The character name is then stored in the playerAttributes dictionary.

import random

playerAttributes={}

response=input("What is your name brave adventurer? ")
playerAttributes["name"]=response

After the player selects their character name, they need to select the character class, which can either be a fighter, wizard or healer. The character class is stored in the playerAttributes dictionary using the “class” key.
To ensure that the player selects a valid option the code is enclosed in a loop. The loop will continue until a valid response is entered as input. A Boolean-valued variable, called validResp, is used to keep track of whether or not a valid response has been entered.

validResp=False
while (not validResp):
    response=input("You may play as a [F]ighter, [W]izard or [H]ealer > ")

    if (response.upper()=="F"):
        playerAttributes["class"]="fighter"
        validResp=True
    elif (response.upper()=="W"):
        playerAttributes["class"]="wizard"
        validResp=True
    elif (response.upper()=="H"):
        playerAttributes["class"]="healer"
        validResp=True
    else:
        print("This is not a valid option")

Next the program sets the character’s health points which are initialised using a random number generator. The random module is imported at the beginning of the code listing. In this case we use the randint function, which generates a random integer (whole number) between the given lower and upper bounds. Two new attributes are added to the player attributes dictionary, the first representing the maximum number of health points (i.e. when the character is in full health), the second representing the current number of health points.

playerAttributes["hpsMax"]=random.randint(20,30)
playerAttributes["hpsCurrent"]=playerAttributes["hpsMax"]

The next part of the code listing sets up class specific attributes. Fighters have might points which can be used to perform special attacks in close combat. Wizards have power points which can be used to cast spells. Healers have salvation points which can be used to heal the player or to create protective wards. For each of the class specific attributes attributes are initialised for the maximum number of points and the current number of points.

if (playerAttributes["class"]=="fighter"):
    playerAttributes["mightMax"]=random.randint(20,40)
    playerAttributes["mightCurrent"]=playerAttributes["mightMax"]
elif (playerAttributes["class"]=="wizard"):
    playerAttributes["powerMax"]=random.randint(20,40)
    playerAttributes["powerCurrent"]=playerAttributes["powerMax"]
elif (playerAttributes["class"]=="healer"):
    playerAttributes["salvationMax"]=random.randint(20,40)
    playerAttributes["salvationCurrent"]=playerAttributes["salvationMax"]

The final part of the code listing defines a function for printing out the player attributes. The attributes are printed within a box, which is created using special box drawing Unicode characters. Note that the box characters will not display correctly in the IDLE environment – you will need to run your program within a Python shell – you can double click on the file in Windows Explorer to run the program. A for loop is used to print out each of the individual key/value pairs in the dictionary. This will print the pairs in the order they have been added to the dictionary. The upper function has been used to convert the attribute keys to all upper case characters.

def printPlayerAttributes():
    print("\u250F"+"\u2501"*18+"\u2533"+"\u2501"*20+"\u2513")
    for k,v in playerAttributes.items():
        print("\u2503{0:<18}\u2503{1:<20}\u2503".format(k.upper(),v))
    print("\u2517"+"\u2501"*18+"\u253B"+"\u2501"*20+"\u251B")  

printPlayerAttributes()

Output from the final code is shown in the image below.

Presenting dictionary attributes inside a box

Developing a text-based adventure game (Part 4)

Developing a battle system

In this tutorial we will look at the development of a simple battle system. We restrict our attention to a one-on-one battle.
We will implement a Dungeons and Dragons like battle system, where the player and the monster they are facing have a number of hitpoints. The player and monster will take it in turns to attack the other entity. Each attack causes damage that reduces the hitpoints of the entity that was hit. The damage caused by an attack will be randomly determined, but must lie between a predetermined minimum and maximum damage. The battle ends when the hitpoints of either the player or monster are reduced to zero or below.

Initial implementation

The battle mechanics will be encapsulated within the battle function.
The function takes seven inputs. The first three inputs are used for player information, describing the number of hitpoints that they had at the beginning of the combat, the minimum damage that one of their attacks does, and the maximum damage done by their attack. The remaining inputs are used for the title/name of the monster, the monster’s hitpoints and the minimum and maximum damage done by their attack.

The code for the battle mechanics is shown below. The battle function is tested by calling the function at the end of the code listing.

import random

def battle(hitpoints, minDamage, maxDamage,
           monsterTitle, monsterHitpoints,
           monsterMinDamage, monsterMaxDamage):
    playerAttack=True
    while (hitpoints>0 and monsterHitpoints>0):
        if (playerAttack):
            damage=random.randint(minDamage, maxDamage)
            monsterHitpoints=monsterHitpoints-damage
            print("You did {0:d} hitpoints damage".format(damage))
            if (monsterHitpoints <= 0):
                print("The {0} is dead".format(monsterTitle))
            playerAttack=False
        else:
            damage=random.randint(monsterMinDamage, monsterMaxDamage)
            hitpoints=hitpoints-damage
            print("You took {0:d} hitpoints damage".format(damage))
            if (hitpoints <= 0):
                print("You have been killed".format(monsterTitle))
            playerAttack=True

battle(20, 1, 8, "Ogre", 25, 1, 12)

Download source code [Battle mechanics (version 1)].

To randomise the amount of damage done we need to import the random module. From this module we use the randint function, which generates a random integer between the lower and upper bounds which are passed as inputs to the function. To keep track of whether it is the player’s turn or the monster’s turn we use a boolean-valued variable called playerAttack (line 6). This variable can be set to either true or false. A while loop is used to provide a sequence of attacks that continue until either the player or monster is reduced to zero or less hitpoints (line 7). Lines 8-14 of the code implement the player’s attack. The randint function is used to determine the amount of damage done, this is then subtracted from the monster’s hitpoints. If the monster’s hitpoints are reduced below zero, then the monster is declared dead. Lines 15-21 use a similar sequence of code to implement the monster’s attack. The boolean-valued playerAttack variable is updated after each attack to ensure that the player and monster have alternate attacks.

Determining if attacks hit

In the first version of the battle mechanics code all attacks hit. However it is more realistic that only some attacks hit – the attacker may have just swung wildly and missed their target, or the defender may have managed to dodge the attack, or perhaps even parry the attack with their shield. The improve the battle mechanics we adapt the code so that only some of the attacks hit their target. In the code shown below we assume that the player has a 50% chance of hitting their opponent, while the monster only has a 25% chance of hitting.

def battle(hitpoints, minDamage, maxDamage,
           monsterTitle, monsterHitpoints,
           monsterMinDamage, monsterMaxDamage):
    playerAttack=True
    while (hitpoints>0 and monsterHitpoints>0):
        if (playerAttack):
            hitRoll=random.randint(1,20)
            if (hitRoll>=11):
                damage=random.randint(minDamage, maxDamage)
                monsterHitpoints=monsterHitpoints-damage
                print("You hit!")
                print("You did {0:d} hitpoints damage".format(damage))
                if (monsterHitpoints <= 0):
                    print("The {0} is dead".format(monsterTitle))
            else:
                print("You missed")
            playerAttack=False
        else:
            hitRoll=random.randint(1,20)
            if (hitRoll>=16):
                damage=random.randint(monsterMinDamage, monsterMaxDamage)
                hitpoints=hitpoints-damage
                print("{0} hit".format(monsterTitle))
                print("You took {0:d} hitpoints damage".format(damage))
                if (hitpoints <= 0):
                    print("You have been killed".format(monsterTitle))
            else:
                print("{0} missed".format(monsterTitle))
            playerAttack=True

Download source code [Battle mechanics (version 2)].

Lines 7 and 8 of the above code are used to determine whether or not the player hits the monster. A random integer between 1 and 20 is generated and if this number if 11 or larger the player hits the monster, otherwise the attack results in a miss. Similar code is given on lines 19-20 to determine whether or not the monster hits.

Improvements

We improve the code by firstly generalising the hit chances of the player and monster by adding these as variables. We also add a return statement at the end of the code showing how many hitpoints the player has at the end of the battle. This will be used when we call the function in the game to keep track of the ongoing health of the player as well as to determine whether or not the game continues.

def battle(hitpoints, minDamage, maxDamage, toHit,
           monsterTitle, monsterHitpoints,
           monsterMinDamage, monsterMaxDamage, monsterToHit):
    playerAttack=True
    while (hitpoints>0 and monsterHitpoints>0):
        if (playerAttack):
            hitRoll=random.randint(1,20)
            if (hitRoll>=toHit):
                damage=random.randint(minDamage, maxDamage)
                monsterHitpoints=monsterHitpoints-damage
                print("You hit!")
                print("You did {0:d} hitpoints damage".format(damage))
                if (monsterHitpoints <= 0):
                    print("The {0} is dead".format(monsterTitle))
            else:
                print("You missed")
            playerAttack=False
        else:
            hitRoll=random.randint(1,20)
            if (hitRoll>=monsterToHit):
                damage=random.randint(monsterMinDamage, monsterMaxDamage)
                hitpoints=hitpoints-damage
                print("{0} hit".format(monsterTitle))
                print("You took {0:d} hitpoints damage".format(damage))
                if (hitpoints <= 0):
                    print("You have been killed".format(monsterTitle))
            else:
                print("{0} missed".format(monsterTitle))
            playerAttack=True
    return hitpoints

Testing the balance of the battle mechanics

Before we add the battle mechanics to the game we need to test it to make sure it has the right balance. We don’t want to always die when they battle a monster, but at the same time the monster should not be a pushover. Furthermore, if the player acquires an item that is supposed to give them an edge in combat then it should indeed give them and edge. To test the battle function we need to call it many times and then analyse the number of player wins versus the number of player losses. The best way to do this to use a for loop as shown in the code below.

wins=0
for i in range(0,1000):
    hps=battle(20, 1, 8, 11, "Ogre", 25, 1, 12, 16)
    if (hps>0):
        wins=wins+1

print(wins)

In this case the player has 20 hitpoints and a to-hit-roll of 11 (meaning they will need at least an 11 on a roll between 1 and 20 to hit). The monster has 25 hitpoints, does more damage, but is only half as likely to hit. Running the battle mechanics 1000 times with the given configuration results in the player winning 60.5% of the battles. This is probably too high in the situation when the player has not found the sword, but too low if they have found the sword. This suggests we need to adjust the player statistics for these two scenarios. We will use trial an error to get a 20% win chance if they don’t have the sword and an 80% win chance if they do have the sword.

Modifying the player’s damage to 1-4 hps and increase the to-hit-roll to 12 gives the player a win percentage of approximately 20%. On the other hand, if we change the player’s damage to 4-10 and decrease the to-hit-roll to 11, the player will have a win percentage of 79%.

Integrating battle mechanics into the game

To integrate the battle mechanics into the game we modify the playLocation3 function. When the player selects the attack option, the code will call the battle function. The values passed to the battle function depend on whether or not the player has picked up the sword earlier in the game, remembering that possessing the sword gives the player a much better chance of defeating the Ogre (compare lines 20 and 23).

def playLocation3():
    global inventory
    print("You reach what looks like a rudimentary camp site.")
    print("You notice a fire pit in the middle of the camp site.")
    print("Scattered around the fire pit are the many bones picked clean of flesh.")
    print("Ominously some of the bones look human like.")
    print("Just as you finish surveying the scene you here a roar.")
    print("Running towards you, wielding a large club is an Ogre.")
    print("You can either (f)lee to the south, (a)ttack the Ogre or try to (t)alk with the Ogre.")
    response=input("> ")
    if (response=="f"):
        print("You run away from the Ogre at full speed")
        print("The Ogre pursues for a while but eventually gives up.")
        print("You survive another day.")
        print("Once you know you are safe you continue your journey southward at a slower pace.")
        location="location1"
    elif (response=="a"):
        if ("sword" in inventory):
            print("As the Ogre draws near you draw your sword.")
            hps=battle(20, 4, 10, 11, "Ogre", 25, 1, 12, 16)
        else:
            print("You pick up a fallen branch from the ground and prepare to battle the Ogre")
            hps=battle(20, 1, 4, 12, "Ogre", 25, 1, 12, 16)
        if (hps >0):
            print("You have felled the evil beast.")
            print("You search the body of the Ogre and find a key.")
            print("You take the key and head back towards the clearing with the chest.")
            location="location1"
            inventory.append("key")
        else:
            print("You fight valiantly with the Ogre.")
            print("You manage to inflict several telling blows against it but it fights on.")
            print("Having dodged several sweeping blows, the Ogre brings its club down upon you with a crash.")
            print("...")
            location="finish"    
    elif (response=="t"):
        print("You attempt to make conversation with the Ogre but this only makes it more angry.")
        location="location3"
    else:
        location="location3"
    return location

Download source code [Stage 4 code]