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
How sprinting works in Roblox
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.
| Piece | Where it lives | What it does |
|---|---|---|
MovementConfig ModuleScript | ReplicatedStorage | The numbers: sprint bonus, stamina and dash. The LocalScript and the server check both read it. |
MovementClient LocalScript | StarterPlayerScripts | Binds 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 LocalScript | StarterCharacterScripts | Gives one extra jump in the air. A fresh copy runs in every new character. |
MovementCheck Script | ServerScriptService | Measures how far each character really moves and warns in the Output when someone keeps going faster than sprinting and dashing allow. |
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.
- In the Explorer, hover over ReplicatedStorage, click the + button and insert a ModuleScript.
- Rename it
MovementConfigand replace its contents with the code below.
-- 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
| Setting | Value | Effect |
|---|---|---|
SPRINT_BONUS | 10 | Added to WalkSpeed while sprinting. Roblox's default WalkSpeed is 16, so a sprint runs at 26 studs per second. |
MAX_STAMINA | 100 | A full bar. |
DRAIN_PER_SECOND | 20 | A full bar lasts 5 seconds of sprinting. |
REGEN_PER_SECOND | 15 | An empty bar takes about 6.7 seconds to refill. |
REGEN_DELAY | 1 | Seconds of rest before the refill starts. |
MIN_TO_START | 20 | Stamina needed to start a sprint. |
DASH_SPEED | 60 | Studs per second during a dash. |
DASH_SECONDS | 0.2 | How long a dash lasts: about 12 studs at 60 studs per second. |
DASH_COST | 25 | Stamina each dash uses. |
DASH_COOLDOWN | 1 | Seconds 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.
- In the Explorer, expand StarterPlayer, hover over StarterPlayerScripts and insert a LocalScript named
MovementClient. - Replace its contents with the code below.
-- 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.
- In the Explorer, expand StarterPlayer, hover over StarterCharacterScripts and insert a LocalScript named
DoubleJump. - Replace its contents with the code below.
-- 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.
- Hover over ServerScriptService and insert a Script named
MovementCheck. - Replace its contents with the code below.
-- 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.
| A cheater | What MovementCheck does |
|---|---|
| Sets WalkSpeed to 100 | Warns within about a second of running |
| Sets WalkSpeed to 50 | Warns 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 once | Warns at the next check |
| Runs at up to 38 studs per second, about one and a half times the sprint speed | Nothing: below 38.5 studs per second the bucket drains as fast as it fills |
| Edits their own MovementConfig for endless stamina | Nothing: a sprint at 26 studs per second is allowed however long it lasts |
| Flies, jumps without limit or walks through walls | Nothing: 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(...)instartRound. - 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
- 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.
- Let go of Shift. After 1 second the bar starts to fill again. Until it is a fifth full, holding Shift does nothing.
- 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_SPEEDorDASH_SECONDSin MovementConfig. - 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.
- Hold Space down through a whole jump. You should get one jump, not two, because the air jump needs a fresh press.
- 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.
- 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.
- 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.
- 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. - If your place has
Workspace.PlayerScriptsUseInputActionSystemset to Enabled, test the double jump again: Roblox's docs do not say whetherJumpRequestbehaves 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.
| Pattern | Why it is a problem | This guide |
|---|---|---|
BodyVelocity for a dash | Deprecated: Roblox says to use LinearVelocity instead | LinearVelocity in Plane mode |
A loop on RunService.RenderStepped | Superseded by PreRender for new work. Game logic such as stamina belongs on Heartbeat, which Roblox describes for periodic tasks like health regeneration | Heartbeat 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 unevenly | Drain and refill scaled by deltaTime every frame |
UserInputService.InputBegan with no check for the chat | Pressing Shift while typing a chat message starts a sprint | ContextActionService, which also adds the phone button |
Setting WalkSpeed = 16 on release or every frame | Overwrites any other speed, such as a game pass perk | Changes WalkSpeed only when a sprint starts or stops, adding to the current value |
| Sprinting through a RemoteEvent so the server sets WalkSpeed | Exploiters set their own WalkSpeed anyway, and the sprint starts a round trip late | Sprint on the client, plus a server distance check |
A double jump on every JumpRequest | JumpRequest fires several times for one jump request, and a held button can keep requesting jumps | A 0.25 second gap and a fresh-press check |
UserInputService.TouchEnabled as an "is mobile" check | It is true on laptops with touch screens | ContextActionService adds the on-screen buttons on touch-enabled devices, with no device check of your own |
Humanoid:LoadAnimation for a run animation | Deprecated in favour of Animator:LoadAnimation | The 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.
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.
- 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
- Roblox Creator Docs: ContextActionService. BindAction, touch buttons, the 7-button limit and why it beats InputBegan for most cases
- Roblox Creator Docs: Humanoid. WalkSpeed and its security note, MoveDirection, StateChanged and ChangeState with its double jump sample
- Roblox Creator Docs: LinearVelocity. Plane mode, tangent axes and MaxForce
- Roblox Creator Docs: Mover constraints. converting the deprecated BodyVelocity to LinearVelocity
- Roblox Creator Docs: UserInputService. JumpRequest and why it needs a debounce
- Roblox Creator Docs: Network ownership, movement validation, and physics. what a client controls, distance checks, leaky buckets and teleport exemptions
- Roblox Creator Docs: Server authority model. the server-simulated movement model, BindToSimulation and the Workspace settings it needs
- Roblox Creator Docs: Input Action System. the Character Sprint example with InputAction and InputBinding
- Roblox Creator Docs: Custom abilities. the Character Controller Library dash ability
- Roblox Creator Docs: Studio testing modes. Device Simulator and Controller Emulator
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.