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 bylocal MIN_LENGTH = 8 -- stop reeling before you faceplant into the anchorlocal SWING_BOOST = 12 -- studs/second of forward kick on attachlocal player = Players.LocalPlayerlocal 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 thenreelConnection:Disconnect()reelConnection = nilend-- 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 thenrope:Destroy()rope = nilendif ropeAnchor thenropeAnchor:Destroy()ropeAnchor = nilendend
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.Characterlocal root = character and character:FindFirstChild("HumanoidRootPart")if not root then return enddetach()local params = RaycastParams.new()params.FilterType = Enum.RaycastFilterType.Excludeparams.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 = truelocal 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.returnend-- 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 = 1ropeAnchor.CanCollide = falseropeAnchor.CanQuery = falseropeAnchor.Massless = trueropeAnchor.CFrame = CFrame.new(result.Position)ropeAnchor.Anchored = result.Instance.AnchoredropeAnchor.Parent = workspaceif not ropeAnchor.Anchored then-- We hit something that moves. Weld to it so the rope tracks it.local weld = Instance.new("WeldConstraint")weld.Part0 = ropeAnchorweld.Part1 = result.Instanceweld.Parent = ropeAnchorendlocal fromCharacter = Instance.new("Attachment")fromCharacter.Parent = rootlocal toWorld = Instance.new("Attachment")toWorld.Parent = ropeAnchorrope = Instance.new("RopeConstraint")rope.Attachment0 = fromCharacterrope.Attachment1 = toWorldrope.Length = (result.Position - root.Position).Magnituderope.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 laterrope.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_BOOSTreelConnection = RunService.Heartbeat:Connect(function(deltaTime)if not rope or not rope.Parent then return endrope.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.MouseButton1or input.UserInputType == Enum.UserInputType.TouchendUserInputService.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 endif isGrappleInput(input) then attach() endend)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() endend)-- 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 grapplelocal REFILL_RATE = 0.75 -- seconds of fuel per second on the groundlocal fuel = MAX_FUELRunService.Heartbeat:Connect(function(deltaTime)if rope thenfuel -= deltaTimeif fuel <= 0 thenfuel = 0detach()endelselocal humanoid = player.Character and player.Character:FindFirstChildOfClass("Humanoid")local grounded = humanoid and humanoid.FloorMaterial ~= Enum.Material.Airif grounded thenfuel = math.min(MAX_FUEL, fuel + REFILL_RATE * deltaTime)endendend)
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.