circuit

Learn Classes by Making a Game in Python


Welcome to Part 4 in a series for beginning Python! In Part 3, we finished covering the fundamentals necessary to program: variables, functions, loops, and conditionals. Today, we are covering what I consider a really fun subject: classes! This will be our first step into Object Oriented Programming, or OOP. If you are completely new to Python, check out Part 1 and Part 2 to start your journey!

Objects are simply self-contained sets of variables and functions, interconnected in a persistent way. Classes are a type of blueprint for us to use to create custom objects. To best illustrate this, why not create an RPG character? We will keep it simple, our character will have a character type, health, attack, and dodge variables as well as functions to attack, take damage, and see if it has died.

Today's Topic: Classes

Topics we will cover:

  • class variables and functions
  • init()
  • initializing objects
  • dictionary

Setup

First things first, head on over to Programiz and put in this starting code:

FIGHTER = {"health": 5, "attack": 3, "dodge": 2}
THIEF = {"health": 3, "attack": 3, "dodge": 4}
MAGE = {"health": 1, "attack": 5, "dodge": 4}


TYPES = {"fighter": FIGHTER, "thief": THIEF, "mage": MAGE}


class Character:
    _health = 0
    _attack = 0
    _dodge = 0

    def __init__(self, char_type):
        self._char_type = char_type
        self._assign_attributes()

    def _assign_attributes(self):
        type_dict = TYPES[self._char_type]
        self._health = type_dict["health"]
        self._attack = type_dict["attack"]
        self._dodge = type_dict["dodge"]


def main():
    fighter_character = Character("fighter")
    print(fighter_character._char_type)


if __name__ == "__main__":
    main()

Dictionary

Let's first start with our constants. We are using curly brackets, like our sets before, but there's a colon!? What we have is a key: value pair, which makes this collection a dictionary. A dictionary is very useful because we can easily use a “key” to reference any particular information we might want to associate with it (even functions!). I've made two examples: one for the different character types and their attributes, and another to hold the types themselves so I can easily retrieve them. If I didn't have a TYPES dictionary, I would have to use if statements to determine my character type, like this:

if char_type == "fighter":
    type_dict = FIGHTER
elif char_type == "thief":
    type_dict = THIEF
elif char_type == "mage":
    type_dict = MAGE
else:
    raise Exception("This character type doesn't exist!")

While we are on the topic, here are the two connected components to the if statement: “else if” or elif and else. Unlike if, elif is only checked if the previous elif or if was False. If the previous if or elif is True, all of the following elifs and else statements are ignored. An else statement is the equivalent to “well if nothing else was True, do this.” In this particular instance, I raise a genetic error called Exception with a message. But I digress.

Class

Class Variables

Right under our dictionary constants, we have our Character class. As per Python coding standards, the class name is capitalized. Right below, we have our class variables. These are variables that can only be called when corresponding to the class itself, either inside the class or outside by referencing the class object and then the variable. The internal references can be seen in the functions (notice self. prior to the variable) and the external reference in main(). Best Practice: all class variables need to either be declared at the top of the class (like _health, _attack, and _dodge) or within the __init__() (like char_type). Because I didn't assign health, attack, and dodge until the _assign_attributes() function, even though it was called in __init__(), I declared the variables and initial values at the top of the class.

Class Functions

A very important class function is __init__(), as it is executed when the object is initialized. Any initial setup goes in here. Also, class functions always have the first argument as self as this is the variable used to reference the other class variables and functions. Best Practice: the underscore in front of the function (and variables) is a sign that the function is “internal only” and shouldn't be used outside other class functions. In other programming languages, there is a level of security in objects where certain variables and functions can be set as private and therefore cannot be accessed outside of the object. Python does not have this level of security. Instead, we have underscores to suggest not to use certain variables or functions outside of the function. It's like using mannequins as mall cops, but it's what we got.

For code simplicity, we will override a class function, or redefine an existing built-in function, called __str__(). Defining a custom __str__() allows us to control what is returned when we execute print() with the object, like print(fighter_character) in main(). By adding the following code to the bottom of the class, we no longer have to code a variable getter, a function namedget_variable() that returns a variable, and can simply print() the object:

def __str__(self):
	return self._char_type

Remember to indent it properly so it's in the class!

Attacking

In the main() function, we've already initialized the class into an object called fighter_character and we've printed out the character's type. Great! Now we are in a good position to extend the Character class to attack and take damage. Since our RPG is very simple, there are no buffs or statuses to influence our attack, so the class function attack() will return the _attack value. In the event that we introduce these features, we will be able to extend the attack() function to include these.

def attack(self):
	return self._attack

Taking Damage

Our RPG has no defense so taking damage is fairly straightforward, the damage received will be subtracted from the character's health. However, our characters do have a chance to dodge. In this RPG, each point in the dodge stat is equivalent to a 5% chance to dodge. Because of this, Thief and Mage types have a 20% chance to dodge! Since we need to take into account whether the attack landed, we will have the dodge check in the same function.

def take_damage(self, damage):
    if self._dodge_success():
        return "Missed!"
    self._health -= damage
    return f"{self._char_type} took {damage} damage."

def _dodge_success(self):
    # Every point in dodge equals 5% chance to dodge
    dodge_chance = self._dodge * 5
    dodge_roll = random.randint(1, 100)
    if dodge_roll <= dodge_chance:
        return True
    return False

Let's start with _dodge_success(). Since every point in dodge equals 5%, we calculate our dodge_chance by multiplying our _dodge by 5. Then we grab a random number and if it's less than or equal to the dodge chance, then the character has successfully dodged the attack! If you are adding this to your current code, don't forget to import random! As for take_damage() we will return a string “Missed!” if the character dodged, and will deduct damage from health and return how much damage they took. In Python, -= is equivalent to self._health = self._health — damage and the same can be accomplished with +=. Best Practice: If an if statement has a return, do not use an else statement.

Is Dead?

The last function we need is to check if the character lost all of their health and is dead.

def is_dead(self):
    return self._health <= 0

It's pretty self explanatory: return if health is less than or equal to 0. We don't want a zombie character with negative health still attacking, which is why we don't just check if the health is 0.

But why create such a small function?

Two reasons: To keep the check in the class and maintain simplicity of the functions. We could have very well rolled this check into the take_damage() function, printed the strings instead of returning them, and only returned True/False to indicate whether the character was still alive. However, testing take_damage() would be more difficult. If a later change caused our characters to dodge 80% of the time instead of 20%, due to a change in the if statement, it would be very difficult to catch in a test because take_damage() would only give us True/False.

And that's it, we have a Character class that we can initialize as an object and it will carry a persistent set of variables and functions, all that's left to do is make two and have them fight!

Bonus: Make Them Fight!

Take a look a the code below. I did not mention the character_fight() or attack_character() functions nor what is in main(), try to read what's going on.

import random

FIGHTER = {"health": 5, "attack": 3, "dodge": 1}
THIEF = {"health": 2, "attack": 3, "dodge": 4}
MAGE = {"health": 1, "attack": 5, "dodge": 4}

TYPES = {"fighter": FIGHTER, "thief": THIEF, "mage": MAGE}


class Character:
    _health = 0
    _attack = 0
    _dodge = 0

    def __init__(self, char_type):
        self._char_type = char_type
        self._assign_attributes()

    def __str__(self):
        return self._char_type

    def _assign_attributes(self):
        type_dict = TYPES[self._char_type]
        self._health = type_dict["health"]
        self._attack = type_dict["attack"]
        self._dodge = type_dict["dodge"]

    def attack(self):
        return self._attack

    def take_damage(self, damage):
        if self._dodge_success():
            return "Missed!"
        self._health -= damage
        return f"{self._char_type} took {damage} damage."

    def _dodge_success(self):
        # Every point in dodge is 5% chance to dodge
        dodge_chance = self._dodge * 5
        dodge_roll = random.randint(1, 100)
        if dodge_roll <= dodge_chance:
            return True
        return False

    def is_dead(self):
        return self._health <= 0


def character_fight(type_1, type_2):
    character_1 = Character(type_1)
    character_2 = Character(type_2)
    coin_toss = random.randint(0, 1)
    if coin_toss == 0:
        first, second = [character_1, character_2]
    else:
        first, second = [character_2, character_1]

    while True:
        if attack_character(first, second):
            return str(first)
        if attack_character(second, first):
            return str(second)


def attack_character(first, second):
    damage = first.attack()
    second.take_damage(damage)
    if second.is_dead():
        return True
    return False


def main():
    char_1 = "fighter"
    char_1_win = 0
    char_2 = "mage"
    char_2_win = 0
    for _ in range(100):
        winner = character_fight(char_1, char_2)
        if winner == char_1:
            char_1_win += 1
        else:
            char_2_win += 1
    print("Results:")
    print(f"{char_1}: {char_1_win}")
    print(f"{char_2}: {char_2_win}")


if __name__ == "__main__":
    main()

The whole basis for this extra code is to see if one character type has an unfair advantage over another. Toss this into Programiz and hit run!

Out of 100 fights, the mage will win ~60% of the time. If our game is meant to be completely balanced between the two, we can adjust the constants at the top and run the code again. We can also make the dodge chance, which is currently 5, a constant and adjust it to 10 or 15 and see how that influences the ratio. Check it out, adjust some values, and have fun trying to find the most optimized characters!

Conclusion

My last example was 90 lines of code! Wow! At around 100-150 lines, I start splitting different components into different files and adding tests. This kind of functionality isn't available in an online interpreter like Programiz, so next time I'll be covering how to setup your own personal Python workspace! Till next time!




Continue Learning