ProximityPrompt Indicators

I thought I’d make some simple code to add indicators for where players can interact with items through proximity prompts. This solves a problem where players don’t know what they can interact with in games that use proximity prompts. This was inspired by a twitter post BitwiseAndrea made, which was inspired by Stray.

This code adds dots to where players can interact with your game through proximity prompts. To add it, all you need to do is copy the script below into a LocalScript and add the LocalScript to StarterPlayer.StarterPlayerScripts.

Here is the code:

local PlayerGui = game:GetService("Players").LocalPlayer.PlayerGui
local workspace = game:GetService("Workspace")

local function getScreenGui()
	local screenGui = PlayerGui:FindFirstChild("ProximityPrompts")
	if screenGui == nil then
		screenGui = Instance.new("ScreenGui")
		screenGui.Name = "ProximityPrompts"
		screenGui.ResetOnSpawn = false
		screenGui.Parent = PlayerGui
	end
	return screenGui
end

local function createIndicator()
	local indicator = Instance.new("BillboardGui")
	indicator.AlwaysOnTop = true
	indicator.LightInfluence = 0
	indicator.MaxDistance = 50
	indicator.Name = "PromptIndicator"
	indicator.Size = UDim2.fromScale(0.4, 0.4)
	indicator.ZIndexBehavior = Enum.ZIndexBehavior.Global
	indicator.ClipsDescendants = false
	do
		local frame = Instance.new("Frame")
		frame.AnchorPoint = Vector2.new(0.5, 0.5)
		frame.BackgroundColor3 = Color3.fromRGB(17, 17, 17)
		frame.BackgroundTransparency = 0.2
		frame.Position = UDim2.fromScale(0.5, 0.5)
		frame.Size = UDim2.fromScale(1, 1)
		do
			local uiCorner = Instance.new("UICorner")
			uiCorner.CornerRadius = UDim.new(0.5, 0)
			uiCorner.Parent = frame

			local uiSizeConstraint = Instance.new("UISizeConstraint")
			uiSizeConstraint.MaxSize = Vector2.new(32, 32)
			uiSizeConstraint.MinSize = Vector2.new(0, 0)
			uiSizeConstraint.Parent = frame

			local roundFrame = Instance.new("Frame")
			roundFrame.AnchorPoint = Vector2.new(0.5, 0.5)
			roundFrame.BackgroundColor3 = Color3.fromRGB(163, 163, 163)
			roundFrame.BackgroundTransparency = 0.5
			roundFrame.Name = "RoundFrame"
			roundFrame.Position = UDim2.fromScale(0.5, 0.5)
			roundFrame.Size = UDim2.fromScale(0.4, 0.4)
			do
				local uiCorner = Instance.new("UICorner")
				uiCorner.CornerRadius = UDim.new(0.5, 0)
				uiCorner.Parent = roundFrame
			end
			roundFrame.Parent = frame
		end
		frame.Parent = indicator
	end
	return indicator
end

local function onDescendantAdded(proximityPrompt)
	if not (proximityPrompt:IsA("ProximityPrompt")) then
		return
	end

	local newIndicator = createIndicator()
	newIndicator.Adornee = proximityPrompt.Parent
	newIndicator.Parent = getScreenGui()

	local isShown = false

	local shownConnection
	local hiddenConnection

	local onShown
	local onHidden

	onShown = function(inputType)
		if shownConnection then
			shownConnection:Disconnect()
		end

		isShown = true
		
		newIndicator.Parent = nil
		newIndicator.Adornee = nil

		hiddenConnection = proximityPrompt.PromptHidden:Connect(onHidden)
	end

	onHidden = function()
		if hiddenConnection then
			hiddenConnection:Disconnect()
		end

		isShown = false

		newIndicator.Adornee = proximityPrompt.Parent
		newIndicator.Parent = getScreenGui()

		shownConnection = proximityPrompt.PromptShown:Connect(onShown)
	end
	
	local enabledConnection
	
	local function updateVisible()
		newIndicator.Enabled = proximityPrompt.Enabled
	end
	
	enabledConnection = proximityPrompt:GetPropertyChangedSignal("Enabled"):Connect(updateVisible)
	updateVisible()
	
	local ancestryChangedConnection

	ancestryChangedConnection = proximityPrompt.AncestryChanged:Connect(function()
		if proximityPrompt:IsDescendantOf(workspace) then
			if not isShown then
				newIndicator.Adornee = proximityPrompt.Parent
			end
		else
			-- Clean up
			if shownConnection then
				shownConnection:Disconnect()
			end
			if hiddenConnection then
				hiddenConnection:Disconnect()
			end
			enabledConnection:Disconnect()
			ancestryChangedConnection:Disconnect()
			newIndicator:Destroy()
		end
	end)

	onHidden()
end

workspace.DescendantAdded:Connect(onDescendantAdded)

for _, descendant in ipairs(workspace:GetDescendants()) do
	onDescendantAdded(descendant)
end

prompt indicators gif

If you have any questions, concerns, or ideas feel free to reach out to me.

18 Likes

Running whenever a descendant is added seems a little inefficient, surely you could make use off the .PromptHidden and .PromptShown events of ProximityPromptService.
Other than that it seems like a cool idea

4 Likes

Same as @TheZanderius, and you should let developers customize the style, I feel like black doesn’t fit the theme for me.

2 Likes

If you are taking about the colour of the grey points, you can easily change that in one line within the code:

2 Likes

Yes, but for newer users this would not be as clear. Plus it wouldn’t be that hard to add.

2 Likes

I looked into that while I was making it and found there was no API for when a prompt is added to the workspace besides DescendantAdded. Prompt hidden and prompt shown don’t fire when a prompt is added.

It’s only running a single if statement for each descendant added that isn’t a prompt, so it’s impact on performance is negligible.

I opted out of adding this because adding a ton of settings can sometimes get confusing for new users. Someone with experience can change the code, and someone without the experience likely doesn’t know how to build UI or use attributes.

If I made it a model I probably would have added customization. I’ve noticed that most people just use the default appearance anyways.

1 Like

There is no excuse not to add it. One setting isn’t going to confuse nobody.

1 Like

People can add it themselves.

OP is releasing a resource to the community - it’s the choice of individual developers on how they wish to apply it, tweak it, and integrate it into their projects. While I completely agree that adding variety is nice and useful (especially for non-programmers), OP isn’t under any obligation to make a one-size fits all release for all developers and projects or subsequently modify the resource for the sake of adding features (unless they want to)

1 Like

That’s a little forceful :sweat_smile: There really isn’t much of a use case for settings, as about 95% of games use default proximity prompts. Additionally, only one setting wouldn’t be very useful.

Anyways, the public has spoken! Here is the code with settings:

local PlayerGui = game:GetService("Players").LocalPlayer.PlayerGui
local workspace = game:GetService("Workspace")

local function getScreenGui()
	local screenGui = PlayerGui:FindFirstChild("ProximityPrompts")
	if screenGui == nil then
		screenGui = Instance.new("ScreenGui")
		screenGui.Name = "ProximityPrompts"
		screenGui.ResetOnSpawn = false
		screenGui.Parent = PlayerGui
	end
	return screenGui
end

local function createIndicator()
	local sizeSetting = script:GetAttribute("Size") or 0.4
	local outerColorSetting = script:GetAttribute("OuterColor") or Color3.fromRGB(17, 17, 17)
	local innerColorSetting = script:GetAttribute("InnerColor") or Color3.fromRGB(163, 163, 163)

	local indicator = Instance.new("BillboardGui")
	indicator.AlwaysOnTop = true
	indicator.LightInfluence = 0
	indicator.MaxDistance = 50
	indicator.Name = "PromptIndicator"
	indicator.Size = UDim2.fromScale(sizeSetting, sizeSetting)
	indicator.ZIndexBehavior = Enum.ZIndexBehavior.Global
	indicator.ClipsDescendants = false
	do
		local frame = Instance.new("Frame")
		frame.AnchorPoint = Vector2.new(0.5, 0.5)
		frame.BackgroundColor3 = outerColorSetting
		frame.BackgroundTransparency = 0.2
		frame.Position = UDim2.fromScale(0.5, 0.5)
		frame.Size = UDim2.fromScale(1, 1)
		do
			local uiCorner = Instance.new("UICorner")
			uiCorner.CornerRadius = UDim.new(0.5, 0)
			uiCorner.Parent = frame

			local uiSizeConstraint = Instance.new("UISizeConstraint")
			uiSizeConstraint.MaxSize = Vector2.new(32, 32)
			uiSizeConstraint.MinSize = Vector2.new(0, 0)
			uiSizeConstraint.Parent = frame

			local roundFrame = Instance.new("Frame")
			roundFrame.AnchorPoint = Vector2.new(0.5, 0.5)
			roundFrame.BackgroundColor3 = innerColorSetting
			roundFrame.BackgroundTransparency = 0.5
			roundFrame.Name = "RoundFrame"
			roundFrame.Position = UDim2.fromScale(0.5, 0.5)
			roundFrame.Size = UDim2.fromScale(0.4, 0.4)
			do
				local uiCorner = Instance.new("UICorner")
				uiCorner.CornerRadius = UDim.new(0.5, 0)
				uiCorner.Parent = roundFrame
			end
			roundFrame.Parent = frame
		end
		frame.Parent = indicator
	end
	return indicator
end

local function onDescendantAdded(proximityPrompt)
	if not (proximityPrompt:IsA("ProximityPrompt")) then
		return
	end

	local newIndicator = createIndicator()
	newIndicator.Adornee = proximityPrompt.Parent
	newIndicator.Parent = getScreenGui()

	local isShown = false

	local shownConnection
	local hiddenConnection

	local onShown
	local onHidden

	onShown = function(inputType)
		if shownConnection then
			shownConnection:Disconnect()
		end

		isShown = true
		
		newIndicator.Parent = nil
		newIndicator.Adornee = nil

		hiddenConnection = proximityPrompt.PromptHidden:Connect(onHidden)
	end

	onHidden = function()
		if hiddenConnection then
			hiddenConnection:Disconnect()
		end

		isShown = false

		newIndicator.Adornee = proximityPrompt.Parent
		newIndicator.Parent = getScreenGui()

		shownConnection = proximityPrompt.PromptShown:Connect(onShown)
	end
	
	local enabledConnection
	
	local function updateVisible()
		newIndicator.Enabled = proximityPrompt.Enabled
	end
	
	enabledConnection = proximityPrompt:GetPropertyChangedSignal("Enabled"):Connect(updateVisible)
	updateVisible()
	
	local ancestryChangedConnection

	ancestryChangedConnection = proximityPrompt.AncestryChanged:Connect(function()
		if proximityPrompt:IsDescendantOf(workspace) then
			if not isShown then
				newIndicator.Adornee = proximityPrompt.Parent
			end
		else
			-- Clean up
			if shownConnection then
				shownConnection:Disconnect()
			end
			if hiddenConnection then
				hiddenConnection:Disconnect()
			end
			enabledConnection:Disconnect()
			ancestryChangedConnection:Disconnect()
			newIndicator:Destroy()
		end
	end)

	onHidden()
end

workspace.DescendantAdded:Connect(onDescendantAdded)

for _, descendant in ipairs(workspace:GetDescendants()) do
	onDescendantAdded(descendant)
end

To change the size add a number attribute to the LocalScript named “Size”. This is the size of the indicators in studs.

To change the inner circle’s color add a Color3 attribute named “InnerColor”.

To change the outer circle’s color add a Color3 attribute named “OuterColor”.

1 Like

You should try to make your resource as accessible as possible so that all types of people can use it. Some people don’t know how to edit code.

1 Like

It is accessible. All people need to do is add the local script. A very small portion of the people who use ProximityPrompts modify them. That means that everyone who doesn’t modify them (aka the vast majority) gets an out-of-the-box set of perfect settings. Even the majority of the rare people who do modify their prompts already have the skills to modify this code.

1 Like

Yeah but how often are you adding a new proximity prompt mid game? Just run it once when the game starts and use the events?

1 Like

It wouldn’t work for all prompts then (or at all with streaming enabled). Plus, it’s client sided. Additionally, I add prompts to my game all the time mid session, so for me and other people that wouldn’t work.

Either way though, the performance impact of this is virtually zero. There is no reason to micro optimize things that don’t need micro optimization (aka golden rule of optimization). This code is running a singular if statement when an Instance is added to workspace. If stuff isn’t being added mid game like you said, then it doesn’t matter at all even.

I can’t emphasize enough how insignificant running a single if statement is, and it’s not even doing that often.

If you’re that concerned about optimization, I might as well use the non-OOP functions of workspace so I can store them in the local scope. The reason I don’t and very few people do though is because it really doesn’t matter in like 99.99% of cases because the difference is so insignificant.

You can learn some optimization tips here if you’re interested:

1 Like

I don’t know if this is a satire or not… But:

Yeah, it’s not meant for the server…
If it runs on the client, it means less server load …

Are you telling me .DescendantAdded on workspace has “virtually zero” performance impact? That’s just ultimately not true. So whenever anything is added to the workspace, your server side script will run and check if it’s a proximity prompt? Maybe for a short-term solution, it’s okay, but for longer-term solutions, you should look into ProximityPromptService.

You can learn how to use proximity prompt service here, if you are interested:

1 Like

A single connection has very little performance impact, so even though I didn’t say that, yes.

As I said, it’s client sided. Read the topic and my reply.

I literally bench marked this, just for you. Running the function on an instance is 300x more performant than creating an instance. So if someone adds 300 instances a second (which is insane) it’s approximately the same as creating (not even running!) a single tween every second (some people move their cameras every frame with tweens).

As I’ve said twice now, and as that article says, those events do not work for this purpose. This module needs to detect when a prompt is added.

1 Like

Great and simple community resource, thank you for posting this. Will use in a roleplay game of mine

I like the idea of doing that, it seems to improve the UX - thanks for the idea & sample code!

1 Like

I don’t think you understand how events work. when you connect an event it doesn’t “create an instance” every frame or whatever you thought it was. It triggers when something happens. You can learn to use events here: Handling Events

1 Like

I was talking about a performance comparison, because you can’t seem to comprehend how insignificant this is (it’s over 300x less than creating an instance). I know exactly how events work, and have programmed them from scratch numerous times in multiple different languages. I don’t think you understand and/or read my replies, so this will be my last one.

Performance can be calculated like this:
function performance * function usage
(There are other factors that don’t apply in this case that I am ignoring, like density and stuff)

I think that you believe running for every instance added to workspace is too much (even though it’s the only way to accomplish the desired behavior).

In some cases this would be a problem, but I don’t think you are properly factoring in the function performance. For each instance added to workspace, the code being ran is approximately 1120x less than creating an instance and adding it to workspace.

That means that if, in terms of performance, creating and parenting an instance is 1 unit of performance, running this code for that instance is 0.00089 units of performance.

Can you see how insignificant that is?

You also talked about scaling. As expected, this performance is linear (O(n)) based on the number of instances added, so because the slope is so low (as seen above) there aren’t any problems with large scaling. It’s not exponential or anything like that.

Again, as I said in my PM, if you want to prove me wrong feel free to benchmark your results. If you make data that contradicts mine, I’ll be happy to look at that and reconsider. I don’t see any reason to further discuss with someone who won’t look at data and facts.

1 Like