Tutorial: How to make a Player Progress Bar

Player Progress bars are essential in games like obbies and racing games to help players track their journey from start to finish. In this tutorial, I’ll show you how to design, animate, and script a distance progress bar in Roblox! I will try to make this as beginner friendly as possible.


Creating the Start and End Points

First thing we need to identify where the start point (where the beginning of the obby/race game) will be. For this we will:

  • Create a Folder inside of workspace. Then name it “Points”. This folder will store the start and end point parts.
    image

  • Add 2 parts into the Folder you just created. Name one part “Start” and the other part “End”. The part named “Start” will let us know where the obby/race begins and the part named “End” will let us know where the obby/race ends.
    image

  • Position the Start part to the beginning of your obby and the End part to the end of your obby.

  • Select both Parts (Start and End) and make sure their properties are the same as the selected ones:


    This will make the parts invisible and undetectable by players when in game since the parts are just indicators.

Creating the UI

After creating the points, we need to create the visual aspect of this system, this will show the progress of the current players in-game from the start part to the end part.

  • Create a ScreenGui inside StarterGui. Name it “ProgressBar”.
    Screenshot 2025-02-11 at 1.03.12 p.m.

  • Create a Frame inside the ScreenGui you just created. Name it “Vertical” since this is a vertical bar we are creating.
    Screenshot 2025-02-11 at 1.08.05 p.m.

  • Set the properties of the Frame to the following:


    This will give the Frame a wide rectangle shape while positioning it a little bit on top and in the middle of the screen. Resulting to something like this:

  • Add a UICorner to the Frame to give it curved corners. Set the CornerRadius property to 0.2,0.

  • Add a UIStroke to the Frame to give it a black outline around the Frame. Set the Thickness property to 3.

  • Add a UIGradient to the Frame to add colors to it. For this you might want to set it to the Color property you want, in my case I went with an iconic red-yellow-green pattern. i will share the colors in Hex format:


    Point 0 is #990D0D
    Point 0.16 is #FF0D0D
    Point 0.32 is #FF4E11
    Point 0.488 is #FF8E15
    Point 0.653 is #FAB733
    Point 0.83 is #ACB334
    Point 1 is #69B34c
    Using the color pattern I used, your Frame should look like this:

  • Add an ImageLabel inside the Frame then name it “playersample”. This ImageLabel will be the placeholder for all the players in the current server inside the frame. Make sure the properties are set to this:

  • Create a LocalScript inside the ScreenGui named “ProgressBar” we created earlier.
    image

  • Drag the ImageLabel named playersample we created earlier into the LocalScript we just created. This will hide the playersample ImageLabel so we can only use it when we need to.
    image

Scripting the system

This is the fun part where we begin to create the magic, and by magic I mean scripting, creating the full functionality of the system through code.

First we will declare the services we will be using into a variable

---------- SERVICES ----------
local TS = game:GetService("TweenService") -- Service for smooth animating
local Players = game:GetService("Players") -- Service for Players

Then we declare the variables we are going to use

---------- VARIABLES ----------
local verticalbar = script.Parent:WaitForChild("Vertical") -- the frame we created earlier named "Vertical"
local pointfolder = game.Workspace:WaitForChild("Points") -- folder that contains the "Start" point and "End" point we created
local startpoint = pointfolder:WaitForChild("Start") -- the Start point
local endpoint = pointfolder:WaitForChild("End") -- the End point

local characters = {} -- where we will store the characters of the players to avoid us from constantly creating a playersample everything. Functions like cache for each player
local StartToEnd = endpoint.Position - startpoint.Position -- the direction from startpoint to endpoint

We will create a function that will clone the ImageLabel named “playersample” for each player. Apart from cloning, it will also get the image from the player´s avatar and set it to the image.

---------- FUNCTIONS ----------
local function get_player_ui(player)
	local sample = script:WaitForChild("playersample"):Clone() --cloning the ImageLabel
	sample.Name = player.Name -- changes the name of the cloned imagelabel to the player's name to give it a unique id
	sample.Position = UDim2.fromScale(0,0.5) --positions the cloned ImageLabel to the beginning of the Frame (Vertical)
	sample.Parent = verticalbar -- puts it inside the Frame
	
	-- we are getting the profile icon of the player, its inside a pcall function incase if it fails anytime
	local s,e = pcall(function()
		sample.Image = Players:GetUserThumbnailAsync(player.UserId,Enum.ThumbnailType.HeadShot,Enum.ThumbnailSize.Size420x420)
	end)
	if not s then -- if it ever fails, it should automatically set to a placement holder
		sample.Image = "rbxassetid://"..15847365339
	end
	return sample -- we are returning the cloned ImageLabel
end

Then we will create a function that will track the player. Basically to know when the player respawns or when their character is added etc.

local function trackplayer(player)
	local character = player.Character or player.CharacterAdded:Wait() -- gets the player's character or wait for it to be added
	
	-- incase if the character does not exist, we will reset their cache
	if not character then
		characters[player.Name] = nil
	end
	
	-- if we get the player, then we will cache their data
	characters[player.Name] = {
		Char = character, --cachces their data
		UIObject = verticalbar:FindFirstChild(player.Name) or get_player_ui(player) ---looks for a cloned Imagelabel or creates one with the get_player_ui() function
	}
end

We will create an event that will fire anytime a new player joins the server. When a new player joins, it tracks the player with the trackplayer function we created.

---------- EVENTS ----------
Players.PlayerAdded:Connect(function(player)
	trackplayer(player)
end)

We will create an event that will fire anytime a player leaves. When a player leaves, we make sure no trace of the player stays like their cache and as well delete their profile image from the bar.

Players.PlayerRemoving:Connect(function(player)
	-- if there is no cache for the player, then we dont continue
	if not characters[player.Name] then
		return
	end
	--checks if the cloned ImageLabel still exists, if it does, delete it. This will delete their profile picture from the Progress Bar
	if characters[player.Name].UIObject then
		characters[player.Name].UIObject:Destroy()
	end
	characters[player.Name] = nil --resets/deletes their cache
end)

In most cases, you are not always the first to join a server, so we need to make sure that when we join the server, we need to track all of them.

---------- RUN ----------
for i, v in pairs(game.Players:GetChildren()) do -- incase there are players already in the server before you joined, track all of them
	trackplayer(v)
end

This is the final part, we need to continuously update the position for each player in the ProgressBar.

while true do
	for i, v in pairs(characters) do -- loop through the cache table
		if not v or not v.Char or not v.UIObject then -- if by any chance the character or the cloned ImageLabel does not exit, skip the player
			continue
		end
		local StartToPlayer = v.Char:GetPivot().Position - startpoint.Position -- subtracting the two gives a direction from the start to the player.
		local length = StartToEnd:Dot(StartToEnd) -- gets the length of when you project the start to end
		local progression = math.clamp(StartToPlayer:Dot(StartToEnd) / length,0,1) 
		-- projects the player's position onto the start-to-end vector.
		-- Dividing by length normalizes it to a 0 to 1 scale
		v.UIObject.Position = UDim2.fromScale(progression,0.5) -- positions the cloned ImageLabel  to the correct position
	end
	task.wait() --wait 1/60 seconds before repeating the whole process
end

At the end this is the final code:

---------- SERVICES ----------
local TS = game:GetService("TweenService") -- Service for smooth animating
local Players = game:GetService("Players") -- Service for Players

---------- VARIABLES ----------
local verticalbar = script.Parent:WaitForChild("Vertical") -- the frame we created earlier named "Vertical"
local pointfolder = game.Workspace:WaitForChild("Points") -- folder that contains the "Start" point and "End" point we created
local startpoint = pointfolder:WaitForChild("Start") -- the Start point
local endpoint = pointfolder:WaitForChild("End") -- the End point

local characters = {} -- where we will store the characters of the players to avoid us from constantly creating a playersample everything. Functions like cache for each player
local StartToEnd = endpoint.Position - startpoint.Position -- the direction from startpoint to endpoint

---------- FUNCTIONS ----------
local function get_player_ui(player)
	local sample = script:WaitForChild("playersample"):Clone() --cloning the ImageLabel
	sample.Name = player.Name -- changes the name of the cloned imagelabel to the player's name to give it a unique id
	sample.Position = UDim2.fromScale(0,0.5) --positions the cloned ImageLabel to the beginning of the Frame (Vertical)
	sample.Parent = verticalbar -- puts it inside the Frame
	
	-- we are getting the profile icon of the player, its inside a pcall function incase if it fails anytime
	local s,e = pcall(function()
		sample.Image = Players:GetUserThumbnailAsync(player.UserId,Enum.ThumbnailType.HeadShot,Enum.ThumbnailSize.Size420x420)
	end)
	if not s then -- if it ever fails, it should automatically set to a placement holder
		sample.Image = "rbxassetid://"..15847365339
	end
	return sample -- we are returning the cloned ImageLabel
end
local function trackplayer(player)
	local character = player.Character or player.CharacterAdded:Wait() -- gets the player's character or wait for it to be added
	
	-- incase if the character does not exist, we will reset their cache
	if not character then
		characters[player.Name] = nil
	end
	
	-- if we get the player, then we will cache their data
	characters[player.Name] = {
		Char = character, --cachces their data
		UIObject = verticalbar:FindFirstChild(player.Name) or get_player_ui(player) ---looks for a cloned Imagelabel or creates one with the get_player_ui() function
	}
end

---------- EVENTS ----------
Players.PlayerAdded:Connect(function(player)
	trackplayer(player)
end)
Players.PlayerRemoving:Connect(function(player)
	-- if there is no cache for the player, then we dont continue
	if not characters[player.Name] then
		return
	end
	--checks if the cloned ImageLabel still exists, if it does, delete it. This will delete their profile picture from the Progress Bar
	if characters[player.Name].UIObject then
		characters[player.Name].UIObject:Destroy()
	end
	characters[player.Name] = nil --resets/deletes their cache
end)

---------- RUN ----------
for i, v in pairs(game.Players:GetChildren()) do -- incase there are players already in the server before you joined, track all of them
	trackplayer(v)
end
while true do
	for i, v in pairs(characters) do -- loop through the cache table
		if not v or not v.Char or not v.UIObject then -- if by any chance the character or the cloned ImageLabel does not exit, skip the player
			continue
		end
		local StartToPlayer = v.Char:GetPivot().Position - startpoint.Position -- subtracting the two gives a direction from the start to the player.
		local length = StartToEnd:Dot(StartToEnd) -- gets the length of when you project the start to end
		local progression = math.clamp(StartToPlayer:Dot(StartToEnd) / length,0,1) 
		-- projects the player's position onto the start-to-end vector.
		-- Dividing by length normalizes it to a 0 to 1 scale
		v.UIObject.Position = UDim2.fromScale(progression,0.5) -- positions the cloned ImageLabel  to the correct position
	end
	task.wait() --wait 1/60 seconds before repeating the whole process
end

Note:

  • If your End part is so far away. Be sure to have StreamingEnabled disabled since the End part wont be rendered for Performance purposes.

You can get the full Game file on my Patreon here. This is just to support me with the work I do, so feel free to join in if you want to :smiley:

2 Likes