RoCreatorLog in

How to make a grappling hook in Roblox Studio

A working grappling hook in one LocalScript: raycast where the camera is aiming, attach a RopeConstraint to whatever it hits, and reel in. Includes the moving-platform case and why the rope is invisible to other players.

Written 11 August 2026 · about 8 minutes · every script here was written against Roblox Studio as it shipped that week

What you are building

Hold the mouse button and a rope fires at whatever you are aiming at. If it connects, you swing from it and get pulled in. Let go and it drops you. That is the whole mechanic, and it is about sixty lines of Luau in a single LocalScript — no models, no tool, no server code.

The version below uses a RopeConstraint rather than the BodyVelocity approach you will find in older tutorials. Two reasons. The physics engine already knows how to swing something on a rope, so you get arcs and momentum for free instead of approximating them; and BodyVelocity has been deprecated for years, so half the code you would be copying no longer matches the API it references.

1. The setup

Put a LocalScript in StarterPlayerScripts. It has to be a LocalScript: this reads the camera and the mouse, and neither exists on the server.

-- StarterPlayer > StarterPlayerScripts > GrappleClient (a LocalScript)
local Players = game:GetService("Players")
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
-- Tune these four and you have a completely different feel.
local MAX_RANGE = 300 -- studs. Past this, the shot just misses.
local REEL_SPEED = 55 -- studs/second the rope shortens by
local MIN_LENGTH = 8 -- stop reeling before you faceplant into the anchor
local SWING_BOOST = 12 -- studs/second of forward kick on attach
local player = Players.LocalPlayer
local camera = workspace.CurrentCamera
-- One rope at a time. These are module-level so detach() can find them.
local rope, ropeAnchor, reelConnection

Four constants, one player, one camera, and three variables held outside every function so that the release handler can find and destroy what the press handler made. If you are ever tempted to make this a table of ropes, that is the moment it stops being a grappling hook and starts being ODM gear — which is a different and much harder guide.

2. Cleanup first

Write the teardown before the setup. It is the function you will call from four different places and the one that decides whether your game leaks a part every time somebody misses.

local function detach()
if reelConnection then
reelConnection:Disconnect()
reelConnection = nil
end
-- Destroying the anchor part takes its Attachment with it, and destroying
-- an Attachment a constraint is using invalidates the constraint, so the
-- order here does not actually matter. Being explicit anyway, because the
-- day you add a second constraint it will.
if rope then
rope:Destroy()
rope = nil
end
if ropeAnchor then
ropeAnchor:Destroy()
ropeAnchor = nil
end
end

3. Work out where the player is actually aiming

-- Where is the player actually aiming?
--
-- Not camera.CFrame.LookVector. That is where the camera is pointing, which is
-- only the same thing in first person or shift-lock. In the default third-person
-- camera the mouse moves independently, and using LookVector gives you a hook
-- that fires at the middle of the screen no matter where you clicked.
local function aimRay()
local mouse = UserInputService:GetMouseLocation()
-- GetMouseLocation and ViewportPointToRay are both in viewport space (they
-- both already exclude the 36px top inset), so these two pair up correctly.
-- ScreenPointToRay is the one that expects screen space; mixing them gives
-- you a shot that lands 36 pixels high and is maddening to debug.
return camera:ViewportPointToRay(mouse.X, mouse.Y)
end

This is the bug you will otherwise spend an hour on

Almost every grappling hook tutorial uses camera.CFrame.LookVector. It works perfectly while you are testing in first person, and then it does not work at all in the default third-person camera, because in third person the mouse and the camera point at different things. The hook fires at the centre of the screen and the bug reads as “the aim is slightly off” rather than “I am using the wrong vector”.

The second half of the trap: UserInputService:GetMouseLocation() pairs with ViewportPointToRay, and Mouse.X/Mouse.Y pairs with ScreenPointToRay. Mix the two and every shot lands 36 pixels high — the height of the Roblox top bar — which is small enough to look like a rounding error and large enough to miss a ledge.

4. Fire the hook

local function attach()
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
if not root then return end
detach()
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { character }
-- Without this, a grapple aimed through a window latches onto the glass.
-- Set CanQuery = false on anything you want the hook to pass through.
params.IgnoreWater = true
local ray = aimRay()
local result = workspace:Raycast(ray.Origin, ray.Direction * MAX_RANGE, params)
if not result then
-- Nothing in range. Do nothing, silently. A miss is a normal outcome of
-- a grappling hook and should not warn, error, or play a failure sound
-- unless you decided it should.
return
end
-- An invisible, massless part gives the rope a fixed far end. Doing it this
-- way rather than attaching straight to result.Instance is what makes the
-- next section work.
ropeAnchor = Instance.new("Part")
ropeAnchor.Name = "GrappleAnchor"
ropeAnchor.Size = Vector3.new(0.2, 0.2, 0.2)
ropeAnchor.Transparency = 1
ropeAnchor.CanCollide = false
ropeAnchor.CanQuery = false
ropeAnchor.Massless = true
ropeAnchor.CFrame = CFrame.new(result.Position)
ropeAnchor.Anchored = result.Instance.Anchored
ropeAnchor.Parent = workspace
if not ropeAnchor.Anchored then
-- We hit something that moves. Weld to it so the rope tracks it.
local weld = Instance.new("WeldConstraint")
weld.Part0 = ropeAnchor
weld.Part1 = result.Instance
weld.Parent = ropeAnchor
end
local fromCharacter = Instance.new("Attachment")
fromCharacter.Parent = root
local toWorld = Instance.new("Attachment")
toWorld.Parent = ropeAnchor
rope = Instance.new("RopeConstraint")
rope.Attachment0 = fromCharacter
rope.Attachment1 = toWorld
rope.Length = (result.Position - root.Position).Magnitude
rope.Restitution = 0 -- 0 = no bounce at full extension. Try 0.4 for a bungee.
rope.Visible = true -- the cheapest possible rope visual; swap for a Beam later
rope.Parent = root
-- A rope alone does not launch you, it only stops you falling further than
-- its length. This is what turns "hang there" into "swing".
root.AssemblyLinearVelocity += ray.Direction.Unit * SWING_BOOST
reelConnection = RunService.Heartbeat:Connect(function(deltaTime)
if not rope or not rope.Parent then return end
rope.Length = math.max(MIN_LENGTH, rope.Length - REEL_SPEED * deltaTime)
end)
end

The part worth slowing down on is the anchor. It would be simpler to attach the rope straight to whatever the raycast hit, and it would work — right up until somebody grapples a moving platform or a spinning door, at which point the rope stays where the platform used to be. Creating our own part and welding it to the hit instance gets the moving case for three lines, and copying result.Instance.Anchored onto it means an anchored wall does not suddenly acquire a dangling physics part.

The SWING_BOOST line is the difference between a grappling hook and a tow rope. A rope constraint only stops you moving further away than its length — it applies no force towards the anchor at all. Without a kick, latching onto a ceiling above you just leaves you hanging.

5. Wire it to the mouse

local function isGrappleInput(input)
return input.UserInputType == Enum.UserInputType.MouseButton1
or input.UserInputType == Enum.UserInputType.Touch
end
UserInputService.InputBegan:Connect(function(input, gameProcessed)
-- gameProcessed is true when the click landed on a TextBox, a button, the
-- chat bar. Skipping those is why your hook will not fire while somebody is
-- typing, which is the behaviour you want and is one line.
if gameProcessed then return end
if isGrappleInput(input) then attach() end
end)
UserInputService.InputEnded:Connect(function(input)
-- Deliberately NOT checking gameProcessed here. If the player clicks, then
-- moves the cursor over a UI element before releasing, the release is
-- "processed" and you would leave them roped to the world forever.
if isGrappleInput(input) then detach() end
end)
-- Respawning replaces the character, and a rope parented to the old
-- HumanoidRootPart goes with it — but the anchor part does not, and you would
-- litter one invisible part per death into Workspace.
player.CharacterRemoving:Connect(detach)

Check gameProcessed on the press, not on the release

It looks symmetrical and it is not. If the player presses, drags the cursor over a UI button, then releases, the release is flagged as processed by the UI. Skip it and the rope is never destroyed — the player is roped to a wall with no way to let go and no error in the output window to explain it.

6. Optional: give it fuel

Left alone, this is a flight mode: a good player will chain grapples indefinitely and never touch the ground. A fuel budget takes about fifteen lines and turns the hook into something you have to spend.

-- Optional: a fuel budget, so the hook is a resource and not a flight mode.
local MAX_FUEL = 3 -- seconds of continuous grapple
local REFILL_RATE = 0.75 -- seconds of fuel per second on the ground
local fuel = MAX_FUEL
RunService.Heartbeat:Connect(function(deltaTime)
if rope then
fuel -= deltaTime
if fuel <= 0 then
fuel = 0
detach()
end
else
local humanoid = player.Character and player.Character:FindFirstChildOfClass("Humanoid")
local grounded = humanoid and humanoid.FloorMaterial ~= Enum.Material.Air
if grounded then
fuel = math.min(MAX_FUEL, fuel + REFILL_RATE * deltaTime)
end
end
end)

Draw fuel / MAX_FUEL into a bar and you have the whole mechanic. Humanoid.FloorMaterial is the cheap grounded check — it reads Enum.Material.Air when there is nothing under the character, and it does not need a raycast of its own.

The thing nobody mentions: other players cannot see your rope

Everything above runs on one client, so the rope, the anchor part and the swing exist in that player’s simulation. Their charactermoves for everyone, because the client owns its own character’s physics and that movement replicates. The rope itself does not. To everybody else, they are swinging on nothing.

For a solo game or a jam entry, ship it — this is not a bug you need to fix. For a real multiplayer game, the fix is to fire a RemoteEvent on attach and detach carrying the hit position, and have a server Script create a Beam between the character and that point for everyone else. Do not move the character on the server: you will fight the client for ownership and produce rubber-banding far worse than an invisible rope.

And the standard caveat about anything client-authoritative: a player who edits their own client can set MAX_RANGE to ten thousand. If that matters for your game, the range check has to be repeated on the server before anything it grants is believed.

Tuning it

The feel lives entirely in the four constants at the top. REEL_SPEED at 55 is brisk; drop it to 25 for something that feels heavy and industrial, push it to 90 for something closer to a superhero swing. Restitution at 0 is a steel cable — set it to 0.4 and the rope behaves like a bungee at full extension, which reads as much more fun and is one property. MIN_LENGTH under about 5 will pull the character into the geometry it is anchored to.

Or skip the typing

Everything above is yours whether or not you ever install anything — that is why the scripts are complete rather than trimmed. RoCreator is a Studio plugin that writes this kind of code into your open place from a sentence, and snapshots the place first so one click puts it back. It is free to try, 5 requests a day, no card.

The honest caveat, since this page has been honest so far: it is good at putting one working feature into a game you already have. It will not build you a game, it will not make terrain, and it does not generate 3D models.

Try it freeHow it works, step by step

Read next