Lua Scripting

ProximityPrompt: interaction ready for keyboard, gamepad and touch, without writing UI

A door, a switch and a button do not need an interface of their own. The ProximityPrompt already shows the right key for each input type — and ProximityPromptService lets the whole game respond from one place.

by Gustavo Cantino9 min readBeginner

Produced with AI from the sources cited below, and checked by automated verification against those same sources. No person read this text before publication. Editorial Policy

Typographic plate with the Lua Scripting category in large type, the beginner level below it, and a field of vertical lime green bars on a dark background.

Every door that opens, switch that lights up and lever that pulls needs the same thing: to tell the player interaction is possible, to say what will happen, and to show which key to press. And the key changes depending on whether the person is on a keyboard, a gamepad or a phone.

That is exactly the work the ProximityPrompt already does. Anyone drawing their own interface for it is rewriting — generally worse, and without support for every input type.

The problem

The ProximityPrompt invites the player to interact on approaching objects such as doors, switches and buttons. On the list of what it solves, two items are the ones that cost dearly when done by hand.

The first: indicating which objects can be interacted with. In a busy scene, knowing what is decoration and what is interactive is half the legibility of the game, and that information has to appear and disappear at the right moment.

The second, and it is the decisive one: showing the correct input for each type — keyboard, gamepad and touch. A hand-drawn button saying "press E" is wrong for whoever is on a gamepad and wrong for whoever is on a phone. And most of the Roblox audience is on a phone.

There is also a less visible problem that appears as the game grows: if each interactive object has its own script, the interaction logic ends up scattered across dozens of places. That is what ProximityPromptService solves, and it is what this guide's artifact uses.

It is worth measuring the size of what you are avoiding. A hand-built interaction button needs a positioned BillboardGui, a distance measurement running every frame, detection of which input type the player is using, an icon per key, touch handling, a rule for when two buttons appear together, and a progress bar for the hold case. That is seven problems, and each one has an edge case that only appears on a device you do not own.

The ProximityPrompt arrives with all seven solved and with an advantage no in-house implementation reaches: it looks the same as the one in other games. A player who has seen a prompt anywhere else on the platform knows what to do without reading anything — and familiarity is something you cannot build alone.

Step by step

1. Put the prompt in the right place. Its parent has to be the part, model or attachment being interacted with. That is not a detail: the prompt's position comes from its parent, and a prompt loose in the workspace has nothing to refer to.

In the Explorer, hover over the object, click the add button and insert a ProximityPrompt.

2. Say what it is. The prompt has to communicate three things: what the object is, what the action is, and what the key is. That is three properties:

  • ObjectText — the object's optional name. "Kitchen door".
  • ActionText — the action's optional name. "Open".
  • KeyboardKeyCode — the key the player presses or holds.

There is also GamepadKeyCode, for the gamepad button. Both texts are optional, but a prompt without them is a key icon floating with no context.

3. Control when it appears. Two properties do most of the work.

MaxActivationDistance defines the radius around the prompt that triggers its visibility: when the character enters that radius, the prompt appears.

RequiresLineOfSight only lets the prompt be visible when there is a clear path from the camera to it — and it comes switched on by default. That is good most of the time and it is the cause of the bug described further down.

4. Settle the fight over the key. When the character is within range of several prompts, the Exclusivity property decides what appears. In OnePerButton mode, only one prompt is visible per key: prompts with different keys all appear, but with the same key only one is visible at a time, according to the camera direction.

That is the behaviour you want in a room with several doors — the camera chooses.

5. Choose how it is triggered. HoldDuration defines how many seconds the key has to be held before the action fires; at zero, the action fires immediately. Holding is what you use for a destructive or irreversible action, because it prevents accidental triggering.

ClickablePrompt defines whether the prompt can be clicked. Switched off, only the key fires it. And there is an important exception: on phones and tablets the player can always click the prompt, regardless of that property's value.

6. React in a script. You can listen to the events on the object itself or globally through ProximityPromptService — and the documentation recommends the second: it lets you manage all the behaviour from one place, with no duplicated code.

The main events: PromptTriggered, when the player interacts; PromptTriggerEnded, when they stop; PromptButtonHoldBegan, when the hold starts on a prompt with a non-zero HoldDuration; and, on the client side, PromptShown and PromptHidden, which fire in a LocalScript when the prompt becomes visible or disappears.

The artifact

The script below is a single handler for every prompt in the game. Each prompt declares what it is through an attribute, and the handler decides what to do — adding a type of interaction becomes adding a function, not scattering another script inside one more object.

And it does the part the prompt does not do: validate. The prompt is a route by which the client triggers the server, exactly like a RemoteEvent, and the distance the client respected is the client's distance. The handler checks the distance on the server, with deliberate slack — latency and movement make the two numbers differ slightly, and without slack an honest player gets refused now and then.

Common mistakes

The most common mistake is about positioning, and the symptom is always the same: the prompt does not appear, and there is no error at all.

The cause is usually RequiresLineOfSight, which comes switched on by default. It requires a clear path from the camera to the object. A prompt placed on a part that is inside another one — the handle inside the frame, the button recessed into the panel, the prompt at the centre of a large model — has line of sight from nowhere. The player walks up and sees nothing.

The diagnosis is quick: switch RequiresLineOfSight off. If the prompt appears, the problem is geometric, and the right solution is not leaving it off — it is moving the prompt to an attachment on the object's visible surface. Leaving it off for good makes the prompt show through walls, which is the opposite problem.

The second mistake is about the scale of MaxActivationDistance. A generous value looks friendly and produces a cluttered screen, with prompts from half the scene competing for attention. It is worth remembering that the radius interacts with Exclusivity: increasing the distance increases how many prompts are in range at the same time, and that is where the fight over the key starts.

The third is trusting the prompt as if it were a lock. It disappears from the screen out of range, and disappearing from the screen prevents nothing — anyone who wants to trigger it from a distance can. Every action that touches game state needs the same server validation a remote would require.

And there is a fourth, specific to mobile: building the whole interaction on "press and hold" without testing on touch. With the click always available on phones and tablets, a high HoldDuration becomes an interaction a phone player does not understand how to perform.

Next step

With the prompts working, the next gain is consistency. Choose two or three keys for the whole game and always use the same ones for the same kind of action — one to interact, another for a destructive action with HoldDuration. The player learns once.

After that, it is worth using PromptShown and PromptHidden on the client for something beyond the prompt: highlighting the object, changing the cursor, playing a discreet sound. They fire in a LocalScript and are the right hook for the visual feedback the prompt alone does not give.

And finally, run your eyes over the existing prompts looking for the ones that fire an action with no server validation. The list is usually shorter than it seems, and every item on it is a door that opens from a distance.

Sources

How we correct mistakes

Ready-to-use artifact

A single handler for every prompt in the game, with the validation the prompt does not do (ServerScriptService)
lua
-- ServerScriptService: ONE handler for every ProximityPrompt in the game.
--
-- ProximityPromptService exists exactly for this: managing the behaviour
-- from one place, with no duplicated code. Instead of a script inside
-- each door, the prompt declares what it is through an attribute, and
-- this file decides what to do.
--
-- And it does the part the prompt does NOT do: validate. The prompt is a
-- route by which the client triggers the server, like a remote. The
-- distance the client respected is the client's distance.

local ProximityPromptService = game:GetService("ProximityPromptService")
local Players = game:GetService("Players")

-- the "Action" attribute on the prompt -> what to do
local ACTIONS = {}

function ACTIONS.open_door(player, prompt)
	local door = prompt.Parent
	if not door or not door:IsA("BasePart") then
		return
	end
	door.CanCollide = false
	door.Transparency = 0.7
	task.delay(3, function()
		if door.Parent then
			door.CanCollide = true
			door.Transparency = 0
		end
	end)
end

function ACTIONS.toggle_light(player, prompt)
	local light = prompt.Parent and prompt.Parent:FindFirstChildOfClass("PointLight")
	if light then
		light.Enabled = not light.Enabled
	end
end

-- ── the validation the prompt does not do ───────────────────────────
-- The prompt disappears from the SCREEN out of range, but disappearing
-- from the screen is not a lock. Check on the server.
local function mayTrigger(player, prompt)
	local character = player.Character
	local humanoid = character and character:FindFirstChildOfClass("Humanoid")
	if not humanoid or humanoid.Health <= 0 then
		return false
	end

	local root = character:FindFirstChild("HumanoidRootPart")
	local target = prompt.Parent
	if not root or not target then
		return false
	end

	local position = target:IsA("BasePart") and target.Position
		or (target:IsA("Model") and target:GetPivot().Position)
		or (target:IsA("Attachment") and target.WorldPosition)
	if not position then
		return false
	end

	-- slack over MaxActivationDistance: latency and movement make the
	-- server's distance differ slightly from the client's. Without slack,
	-- an honest player gets refused now and then.
	local limit = prompt.MaxActivationDistance + 5
	return (root.Position - position).Magnitude <= limit
end

-- ── anti-repeat ─────────────────────────────────────────────────────
local last = {}
local INTERVAL = 0.5

local function tooFast(player)
	local now = os.clock()
	if last[player] and (now - last[player]) < INTERVAL then
		return true
	end
	last[player] = now
	return false
end

ProximityPromptService.PromptTriggered:Connect(function(prompt, player)
	local name = prompt:GetAttribute("Action")
	local action = name and ACTIONS[name]
	if not action then
		warn("prompt with no valid action: " .. prompt:GetFullName())
		return
	end

	if tooFast(player) or not mayTrigger(player, prompt) then
		return
	end

	local ok, err = pcall(action, player, prompt)
	if not ok then
		warn("action " .. name .. " failed: " .. tostring(err))
	end
end)

Players.PlayerRemoving:Connect(function(player)
	last[player] = nil
end)
In this article

Keep reading