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
- Creating player stats
- Setting up CreatureStats, MoveStats and NPCStats
- Making the BattleCreator, OOP approach
- Handling battles
- Client-side interaction
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:
- Download the place file: BattleSystemTutorial.rbxl (44.1 KB)
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!