How To Create a Modular Job System

How It Works:

Players will either have the option to select a job through gui, or go to a location and activate a proximity prompt to start their shift at a job. If the player leaves the job area or selects a quit job gui, their shift will end and they will be paid with a leaderstat.

A while ago, I created a similar tutorial but I’ve gotten request to add the ability to add multiple jobs. Using this script, you will be able to add as many jobs to your game as you like! Also, I promised in my last tutorial I would make a part two on tasks. which I never did lol, but this time I will deliver!

Step 1: Set Up

You must first create a part which will cover the floor of your job’s workspace. It may be invisible and have collisions off or even directly below the surface if need be. Name each area along the lines of “CustodianArea”, “CookArea” etc…

Next place a proximity prompt inside a part and put that somewhere inside you area. For example, if the job is a custodian, you can place it inside of a register. These part must also be names appropriately (ex .CustodianPrompt). The default settings are alright but I’d recommend increasing hold duration and changing the action text of course.

Ensure your workspace looks something like this:
1

:bangbang: It is extremely important that your parts are organized in a consistent way. See how I have one folder labeled GameStuff and each folder inside is named after a job, the proximity prompts and job areas are a direct child of these job folders. I would recommend setting up your workspace exactly as mine since this is how job parts are located.

The rest of your explorer should look like this:
2

**I changed EventModule name to PlayerModule and TaskModule is not necessary yet I will discuss it in part 2,

To clarify, a remote event in replicated storage, a script with 2 module scripts inside it in server script service, a gui inside a folder in server storage, and a local script in starter player scripts.

This is what the gui looks like inside:
4

And this is what the GUI and my job areas looks like:

The JobFrame GUI visibility is set to false and the Selector is true.

You must create a button for each job you have. I’ll probably automate this in the second part lol.

Step 2: Setting Up the Job Data Table

Job Manager will be our main script. The functions in this script kind of jump around all over the place so ill put a comment at the top of each block of code what the name of the script is, even though its kinda obvious sometimes lol.

Job Manager connects to all the modules, so we start off by defining them, then, our services and variables:

--Job Manager
local DataModule = require(script.DataModule)
local PlayerModule = require(script.PlayerModule)

local Players = game:GetService("Players")
local RunS = game:GetService("RunService")
local SS = game:GetService("ServerStorage")
local RS = game:GetService("ReplicatedStorage")

local Remote = RS:WaitForChild("RemoteEvent")

local PlayerUpdateInterval = 0.1 
local PaymentInterval = 1   --Time between payments (seconds)

local UpdateAccumulator = 0
local PaymentAccumulator = 0

The last 4 variables will be discussed in our heartbeat loop later on. You may change the payment interval to whatever you like, I kept it at one second for testing purposes.

Now we will call a function which is not yet defined yet:

--Job Manager
DataModule.SetupJob("Cashier", "RegPrompt", "RegArea")
DataModule.SetupJob("Stocker", "StockPrompt", "StockArea")
DataModule.SetupJob("Custodian", "CustPrompt", "CustArea")

For the sake of this tutorial we will have these three jobs. The first string can be the name of any job you want, the second will be the name of the prompt part (the parent of the proximity prompt specifically), and last is the name of the area part.

Now, inside of DataModule, make sure to change the name of the prewritten script, then type this:

--Data Module
JobData = {
	Custodian = {
		Players = {},
	},

	Stocker = {
		Players = {},
	},

	Cashier = {
		Players = {},
	},
}

This is a table where the information about the prompts and areas as well as other useful information relating to our jobs will be stored.

Next. create this function:

--Data Module
function DataModule.SetJobData(JobName, Key, Value)
	if  JobData[JobName] then
		JobData[JobName][Key] = Value
	end
end

This will be for updating the information in the table.

Next we define the function we last wrote in Job Manager.

--Data Module
function DataModule.SetupJob(JobName, PromptName, AreaName)
	local GameStuff = workspace:WaitForChild("GameStuff")
	local JobFolder = GameStuff:FindFirstChild(JobName)

	if JobFolder then
		local Prompt = JobFolder:FindFirstChild(PromptName) and JobFolder[PromptName]:FindFirstChild("ProxPrompt")
		local JobArea = JobFolder:FindFirstChild(AreaName)

		if Prompt and JobArea then
			DataModule.SetJobData(JobName, "Prompt", Prompt)
			DataModule.SetJobData(JobName, "JobArea", JobArea)
		else
			warn("Invalid job setup for:", JobName)
		end
	else
		warn("Missing job object:", JobName)
	end
end

Essentially what this function does is:

  1. Searches the workspace for “GameStuff”

  2. Searches “GameStuff” for the “JobFolder” - this is the first argument you provided when this function was called in JobManager, ex if you put “Cashier”, it will search for a folder named “Cashier”

  3. Ensures this folder exists, if not it will throw a warning

  4. Inside this “JobFolder” it will search for the prompt (2nd argument provided) and the job area (3rd argument provided)

  5. If prompt and area exists then it will call the function to add them to the job data table, if not then it will throw a warning

Ensure everything leads to how YOU set up your own workspace. Not to be redundant but, if you didn’t organize and name your objects appropriately, then this is where the script would break. You should run the script now to check if you get the warning.

Step 2b: Managing Table Data

We will require a couple more functions which we’ll use throughout this system to manage the data in this table.

--Data Module
function DataModule.GetJobData(JobName) -- Gets data from a specific job
	return  JobData[JobName]
end

function DataModule.GetAllJobData() -- Gets data from all jobs
	return JobData
end

function DataModule.RemoveJobData(JobName) -- Sets data to nil, removing any data
	if JobData[JobName] then
		JobData[JobName] = nil
	end
end

Jumping back to Job Manager we will add this loop:

--Job Manager
for JobName, JobInfo in pairs(DataModule.GetAllJobData()) do
	if not JobInfo.Prompt or not JobInfo.JobArea then
		warn("Invalid job data for: " .. JobName)

		DataModule.RemoveJobData(JobName)
	end
end

As stated earlier, one part out of place can stop the entire the script, but this loop should help prevent that . It works by simply removing that job entirely. Other jobs should still work.

Step 2c: Adding More Job Data

Now we will handle the data when a player joins the server with this:

--Job Manager
Players.PlayerAdded:Connect(function(Player)
	local PlayerGui = Player:WaitForChild("PlayerGui")
	local JobGui = SS:WaitForChild("JobStuff").JobGui

	if JobGui then
		local ClonedJobGui = JobGui:Clone()
		ClonedJobGui.Parent = PlayerGui

		local Frame = ClonedJobGui.SelectorFrame

		for JobName, JobInfo in pairs(DataModule.GetAllJobData()) do
			JobInfo.Button = Frame:FindFirstChild(JobName)

			DataModule.SetJobData(JobName, "Button", JobInfo.Button)
		end
	end
end)

This is more so related to the GUI than anything. Once the player joins, it gives them a GUI, then it loops through the buttons from the selector frame (the gui where the player picks a job) and adds it to the job data table. Make sure everything is named and connected back to your own!

Next, when a player leaves:

--Job Manager
Players.PlayerRemoving:Connect(function(Player)
	PlayerModule.CallFirePlayer(Player)
end)

Of course they must be fired from their job. We will write this function later.

Step 3: The Game Loop

Firstly, we start off by creating this local function:

-- Job Manager
local function ProcessPlayers(Action, Interval)
	for _, Player in ipairs(Players:GetPlayers()) do
		local success, err = pcall(function()
			if PlayerModule.GetVariable(Player, "Hired") then
				Action(Player, Interval) 
			end
		end)
		if not success then
			warn("Error processing player: " .. tostring(err))
		end
	end
end

We will use this a few times inside our loop. It goes through all players and checks if they’re hired. Then it’ll throw the function originally provided. It is also wrapped in a pcall.

Also, for those who don’t know what a pcall is, essentially, it continues the script even if something goes wrong, which is then when we fire the warning. This is crucial since, like I said, this loop is like the heart of our script. if anything goes the whole thing is broken. Pretty much every function in this system can be improved with a pcall, but for the sake of simplicity I did not include them - but I highly recommend adding them!

Now we will define a hearbeat loop which aptly named, is like the heart of our system. Everything will run based on this function.

RunS.Heartbeat:Connect(function(Step)
	UpdateAccumulator += Step
	PaymentAccumulator += Step

	if UpdateAccumulator >= PlayerUpdateInterval then
		UpdateAccumulator -= PlayerUpdateInterval

So essentially, heartbeats fire every frame. But, this can greatly differ depending on device (ex. 30 frames a second) and we want this to happen at a set interval (every 0.1 second for our case). And to do that, we have an accumulator (which is just a variable that = 0) . We add step (aka delta time, aka time between when heartbeat is fired) to the accumulator each time its fired, which adds up. Once we get to 0.1 seconds elapsed is when the rest of the function is fired and we reset the accumulator. We also have the payment accumulator which fires on a interval of 1 second. (But you can change this)

Now for the update player loop:

--Job Manager
				ProcessPlayers(function(Player, Interval)
			local JobName = PlayerModule.GetVariable(Player, "Job")
			local JobInfo = DataModule.GetJobData(JobName)

			if JobInfo then
				local HRP = PlayerModule.GetVariable(Player, "Root")
				PlayerModule.UpdatePlayer(Player, HRP, JobInfo.Prompt, JobInfo.JobArea)
			end
		end, PlayerUpdateInterval)
	end

Lets break it down. We fire that ProcessPlayers function from before (which checks if theyre hired). Then we grab the players job data. After that we get their humanoid root part and pass that along to the update player functions, which we’ll define later of course.

The reason this information is checked on a loop is because a player can leave their job area or activate the proximity prompts. at anytime. It’s also crucial for the payment timer which is next:

-- Job Manager
	if PaymentAccumulator >= PaymentInterval then
		PaymentAccumulator -= PaymentInterval 
		ProcessPlayers(function(Player)
			PlayerModule.PayPlayer(Player)
		end)
	end
end)

Same thing as before pretty much but only checks if player is hired.

Step 4: Setting Up Proximity Prompts

Now onto managing the proximity prompts

--Job Manager
local function ConnectJobPrompts()
	for JobName, JobInfo in pairs(DataModule.GetAllJobData()) do
		if JobInfo.Prompt then
			JobInfo.Prompt.Triggered:Connect(function(Player)
				PlayerModule.HirePlayer(Player, JobName, JobInfo.JobArea, JobInfo.Prompt)
			end)
		end
	end
end

ConnectJobPrompts()

Since we added all the prompts to the job data table earlier, we can loop through each of them and check if they are triggered. Once we do that, we can collect the other information about this job as well as the player who triggered it, then we send it over in a function and give them their job.

Step 4b: Setting Up The GUI Buttons

Now what If the player hits the gui button instead:

--Job Manager
local function HandleRemoteEvent(Player, Action, Job)
	if Action == "Hire" then
		local JobInfo = DataModule.GetJobData(Job)
		if JobInfo then
			PlayerModule.TeleportToJob(Player, JobInfo.JobArea)
			PlayerModule.HirePlayer(Player, Job, JobInfo.JobArea, JobInfo.Prompt)
		end
	elseif Action == "Quit" then
		PlayerModule.CallFirePlayer(Player)
	end
end


Remote.OnServerEvent:Connect(function(Player, Action, Job)
	HandleRemoteEvent(Player, Action, Job)
end)

We can’t get the information we need server side only, so we have to use a remote event.

You can paste this same script into each of the GUI Buttons( quit as well as the ones from the selector)

--Local Script
local Button = script.Parent

local RS = game:GetService("ReplicatedStorage")
local Remote = RS:WaitForChild("RemoteEvent")

Button.Activated:Connect(function()
	Remote:FireServer("Quit")
end)

But for the selector buttons change the fire server to:

Remote:FireServer("Hire", Button.Name) -- Whatever job it is should be the name

Everything to do with your Job Manager script is now completely finsihed :+1:

Step 5: Setting Up Player Data Table

Now onto the final script, open up the Player Module, make sure to change the prewritten code, and type this:

--Player Module
local DataModule = require(script.Parent.DataModule)

local RS = game:GetService("ReplicatedStorage")
local Remote = RS:WaitForChild("RemoteEvent")

local PlayerData = {}

Player data is another table that we’ll use that stored data specific to each player and their job.

--Player Module
function PlayerModule.GetVariable(Player, Var)
	local JobGui = Player:FindFirstChild("PlayerGui") and Player.PlayerGui:FindFirstChild("JobGui")
	if not JobGui then return nil end

	if Var == "Root" then --players rootpart
		local HRP = Player.Character and Player.Character:FindFirstChild("HumanoidRootPart")
		return HRP
	elseif Var == "GUI" then -- players gui
		return JobGui
	elseif Var == "Hired" then --if the player is hired or not
		return PlayerData[Player] and PlayerData[Player].Hired
	elseif Var == "Job" then -- player's job
		return PlayerData[Player] and PlayerData[Player].Job
	end
end

This is how we’ll grab info from the player data table.

Now that I think about it, this is not the best way to do this and I couldv’e implemented this table into data module since it needs the same functions to call and add info, but, I’m too far in this tutorial now :sweat_smile: I guess I can edit this later. Anyway!

Step 6: Managing Player’s Location

-- Checks if player is in job area
function PlayerModule.IsInJob(Position, Area)
	if not Area then return false end
	local V3 = Area.CFrame:PointToObjectSpace(Position) 
	return (math.abs(V3.X) <= Area.Size.X / 2)
		and (math.abs(V3.Z) <= Area.Size.Z / 2)
		and (V3.Y >= 0)
end

This is a function that will simply return a true or a false depending on if the player Root Part is above the JobArea part from earlier. It only compares the X and Z position meaning, like I said earlier, the player can still jump and stand on things, even an entire floor part.

Onto the next function:

function PlayerModule.TeleportToJob(Player, JobArea)
	local HRP = PlayerModule.GetVariable(Player, "Root")
	if HRP then
		HRP.CFrame = JobArea.CFrame + Vector3.new(0, 5, 0)
	end
end

All this does it place the player 5 studs above and in the center of the job area, additionally, you can place a part somewhere you wish the player to begin their shift and adjust the code from there. Its important to also consider players with oversized avatars and ensuring they won’t clip into the area surrounding.

I personally think having the player assigned to a specific area for their job is the way to go, but I can see how this might not be necessary for some games and for this you can skip any mention of these functions as well as the mention of JobArea previously in this tutorial.

Step 7: Hiring

--Player Module
function PlayerModule.HirePlayer(Player, JobName, Area, Prompt)
	local HRP =  PlayerModule.GetVariable(Player, "Root")
	if not HRP then return end

	if PlayerModule.IsInJob(HRP.Position, Area) ==  false then
		PlayerModule.TeleportToJob(Player, Area)
	end

	PlayerData[Player] = PlayerData[Player] or {
		Hired = false,
		Earned = 0,
		Job = JobName,
		JobArea = Area,
	}

	local Data = PlayerData[Player]

This is the first half of our hiring function, remember this is fired whenever a gui button or a prompt is activated. First, we get the player root part, if there is no root part then something isn’t right and we don’t hire that player. Next we see if they’re in the job area, if not then we put them there. Finally, we define the info in the player data table. Then we set the player data to this data.

Now, for the second half of the function:

--Player Module
	if not Data.Hired and PlayerModule.IsInJob(HRP.Position, Area) then
		Data.Hired = true

		local JobGui = PlayerModule.GetVariable(Player, "GUI")

		if JobGui then
			JobGui.JobFrame.JobTitle.Text = JobName
			JobGui.JobFrame.CashEarned.Text = "Cash Earned: $" .. Data.Earned
			JobGui.SelectorFrame.Visible = false
			JobGui.JobFrame.Visible = true
		end

		Remote:FireClient(Player, "JobPrompts", Prompt, false)
	end
end

Now we ensure the player is not already hired somewhere else. Then we check that they are in the jobs area. Now, we set the hired variable to true. This change will activate the heartbeat from earlier! Now we check for the JobGui and update it. We change the default text to the name of the players job, since the player just got hired Data.Earned will = 0, and then we make the selector frame invisible and the job frame visible.

Lastly, we fire a Remote Event on the client to make the job prompts invisble.

Step 7b: Managing the Prompts

Now, lets go to that local script we put in starter player server scripts and type this:

--Local Prompts
local Player = game.Players.LocalPlayer
local RS = game:GetService("ReplicatedStorage")
local Remote = RS:WaitForChild("RemoteEvent")

Remote.OnClientEvent:Connect(function(Event, Arg, Bool)
	if Event == "JobPrompts" then
		if Arg and Arg:IsA("ProximityPrompt") then
			Arg.Enabled = Bool -- Enable or disable based on the argument
		end
	end
end)

So basically, this disables or enables the hiring proximity prompts on the players side when they get hired or fired. Because, of course, if they activated the prompt again after being hired that wouldn’t make sense. I plan on having other remote events which is why the arguments passed along are so vaguely named lol.

Now back to the PlayerModule.

Step 7c: Firing

function PlayerModule.FirePlayer(Player, Prompt)
	local JobGui = Player:FindFirstChild("PlayerGui") and Player.PlayerGui:FindFirstChild("JobGui")
	local Cash = Player:FindFirstChild("leaderstats") and Player.leaderstats:FindFirstChild("Cash")
	local Data = PlayerData[Player]

	if not JobGui or not Cash or not Data then return end

	Cash.Value = Cash.Value + Data.Earned
	Data.Hired = false

	JobGui.SelectorFrame.Visible = true
	JobGui.JobFrame.Visible = false

	Remote:FireClient(Player, "JobPrompts", Prompt, true)

	PlayerData[Player] = nil
end

I’ll go over the leaderstats things last since its kinda unrelated. But first of all, we check if player has all job data they need, and if they don’t, then something isn’t right and it ends the function. Then we add the amount the player earned during their shift (which was accumulating in the player data table) and add it to their leaderstat value. Next we assign the value of hired to false. This stops the heartbeat loop from earlier. Then we make the selector frame visible and fire the remote which turns the prompts visible also. This makes it so the player is free to choose another job. Finally, we reset their data table.

We also include this function:

function PlayerModule.CallFirePlayer(Player, Job)
	local JobName = PlayerModule.GetVariable(Player, "Job")
	local JobInfo =  DataModule.GetJobData(JobName)

	if JobInfo then
		PlayerModule.FirePlayer(Player, JobInfo.Prompt)
	end
end

This function is fired (lol fired haha) in Job Manager when we only have the Player to go off of and no job data (specifically the prompt data, which fireplayer needs to deactivate it), which is why this function grabs the job data first and checks if it exists before firing the player.

Step 8: Payment

This function was one mentioned inside the heartbeat from JobManager

--Player Module
function PlayerModule.PayPlayer(Player)
	local Data = PlayerData[Player]
	if not Data then return end
	
	if Data.Hired then
		Data.Earned = Data.Earned + 10 -- amount payer is played
	else
		Data.Earned = 0		
	end

	local JobGui = PlayerModule.GetVariable(Player, "GUI")
	if JobGui then			
		JobGui.JobFrame.CashEarned.Text = "Cash Earned: $" .. Data.Earned
	end
end

What it does is check if the player has a data table, if not then thats suspicious so it ends the function. Then it checks if they’re hired, if yes then it’ll add 10 dollars to their data table. You may make this value whatever you’d like. If they’re not hired then they’re being fired so it resets the amount earned. Lastly, we update the GUI to reflect this earned amount.

Step 9: Updating the Player

This was the other function mentioned in the heartbeat loop.

-- Player Module
function PlayerModule.UpdatePlayer(Player, HRP, Prompt, Area)
	local JobGui = PlayerModule.GetVariable(Player, "GUI")
	if not JobGui then return end

	if PlayerModule.IsInJob(HRP.Position, Area) then
		JobGui.SelectorFrame.Visible = false
		JobGui.JobFrame.Visible = true
	elseif not PlayerModule.IsInJob(HRP.Position, Area) or not PlayerData[Player].Hired then
		PlayerModule.FirePlayer(Player, Prompt)
	end
end

Again we check for the job gui, if it doesnt exists then suspicious return end. Now we check if the player is in their job area. If they are then we change the visibility of the GUI. If they arent in their job area or they aren’t even hired. then they must be fired.

Step 10: Leaderstats

So, I like to keep this in a separate script since games can have many leaderstats. Create another script inside server script service and paste this:

local Players = game:GetService("Players")

local function LoadData(Player)
	local leaderstats = Instance.new("Folder")
	leaderstats.Name = "leaderstats"
	leaderstats.Parent = Player

	local Cash = Instance.new("IntValue")
	Cash.Name = "Cash"
	Cash.Parent = leaderstats
end

Players.PlayerAdded:Connect(function(Player)
	LoadData(Player)
end)

Yup it creates a leaderstat. I’d also reccomend creating a data store so players will keep the cash they earned when they leave the game.

Step 11: Play Testing

Woohoo we are finally finished with the scripts. Now go ahead and press play

Things to look for:

  1. Ensure you have a leaderstat for “Cash” and that it equal 0, the selector gui should also be visible, but not the job gui.
  2. Wait a minute, check and see if any warnings or errors pop up in output
  3. Walk up to each proximity prompt and ensure they pop up
    4.Activate a proximity prompt, selector gui should become invisble and job gui should be visible.
  4. Hit the End Shift gui button. The cash earned should now be added to your leaderstat. Job gui should become invisble again and selector visible.
  5. Now pick a job from the selector GUI, you should be teleporter there if not standing in the job area.
  6. Walk off the job area. The cash earned should now be added to your leaderstat. Job gui should become invisble again and selector visible.

If everything works without any errors then congrats!! You successfully created a job system

How To Add More Jobs:

  1. Create a new folder inside of GameStuff, create a new job area and a new proximity prompt part.
  2. Open JobManager, add it to the list of DataModule.SetupJob functions.

Example:

DataModule.SetupJob("Chef", "ChefPrompt", "ChefArea")
  1. Add job to the table in DataModule, make sure to include Players = {}, inside
  2. Add new button to selector gui.

Repeat play testing to make sure it works :+1:

I think I will edit this tutorial later on to make this process more automated

Thank You!!!

Finished Video:

Thanks for reading my tutorial. Any questions, issues, or suggestions feel free to comment!!

8 Likes