Kurdiez Scripting #4: Object Oriented Programming in Lua

scripting

#1

This tutorial is all about coding up some OOP in Lua. Let’s get right into it.

There are many ways to do OOP in Lua. I won’t claim one specific way is the right way to do it. One way I have fallen in love with is writing classes using Luke’s CreateClass library. I met Luke while streaming on twitch and he saw me how I was inefficiently writing classes because I don’t know Lua all that well. Soon we began collaborating with him writing what is now become the CreateClass library and me beta testing it in my game everywhere. You do not have to follow this approach as I said, there are many other ways to do OOP in Lua.

Let’s write some simple classes to simulate creating of NPCs and dealing damage, putting buffs/debuffs and healing.

1. Write down the requirements first
I always write down what needs to be coded before starting to code them. These don’t need to be in particular order. Just need to cover everything that you want to see happen.

  • Be able to print their individual current states (name, health, armor, etc)
  • Be able to calculate raw damage output
  • Be able to take damage after applying armor value
  • Be able to buff base armor values
  • Be able to debuff base armor values
  • Be able to heal health
  • Be able to do everything above in class-specific ways where applicable (warrior and rogue)

2. Class design
We have two character types in this simulation. Warrior and Rogue. Warrior will have higher armor value and Rogue will be able to do random critical damage (x2 regular damage) with 30% proc chance. Aside from these differences, there are also common features they share between the character types such as being able to buff/debuff armor and healing health. We want to have these common logics shared in one class while having the differences in two separate classes. By doing step 1, we were able to sort these out which most developers often skip.

image

Character class will be our parent class which will have all the default values and behaviors. This is where all the commonly shared features all across different character types will be coded. Warrior and Rogue classes are the children of the Character class and they implement their specific features. Warrior class will calculate taking damage differently as it will have additional bonus armor due to warriors being tankier. Rogue class will deal damage differently than others as they are able to do critical damage at a 30% chance. Let’s not worry about the children classes for now and start coding the parent Character class.

3. Coding the Character class and testing it
We begin with saving CreateClass library as a separate module script and put it into ReplicatedStorage. You can just create a new module script and copy and paste the content of it.

Using this helper library, we can put in a completely empty Character class to start. To do this, create a module script in ServerScriptService called “Character” and type in the following

image

-- Character class
local CreateClass = require(game.ReplicatedStorage.CreateClass)

local Character = CreateClass {}

return Character

Each class we write is going to be a single module script on its own. Let’s take a look at this 3 lines of code that defines an empty class.

  • First line imports the CreateClass library by require(). This is a standard way to require a module script in Roblox.
  • Second line defines an empty class using the CreateClass and make Character variable point to it.
  • Third line returns the class pointed by the Character variable.

Remember what was discussed about what it means to be a class in the previous tutorial? Class is nothing but a collection of variables and functions that are related to each other. Let’s start by adding variables that are required in the Character class in order to do what we want.

-- Character class
local CreateClass = require(game.ReplicatedStorage.CreateClass)

local Character = CreateClass {
	__init = function(self, health, baseArmor, damage)
		self.health = health
		self.baseArmor = baseArmor
		self.damage = damage
	end
}

return Character

Inside the CreateClass Lua table we introduce a key “__init” that points to a function with 4 parameters. Whatever the function that is pointed by the __init key becomes the “constructor” function of this class. The constructor function is used to literally construct an object out of this class at the time of object instantiation. (See previous tutorial for what all this means). When an object is instantiated out of this class we need to give it some initial values it starts off at its birth. For our simulation purposes, we need three variables that need to be kept track for every NPC we are going to simulate. These are health, baseArmor and damage numbers. I like to test my code bit by bit as I am coding so soon we will test this basic class.

When we instantitate an object from this class in our test code, we will be calling the constructor function and pass health, baseArmor and damage numbers. These values are then assigned to their respective self.health, self.baseArmor and self.damage variables. So what is this self variable? Because we are doing self followed by the period “.” we know that this self variable points to some kind of a Lua table and accessing a key in it. Self variable here points to literally the object itself that is being created. What is the English word “self” mean to you? It means its own self right? This means when the object is being instantiated through this constructor it is calling onto itself and creating its own variables health, baseArmor, and damage and setting the initial values with whatever was passed into the constructor as parameters.

Let’s create a Script that will be used as our test script. Create a Script in the ServerScriptService and name it “Test”.

image

-- Test script
local Character = require(game.ServerScriptService.Character)

local testCharacter = Character.new(100, 10, 15)

print(testCharacter.health)
print(testCharacter.baseArmor)
print(testCharacter.damage)

When you now run the game, you will see in the output window 3 numbers being printed. How did this happen? Everything else in the Test script is easy to understand except the second line. We call the New() function from the Character class we required above. Although we did not define this New() function anywhere this is given to us by the CreateClass library. So when we call the New() function of this class it calls the constructor function defined to the key __init in the Character class file earlier. Any time you want to instantiate a new object of any class created by the CreateClass library, you simply need to call its New() function. We do not need to pass the first “self” parameter. This is automatically done by the CreateClass again. We only need to worry about the subsequent parameters that are defined in the constructor. In this test example, we are passing the number 100 for health, 10 for armor and 15 for damage to be set as initial values for the object we are instantiating. Once the object is instantiated it is created in the memory of our computer and its address it being pointed to by the “testCharacter” local variable. Since we have a variable pointing to it we can take a look at its object variables as shown by the print statements. Since we are going to be doing a lot of testing using these classes let’s put in default print out function right inside the class.

-- Character class
local CreateClass = require(game.ReplicatedStorage.CreateClass)

local Character = CreateClass {
	__init = function(self, name, health, baseArmor, damage)
		self.name = name
		self.health = health
		self.baseArmor = baseArmor
		self.damage = damage
	end
}

function Character:PrintCurrentState()
	print("Printing the state of character " .. self.name)
	print("Health: " .. tostring(self.health))
	print("Base Armor: " .. tostring(self.baseArmor))
	print("Damage: " .. tostring(self.damage))
end

return Character

I have added one more parameter to the constructor “name”. I also added the PrintCurrentState() function and attached it to the Character class. Remember that any variables and functions inside a class are related to each other? Here we see a good example of this inside the PrintCurrentState() function. The goal of this function is to print current object’s health, base armor and damage values, therefore the keyword “self” must be used to refer to myself’s values from the calling object’s point of view. Let’s modify the Test script and see how this new function is used.

-- Test script
local Character = require(game.ServerScriptService.Character)

local testCharacter = Character.new("Test Character", 100, 10, 15)

testCharacter:PrintCurrentState()

We have to pass the name of this character as well now since we’ve added the new parameter to the constructor. Once you have testCharacter variable point to newly instantiated object, all you have to do is calling the function PrintCurrentState() using the “:” in front of it. You should see a print out in the output window that looks like this.

image

At this point it is absolutely critical why the “self” keyword had to be used inside the PrintCurrentState() function. If you don’t understand clearly what this “self” keyword is all about, there is almost no point in moving any further in this tutorial. Take a moment to experiment with the code with and without the self keyword. Ask other programmers on forums or Discord groups for help. Once you’ve understood what self is all about, you are in a good shape to continue to the next part of this tutorial.

If we scroll back up to the “Class Design” diagram, we are missing so many things in the Character class. Let’s fill in all the gaps.

-- Character class
local CreateClass = require(game.ReplicatedStorage.CreateClass)

local Character = CreateClass {
	__init = function(self, name, health, baseArmor, damage)
		self.name = name
		self.health = health
		self.baseArmor = baseArmor
		self.damage = damage
	end
}

function Character:PrintCurrentState()
	print("Printing the state of character " .. self.name)
	print("Health: " .. tostring(self.health))
	print("Base Armor: " .. tostring(self.baseArmor))
	print("Damage: " .. tostring(self.damage))
end

function Character:Heal(amount)
	self.health = self.health + amount
end

function Character:TakeDamage(amount)
	self.health = self.health - (amount - self.baseArmor)
end

function Character:BuffArmor(amount)
	self.baseArmor = self.baseArmor + amount
end

function Character:DebuffArmor(amount)
	self.baseArmor = self.baseArmor - amount
end

function Character:DealDamage()
	return self.damage
end

return Character

If you have understood how the “self” keyword works, you would have no problem understanding what is happening in each on of these added functions. There are bugs in this code but ignore them as I kept implementations of these functions here as simple as possible on purpose. Now let’s go test these added functions one by one by modifying the Test script.

-- Test script
local Character = require(game.ServerScriptService.Character)

local testCharacter = Character.new("Test Character", 100, 10, 15)

print("-------------------------------")
print("Initial testCharacter state")
testCharacter:PrintCurrentState()

print("-------------------------------")
print("testCharacter takes 20 damage, health should be 90")
testCharacter:TakeDamage(20)
testCharacter:PrintCurrentState()

print("-------------------------------")
print("testCharacter is buffed +10 armor and takes 20 damage, health should be 90")
testCharacter:BuffArmor(10)
testCharacter:TakeDamage(20)
testCharacter:PrintCurrentState()

print("-------------------------------")
print("testCharacter is debuffed -20 armor and takes 20 damage, health should be 70")
testCharacter:DebuffArmor(20)
testCharacter:TakeDamage(20)
testCharacter:PrintCurrentState()

print("-------------------------------")
print("testCharacter is healed for 30, health should be back to 100")
testCharacter:Heal(30)
testCharacter:PrintCurrentState()

I hope all of these test cases are self explanatory. We have done a lot so let me just summarize what has been done so far. We created a Character class then we instantiated a testCharacter in our Test script using the Character class and performed bunch of actions with it. The real beauty of Object Oriented Programming lies when you start creating more than one objects from the same class. Let’s modify the Test script to the following:

-- Test script
local Character = require(game.ServerScriptService.Character)

-- instantiate test characters
local testCharacter1 = Character.new("Test Character 1", 100, 10, 15)
local testCharacter2 = Character.new("Test Character 2", 100, 10, 15)

-- armor modifications
testCharacter1:DebuffArmor(10)
testCharacter2:BuffArmor(10)

-- take damages
testCharacter1:TakeDamage(20)
testCharacter2:TakeDamage(20)

-- print state
testCharacter1:PrintCurrentState()
testCharacter2:PrintCurrentState()

We have instantiated two distinct characters from one Character class. And we have two distinct variables “testCharacter1” and “testCharacter2” pointing to each created objects. We do a series of different operations on these two characters and when we print their state to see the results, they are affected distinctively. This is how you manage individual NPCs in a game. If a player wants to debuff the armor of one particular enemy character, find the variable that points to the right character object and call its DebuffArmor. Only that NPC will be affected and its aftereffects maintained in the object variable. I want to discuss one last key feature in OOP before I wrap up this tutorial. And that is class inheritance.

4. Warrior child class
Before discussing the inheritance of classes in code, let’s think about inheritance in general in biology. For being a child of our biological parents, we inherit certain traits from our parents. Some things are passed onto the child from the parents. In OOP, something similar can be done virtually. If we create a child class called “Warrior” and mark it as the child of Character class we wrote above, the Warrior class will inherit all the variables and functions that are defined in the Character class for free. Furthermore, you can define additional variables and functions that are specific to the Warrior class and add them inside it. If an object was to be instantiated from the Warrior class, this object will be born with all of the variables defined in Character and Warrior classes as well as the functions likewise. Let’s create the empty Warrior class and mark it as the child of Character class using the CreateClass library.

-- Warrior class
local CreateClass = require(game.ReplicatedStorage.CreateClass)
local Character = require(game.ServerScriptService.Character)

local Warrior = CreateClass {
	__super = Character,
	__init = function(self, name, health, baseArmor, damage)
		self:__super(name, health, baseArmor, damage)
	end
}

return Warrior

For the child Warrior class to work, you need to import two things: CreateClass library and Character parent class. This is done by the two require() lines at the top. In order to “mark” Character class as the parent of the Warrior class, you must set it as the “__super” in the CreateClass library. Once you’ve marked it you also need to take care of the constructor linking. When you instantiate an object of the Warrior class, this object is classified as both a Character (because Warrior class inherits the Character class) and also a Warrior. This means you must satisfy the requirements of the Character class’ constructor as well. We do this as the first line inside the Warrior class’ constructor. Because the Warrior class is very simple for now it simply passes all the parameters right up to its parent class’ constructor, which is the Character class. Let’s see if the object has been created properly by modifying the Test script and testing it.

-- Test script
local Warrior = require(game.ServerScriptService.Warrior)

local warrior = Warrior.new("Warrior 1", 100, 10, 15)

warrior:PrintCurrentState()

Notice how we are able to call the PrintCurrentState() without an error from the warrior object. This is because this function is inherited from the Character class. Going way back up to the Class Design diagram again, we see that the Warrior class needs to have its own implementation of Take Damage. Taking less damage than other character types is what makes Warriors different in this simulation. We are going to achieve this by letting the warrior have additional armor and “overriding” the TakeDamage function it inherits from the parent Character class.

-- Warrior class
local CreateClass = require(game.ReplicatedStorage.CreateClass)
local Character = require(game.ServerScriptService.Character)

local Warrior = CreateClass {
	__super = Character,
	__init = function(self, name, health, baseArmor, damage, bonusArmor)
		self:__super(name, health, baseArmor, damage)
		self.bonusArmor = bonusArmor
	end
}

function Warrior:TakeDamage(amount)
	local totalArmor = self.baseArmor + self.bonusArmor
	self.health = self.health - (amount - totalArmor)
end

return Warrior

Warrior class is modified to take in one additional parameter into its constructor: bonusArmor. Notice how the bonusArmor does not belong to the parent Character class so we do not have to pass it into parent’s constructor. Instead, we declare a new self variable inside the Warrior class and assign it there. Warrior class also implements its own TakeDamage() function and by doing so, you will completely override the parent Character class’ function all together. I don’t think I have to explain the math inside the TakeDamage() function as it is simple enough. Let’s test the new Warrior class in the Test script.

-- Test script
local Warrior = require(game.ServerScriptService.Warrior)

local warrior = Warrior.new("Warrior 1", 100, 10, 15, 5)

warrior:TakeDamage(20)

warrior:PrintCurrentState()

Once the script is run, you will find that the health was only deducted by 5 because this warrior object has 10 base armor and 5 bonus armor for being an object of the Warrior class. I will leave the Rogue class implementation as a fun exercise for everyone to do.

Conclusion
You write class once and instantiate as many objects as the host computer memory allows. Once objects are instantiated their states such as health, armor, damage, etc are all managed individually. Anytime a class function refers to a variable using the “self” keyword, it is referring to the invoked object itself. You can also design a class hierarchy and inherit all the parent class’ variables and functions as the child. I have only covered the most basics of the OOP. Please take the time to research the internet and learn about many more features provided by the OOP style of coding.

Contact
You can find out more about my discord and any other contact information about me on my twitch channel.


#2

Amazing tutorial, this helped me out a lot!


#4

Never looked into taking advantage of metatables until now, this really cleared a lot of things up. Appreciate the work you put into this post!