---
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.
