Tutorial · Game systems

Roblox sprint script: stamina, dash and double jump

Shift to sprint in Roblox comes down to one number, the Humanoid's WalkSpeed, raised while Shift is held. The work is everything around it: a stamina bar, a Sprint button for phones, a dash built on current physics, a double jump that ignores a held button, and knowing what the server can and cannot check. This guide builds all of it in four scripts you can paste in.

Updated 13 October 202622 min readBeginnerLuau

In short

Put a LocalScript in StarterPlayerScripts that binds a Sprint action with ContextActionService:BindAction(..., true, Enum.KeyCode.LeftShift, Enum.KeyCode.ButtonL3); the true adds an on-screen Sprint button on touch screens. While the action is held and the character is moving, raise Humanoid.WalkSpeed (16 becomes 26) and drain stamina in a RunService.Heartbeat loop; refill it after a short rest. A dash is a LinearVelocity on the root part for 0.2 seconds, and a double jump is Humanoid:ChangeState(Enum.HumanoidStateType.Jumping) from a UserInputService.JumpRequest handler while the character is in Freefall.

  • Movement runs on the player's device, because each device simulates its own character. The server cannot enforce a WalkSpeed; it can only measure how far characters move and flag speeds that sprinting and dashing cannot explain.
  • Change WalkSpeed only when sprinting starts and stops, and add to the current value, so a speed boost from a game pass is kept.
  • Use LinearVelocity for the dash: BodyVelocity is deprecated.
On this page
  1. How it works
  2. 1. Settings module
  3. 2. Sprint and dash
  4. 3. Double jump
  5. 4. Server check
  6. 5. Test it
  7. Older scripts
  8. Input Action System
  9. Extending it
  10. Common errors
  11. With RoCode
  12. Questions
  13. Sources

How sprinting works in Roblox

IllustrationShift sprints at WalkSpeed 26 while the bar drains, Q dashes for a quarter of the bar, and a second jump press in the air jumps again.

Each player's device moves that player's own character. The keyboard, controller or touch screen decides where the character goes, and Roblox passes the result on to the server and everyone else. So a sprint is a LocalScript that raises Humanoid.WalkSpeed while a key is held. The rest of this guide is what a real game needs around that: a stamina bar, a button for phones, a dash, a double jump, and a server check for players who cheat.

The four pieces of the movement system
PieceWhere it livesWhat it does
MovementConfig ModuleScriptReplicatedStorageThe numbers: sprint bonus, stamina and dash. The LocalScript and the server check both read it.
MovementClient LocalScriptStarterPlayerScriptsBinds Sprint and Dash to a key, a gamepad button and a touch button, runs the stamina loop, draws the bar and performs the dash.
DoubleJump LocalScriptStarterCharacterScriptsGives one extra jump in the air. A fresh copy runs in every new character.
MovementCheck ScriptServerScriptServiceMeasures how far each character really moves and warns in the Output when someone keeps going faster than sprinting and dashing allow.
Explorer when the movement system is finished
ReplicatedStorage
  MovementConfig ModuleScript new
ServerScriptService
  MovementCheck Script new
StarterPlayer
  StarterCharacterScripts
    DoubleJump LocalScript new
  StarterPlayerScripts
    MovementClient LocalScript new

If the split between Script and LocalScript is new to you, Script vs LocalScript vs ModuleScript explains where each kind runs and why.

Step 1: Put the numbers in one module

All the numbers live in one ModuleScript, so you tune the feel in one place. MovementClient reads them on each player's device, and MovementCheck reads the same module on the server to know how fast a sprint and a dash should be.

  1. In the Explorer, hover over ReplicatedStorage, click the + button and insert a ModuleScript.
  2. Rename it MovementConfig and replace its contents with the code below.
ModuleScript in ReplicatedStorage named MovementConfig
-- One set of numbers for the movement LocalScript and the server's speed check.

local MovementConfig = {
	SPRINT_BONUS = 10, -- added to the current WalkSpeed: 16 (Roblox's default) becomes 26

	MAX_STAMINA = 100,
	DRAIN_PER_SECOND = 20, -- 5 seconds of sprinting from full
	REGEN_PER_SECOND = 15,
	REGEN_DELAY = 1, -- seconds after sprinting stops before stamina comes back
	MIN_TO_START = 20, -- stamina needed to start sprinting, so an empty bar cannot flicker on and off

	DASH_SPEED = 60, -- studs per second during a dash
	DASH_SECONDS = 0.2, -- so a dash covers about 12 studs
	DASH_COST = 25, -- stamina
	DASH_COOLDOWN = 1,
}

return MovementConfig
What each setting does
SettingValueEffect
SPRINT_BONUS10Added to WalkSpeed while sprinting. Roblox's default WalkSpeed is 16, so a sprint runs at 26 studs per second.
MAX_STAMINA100A full bar.
DRAIN_PER_SECOND20A full bar lasts 5 seconds of sprinting.
REGEN_PER_SECOND15An empty bar takes about 6.7 seconds to refill.
REGEN_DELAY1Seconds of rest before the refill starts.
MIN_TO_START20Stamina needed to start a sprint.
DASH_SPEED60Studs per second during a dash.
DASH_SECONDS0.2How long a dash lasts: about 12 studs at 60 studs per second.
DASH_COST25Stamina each dash uses.
DASH_COOLDOWN1Seconds between dashes.

Each side runs its own copy of a module (a module runs once per side), so a player who edits these numbers on their device changes only their own copy. That protects nothing here, though: their device already moves their character, so it can give itself endless stamina whatever the module says. The server check in Step 4 is built with that in mind.

Step 2: Sprint with stamina, and dash

This LocalScript does the work players feel. It binds Sprint and Dash, draws the stamina bar, runs the stamina loop every frame and performs the dash.

  1. In the Explorer, expand StarterPlayer, hover over StarterPlayerScripts and insert a LocalScript named MovementClient.
  2. Replace its contents with the code below.
LocalScript in StarterPlayerScripts named MovementClient
-- Hold Shift (or L3, or the Sprint button on a phone) to sprint while stamina lasts.
-- Press Q (or B, or the Dash button) to dash in the direction you are moving.

local ContextActionService = game:GetService("ContextActionService")
local Debris = game:GetService("Debris")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

type Config = {
	SPRINT_BONUS: number,
	MAX_STAMINA: number,
	DRAIN_PER_SECOND: number,
	REGEN_PER_SECOND: number,
	REGEN_DELAY: number,
	MIN_TO_START: number,
	DASH_SPEED: number,
	DASH_SECONDS: number,
	DASH_COST: number,
	DASH_COOLDOWN: number,
}
local Config = require(ReplicatedStorage:WaitForChild("MovementConfig")) :: Config

local player = Players.LocalPlayer
local stamina = Config.MAX_STAMINA
local sprintHeld = false
local sprinting = false
local normalSpeed = 16 -- the WalkSpeed to go back to when sprinting stops
local lastSprintTime = 0
local lastDashTime = -math.huge

-- Stamina bar: a frame with a fill whose width is the stamina left
local gui = Instance.new("ScreenGui")
gui.Name = "StaminaGui"
gui.ResetOnSpawn = false
local bar = Instance.new("Frame")
bar.AnchorPoint = Vector2.new(0.5, 1)
bar.Position = UDim2.new(0.5, 0, 1, -24)
bar.Size = UDim2.fromOffset(240, 14)
bar.BackgroundColor3 = Color3.fromRGB(30, 26, 40)
bar.Parent = gui
local fill = Instance.new("Frame")
fill.Size = UDim2.fromScale(1, 1)
fill.BackgroundColor3 = Color3.fromRGB(80, 220, 150)
fill.BorderSizePixel = 0
fill.Parent = bar
for _, frame in { bar, fill } do
	local corner = Instance.new("UICorner")
	corner.CornerRadius = UDim.new(1, 0)
	corner.Parent = frame
end
gui.Parent = player:WaitForChild("PlayerGui")

local function getHumanoid(): Humanoid?
	local character = player.Character
	local humanoid = character and character:FindFirstChildOfClass("Humanoid")
	if humanoid and humanoid.Health > 0 then
		return humanoid
	end
	return nil
end

-- Sprint: only a flag here; the Heartbeat loop below decides every frame whether to sprint
local function onSprint(_name: string, state: Enum.UserInputState, _input: InputObject): Enum.ContextActionResult
	-- Only a press or a release changes it: a finger sliding on the touch button sends Change
	if state == Enum.UserInputState.Begin then
		sprintHeld = true
	elseif state == Enum.UserInputState.End or state == Enum.UserInputState.Cancel then
		sprintHeld = false
	end
	return Enum.ContextActionResult.Pass
end

local function dash()
	local humanoid = getHumanoid()
	local rootPart = humanoid and humanoid.RootPart
	local now = os.clock()
	-- No dash while sitting: a seated character is welded to its seat, so it would push the whole vehicle
	if not (humanoid and rootPart) or humanoid.Sit or now - lastDashTime < Config.DASH_COOLDOWN or stamina < Config.DASH_COST then
		return
	end
	local attachment = rootPart:FindFirstChild("RootAttachment")
	if not (attachment and attachment:IsA("Attachment")) then
		return
	end
	lastDashTime = now
	stamina -= Config.DASH_COST
	lastSprintTime = now -- pause the refill, as sprinting does

	-- Dash the way the player is moving, or the way they face if they are standing still
	local direction = humanoid.MoveDirection
	if direction.Magnitude < 0.1 then
		direction = rootPart.CFrame.LookVector
	end
	direction = Vector3.new(direction.X, 0, direction.Z).Unit

	-- LinearVelocity in Plane mode holds the horizontal speed and leaves gravity alone
	local velocity = Instance.new("LinearVelocity")
	velocity.Attachment0 = attachment
	velocity.RelativeTo = Enum.ActuatorRelativeTo.World
	velocity.VelocityConstraintMode = Enum.VelocityConstraintMode.Plane
	velocity.PrimaryTangentAxis = Vector3.xAxis
	velocity.SecondaryTangentAxis = Vector3.zAxis
	velocity.PlaneVelocity = Vector2.new(direction.X, direction.Z) * Config.DASH_SPEED
	velocity.MaxForce = math.huge
	velocity.Parent = rootPart
	Debris:AddItem(velocity, Config.DASH_SECONDS)
end

local function onDash(_name: string, state: Enum.UserInputState, _input: InputObject): Enum.ContextActionResult
	if state == Enum.UserInputState.Begin then
		dash()
	end
	return Enum.ContextActionResult.Pass
end

-- One call each binds a key, a gamepad button and, on touch devices, an on-screen button
ContextActionService:BindAction("Sprint", onSprint, true, Enum.KeyCode.LeftShift, Enum.KeyCode.ButtonL3)
ContextActionService:BindAction("Dash", onDash, true, Enum.KeyCode.Q, Enum.KeyCode.ButtonB)
ContextActionService:SetTitle("Sprint", "Sprint")
ContextActionService:SetTitle("Dash", "Dash")

-- Every frame: drain stamina while sprinting and moving, refill it after a short rest
RunService.Heartbeat:Connect(function(deltaTime: number)
	local humanoid = getHumanoid()
	if not humanoid then
		return
	end
	local moving = humanoid.MoveDirection.Magnitude > 0
	local now = os.clock()
	local enoughStamina = if sprinting then stamina > 0 else stamina >= Config.MIN_TO_START
	local shouldSprint = sprintHeld and moving and enoughStamina

	-- Change WalkSpeed only when sprinting starts or stops, so a speed set by
	-- another script (such as a speed pass) is kept and sprinting adds to it
	if shouldSprint and not sprinting then
		sprinting = true
		normalSpeed = humanoid.WalkSpeed
		humanoid.WalkSpeed = normalSpeed + Config.SPRINT_BONUS
	elseif not shouldSprint and sprinting then
		sprinting = false
		humanoid.WalkSpeed = normalSpeed
	end

	if sprinting then
		stamina = math.max(0, stamina - Config.DRAIN_PER_SECOND * deltaTime)
		lastSprintTime = now
	elseif now - lastSprintTime >= Config.REGEN_DELAY then
		stamina = math.min(Config.MAX_STAMINA, stamina + Config.REGEN_PER_SECOND * deltaTime)
	end
	fill.Size = UDim2.fromScale(stamina / Config.MAX_STAMINA, 1)
end)

-- A new character starts with full stamina and nothing held
player.CharacterAdded:Connect(function()
	stamina = Config.MAX_STAMINA
	sprintHeld = false
	sprinting = false -- the new Humanoid starts at its normal WalkSpeed
end)

The stamina bar is built in code with ResetOnSpawn set to false, so it stays on screen through respawns, and the CharacterAdded handler at the bottom fills it again for each new character. The type Config lines describe what the module returns, so strict type checking can catch a misspelt setting; they change nothing when the game runs.

Keyboard, gamepad and a phone button with one call

ContextActionService:BindAction takes an action name, a handler function, whether to create a touch button, and then the inputs. Passing true as the third argument makes ContextActionService add an on-screen button on touch-enabled devices, inside a ScreenGui named ContextActionGui in the player's PlayerGui. SetTitle writes "Sprint" and "Dash" on those buttons. BindAction can create at most 7 touch buttons, so keep count if other scripts in your game use them too.

The handler receives the input's state: Begin on a press and End on the release. onSprint only records whether Sprint is held. It treats Cancel as a release too, which ContextActionService sends when another binding takes over the input, and it ignores Change, which a finger moving on the touch button may send. Both handlers return Pass, so the next binding on the same key still gets the input.

Roblox's ContextActionService reference recommends it over UserInputService.InputBegan for most cases. Its example is a car horn on H that should not honk while the player types in chat: a binding only fires while it is the most recent one for that input, so when the chat box takes the keyboard you have nothing to check. The gamepad inputs are ButtonL3 (pressing the left stick in, held while you move) and ButtonB. Roblox's own sprint example uses ButtonY, so change the KeyCode in BindAction if that suits your game better.

The stamina loop

RunService.Heartbeat fires every frame after the physics step and passes deltaTime, the seconds since the previous frame. Multiplying the drain and refill rates by it makes a full bar last 5 seconds whether the game runs at 30 or 240 frames per second.

Each frame the loop decides whether the character should be sprinting: Sprint is held, the character is walking (Humanoid.MoveDirection is a zero vector when it is not), and there is stamina left. Starting a sprint needs at least MIN_TO_START (20); keeping one going needs anything above 0. Without that gap, an empty bar that refills a sliver each frame would switch the sprint on and off every frame. The refill starts REGEN_DELAY (1 second) after the last sprint or dash, so after running the bar dry a player waits about 2.3 seconds before they can sprint again.

WalkSpeed changes only on the frame a sprint starts or stops. At the start the script remembers the current WalkSpeed and adds SPRINT_BONUS; at the stop it puts the remembered value back. That is why a speed set by another script survives: the game pass guide's Speed perk sets WalkSpeed to 24, and with this script its owners sprint at 34. A script that writes WalkSpeed = 16 every frame, or on every release, would quietly cancel that perk.

The dash

The dash pushes the character the way it is moving, or the way it faces when it stands still, flattened so it never points up or down. It costs 25 stamina, pauses the refill like a sprint does, and has a 1 second cooldown. Nothing in it checks for the ground, so it also works in the air, which pairs well with the double jump. It does nothing while the character is sitting: a seated character is welded to its seat, so in a vehicle the push could shove the whole vehicle.

The push is a LinearVelocity constraint on the root part's RootAttachment. In Plane mode with the X and Z axes it holds the horizontal velocity at 60 studs per second and leaves the vertical direction alone, so gravity still works and a dash off a ledge falls normally. MaxForce is math.huge, so the push is not capped. Debris:AddItem destroys the constraint after DASH_SECONDS without making the script wait, so the dash lasts 0.2 seconds, about 12 studs at that speed.

Older tutorials use BodyVelocity, which Roblox marks deprecated with the note to use LinearVelocity instead. Its trick of setting MaxForce to zero on one axis is what LinearVelocity's Line, Plane and Vector modes replace. Because the player's device simulates its own character, the constraint the LocalScript creates moves it, and other players see the dash like any other movement.

Roblox's own UI tutorial attaches a constraint to humanoid.RootPart.RootAttachment in the same way. If your game uses a custom character whose root part has no Attachment named RootAttachment, the dash does nothing: add one to the HumanoidRootPart of your StarterCharacter.

Step 3: Add a double jump

The double jump lives in its own LocalScript in StarterCharacterScripts. Roblox puts a copy of that container's scripts into each character when it spawns, and those copies do not survive a respawn, so every life starts with a fresh script and nothing carries over from the last one.

  1. In the Explorer, expand StarterPlayer, hover over StarterCharacterScripts and insert a LocalScript named DoubleJump.
  2. Replace its contents with the code below.
LocalScript in StarterCharacterScripts named DoubleJump
-- Gives one extra jump in the air. It runs again for every new character,
-- so nothing carries over after a respawn.

local UserInputService = game:GetService("UserInputService")

local character = script.Parent :: Model
local humanoid = character:WaitForChild("Humanoid") :: Humanoid

local EXTRA_JUMPS = 1
local MIN_SECONDS_BETWEEN_JUMPS = 0.25 -- JumpRequest fires several times for one press
local NEW_PRESS_GAP = 0.1 -- a held jump button keeps firing JumpRequest; a pause this long means it was let go

local jumpsLeft = 0
local lastJump = 0
local lastRequest = 0

humanoid.StateChanged:Connect(function(oldState: Enum.HumanoidStateType, newState: Enum.HumanoidStateType)
	if newState == Enum.HumanoidStateType.Jumping and oldState ~= Enum.HumanoidStateType.Freefall then
		-- A jump from the ground (not one of our air jumps) refills the air jumps
		jumpsLeft = EXTRA_JUMPS
		lastJump = os.clock()
	elseif newState == Enum.HumanoidStateType.Landed then
		jumpsLeft = 0
	end
end)

-- JumpRequest fires whenever the player asks to jump, such as with Space or the phone's jump button
UserInputService.JumpRequest:Connect(function()
	local now = os.clock()
	local newPress = now - lastRequest > NEW_PRESS_GAP
	lastRequest = now
	-- Only a fresh press jumps again, so holding the button does not use up the air jump
	if not newPress or jumpsLeft <= 0 or now - lastJump < MIN_SECONDS_BETWEEN_JUMPS then
		return
	end
	if humanoid:GetState() == Enum.HumanoidStateType.Freefall then
		jumpsLeft -= 1
		lastJump = now
		humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
	end
end)

Set EXTRA_JUMPS to 2 for a triple jump.

The script follows the character's state with Humanoid.StateChanged. A change to Jumping that did not come from Freefall is a jump from the ground, so it hands out the air jump, and Landed takes it away again. Walking off a ledge goes to Freefall without a jump, so it gives no air jump. If you want one there too, also set jumpsLeft when the state changes to Freefall from anything other than Jumping.

UserInputService.JumpRequest fires when the player asks to jump, for example with Space or the jump button on a phone. Roblox's reference warns that it fires several times for a single jump request and recommends a debounce, so the script ignores requests within 0.25 seconds of the last jump. Holding the jump button can also keep requesting jumps, which would spend the air jump the moment the character leaves the ground, so a request only counts as a new press after 0.1 seconds with none.

The air jump itself is Humanoid:ChangeState(Enum.HumanoidStateType.Jumping). Roblox's reference says it has to be called from a LocalScript on a device that has network ownership of the character, which by default is the player's own device, not the server. Its code sample for ChangeState is a double jump.

Step 4: Flag speed exploits on the server

Sprint tutorials often stop at the client. A server Script cannot trust a player's WalkSpeed, because exploiters change it on their own device, but it can see where their character ends up. MovementCheck measures that twice a second and warns in the Output when someone keeps moving faster than sprinting and dashing allow.

  1. Hover over ServerScriptService and insert a Script named MovementCheck.
  2. Replace its contents with the code below.
Script in ServerScriptService named MovementCheck
-- Players' devices move their own characters, so an exploiter can set any WalkSpeed.
-- This check measures how far each character really moves and warns in the Output
-- when someone keeps going faster than sprinting and dashing allow. It only warns:
-- lag, teleports and conveyors can all look like speeding, so tune it before you punish anyone.

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StarterPlayer = game:GetService("StarterPlayer")

type Config = { SPRINT_BONUS: number, DASH_SPEED: number, DASH_SECONDS: number }
local Config = require(ReplicatedStorage:WaitForChild("MovementConfig")) :: Config

local CHECK_EVERY = 0.5 -- seconds
-- Sprint speed plus room for lag. Raise it if you sell speed boosts.
local SPEED_ALLOWANCE = (StarterPlayer.CharacterWalkSpeed + Config.SPRINT_BONUS) * 1.25
local BURST_ALLOWANCE = Config.DASH_SPEED * Config.DASH_SECONDS * 2 -- room for a dash or two
local DRAIN_PER_CHECK = BURST_ALLOWANCE / 8 -- how fast past bursts are forgiven

local lastPosition: { [Player]: Vector3 } = {}
local lastRoot: { [Player]: BasePart } = {} -- the root part lastPosition was measured on
local excess: { [Player]: number } = {} -- studs moved beyond the allowance, slowly forgiven

Players.PlayerRemoving:Connect(function(player: Player)
	lastPosition[player] = nil
	lastRoot[player] = nil
	excess[player] = nil
end)

while true do
	task.wait(CHECK_EVERY)
	for _, player in Players:GetPlayers() do
		local character = player.Character
		local rootPart = character and character:FindFirstChild("HumanoidRootPart")
		if rootPart and rootPart:IsA("BasePart") then
			local position = rootPart.Position
			-- A respawn is a new root part, and a server teleport (a round start, a checkpoint)
			-- sets the attribute below: start measuring again instead of flagging the jump
			local previous = if lastRoot[player] == rootPart then lastPosition[player] else nil
			if player:GetAttribute("MovementCheckSkip") then
				player:SetAttribute("MovementCheckSkip", nil)
				previous = nil
			end
			lastPosition[player] = position
			lastRoot[player] = rootPart
			if previous then
				-- Only horizontal movement: falling fast is not cheating
				local moved = Vector3.new(position.X - previous.X, 0, position.Z - previous.Z).Magnitude
				local extra = moved - SPEED_ALLOWANCE * CHECK_EVERY
				local total = math.max(0, (excess[player] or 0) + extra - DRAIN_PER_CHECK)
				excess[player] = total
				if total > BURST_ALLOWANCE then
					warn(`MovementCheck: {player.Name} moved {math.round(moved / CHECK_EVERY)} studs/s`)
					excess[player] = 0
				end
			end
		else
			lastPosition[player] = nil
			lastRoot[player] = nil
		end
	end
end

The warning shows in Studio's Output. In a live game it goes to the server log of the Developer Console, which you can read as the game's owner: see output in a live game.

What it measures

Every half second the script reads each character's root part position and measures how far it moved sideways since the last check. Height is ignored, so falling or jumping never counts; Roblox's movement validation advice suggests the same projection onto the XZ plane for ground-based games. The allowance is the default walk speed plus the sprint bonus, plus a quarter for lag: 32.5 studs per second. Distance beyond that goes into a leaky bucket, a kind of accumulator that Roblox's docs say many developers find works well for burst movement. The bucket loses 3 studs every check, and the script warns when it holds more than 24 studs, which is room for about two dashes.

What the check catches, worked out from its numbers
A cheaterWhat MovementCheck does
Sets WalkSpeed to 100Warns within about a second of running
Sets WalkSpeed to 50Warns after about 2.5 seconds of running
Chains dashes with no cooldown (60 studs per second)Warns after about 1.5 seconds
Teleports more than about 43 studs at onceWarns at the next check
Runs at up to 38 studs per second, about one and a half times the sprint speedNothing: below 38.5 studs per second the bucket drains as fast as it fills
Edits their own MovementConfig for endless staminaNothing: a sprint at 26 studs per second is allowed however long it lasts
Flies, jumps without limit or walks through wallsNothing: the check ignores height and does not look at collisions

So the check catches the crude cheats, the ones that make a player obviously faster than everyone else, and misses careful ones. That is the honest limit of a distance check, and the reason Roblox is building something stronger (see server authority below).

Why it only warns

Roblox's docs point out that simple speed checks can flag innocent players with unstable connections and break down in games with vehicles or other complex movement. Lag spikes, conveyor belts, vehicles, explosions that fling characters and your own teleports can all look like speeding, so the script only prints a warning. Watch the Output while you and your testers play normally, raise SPEED_ALLOWANCE if honest players trip it, and only add a punishment, such as moving the player back, once the check stays quiet in normal play. If explosions fling characters in your game, also skip players whose Humanoid has no health left.

Respawns and teleports

A respawn creates a new root part, and the script starts measuring again when it sees one, so dying far from the spawn point never counts as speeding. Teleports made by your own server scripts need an exemption, as Roblox's docs say for any game with teleport mechanics. After the teleport, add player:SetAttribute("MovementCheckSkip", true), and the next check starts measuring from the new position instead of flagging the jump:

  • In the round system, right after character:PivotTo(...) in startRound.
  • In the obby checkpoint script, right after each character:PivotTo(...) that moves a player to their checkpoint.

Only the server's value counts. An attribute a player sets on their own device stays on that device (see what replicates and what does not), so a cheater cannot use it to switch the check off.

The long-term fix: server authority

Roblox's security docs call the server authority model, in beta at the time of writing, the most reliable way to stop movement exploits. The server becomes the source of truth and players' devices only report their inputs, which the docs say prevents speed hacks because a device is never trusted to report its own position.

Switching is a rewrite, not a setting. Under that model the server runs movement, so a WalkSpeed or LinearVelocity that only a LocalScript sets is undone when the server's state arrives. Roblox's docs say the core logic has to run in RunService:BindToSimulation() functions in a ModuleScript that both the client and the server run. The model also needs several Workspace settings, including PlayerScriptsUseInputActionSystem, so your controls would move to the Input Action System too. Keep this guide's version until you move the sprint, stamina and dash into that setup. Until then, a distance check like this one is what Roblox's movement validation section describes.

Step 5: Test it on every device

  1. Press Play and hold Shift while you walk. You speed up from 16 to 26 studs per second and the green bar at the bottom of the screen shrinks. After about 5 seconds it is empty and you drop back to walking speed.
  2. Let go of Shift. After 1 second the bar starts to fill again. Until it is a fifth full, holding Shift does nothing.
  3. Press Q while walking: you shoot forward a short way and a quarter of the bar is gone. Press Q again straight away and nothing happens until a second has passed. If the dash feels too short or too long, change DASH_SPEED or DASH_SECONDS in MovementConfig.
  4. Jump, then press jump again in the air for a second jump. Land and try again: every jump from the ground gives the air jump back.
  5. Hold Space down through a whole jump. You should get one jump, not two, because the air jump needs a fresh press.
  6. Turn on Studio's Device Simulator (a beta at the time of writing: File, Beta Features, New Device Simulator, then restart Studio), or turn on the older Device Emulator from the Test menu. Choose a phone and press Play. Sprint and Dash buttons appear. Hold Sprint while you steer, then hold the jump button through a jump and check that you get only one jump.
  7. Open the Controller Emulator (Controller on the Emulation tab, or Virtual controller in the Device Simulator toolbar). Hold L3 while moving to sprint, press B to dash and press A twice for the double jump.
  8. Reset your character from the in-game menu. The bar is full again, the Output shows no MovementCheck warning and, if you are still simulating a phone, the Sprint and Dash buttons are still there.
  9. Now play the cheater. Click the Client/Server toggle until the Explorer shows the client, select your character's Humanoid under Workspace and set WalkSpeed to 60 in the Properties window. Walk for a few seconds: the Output shows a warning such as MovementCheck: YourName moved 60 studs/s. Set it back to 16, then sprint and dash normally for a minute and check that the Output stays quiet.
  10. If your place has Workspace.PlayerScriptsUseInputActionSystem set to Enabled, test the double jump again: Roblox's docs do not say whether JumpRequest behaves the same with it.

If nothing happens at all, open the Output window from Studio's Window menu and look for a red error from MovementClient, or see Common errors.

What older sprint scripts get wrong

Older sprint, dash and double jump tutorials often still run, but many use patterns Roblox has since replaced, or break other systems in your game. If you are fixing a script you copied, check it against this list.

Patterns in older tutorials, and what this guide does instead
PatternWhy it is a problemThis guide
BodyVelocity for a dashDeprecated: Roblox says to use LinearVelocity insteadLinearVelocity in Plane mode
A loop on RunService.RenderSteppedSuperseded by PreRender for new work. Game logic such as stamina belongs on Heartbeat, which Roblox describes for periodic tasks like health regenerationHeartbeat with deltaTime
A stamina loop built on wait()wait() is deprecated in favour of task.wait(), and it is throttled, so it can wait longer than asked and a fixed amount per wait drains unevenlyDrain and refill scaled by deltaTime every frame
UserInputService.InputBegan with no check for the chatPressing Shift while typing a chat message starts a sprintContextActionService, which also adds the phone button
Setting WalkSpeed = 16 on release or every frameOverwrites any other speed, such as a game pass perkChanges WalkSpeed only when a sprint starts or stops, adding to the current value
Sprinting through a RemoteEvent so the server sets WalkSpeedExploiters set their own WalkSpeed anyway, and the sprint starts a round trip lateSprint on the client, plus a server distance check
A double jump on every JumpRequestJumpRequest fires several times for one jump request, and a held button can keep requesting jumpsA 0.25 second gap and a fresh-press check
UserInputService.TouchEnabled as an "is mobile" checkIt is true on laptops with touch screensContextActionService adds the on-screen buttons on touch-enabled devices, with no device check of your own
Humanoid:LoadAnimation for a run animationDeprecated in favour of Animator:LoadAnimationThe run animation below uses the Animator

Roblox's newer way: the Input Action System

Roblox's Input Action System page uses a character sprint as its first example. Instead of BindAction calls, you set the controls up in Studio: an InputAction named CharacterSprint inside an input context in ReplicatedStorage, with one InputBinding for Left Shift, one for the gamepad's Y button and one linked to an on-screen button you design in StarterGui. A client script then listens to the action's Pressed and Released events, doubling WalkSpeed on press and setting it back on release.

It is worth switching when you want the controls laid out in Studio rather than in code, your own touch button instead of ContextActionService's, or contexts that swap whole sets of controls, such as a menu or a vehicle. The page also recommends setting Workspace.PlayerScriptsUseInputActionSystem to Enabled, and server authority requires it.

This guide's stamina loop and dash work the same way with the Input Action System on its own (without server authority). Keep MovementConfig, the Heartbeat loop and dash(), delete the two BindAction calls and their handlers, and connect the actions instead: the sprint action's Pressed sets sprintHeld to true and its Released sets it to false, and the dash action's Pressed calls dash(). Keep looking the Humanoid up each frame as MovementClient does, because the docs' sample stores the first character's Humanoid, which is gone after a respawn.

Roblox is also testing a Character Controller Library, a beta you turn on in Studio's Beta Features and Avatar Settings. Its custom abilities page builds a dash as its example, with a 0.2 second active window and a 1 second cooldown, the same timing as this guide's dash.

Extending it: animation, camera and a sprint pass

A run animation

Make a run animation in the Animation Editor, set its priority to Movement (Roblox's recommended priority for walk and run animations), publish it and copy its asset ID. In MovementClient, create an Animation with that AnimationId, load it with Animator:LoadAnimation using the Animator inside the character's Humanoid, play the track where a sprint starts and stop it where the sprint stops. Load it again for each new character.

Animations your device starts on your own character are shown to everyone, as long as you use the Animator Roblox put there: tracks loaded on an Animator that a LocalScript created do not replicate. Humanoid:LoadAnimation, which many older tutorials use, is deprecated.

Widen the camera while sprinting

A slightly wider field of view makes a sprint feel faster. In the same two places, tween workspace.CurrentCamera.FieldOfView from its default of 70 to about 80 when a sprint starts and back when it stops, for example with TweenService:Create(workspace.CurrentCamera, TweenInfo.new(0.25), { FieldOfView = 80 }):Play(). Roblox clamps the value between 1 and 120 and warns that very low or very high values can disorientate players. If another script in your game also changes the field of view, tween back to the value you found rather than to 70.

A faster sprint as a game pass

The game pass guide already works with this script: its Speed perk sets WalkSpeed to 24 on the server for every new character, MovementClient adds the sprint bonus on top, and 34 studs per second stays inside what MovementCheck allows.

For a pass that makes the sprint itself faster, add a FastSprint pass to that guide's PassConfig.ids. Its server script then sets the attribute OwnsPass_FastSprint on the player once the pass is confirmed. In MovementClient, use a bigger bonus than Config.SPRINT_BONUS when player:GetAttribute("OwnsPass_FastSprint") == true. In MovementCheck, give those players a larger allowance, reading the same attribute on the server, where a player cannot change it, and check that the new top speed stays under what the check flags.

One limit to know: if another script changes WalkSpeed in the middle of a sprint, the end of that sprint puts back the speed from before it. The next respawn sets the pass speed again, because the game pass script applies it to every new character.

Common errors and fixes

Open the Output window (Window menu, then Output) before you press Play. Most problems with these scripts print an error or a warning there, with the script name and line.

Infinite yield possible on 'ReplicatedStorage:WaitForChild("MovementConfig")'

MovementClient or MovementCheck is waiting for a module that is not there under that exact name. It must be a ModuleScript named MovementConfig directly inside ReplicatedStorage. The script errors guide explains this warning in detail.

Holding Shift does nothing

Check that MovementClient is a LocalScript, not a Script, and that it sits in StarterPlayerScripts. A sprint also needs the character to be walking and at least 20 stamina to start, so hold Shift while you move. If Shift Lock switches instead, see the Shift Lock question below.

The sprint stops after a few seconds

That is the stamina running out: 100 stamina at 20 a second lasts 5 seconds. Raise MAX_STAMINA or lower DRAIN_PER_SECOND in MovementConfig.

The dash does nothing

A dash needs 25 stamina, waits 1 second after the last one and does nothing while you are sitting. If none of those is the problem, your character's root part may have no Attachment named RootAttachment, which happens with some custom characters: add one to the HumanoidRootPart of your StarterCharacter.

The double jump never happens

DoubleJump must be a LocalScript in StarterCharacterScripts, not StarterPlayerScripts, because it reads script.Parent as the character. Press jump a second time after leaving the ground: holding the button does not count as a new press, and walking off a ledge gives no air jump.

There is no Sprint or Dash button on a phone

ContextActionService only adds its buttons on touch-enabled devices, so they never show on a PC without a touch screen. Test with a phone in Studio's Device Simulator (a beta: turn on New Device Simulator under File, Beta Features, and restart Studio). BindAction can create at most 7 touch buttons, so count how many other scripts in your game make them.

MovementCheck warns about honest players

The usual causes are a teleport made by another server script, a conveyor or vehicle, or lag. Set the MovementCheckSkip attribute after your own teleports (see Respawns and teleports), and raise SPEED_ALLOWANCE if it still fires in normal play.

A speed boost is lost after sprinting

Another script changed WalkSpeed during the sprint, and the sprint ended by putting back the speed it saw when it started. Apply speed boosts when the character spawns, as the game pass guide does, so a respawn sets the right speed again.

Build it with RoCode

If you would rather not set this up by hand, RoCode can build it in the place you have open. RoCode is an independent AI agent for Roblox Studio, not made by Roblox: you describe the change in a web chat, and its Studio plugin creates the scripts in your Explorer, so there is no code to paste.

You type

Add shift to sprint with a stamina bar: walk speed plus 10 while Shift is held, 100 stamina draining 20 a second, refilling 15 a second after one second of rest, a Sprint button on phones, Q to dash, and a double jump.

RoCode does
  • Can read your place first with Search Scripts and Read Script, to find other scripts that change WalkSpeed (such as a game pass perk) so the sprint adds to them
  • Writes the ModuleScript, the LocalScripts and the server check with Create Script
  • Is told to compile-check each script it writes with Check Script, which compiles it in Studio without running it
  • Sets an undo point before and after each batch of changes, so Ctrl+Z steps back one batch at a time

RoCode cannot make a run animation: create one in the Animation Editor and paste its ID. With plugin 3.9 or newer RoCode can run a short playtest and read the errors, but it does not press keys or move the character, so you try the sprint, dash and double jump yourself, and use Studio's Device Simulator for the phone buttons. Movement is one system among many in a game; building a whole game with RoCode works one system per prompt like this. How RoCode connects to Studio.

Questions

Why does my sprint script not work on mobile?

A phone has no Shift key. Bind the sprint with ContextActionService:BindAction and pass true as the third argument, which adds an on-screen button on touch devices, as MovementClient does.

Should sprinting be done on the server?

No. Each device moves its own character, so a WalkSpeed set by the server does not stop a cheater, who can set their own, and the new speed only arrives after a round trip, so the sprint starts late. Sprint on the client, and check on the server how far characters really move, as in Step 4. Roblox's server authority beta works differently and needs the movement code rewritten.

Why does my character keep sprinting after I let go of Shift?

The script missed the release, usually because it only listens for the press. ContextActionService sends End when the key goes up, and Cancel when another binding takes it over; MovementClient stops on both, and also when stamina runs out or the character stops moving.

How do I add a sprint animation?

Load a run animation with the Animator in the character's Humanoid in the same LocalScript, play it when sprinting starts and stop it when sprinting ends. Give it the Movement priority. Animations started on your own character are shown to everyone.

Why does my double jump trigger on its own on mobile?

Holding the phone's jump button keeps requesting jumps. Only count a jump as new after the requests stop for a moment, as the DoubleJump script does.

Does Shift to sprint clash with Shift Lock?

It can. Players who turn on Shift Lock in Roblox's settings switch it with the Shift key, so one key does two jobs. Roblox's docs say Shift Lock APIs are being deprecated. If the clash bothers your players, turn off StarterPlayer.EnableMouseLockOption, or bind the sprint to another key such as Left Ctrl in MovementClient.

Sources and further reading

How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 13 October 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest in Studio before you publish.

Try RoCode on your own place.

Free every day, no card required. Install the plugin, describe the feature, and review what lands in Studio.