Creating a battle system: OOP style

Creating a battle system: OOP style

Introduction

Recently, I’ve been working on my game and I’ve created a battle system fully OOP.
The system is so nice to work with, I thought: “why don’t I share this to the world?”.

This tutorial will require good knowledge on OOP and ValueBases - as that’s what I use to store the in-battle data. If you haven’t learnt OOP or are a bit misunderstanding of it then I’d recommend this tutorial:

This battle system will be “pokemon-style”, one move per turn basis but of course you can extend upon that, I’ll be using made up creatures as well. It’ll only include two options (Attack and Heal) however you could add many others very easily. Throughout this tutorial I’ll be naming Instances “My” for a Player’s and “Op” as in Opponent (NPC)

Now, shall we begin? Yes, I think we should.


Tutorial parts


Instances required

First and foremost, the required Instances need to be added:

Key:

  • ClassName = ClassName of that Instance (what type you should insert)
  • Parent = Parent in hierarachy of that Instance (- means that service)
  • Name = Name of that Instance (what you should set it’s Name to, press F2 to rename)

ReplicatedStorage

ClassName Parent Name
Folder - RemoteEvents
RemoteEvent RemoteEvents BattleAction
RemoteEvent RemoteEvents UpdateStats
Folder - RemoteFunctions
RemoteFunction RemoteFunctions GetStat

ServerScriptService

ClassName Parent Name
ModuleScript - BattleCreator
Script - Stats
ModuleScript [DataStore2] Stats DataStore2
Script - Handler
Folder - BindableFunctions
BindableFunction BindableFunctions GetStat
Folder - BindableEvents
BindableEvent BindableEvents SetStat

StarterGui

ClassName Parent Name
ScreenGui - UI
LocalScript UI UIHandler
TextLabel UI MoneyLabel
TextLabel UI CreatureLabel
Frame UI MyStats
Frame MyStats MyHealth
Frame MyHealth Percent
TextLabel MyStats MyAttack
TextLabel MyStats MyName
Frame UI OpStats
Frame OpStats OpHealth
Frame OpHealth Percent
TextLabel OpStats OpAttack
TextLabel OpStats OpName
Frame UI Choices
TextButton Choices Attack
TextButton Choices Heal

ServerStorage

ClassName Parent Name
ModuleScript - CreatureStats
ModuleScript - MoveStats
ModuleScript - NPCStats
Folder - CreatureModels
Model CreatureModels Cheese Monster
Part Cheese Monster MainPart [set PrimaryPart of CheeseMonster to this!]
Model CreatureModels Bean Lord
Part Bean Lord MainPart [set PrimaryPart of Bord Lord to this!]
Folder - Areas
Model Areas Default
Part Default MySpawn (where the player’s creature will go)
Part Default OpSpawn (where the npc’s creature will go)
Part Default MyStand (where the player will stand)
Part Default OpStand (where the npc will stand)

Workspace

ClassName Parent Name
Model - Bob
Part Bob MainPart (set the PrimaryPart of Bob to this!)

And, that’s everything!
If you are unsure as to where things go exactly you can edit the uncopylocked game in the resources section.


Creating player stats

Secondly, you’ll need to setup player statistics in the Stats Script.

For simplicity, you shold use DataStore2 to save your data - I’ve never had a problem with DataStore2 and it’s always creating backups, cache etc.

For this tutorial I’m going to use a dictionary to hold a Money Key and a Creature Key, and for retriving that we’ll bind a function for the GetStat BindableFunction/RemoteFunction:

--// Stats, ServerScriptService

--// Dependencies
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataStore2 = require(script.DataStore2)

--// Variables
local RemoteEvents = ReplicatedStorage.RemoteEvents
local RemoteFunctions = ReplicatedStorage.RemoteFunctions
local BindableEvents = ServerScriptService.BindableEvents
local BindableFunctions = ServerScriptService.BindableFunctions
local DataStoreName = "Stats"
local DefaultValue = {
    Money = 100;
    Creature = "Bean Lord";
}

--// Combine the datastore
DataStore2.Combine("DATA", DataStoreName)

--// Functions
local function PlayerAdded(Player)
    local Store = DataStore2(DataStoreName, Player)
	--// When their data is updated, tell the client to update the stats
    Store:OnUpdate(function(NewData)
        RemoteEvents.UpdateStats:FireClient(Player, NewData)
    end)
end

local function GetData(Player)
    local Store = DataStore2(DataStoreName, Player)
    local Data = Store:GetTable(DefaultValue)
    return Data, Store
end

local function GetStat(Player, Stat)
    local Data = GetData(Player)

    return Data[Stat]
end

local function SetStat(Player, Stat, Value)
    local Data, Store = GetData(Player)
    Data[Stat] = Value
    Store:Set(Data)
end

--// Connections
Players.PlayerAdded:Connect(PlayerAdded)
BindableEvents.SetStat.Event:Connect(SetStat)
--// Invokes
RemoteFunctions.GetStat.OnServerInvoke = GetStat
BindableFunctions.GetStat.OnInvoke = GetStat

Now we have the stats, we can base off of these.


Setting up CreatureStats, MoveStats and NPCStats

Thirdly, we’ll need to house the data for the indiviual creatures and stats yet also the data for the npcs.

CreatureStats will include a dictionary, where each Key is the Creature’s Name and the Value is a Dictionary holding their Attack, Heal and Health:

--// CreatureStats, ServerStorage

local CreatureStats = {
    ["Cheese Monster"] = {
        Attack = "Cheese Attack";
        Heal = "Eat Cheese";
	Health = 100;
    };

    ["Bean Lord"] = {
        Attack = "Beans";
        Heal = "Eat Beans";
	Health = 150;
    };
}

return CreatureStats

Next, MoveStats will include info for these moves in an array with the amount being the first index and the type being the second:

--// MoveStats, ServerStorage

local MoveStats = {
    ["Cheese Attack"] = {20, "Attack"};
    ["Eat Cheese"] = {10, "Heal"};
    ["Beans"] = {30, "Attack"};
    ["Eat Beans"] = {20, "Heal"};
}

return MoveStats

Finally, NPCStats will involve the data of what the Creatures the NPC owns and how much cash they’ll award. This’ll be the exact same to the Player data:

--// NPCStats, ServerStorage

local NPCStats = {
    ["Bob"] = {
        Money = 200;
        Creature = "Cheese Monster";
    };
}

return NPCStats

Eh voila, we have all of the data done now.


Making the BattleCreator, OOP approach

Forthly, to the interesting part, creating the main battle system!

As OOP goes, we’ll incorporate a .new function to commence a new battle and create the essential instances such as a Folder to hold a Folder for both the Player’s Creature and the NPC’s Creature which houses the ValueBases of information.

One extremely helpful function I use is called “CreateValue”, it takes a datatype name such as “Int” or “Number” and concatenates it with “Value”, of course there’s a few other arguments for the Value, Name and Parent - it returns back the ValueBase too which then can be assigned to Key.

Also in the new function, it’ll be best to create the BattleArea of which is what you see and that holds the models. This BattleArea will need to be parented to ReplicatedStorage but then changed to Workspace on the client so other clients cannot see it. At his point as well, the Creature models will be cloned and added along with a clone of the NPC and the Player’s Character (remember to set the Archivable property true on the Player’s character so we can clone them!)

Lets begin writing then:

--// BattleCreator, ServerScriptService

--// Dependencies 
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local CreatureStats = require(ServerStorage.CreatureStats)
local MoveStats = require(ServerStorage.MoveStats)
local NPCStats = require(ServerStorage.NPCStats)

--// Variables
local MovementTypes = {"Attack", "Heal"}
local BattleCreator = {}
BattleCreator.__index = BattleCreator

--// Functions
local function CreateValue(Class, Value, Name, Parent)
	local ValueBase = Instance.new(Class .. "Value")
	ValueBase.Value = Value
	ValueBase.Name = Name
	ValueBase.Parent = Parent
	
	return ValueBase
end

function BattleCreator.new(Player, NPC)
	--// Get the NPC's stats and therefore creature
	local NonPlayerStats = NPCStats[NPC.Name]
	local OpCreature = NonPlayerStats.Creature
	--// Invoke the BindableFunction and get our Creature
	local MyCreature = ServerScriptService.BindableFunctions.GetStat:Invoke(Player, "Creature")
	
	--// Main information Folder
	local BattleInformation = Instance.new("Folder")
	BattleInformation.Name = "BattleInformation"
	
	--// Creature stats for both our's and the NPC's
	local OurStats = {
		My = CreatureStats[MyCreature];
		Op = CreatureStats[OpCreature];
	}
	
	--// Iterate through the stats
	for Name, Stats in pairs(OurStats) do
		--// Create a Folder for each
		local Folder = Instance.new("Folder")
		Folder.Name = Name .. "Information"
		
		--// Create the Attack and Heal NumberValues with a StringValue inside of the name of the move
		local Attack = CreateValue("Number", MoveStats[Stats.Attack][1], "Attack", Folder)
		CreateValue("String", Stats.Attack, "Move", Attack)
		
		local Heal = CreateValue("Number", MoveStats[Stats.Heal][1], "Heal", Folder)
		CreateValue("String", Stats.Heal, "Move", Heal)
		
		--// Create Health IntValue
		CreateValue("Int", Stats.Health, "Health", Folder)
		
		--// Create MaxHealth IntValue for when they're healing, to have a value to clamp it under
		CreateValue("Int", Stats.Health, "MaxHealth", Folder)
		
		--// Create a ObjectValue to point to the creature's model
		CreateValue("Object", nil, Name .. "Model", BattleInformation)
		
		--// Parent the folder
		Folder.Parent = BattleInformation
	end
	
	--// Clone the BattleArea
	local BattleArea = ServerStorage.Areas.Default:Clone()
	--// Parent the information to the BattleArea
	BattleInformation.Parent = BattleArea
	
	BattleArea.Parent = ReplicatedStorage
	
	--// Clone the creature models, position them and set the ObjectValues' Values
	local MyCreatureModel = ServerStorage.CreatureModels[MyCreature]:Clone()
	MyCreatureModel:SetPrimaryPartCFrame(BattleArea.MySpawn.CFrame * CFrame.new(0,0.5,0))
	MyCreatureModel.Parent = BattleArea
	BattleInformation.MyModel.Value = MyCreatureModel
	
	local OpCreatureModel = ServerStorage.CreatureModels[OpCreature]:Clone()
	OpCreatureModel:SetPrimaryPartCFrame(BattleArea.OpSpawn.CFrame * CFrame.new(0,0.5,0))
	OpCreatureModel.Parent = BattleArea
	BattleInformation.OpModel.Value = OpCreatureModel
	
	--// Clone the Player's Character and NPC's character, yet also position them
	local Character = Player.Character or Player.CharacterAdded:Wait()
	Character.Archivable = true
	
	local PlayerClone = Character:Clone()
	PlayerClone:SetPrimaryPartCFrame(BattleArea.MyStand.CFrame * CFrame.new(0,4,0))
	PlayerClone.Parent = BattleArea
	
	local NPCClone = NPC:Clone()
	NPCClone:SetPrimaryPartCFrame(BattleArea.OpStand.CFrame * CFrame.new(0,0.5,0))
	NPCClone.Parent = BattleArea
	
	--// Set the BattleArea's Parent
	BattleArea.Parent = ReplicatedStorage
	
	--// Return the metatable with the new battle object
	return setmetatable({
		Player = Player;
		NPC = NPC;
		BattleArea = BattleArea;
		BattleInformation = BattleInformation;
		PlayerCharacter = PlayerClone;
		NPCCharacter = NPCClone;
		BattleHasEnded = false;
		MoneyToReward = NonPlayerStats.Money;
	}, BattleCreator)
end

Now in any of the : functions of the BattleCreatorwe can index self.
Everything is at your fingertips now, the BattleArea, BattleInfomation, etc!
Lets keep writing, here I’m mostly going to use isPlayer to decide if either “My” or “Op” should be used as the prefix:

--// Function for handling the battle ending
function BattleCreator:EndBattle(IsPlayer)
	--// Set that the battle has ended
	self.BattleHasEnded = true
	--// If the player won
	if IsPlayer then
		--// Reward them
		local CurrentMoney = ServerScriptService.BindableFunctions.GetStat:Invoke(self.Player, "Money")
		
		ServerScriptService.BindableEvents.SetStat:Fire(self.Player, "Money", CurrentMoney + self.MoneyToReward)
	end
	
	--// Destroy the BattleArea, this completely removes all connections
	self.BattleArea:Destroy()
end

--// Function for handling movement
function BattleCreator:HandleMovement(Movement, IsPlayer)
	--// If it isn't accepted then return
	if not table.find(MovementTypes, Movement) then
		return
	end
	--// Get prefixes for me and the opponent
	local Prefix = IsPlayer and "My" or "Op"
	local OpPrefix = IsPlayer and "Op" or "My"
	
	--// Get information folders
	local Folder = self.BattleInformation[Prefix .. "Information"]
	local OpFolder = self.BattleInformation[OpPrefix .. "Information"]
	
	--// Compare to see what movement it is
	if Movement == "Attack" then
		--// Deal damage, remember to clamp so they cannot go below 0
		OpFolder.Health.Value = math.clamp(OpFolder.Health.Value - Folder.Attack.Value, 0, OpFolder.MaxHealth.Value)
		--// Did they die?
		if OpFolder.Health.Value == 0 then
			--// They did, handle for that
			self:EndBattle(IsPlayer)
		end
	elseif Movement == "Heal" then
		--// Heal up, remember to clamp it so they cannot go over their MaxHealth
		Folder.Health.Value = math.clamp(Folder.Health.Value + Folder.Heal.Value, 0, Folder.MaxHealth.Value)
	end
end

--// Function to choose what move the AI goes for
function BattleCreator:OpMove()
	local MyInformation = self.BattleInformation.MyInformation
	local OpInformation = self.BattleInformation.OpInformation
	
	--// If they can kill in an attack, heal
	if 0 >= (OpInformation.Health.Value - MyInformation.Attack.Value) then
		--// Heal now, fyi you don't have to give false as nil evaluates to false
		self:HandleMovement("Heal", false)
	
	--// Else attack
	else
		self:HandleMovement("Attack", false)	
	end
	--// This is a very simple AI, of course it can be customised
end

return BattleCreator

Hurrah, time to start handling these battles.


Handling battles

Fifthly, it’s time to handle the battles.

In this tutorial I’ll be commencing the battle on PlayerAdded (CharacterAppearanceLoaded to be exact), however you can do so by other means such as Rays or Touched. If you have the character but want to get the Player I endorse the use of GetPlayerFromCharacter instead of finding the character’s name in Players - this’ll return nil if no Player is found for the given character.

We’ll fabricate a communication between the client and server via the BattleAction RemoteEvent, specifically for the move choices we’ll Connect OnServerEvent and have the client fire to the Remote. The next part of this tutorial will cover the client-side, but for now we’ll plan out what’ll be needed to be implemented.

Type, type, type:

--// BattleHandler, ServerScriptService

--// Dependencies
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local BattleCreator = require(ServerScriptService.BattleCreator)

--// Variables
local BattleAction = ReplicatedStorage.RemoteEvents.BattleAction
local NPC = workspace.Bob

--// Functions
local function PlayerAdded(Player)
	--// When their appearance loads
	Player.CharacterAppearanceLoaded:Connect(function()
		wait(2) --// Just so they're ready
		--// Create the battle
		local Battle = BattleCreator.new(Player, NPC)
		--// Tell the client to setup the battle client-side
		BattleAction:FireClient(Player, "setup", Battle.BattleInformation)
		
		--// Create the connection, but define it as a variable before hand so we can disconnect it inside
		local Connection do		
			--// Debounce for the Player choosing actions
			local Debounce = true
			--// Function to handle if the battle ended
			local function IfEnded()
				if Battle.BattleHasEnded then
					--// Tell the client to clear up and end the battle
					BattleAction:FireClient(Player, "cleanup")
					--// Disconnect the connection
					Connection:Disconnect()
					--// Return that it did end
					return true
				end
			end
			
			--// Tell the client to choose
			BattleAction:FireClient(Player, "choose")
			--// Connect on when they choose a move
			Connection = BattleAction.OnServerEvent:Connect(function(PlayerFired, Action)
				--// If it's our Player and Debounce is true
				if PlayerFired == Player and Debounce then
					--// Make sure they can't move anymore
					Debounce = false
					--// Handle their movement
					Battle:HandleMovement(Action, true)
					--// If the battle ended then return
					if IfEnded() then
						return
					end
					--// Handle the AI's movement
					Battle:OpMove()
					--// If the battle ended then return
					if IfEnded() then
						return
					end
					
					wait(1) --// Just to ensure it tweened out and now in
					--// Let them choose again
					BattleAction:FireClient(Player, "choose")
					Debounce = true
				end
			end)
		end
	end)
end

--// Connections
Players.PlayerAdded:Connect(PlayerAdded)

Client-side interaction

Last but not least, we need to generate the client side interactions which are handled via the UIHadler LocalScript. Ensure at this point that you have a Position ready for when each of the Instances directly in the UI are on/off the screen.

Let’s get typing:

--// UI Handler, StarterGui -> UI

--// Dependencies
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService = game:GetService("TweenService")
local Players = game:GetService("Players")

--// Variables
local Camera = workspace.Camera
local LocalPlayer = Players.LocalPlayer
local RemoteEvents = ReplicatedStorage.RemoteEvents
local RemoteFunctions = ReplicatedStorage.RemoteFunctions
local UI = script.Parent
local Info = TweenInfo.new(0.75, Enum.EasingStyle.Sine)
local Connections = {}
local Names = {"My", "Op"}
--// Give your own Positiional values in here
local Positions = {
	--// On screen values
	On = {
		Choices = UDim2.new(0.7,0,0.4,0);
		MyStats = UDim2.new(0,0,0,0);
		OpStats = UDim2.new(0.7,0,0,0);
		CreatureLabel = UDim2.new(0,3,0.9,0);
		MoneyLabel = UDim2.new(0.85,-3,0.9,0);
	};	
	--// Off screen values
	Off = {
		Choices = UDim2.new(1.5,0,0.4,0);
		MyStats = UDim2.new(-0.5,0,0,0);
		OpStats = UDim2.new(1.5,0,0,0);
		CreatureLabel = UDim2.new(-0.5,3,0.9,0);
		MoneyLabel = UDim2.new(1.5,-3,0.9,0);
	};
}

--// Ensure the correct elements are on-screen/off-screen
UI.Choices.Position = Positions.Off.Choices
UI.MyStats.Position = Positions.Off.MyStats
UI.OpStats.Position = Positions.Off.OpStats
UI.CreatureLabel.Position = Positions.On.CreatureLabel
UI.MoneyLabel.Position = Positions.On.MoneyLabel

--// Tweens
local Tweens = {On = {}; Off = {};}

--// Functions
local function SetupTweens()
	--// Iterate through On and create the tweens for them
	for Name, Position in pairs(Positions.On) do
		Tweens.On[Name] = TweenService:Create(UI[Name], Info, {Position = Position})
	end
	
	--// Iterate through Off and create tweens for them
	for Name, Position in pairs(Positions.Off) do
		Tweens.Off[Name] = TweenService:Create(UI[Name], Info, {Position = Position})
	end
end

local function SetupLabels(Data)
	local Creature, Money 
	
	--// If we're given Data
	if Data then
		Creature = Data.Creature
		Money = Data.Money
	--// Else we should get them
	else
		Creature = RemoteFunctions.GetStat:InvokeServer("Creature")
		Money = RemoteFunctions.GetStat:InvokeServer("Money")
	end
	
	UI.CreatureLabel.Text = string.format("Creature: %s", Creature)
	UI.MoneyLabel.Text = string.format("Money: %d", Money)
end

local function BattleAction(Action, ...)
	--// Pack all passed arguments into an array
	local Arguments = table.pack(... or {})
	--// Ensure it's lowered
	local Action = string.lower(Action)
	
	if Action == "setup" then
		local BattleInfo = Arguments[1]
		--// Parent the BattleArea to workspace
		BattleInfo.Parent.Parent = workspace
		--// Iterate through the names (My, Op)
		for _, Name in ipairs(Names) do
			local CurrentInfo = BattleInfo[Name .. "Information"]
			--// Set the attack and name
			local Stats = UI[Name .. "Stats"]

			Stats[Name .. "Name"].Text = BattleInfo[Name .. "Model"].Value.Name
			Stats[Name .. "Attack"].Text = CurrentInfo["Attack"].Value
			
			--// If it's my then set the labels and set the Camera
			if Name == "My" then
				UI.Choices.Attack.Text = string.format("%s [%d]", CurrentInfo["Attack"].Move.Value, CurrentInfo["Attack"].Value)
				UI.Choices.Heal.Text = string.format("%s [%d]", CurrentInfo["Heal"].Move.Value, CurrentInfo["Heal"].Value)
				
				Camera.CameraType = Enum.CameraType.Scriptable
				Camera.CameraSubject = BattleInfo["MyModel"].Value.PrimaryPart
				Camera.CFrame = Camera.CameraSubject.CFrame * CFrame.new(3,2,4)
			end
			
			local function SetHealth()
				--// Set it to the percent of the health
				Stats[Name .. "Health"].Percent.Size = UDim2.new(CurrentInfo["Health"].Value/CurrentInfo["MaxHealth"].Value,0,1,0)
			end
			
			--// Set it up
			SetHealth()
			--// Connect whenever it changes, place it inside the connections array so it'll be disconnected
			table.insert(Connections, CurrentInfo["Health"].Changed:Connect(SetHealth))
		end
		--// Tween out the labels and tween in the stats 
		Tweens.Off.CreatureLabel:Play()
		Tweens.Off.MoneyLabel:Play()
		Tweens.On.MyStats:Play()
		Tweens.On.OpStats:Play()
		
	elseif Action == "cleanup" then
		--// Iterate through the connections and disconnect them
		for _, Connection in ipairs(Connections) do
			Connection:Disconnect()
		end
		
		--// Reset the connections
		Connections = {}
		
		--// Tween out the stats and in the labels
		Tweens.On.CreatureLabel:Play()
		Tweens.On.MoneyLabel:Play()
		Tweens.Off.MyStats:Play()
		Tweens.Off.OpStats:Play()
		--// Set the camera back
		Camera.CameraType = Enum.CameraType.Custom
		Camera.CameraSubject = LocalPlayer.Character.Humanoid	
			
	elseif Action == "choose" then
		local ChooseConnections = {}
		
		--// Setup clicks on the choices and fire if they click, yet also disconnect the connections
		for _, Choice in ipairs(UI.Choices:GetChildren()) do
			table.insert(ChooseConnections, Choice.MouseButton1Click:Connect(function()
				for _, Connection in ipairs(ChooseConnections) do
					Connection:Disconnect()
				end
				
				RemoteEvents.BattleAction:FireServer(Choice.Name)
				Tweens.Off.Choices:Play()
			end))
		end
		
		Tweens.On.Choices:Play()
	end
end

--// Setup
SetupTweens()
SetupLabels()
--// Connections
RemoteEvents.UpdateStats.OnClientEvent:Connect(SetupLabels)
RemoteEvents.BattleAction.OnClientEvent:Connect(BattleAction)

You’re done!

If you’ve followed this tutorial correctly, you’ll have a full on functioning battle system!
Enjoy your creation, play around with it and have fun.
Make sure to experiment with it and change it to how you’d like it, I’d love to see what y’all create with this!


Resources

If you’d like to have the place I made for this tutorial, you can:

or

  • Edit the uncopylocked place: here

Thanks for reading!

This was my fourth community tutorial, hope you did enjoy.
As per usual, don’t hessitate to ask about anything or correct me.
Peace!

2 Likes