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
If you have any questions, concerns, or ideas feel free to reach out to me.
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
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.
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)
That’s a little forceful 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”.
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.
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:
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:
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.
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
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.