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.LocalPlayerlocal function setUpCharacter(character)local humanoid = character:WaitForChild("Humanoid")local root = character:WaitForChild("HumanoidRootPart")local jumpsUsed = 0local lastExtraJump = 0humanoid.StateChanged:Connect(function(_old, new)if new == Enum.HumanoidStateType.Landed or new == Enum.HumanoidStateType.Running thenjumpsUsed = 0elseif 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)endend)UserInputService.JumpRequest:Connect(function()if humanoid.Health <= 0 then return endif humanoid:GetState() ~= Enum.HumanoidStateType.Freefall then return endif jumpsUsed > EXTRA_JUMPS then return endlocal now = os.clock()if now - lastExtraJump < RETRIGGER_DELAY then return endlastExtraJump = nowjumpsUsed += 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.AssemblyLinearVelocityroot.AssemblyLinearVelocity = Vector3.new(velocity.X, EXTRA_JUMP_POWER, velocity.Z)humanoid:ChangeState(Enum.HumanoidStateType.Jumping)end)endif player.Character thensetUpCharacter(player.Character)endplayer.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.StateChangedcatches 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
jumpsUsedlives insidesetUpCharacter— 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
jumpsUsedat 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.Cylinderring.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 = truering.CanCollide = falsering.CanQuery = false -- so it never blocks a raycast from anything elsering.Material = Enum.Material.Neonring.Transparency = 0.35ring.Parent = workspacegame: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_POWERPlayers.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 doif 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")endtask.wait(0.5)endend)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.