Use when players interact with things in the 3D world in a Roblox game - doors, shops, NPCs, pickups, levers, "hold E to open" - with ProximityPrompt and ProximityPromptService. Covers every prompt property and event, where to parent prompts, validating Triggered on the server (distance, ownership, cooldown), hold interactions, and a custom prompt UI that shows the right key or gamepad glyph.
Use the robloxvibecoders MCP: call get_resource with id "skill-roblox-proximity-prompts" and install it as a Claude Code skill in this project (.claude/skills/roblox-proximity-prompts/SKILL.md).
Skills in ~/.claude/skills work in every project. Put them in your project's .claude/skills folder to share them with your team.
What your AI reads
SKILL.md
---
name: roblox-proximity-prompts
description: Use when players interact with things in the 3D world in a Roblox game - doors, shops, NPCs, pickups, levers, "hold E to open" - with ProximityPrompt and ProximityPromptService. Covers every prompt property and event, where to parent prompts, validating Triggered on the server (distance, ownership, cooldown), hold interactions, and a custom prompt UI that shows the right key or gamepad glyph.
---
# Proximity prompts
A `ProximityPrompt` shows "[E] Open Door" when a player's character comes near an object, and fires `Triggered` when
they press the key, press the gamepad button, or tap it on mobile. Roblox draws the UI and handles keyboard, gamepad
and touch for you. Prefer it over hand-made "press E" proximity loops.
## Where to put it
Parent the prompt to a `BasePart`, an `Attachment`, or a `Model` with `PrimaryPart` set, in the Workspace. Anywhere
else it never shows. Use an Attachment to place the prompt at a specific point (a door handle, a counter).
## Properties (defaults in brackets)
| Property | What it does |
|---|---|
| `ActionText` ["Interact"] | The verb: "Open", "Buy", "Talk" |
| `ObjectText` [""] | Optional name of the thing: "Door", "Sword - 250" |
| `KeyboardKeyCode` [E] | Key that triggers it |
| `GamepadKeyCode` [ButtonX] | Gamepad button that triggers it |
| `HoldDuration` [0] | Seconds to hold before `Triggered`; 0 triggers on press |
| `MaxActivationDistance` [10] | Studs from the character at which the prompt appears |
| `RequiresLineOfSight` [true] | Hide the prompt when something blocks the camera's view of the parent (the parent itself is ignored) |
| `Exclusivity` [OnePerButton] | `OnePerButton`: one prompt per key shown at a time; `OneGlobally`: only one prompt with this setting shows; `AlwaysShow`: always shown in range |
| `ClickablePrompt` [true] | Clicking or tapping the prompt UI triggers it. Touch devices can always tap |
| `Enabled` [true] | Shown or not |
| `Style` [Default] | `Default` is Roblox's UI; `Custom` hides it so you draw your own |
| `UIOffset` [0, 0] | Pixel offset of the prompt UI |
| `AutoLocalize` [true], `RootLocalizationTable` | Translate `ActionText` and `ObjectText` |
| `MaxIndicatorDistance` [0] | When above 0, shows a small indicator between this distance and `MaxActivationDistance` |
Methods: `InputHoldBegin()` and `InputHoldEnd()` press and release the prompt from your own UI button.
Events (on the prompt; the player is the argument unless noted):
- `Triggered(player)`: the interaction happened (after the hold, if `HoldDuration` > 0).
- `TriggerEnded(player)`: the key was released after triggering, for "hold to keep doing it" actions.
- `PromptButtonHoldBegan(player)` / `PromptButtonHoldEnded(player)`: hold started or stopped (non-zero `HoldDuration`).
- `PromptShown(inputType)` / `PromptHidden()`: client-side only; `inputType` is `Enum.ProximityPromptInputType`
(`Keyboard`, `Gamepad`, `Touch`).
- `IndicatorShown()` / `IndicatorHidden()`: client-side, for the distance indicator.
## ProximityPromptService
Global versions of every event, with the prompt as the first argument: `PromptTriggered(prompt, player)`,
`PromptTriggerEnded`, `PromptButtonHoldBegan`, `PromptButtonHoldEnded`, `PromptShown(prompt, inputType)`,
`PromptHidden(prompt)`, `IndicatorShown`, `IndicatorHidden`. Properties: `Enabled` (turn every prompt off, for
example during a cutscene), `MaxPromptsVisible` [16], `MaxIndicatorsVisible` [16].
One service-level handler plus CollectionService tags scales better than a Script in every door.
## Handle Triggered on the server, and validate
The trigger comes from the player's client, and a modified client can't be trusted to respect distance, timing or
game rules. The server re-checks everything that matters before acting.
```luau
-- ServerScriptService/Interactions.server.luau
local Players = game:GetService("Players")
local ProximityPromptService = game:GetService("ProximityPromptService")
local COOLDOWN = 0.5
local DISTANCE_SLACK = 4 -- studs of tolerance for movement and latency
local lastUse: { [Player]: number } = {}
local function promptPosition(prompt: ProximityPrompt): Vector3?
local parent = prompt.Parent
if parent == nil then
return nil
elseif parent:IsA("Attachment") then
return parent.WorldPosition
elseif parent:IsA("BasePart") then
return parent.Position
elseif parent:IsA("Model") and parent.PrimaryPart then
return parent.PrimaryPart.Position
end
return nil
end
local function canUse(player: Player, prompt: ProximityPrompt): boolean
local now = os.clock()
if not prompt.Enabled or now - (lastUse[player] or 0) < COOLDOWN then
return false
end
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart") :: BasePart?
local position = promptPosition(prompt)
if not root or not position then
return false
end
if (root.Position - position).Magnitude > prompt.MaxActivationDistance + DISTANCE_SLACK then
return false
end
lastUse[player] = now
return true
end
ProximityPromptService.PromptTriggered:Connect(function(prompt, player)
if not canUse(player, prompt) then
return
end
if prompt:HasTag("OwnedDoor") then
local door = prompt.Parent :: Instance
if door:GetAttribute("OwnerUserId") ~= player.UserId then
return -- not your door
end
door:SetAttribute("Open", not door:GetAttribute("Open"))
end
end)
Players.PlayerRemoving:Connect(function(player)
lastUse[player] = nil
end)
```
- Check state on the server too: is the shop item in stock, can the player afford it, is the door already open.
- For a one-time pickup, set `prompt.Enabled = false` (or destroy the prompt) on the server before granting the
reward, so two fast triggers can't both pay out.
- Per-player visibility (a quest NPC only some players can talk to): toggle `Enabled` from a LocalScript. That only
hides it for that client, so the server check still decides.
## Hold interactions
Set `HoldDuration` (for example 1.5) for actions that should not happen by accident: opening a chest, reviving a
teammate. `Triggered` fires only after the full hold. For "keep holding to keep doing it", start on `Triggered` and
stop on `TriggerEnded`.
## Custom prompt UI
Set `Style = Enum.ProximityPromptStyle.Custom` on the prompts you want to restyle, then build the UI on the client
when they are shown. Show the glyph for the input type the event reports.
```luau
-- StarterPlayerScripts/CustomPrompts.client.luau
local Players = game:GetService("Players")
local ProximityPromptService = game:GetService("ProximityPromptService")
local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")
local playerGui = Players.LocalPlayer:WaitForChild("PlayerGui")
local function keyVisual(prompt: ProximityPrompt, inputType: Enum.ProximityPromptInputType): (string, string)
if inputType == Enum.ProximityPromptInputType.Gamepad then
local image = UserInputService:GetImageForKeyCode(prompt.GamepadKeyCode)
return image, if image == "" then UserInputService:GetStringForKeyCode(prompt.GamepadKeyCode) else ""
elseif inputType == Enum.ProximityPromptInputType.Touch then
return "", "Tap"
end
return "", UserInputService:GetStringForKeyCode(prompt.KeyboardKeyCode) -- respects AZERTY and other layouts
end
ProximityPromptService.PromptShown:Connect(function(prompt, inputType)
if prompt.Style ~= Enum.ProximityPromptStyle.Custom then
return
end
local adornee = prompt.Parent
if adornee and adornee:IsA("Model") then
adornee = adornee.PrimaryPart
end
local billboard = Instance.new("BillboardGui")
billboard.Adornee = adornee
billboard.AlwaysOnTop = true
billboard.Active = true -- buttons inside need Active on the BillboardGui and the button
billboard.Size = UDim2.fromOffset(200, 56)
billboard.StudsOffset = Vector3.new(0, 2, 0)
local button = Instance.new("TextButton")
button.Size = UDim2.fromScale(1, 1)
button.AutoButtonColor = false
button.Text = `{prompt.ActionText} {prompt.ObjectText}`
button.Parent = billboard
local image, keyText = keyVisual(prompt, inputType)
local key = Instance.new("ImageLabel")
key.Size = UDim2.fromOffset(40, 40)
key.Position = UDim2.fromOffset(8, 8)
key.Image = image
key.Parent = button
local keyLabel = Instance.new("TextLabel")
keyLabel.Size = UDim2.fromScale(1, 1)
keyLabel.BackgroundTransparency = 1
keyLabel.Text = keyText
keyLabel.Parent = key
local bar = Instance.new("Frame") -- hold progress
bar.Size = UDim2.new(0, 0, 0, 4)
bar.Position = UDim2.fromScale(0, 1)
bar.Parent = button
billboard.Parent = playerGui -- must be under PlayerGui for the button to receive input
local holdTween: Tween? = nil
local connections = {
button.InputBegan:Connect(function(input)
if input.UserInputType == Enum.UserInputType.Touch or input.UserInputType == Enum.UserInputType.MouseButton1 then
prompt:InputHoldBegin()
end
end),
button.InputEnded:Connect(function(input)
if input.UserInputType == Enum.UserInputType.Touch or input.UserInputType == Enum.UserInputType.MouseButton1 then
prompt:InputHoldEnd()
end
end),
prompt.PromptButtonHoldBegan:Connect(function()
holdTween = TweenService:Create(bar, TweenInfo.new(prompt.HoldDuration, Enum.EasingStyle.Linear), {
Size = UDim2.new(1, 0, 0, 4),
})
holdTween:Play()
end),
prompt.PromptButtonHoldEnded:Connect(function()
if holdTween then
holdTween:Cancel()
end
bar.Size = UDim2.new(0, 0, 0, 4)
end),
}
prompt.PromptHidden:Once(function()
for _, connection in connections do
connection:Disconnect()
end
billboard:Destroy()
end)
end)
```
`PromptShown` passes the input type every time the prompt appears, so build the glyph from that argument instead of
caching it. For the rest of the input glyph rules, see the `roblox-input` skill.
## Mistakes to avoid
- Trusting `Triggered`: no distance, cooldown, ownership or state check on the server.
- Parenting the prompt to a Folder, a Model without `PrimaryPart`, or anything outside the Workspace.
- Doing the action in a LocalScript's `Triggered` handler. Client changes only affect that player.
- Hard-coding "E" in custom UI instead of reading `KeyboardKeyCode`/`GamepadKeyCode` and the input type.
- Custom UI that never cleans up on `PromptHidden`, leaving a BillboardGui per approach.
- Many prompts with `AlwaysShow` stacked on one spot. Use `OnePerButton` or `OneGlobally`, or different keys.
- A `RequiresLineOfSight` prompt inside a wall or behind glass the camera can't see through. Move it or turn it off.