How to make a simple Round System with Object Oriented Programming

Hey everyone, in this tutorial I will be teaching you how to make a fully functional and configurable round system that involves object oriented programming.

It’s recommended that you have a basic understanding on:

  1. Metatables and their use cases
  2. Creating your own classes in module scripts
  3. Remote events and how they are useful in server-client communication
  4. (optional) Guis, such as frames and labels, knowing their properties.

Okay, so before we actually create our scripts, we want to keep our game organised, right? So, I’ll load up a fresh baseplate and create some folders.

Firstly, let’s create two folders in ServerScriptService. We’ll create one called Modules to store our module scripts and one called Scripts to store our server scripts.

image

Next and lastly, let’s create a folder in ReplicatedStorage called RemoteEvents.

image

Now, this next part is optional and completely up to you, but it will guide you in creating the Gui for the round system, being the status of the game and the timer. The end product should look something like this:

Firstly, let’s create a ScreenGui in StarterGui and I’ll call it StatusGui, but really you can call it whatever you like as long as it is memorable and distinct to you. Make sure that IgnoreGuiInset is set to true so that your gui stays at the top, or else it will indent to the middle of your screen.

image

Next, we’ll add a frame in the Gui called MainFrame, which is essentially just a transparent frame which takes up the whole screen, and it’s a personal preference I have so that my guis are more compatible with tweening and enabling/disabling, but it’s up to you.

image

After that, let’s go ahead and add a frame called Container, which will actually contain the textLabels for both our status and our timer.
image

The container should be located around here, (this is near the top of the screen)

Once we’ve done that, lets add in two textLabels called Status and Timer

image

They should look like this, but it does not need to be exact, whatever style you’re going for works fine:

Oh yeah, you can also make the container fully transparent so you can see the text better. I just had it translucent so I could position the text easily.

And there you go! Everything is ready and we can now start with the main focus of this tutorial, scripting the round system. Let’s go ahead and create a ModuleScript in our Modules folder in ServerScriptService. Let’s call this script Round, as it is essentially a class that we can use in our server script that we will create later on.

image

Now, let’s create a simple constructor for our class.

local Players = game:GetService("Players")

local Round = {}
Round.__index = Round

function Round.new()
	local self = setmetatable({}, Round)
	
	return self
end

return Round

Let’s go ahead and define some properties for our Round class.

local Players = game:GetService("Players")

local Round = {}
Round.__index = Round

function Round.new()
	local self = setmetatable({}, Round)
	self.IntermissionTime = 15
	self.GraceTime = 10 -- this is the period after intermission but before round
	self.RoundTime = 60
	self.IsRound = false -- this is quite useful for certain functions
	return self
end

return Round

If you want your properties to be easily configurable, you can use parameters instead, but for my example I won’t use them.

local Players = game:GetService("Players")

local Round = {}
Round.__index = Round

function Round.new(intermissionTime, graceTime, roundTime, isRound)
	local self = setmetatable({}, Round)
	self.IntermissionTime = intermissionTime
	self.GraceTime = graceTime
	self.RoundTime = roundTime
	self.IsRound = isRound
	return self
end

return Round

Okay, let’s create a new function called Round.EnoughPlayers(), and the goal of this function is to return whether #Player:GetPlayers() is >= (number). This is so that you don’t start the round with only 1 player, for example.

function Round.EnoughPlayers()
	return #Players:GetPlayers() >= 2 -- minimum of 2 players to start a round
end

return Round

The next step is to create a function that counts down from the intermissionTime of the Round class to 0. we can viszualise this with a for loop and print statements.

function Round:Intermission()
	if not self.EnoughPlayers() then return end
	for index = self.IntermissionTime, 0, -1 do
		print(index)
		task.wait(1)
	end
end

return Round

Okay, we’ve gotten to the point where we might want to start testing out our class, so let’s insert a script into our Scripts folder in ServerScriptService called RoundServer.

image

Now, we want to require the Round module we have in our Modules folder, so let’s do that quickly.

local ServerScriptService = game:GetService("ServerScriptService")

local Round = require(ServerScriptService.Modules.Round)

Let’s also create a new Round object and lets run the Round:Intermission() function.

local ServerScriptService = game:GetService("ServerScriptService")

local Round = require(ServerScriptService.Modules.Round)

local newRound = Round.new()
newRound:Intermission()

Why is it not working? Have I done something wrong?

image

There’s nothing in the output, but remember how the Round.EnoughPlayers() was implemented?, Well, I set it to return whether #Players:GetPlayers() was greater than or equal to 2, but we ran the script in a server with only 1 player. Now, you may be thinking "oh that’s a pretty easy fix I’ll just set the number to 1. Well technically this does work but, it still doesn’t work!? Now, the second problem is we haven’t waited for the player object to actually load in. To fix this we can run the code within a Player.PlayerAdded event and now it should work.

local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")

local Round = require(ServerScriptService.Modules.Round)
local newRound = Round.new()

Players.PlayerAdded:Connect(function()
	newRound:Intermission()
end)

image

And alas, it works! But, we haven’t done much, so this is where the tutorial really starts to get fun as we make more progress through it.

There’s a term in programming known as Abstraction, where we want the surface level of our code to be as simple as just running a function. To implement this, we can create another function called Round:Initiate(), which will sort of daisy chain all of our other methods/functions together. In the server script, we look to just call this function once and have the entirety of our round system up and running seamlessly. So, let’s go ahead and make it.

function Round:Initiate()
	if not self.EnoughPlayers() then return end -- we run this here so we can remove it from the Round:Intermission() function so it applies to the whole thread
	if self.IsRound then return end -- to prevent Players.PlayerAdded() from creating a new round system, we will set this to true when the round starts.
	self:Intermission() -- this is important, make sure it is self:Intermission() and not Round:Intermission() or else this will error; this applies for other functions that we will call
end

Our server script should now look like this:

local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")

local Round = require(ServerScriptService.Modules.Round)
local newRound = Round.new()

Players.PlayerAdded:Connect(function()
	newRound:Initiate()
end)

image

There we go! We have successfully abstracted our code! This abstraction will be very useful later on.

Now, let’s create a new function that teleports/brings the players to a random position. In my example, I will be using the baseplate as a map but you can make it whatever you like.

function Round:BringPlayers()
	self.IsRound = true -- as mentioned earlier, prevents new players that join from creating a new round as we exit the Round:Initiate() function if this is set to true
	for index, player in Players:GetPlayers() do
		local character = player.Character
		local randomNumberX = math.random(-100, 100)
		local randomNumberZ = math.random(-100, 100)
		character:SetPrimaryPartCFrame(CFrame.new(randomNumberX, 0, randomNumberZ)) -- using random number to randomise cframe of character
        print("Working!")
	end
end

image

There we go! It prints “Working!” and my character’s position has changed.

image

Okay, we’re getting progress in steadily, but I’m sorry this tutorial is quite long. Anyways, let’s create a function for the grace period (the time after intermission has ended and before the round has started)

function Round:GracePeriod()
	for index = self.GraceTime, 0, -1 do
		print(index .. " seconds left till the round starts.") -- i concatenated this so that you don't get confused with the intermission print statement.
		task.wait(1)
	end
end

Also, have you noticed how we haven’t changed the server script at all? We just add the functions into the Round:Initiate() function so that it chains for us, making our code really neat and organised.

function Round:Initiate()
	if not self.EnoughPlayers() then return end
	if self.IsRound then return end
	self:Intermission()
	self:BringPlayers()
	self:GracePeriod()
end

return Round
-- module script with chaining functions in Round:Initiate()
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")

local Round = require(ServerScriptService.Modules.Round)
local newRound = Round.new()

Players.PlayerAdded:Connect(function()
	newRound:Initiate()
end)
-- server script is still the same

This is why abstraction is really nice when it comes to programming, and this is also one of the reasons why I use object oriented programming in my projects, as it reduces alot of clutter in my scripts and makes them truly organised.

Let’s create a new function called Round:Start() which will create a for loop similar to the intermission and grace period functions, but, when this timer finishes, self.IsRound will be set to false.

function Round:Start()
	for index = self.RoundTime, 0, -1 do
		print(index .. " seconds left till the round ends.")
		task.wait(1)
	end
	self.IsRound = false
end

Now, we’ve basically finished the round system, but it doesn’t loop, so to do that we need to run a while true do loop in Round:Initiate()

function Round:Initiate()
	while true do
		if not self.EnoughPlayers() then return end
		if self.IsRound then return end
		self:Intermission()
		self:BringPlayers()
		self:GracePeriod()
		self:Start()
	end
end

return Round

We’ve completed the main part of our round system! It’s not as complicated as it looks, but we still need to send information about the status of our round to the client (whether or not it is intermission or grace period, etc).

This is what our module script currently looks like:

local Players = game:GetService("Players")

local Round = {}
Round.__index = Round

function Round.new()
	local self = setmetatable({}, Round)
	self.IntermissionTime = 15
	self.GraceTime = 15
	self.RoundTime = 60
	self.IsRound = false
	return self
end

function Round.EnoughPlayers()
	return #Players:GetPlayers() >= 1
end

function Round:Intermission()
	for index = self.IntermissionTime, 0, -1 do
		print(index .. " seconds left till the intermission ends.")
		task.wait(1)
	end
end

function Round:BringPlayers()
	self.IsRound = true
	for index, player in Players:GetPlayers() do
		local character = player.Character
		local randomNumberX = math.random(-100, 100)
		local randomNumberZ = math.random(-100, 100)
		character:SetPrimaryPartCFrame(CFrame.new(randomNumberX, 0, randomNumberZ))
	end
	task.wait(1)
end	

function Round:GracePeriod()
	for index = self.GraceTime, 0, -1 do
		print(index .. " seconds left till the round starts.")
		task.wait(1)
	end
end

function Round:Start()
	for index = self.RoundTime, 0, -1 do
		print(index .. " seconds left till the round ends.")
		task.wait(1)
	end
	self.IsRound = false
end

function Round:Initiate()
	while true do
		if not self.EnoughPlayers() then return end
		if self.IsRound then return end
		self:Intermission()
		self:BringPlayers()
		self:GracePeriod()
		self:Start()
	end
end

return Round

And our server script is still the same!

local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")

local Round = require(ServerScriptService.Modules.Round)
local newRound = Round.new()

Players.PlayerAdded:Connect(function()
	newRound:Initiate()
end)


Let’s move on to the client-side of our round system. We need to create two remote events in our RemoteEvents folder in ReplicatedStorage. The first will be called TimerRemoteEvent and the second will be called StatusRemoteEvent. These will send information to the client about the timer and status respectively.

image

Now that we have done that, let’s create a script in StarterPlayerScripts called RoundClient.

image

And we’ll obviously get the two remote events we’ve just created.

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local statusRemoteEvent = ReplicatedStorage.RemoteEvents.StatusRemoteEvent
local timerRemoteEvent = ReplicatedStorage.RemoteEvents.StatusRemoteEvent

Let’s go ahead and create functions for whenever FireAllClients() gets called for both remote events. We’ll also get the statusGui that we created at the start of the tutorial.

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local statusRemoteEvent = ReplicatedStorage.RemoteEvents.StatusRemoteEvent
local timerRemoteEvent = ReplicatedStorage.RemoteEvents.TimerRemoteEvent

local player = Players.LocalPlayer
local playerGui = player.PlayerGui
local statusGui = playerGui:WaitForChild("StatusGui")

local function onStatusRemoteEvent(status: string)
	-- our code
end

local function onTimerRemoteEvent(timer: number)
	-- our code
end

statusRemoteEvent.OnClientEvent:Connect(onStatusRemoteEvent)
onTimerRemoteEvent.OnClientEvent:Connect(onTimerRemoteEvent)

Okay, we’ve missed two crucial steps. The first being adding two new properties to our Round class. Let’s do this quickly and add them; one being called self.Status and the other being called self.Timer.

local Round = {}
Round.__index = Round

function Round.new()
	local self = setmetatable({}, Round)
	self.IntermissionTime = 2
	self.GraceTime = 5
	self.RoundTime = 10
	self.IsRound = false
	self.Status = "" -- our new property
    self.Timer = 0 -- our other new property
	return self
end

And the second step is to update the status depending on the function and the timer depending on the index of the for loop.

local Players = game:GetService("Players")

local Round = {}
Round.__index = Round

function Round.new()
	local self = setmetatable({}, Round)
	self.IntermissionTime = 2
	self.GraceTime = 5
	self.RoundTime = 10
	self.IsRound = false
	self.Status = ""
	self.Timer = 0
	return self
end

function Round.EnoughPlayers() -- this is a dot (.) function so we use the metatable instead of self
    Round.Status = "Not enough players"
	return #Players:GetPlayers() >= 1
end

function Round:Intermission()
	self.Status = "Intermission"
	for index = self.IntermissionTime, 0, -1 do
		self.Timer = index
		task.wait(1)
	end
end

function Round:BringPlayers()
	self.IsRound = true
	self.Status = "Bringing players..."
	for index, player in Players:GetPlayers() do
		local character = player.Character
		local randomNumberX = math.random(-100, 100)
		local randomNumberZ = math.random(-100, 100)
		character:SetPrimaryPartCFrame(CFrame.new(randomNumberX, 0, randomNumberZ))
	end
	task.wait(1)
end	

function Round:GracePeriod()
	self.Status = "Grace"
	for index = self.GraceTime, 0, -1 do
		self.Timer = index
		task.wait(1)
	end
end

function Round:Start()
	self.Status = "Round"
	for index = self.RoundTime, 0, -1 do
		self.Timer = index
		task.wait(1)
	end
	self.IsRound = false
end

function Round:Initiate()
	while true do
		if not self.EnoughPlayers() then return end
		if self.IsRound then return end
		self:Intermission()
		self:BringPlayers()
		self:GracePeriod()
		self:Start()
	end
end

return Round

Our module script should now be looking like this with the new additions. But, we need to use the remote events to FireAllClients()

I mean, that’s pretty simple, but let’s actually put it into action. So, we need to get the remote events like we did in the client script. I’m assuming you know how to do that so let’s get right into implementing it into our functions, as this will be called within our methods.

function Round:GracePeriod()
	self.Status = "Grace"
	statusRemoteEvent:FireAllClients(self.Status) -- right after self.Status is being defined
	for index = self.GraceTime, 0, -1 do
		self.Timer = index
		timerRemoteEvent:FireAllClients(self.Timer) -- right after self.Timer is being defined
		task.wait(1)
	end
end

Make sure you do this for every function where it’s needed. Don’t worry though. after we finish this tutorial I’ll paste all 3 scripts at the bottom. Okay, let’s move on to editing the statusGui Status and Timer with the information we pass from the module script. To test if it works, you can add a print statement.

ocal function onStatusRemoteEvent(status: string)
	print(status)
end

local function onTimerRemoteEvent(timer: number)
	print(timer)
end

statusRemoteEvent.OnClientEvent:Connect(onStatusRemoteEvent)
timerRemoteEvent.OnClientEvent:Connect(onTimerRemoteEvent)

image

There you go! we have the timer and status information on the client! (I changed the intermission time to 5 seconds so I could test faster but don’t worry about that).

Now, we move on to the final step of this tutorial. Updating the statusGui with the information we have now received on the client. We can remove the print statements now.

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local statusRemoteEvent = ReplicatedStorage.RemoteEvents.StatusRemoteEvent
local timerRemoteEvent = ReplicatedStorage.RemoteEvents.TimerRemoteEvent

local player = Players.LocalPlayer
local playerGui = player.PlayerGui
local statusGui = playerGui:WaitForChild("StatusGui")
local container = statusGui.MainFrame.Container

local function onStatusRemoteEvent(status: string)
	container.Status.Text = status
end

local function onTimerRemoteEvent(timer: number)
	container.Timer.Text = timer
end

statusRemoteEvent.OnClientEvent:Connect(onStatusRemoteEvent)
timerRemoteEvent.OnClientEvent:Connect(onTimerRemoteEvent)

There you go. You’ve created your own simple round system with object oriented programming! I did mention I would paste the scripts so here they are.

Round module script

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local statusRemoteEvent = ReplicatedStorage.RemoteEvents.StatusRemoteEvent
local timerRemoteEvent = ReplicatedStorage.RemoteEvents.TimerRemoteEvent

local Round = {}
Round.__index = Round

function Round.new()
	local self = setmetatable({}, Round)
	self.IntermissionTime = 15
	self.GraceTime = 15
	self.RoundTime = 60
	self.IsRound = false
	self.Status = ""
	self.Timer = 0
	return self
end

function Round.EnoughPlayers()
	Round.Status = "Not enough players"
	statusRemoteEvent:FireAllClients(Round.Status)
	return #Players:GetPlayers() >= 1
end

function Round:Intermission()
	self.Status = "Intermission"
	statusRemoteEvent:FireAllClients(self.Status)
	for index = self.IntermissionTime, 0, -1 do
		self.Timer = index
		timerRemoteEvent:FireAllClients(self.Timer)
		task.wait(1)
	end
end

function Round:BringPlayers()
	self.IsRound = true
	self.Status = "Bringing players..."
	statusRemoteEvent:FireAllClients(self.Status)
	for index, player in Players:GetPlayers() do
		local character = player.Character
		local randomNumberX = math.random(-100, 100)
		local randomNumberZ = math.random(-100, 100)
		character:SetPrimaryPartCFrame(CFrame.new(randomNumberX, 0, randomNumberZ))
	end
	task.wait(1)
end	

function Round:GracePeriod()
	self.Status = "Grace"
	statusRemoteEvent:FireAllClients(self.Status)
	for index = self.GraceTime, 0, -1 do
		self.Timer = index
		timerRemoteEvent:FireAllClients(self.Timer)
		task.wait(1)
	end
end

function Round:Start()
	self.Status = "Round"
	statusRemoteEvent:FireAllClients(self.Status)
	for index = self.RoundTime, 0, -1 do
		self.Timer = index
		timerRemoteEvent:FireAllClients(self.Timer)
		task.wait(1)
	end
	self.IsRound = false
end

function Round:Initiate()
	while true do
		if not self.EnoughPlayers() then return end
		if self.IsRound then return end
		self:Intermission()
		self:BringPlayers()
		self:GracePeriod()
		self:Start()
	end
end

return Round

RoundServer server script (it’s still the same)

local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")

local Round = require(ServerScriptService.Modules.Round)
local newRound = Round.new()

Players.PlayerAdded:Connect(function()
	newRound:Initiate()
end)

RoundClient client script

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local statusRemoteEvent = ReplicatedStorage.RemoteEvents.StatusRemoteEvent
local timerRemoteEvent = ReplicatedStorage.RemoteEvents.TimerRemoteEvent

local player = Players.LocalPlayer
local playerGui = player.PlayerGui
local statusGui = playerGui:WaitForChild("StatusGui")
local container = statusGui.MainFrame.Container

local function onStatusRemoteEvent(status: string)
	container.Status.Text = status
end

local function onTimerRemoteEvent(timer: number)
	container.Timer.Text = timer
end

statusRemoteEvent.OnClientEvent:Connect(onStatusRemoteEvent)
timerRemoteEvent.OnClientEvent:Connect(onTimerRemoteEvent)

And there we have it. The end of this tutorial. If you have any suggestions or feedback it would be highly appreciated to leave a comment and I will try my best to respond. Have a great day guys

Made by @bosszemaa (my 1st post / tutorial so it’s probably not the best)

12 Likes