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:
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:
**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:
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:
-
Searches the workspace for “GameStuff”
-
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”
-
Ensures this folder exists, if not it will throw a warning
-
Inside this “JobFolder” it will search for the prompt (2nd argument provided) and the job area (3rd argument provided)
-
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
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 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:
- Ensure you have a leaderstat for “Cash” and that it equal 0, the selector gui should also be visible, but not the job gui.
- Wait a minute, check and see if any warnings or errors pop up in output
- 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. - 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.
- Now pick a job from the selector GUI, you should be teleporter there if not standing in the job area.
- 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:
- Create a new folder inside of GameStuff, create a new job area and a new proximity prompt part.
- Open JobManager, add it to the list of DataModule.SetupJob functions.
Example:
DataModule.SetupJob("Chef", "ChefPrompt", "ChefArea")
- Add job to the table in DataModule, make sure to include Players = {}, inside
- Add new button to selector gui.
Repeat play testing to make sure it works
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!!