RoCreatorLog in

How to make a double jump in Roblox Studio

Double jump done with Humanoid states rather than a timer, so it survives jump pads, ledges and respawns. Covers the two bugs almost every tutorial version of this ships with.

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

The whole thing

One LocalScript in StarterPlayerScripts. No models, no RemoteEvents, no changes to the default character. Paste it, press Play, jump twice.

-- StarterPlayer > StarterPlayerScripts > DoubleJumpClient (a LocalScript)
local Players = game:GetService("Players")
local UserInputService = game:GetService("UserInputService")
local EXTRA_JUMPS = 1 -- 1 = double jump. 2 = triple. It scales.
local EXTRA_JUMP_POWER = 45 -- studs/second. Humanoid.JumpPower defaults to 50.
local RETRIGGER_DELAY = 0.2 -- seconds. See the note about JumpRequest below.
local player = Players.LocalPlayer
local function setUpCharacter(character)
local humanoid = character:WaitForChild("Humanoid")
local root = character:WaitForChild("HumanoidRootPart")
local jumpsUsed = 0
local lastExtraJump = 0
humanoid.StateChanged:Connect(function(_old, new)
if new == Enum.HumanoidStateType.Landed or new == Enum.HumanoidStateType.Running then
jumpsUsed = 0
elseif new == Enum.HumanoidStateType.Jumping then
-- Count the FIRST jump here, in the state handler, not in the input
-- handler below. The engine fires Jumping for every jump however it
-- was caused — space bar, the mobile jump button, a jump pad, a
-- script setting Humanoid.Jump = true. Counting it on keypress
-- instead means anything that is not the space bar goes uncounted
-- and quietly grants the player an extra jump.
jumpsUsed = math.max(jumpsUsed, 1)
elseif new == Enum.HumanoidStateType.Freefall then
-- Walking off a ledge is not a jump, but it should still cost you
-- the ground jump — otherwise stepping off a crate gives you two
-- air jumps instead of one. Delete these two lines if you would
-- rather be generous about it; some games are, deliberately.
jumpsUsed = math.max(jumpsUsed, 1)
end
end)
UserInputService.JumpRequest:Connect(function()
if humanoid.Health <= 0 then return end
if humanoid:GetState() ~= Enum.HumanoidStateType.Freefall then return end
if jumpsUsed > EXTRA_JUMPS then return end
local now = os.clock()
if now - lastExtraJump < RETRIGGER_DELAY then return end
lastExtraJump = now
jumpsUsed += 1
-- REPLACE the vertical velocity, do not add to it. Adding means a
-- double jump fired at the top of the arc (where you are barely moving)
-- is tiny, and one fired the instant you leave the ground is enormous.
-- Replacing makes every air jump identical, which is what makes the
-- move feel reliable rather than random.
local velocity = root.AssemblyLinearVelocity
root.AssemblyLinearVelocity = Vector3.new(velocity.X, EXTRA_JUMP_POWER, velocity.Z)
humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
end)
end
if player.Character then
setUpCharacter(player.Character)
end
player.CharacterAdded:Connect(setUpCharacter)

Why it is written against states and not a counter

The obvious implementation is a boolean — canDoubleJump = true on jump, false after using it, back to true on landing — and you will find that version in most tutorials. It breaks in three ordinary situations, and the version above is the same length.

  • Jump pads and launchers. Anything that puts the player in the air without a keypress never runs the reset, so the player either loses their air jump or gains an extra one depending on which way the boolean was written. Listening to Humanoid.StateChanged catches every route into the air, including ones you have not written yet.
  • Respawning. A new character is a new Humanoid. Anything connected to the old one is gone, and any state held outside the per-character function survives when it should not. That is why jumpsUsed lives inside setUpCharacter — each character gets its own, and death resets it by construction rather than by remembering to.
  • Walking off a ledge. Handled by the Freefall branch. Without it, stepping off a crate leaves jumpsUsed at 0 and you get two air jumps.

Two bugs almost every version of this ships with

JumpRequest fires repeatedly, not once

UserInputService.JumpRequestis not “the player pressed jump”. It fires continuously while the jump input is held down. Bind an air jump straight to it and holding space burns every jump you have in about two frames — which reads as “the double jump doesn’t work”, not as “the double jump fired three times”.

That is what RETRIGGER_DELAY and lastExtraJump are for. 0.2 seconds is short enough that a fast deliberate double-tap still works and long enough that a held key cannot.

Adding to Y velocity instead of replacing it

velocity.Y + EXTRA_JUMP_POWER looks more physical and feels much worse. Jump again immediately after leaving the ground and you are still moving up fast, so you get a huge launch; jump at the apex, where vertical speed is near zero, and you get a normal one; jump while falling and the two partly cancel. Same keypress, three different heights, and the player has no way to learn it.

Replacing Y — and leaving X and Z alone, so horizontal momentum is preserved — makes every air jump the same height regardless of when it is used. That consistency is the entire feel of the move.

Make it triple, or infinite

EXTRA_JUMPS is the only number to change. Set it to 2 for a triple jump. For an unlockable, drive it from an attribute instead of a constant — character:GetAttribute("AirJumps") — and set that attribute on the server when the player earns it. Attributes replicate, so the client reads it for free and the server stays the thing that decides.

Optional: a ring so the jump reads

A double jump with no feedback is hard to tell apart from a bug in your own jump timing. Twenty lines of neon cylinder fixes that.

-- Optional: something to see. Drop this inside the JumpRequest handler,
-- right after jumpsUsed += 1.
local ring = Instance.new("Part")
ring.Shape = Enum.PartType.Cylinder
ring.Size = Vector3.new(0.2, 6, 6)
ring.CFrame = CFrame.new(root.Position - Vector3.new(0, 2.5, 0)) * CFrame.Angles(0, 0, math.rad(90))
ring.Anchored = true
ring.CanCollide = false
ring.CanQuery = false -- so it never blocks a raycast from anything else
ring.Material = Enum.Material.Neon
ring.Transparency = 0.35
ring.Parent = workspace
game:GetService("Debris"):AddItem(ring, 0.4)
game:GetService("TweenService")
:Create(ring, TweenInfo.new(0.4, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
Size = Vector3.new(0.2, 14, 14),
Transparency = 1,
})
:Play()

Debris:AddItem rather than task.delay and Destroy: Debris survives the script being destroyed mid-tween, which is exactly what happens if the player dies during the effect.

Does the server need to know?

For most games, no. The client already owns its own character’s physics — that is how the normal jump works too — so setting your own velocity is not an exploit, it is the engine’s design. A modified client could always jump as high as it likes whether or not you wrote this script.

It starts to matter when height is worth something: a race, a leaderboard, a badge for reaching somewhere. Then you want the server to notice implausible movement — not to fight it frame by frame, which you will lose.

-- ServerScriptService > JumpGuard (a Script)
-- Only worth adding if you have a leaderboard, a badge, or anything else a
-- player would gain by cheating. For a single-player obby it is wasted work.
local Players = game:GetService("Players")
local MAX_PLAUSIBLE_UPWARD_SPEED = 60 -- a bit above EXTRA_JUMP_POWER
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function(character)
local root = character:WaitForChild("HumanoidRootPart")
local humanoid = character:WaitForChild("Humanoid")
while character.Parent and humanoid.Health > 0 do
if root.AssemblyLinearVelocity.Y > MAX_PLAUSIBLE_UPWARD_SPEED then
-- Do not kill or kick on one reading. Legitimate physics
-- (an explosion, a launcher pad, a conveyor) exceeds this all
-- the time. Count it, and act on a pattern.
warn(player.Name .. " exceeded the plausible jump ceiling")
end
task.wait(0.5)
end
end)
end)

Note what it does not do. It warns rather than punishing, because legitimate physics exceeds that ceiling constantly (explosions, launchers, conveyors) and a system that kills players for being near a rocket is worse than the cheating. Act on a pattern across many samples, and only where the reward justifies the false-positive risk.

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