The zoom-zoom: a tutorial-resource for sprinting, dashing, and double-jumps
Contents
- Github repo, experience
- Category compliance, README
- Brief Luau practices used in tutorial (for those uninformed)
- Full scripts
- Step-by-step
Github repo, experience
1. Category compliance, README
Compliance with #resources:community-tutorials
I have been approached by a sizable number of developers regarding my dashing post
It would be a disservice to these developers by providing supplementary info/tweaks/fixes to code that’s nearly 2 years old
As I am still learning the hokey-pokey of Luau (: operators, etc). I apologize in advance for any confusion or misinformation that is imparted from this tutorial. If there are any corrections to be made to the definitions please let me know.
All code written in Visual Studio and synced with Rojo, and I’d highly recommended it for all developers
Cross-platform compatibility
In terms of services, I use both ContextActionService and UserInputService
There are, however, some “ghetto” workarounds that I use
(such as the mobile jump button .Activated
event firing when the input ends [makes zero sense])
2. Luau practices used
To avoid confusion down the line
Skip if you already know type definitions; only covers the Luau practices used in this tutorial
To the best of my knowledge, these do not directly affect scripts or their performance; they are simply used for readability
2a. type definitions
local x : number = 5
-- ^ ^ ":" tells us that variable x 's type is a number
local mightbeanumber : number? = nil
--[[
a `?` can be added at the end of the type declaration, showing it *may* be that type or nil
for example: ``
in this case our variable `mightbeanumber` is nil
]]
2b. return types
adding “: type
” after declaring a function says the type of variable that the function returns
function isanumber(x : any): boolean
return type(x) == "number"
end
if more than 1 variable is returned, the return types must be wrapped in parentheses:
function multiplyanddivide(x : number, y : number): (number, number)
return x * y, x / y
end
local product, quotient = multiplyanddivide(5, 10)
-- 50, 0.5
3. Full scripts:
MoverUtil (src/Movement/MoverUtil.lua)
A handy Util for applying a BodyMover to a BasePart (imported from my projects); I added explanations to the best of my ability
In the scope of this tutorial, it is used to handle Dash physics
-- ModuleScript
assert(script.ClassName == "ModuleScript", "script is not a ModuleScript")
-- create new type MoverInfo -- THIS IS ONLY FOR READABILITY/VISUAL AID
export type MoverInfo = { --create new type "MoverInfo"
BasePart : BasePart; -- basepart that the bodymover will act on
Type : string; -- type of bodymover ("BodyVelocity", "BodyGyro", etc)
Properties : Dictionary<any>; -- properties of bodymover ^
Lifetime : number? -- how long will the bodymover act upon the basepart?
}
local Main = {}
--@desc Applies Instance.new(`MoverInfo.Type`) to part `MoverInfo.BasePart` with properties `MoverInfo.Properties`, for an optional duration of `MoverInfo.Lifetime` seconds
--@param {MoverInfo : MoverInfo} type MoverInfo
--@returns returns BodyMover
function Main:ApplyMover(MoverInfo : MoverInfo): BodyMover
local Mover = Instance.new(MoverInfo.Type) -- create new BodyMover as defined by MoverInfo
assert(Mover, ("%s is not instance or no Mover type provided"):format(MoverInfo.Type or "nil")) -- assert() failsafe
for Property, Value in pairs(MoverInfo.Properties) do -- apply properties to Mover
Mover[Property] = Value
end
Mover.Parent = MoverInfo.BasePart -- parent Mover to target BasePart
if MoverInfo.Lifetime then -- if lifetime is provided, then set lifetime
coroutine.wrap(function() -- Debris service is inefficient, better-off writing your own
task.wait(MoverInfo.Lifetime)
Mover:Destroy()
end)()
end
return Mover -- return mover being applied
end
--@desc see @returns
--@param {Direction : Vector3} normalized Vector3
--@param {RelativeTo : CFrame} CFrame
--@returns returns a normalized Vector3, where the CFrame (Y-rotation only) is oriented in the direction of the Direction vector
function Main:GetRelativeDirection(Direction : Vector3, RelativeTo : CFrame): Vector3
return CFrame.lookAt(RelativeTo.Position, (RelativeTo.Position + Direction)).LookVector
end
return Main
LocalScript (src/Movement/Local.client.lua)
-- LocalScript
assert(script.ClassName == "LocalScript", "script is not a LocalScript")
-- services
local UIS = game:GetService("UserInputService")
local CAS = game:GetService("ContextActionService")
local MoverUtil = require(script.Parent:WaitForChild("MoverUtil"))
-- variables, instances
local Player = game:GetService("Players").LocalPlayer
local PlayerGui = Player:FindFirstChild("PlayerGui") or Player:WaitForChild("PlayerGui")
local Character = Player.Character or Player.CharacterAdded:Wait()
local RootPart : BasePart = Character:WaitForChild("HumanoidRootPart")
local Humanoid : Humanoid = Character:WaitForChild("Humanoid")
local Camera = workspace.CurrentCamera
-- settings
local BaseSpeed = game:GetService("StarterPlayer").CharacterWalkSpeed -- variable, default value is 16
local SprintSpeed = 32 -- variable
local DoubleJumpPower = 80 -- variable
local DashVelocity = 80 -- variable
local DashCooldown = 1 -- variable
local DoubleTapTimeout = 0.2 -- grace period to trigger double-tap
-- booleans
local CanSprint = false -- can client trigger the sprint double-tap?
local DoubleJumped = false -- did client already use their double-jump?
local DashReset = true -- did client reset their air-dash?
local LastDash = 0 -- time of last successful dash
-- base functions
local function Dash(_, InputState : Enum.UserInputState, InputObject : InputObject) -- dash
if InputState == Enum.UserInputState.Begin then
-- cooldown logic
local TimeOfRequest = tick()
local Delta = TimeOfRequest - LastDash
if Delta < DashCooldown then return end
-- can only-dash-once-in-air logic
if Humanoid.FloorMaterial == Enum.Material.Air then
if not DashReset then return end
DashReset = false
end
-- now that all the checks are passed, actually do the dash
print("dash")
local Direction = Humanoid.MoveDirection -- default .movedirection
if Direction.Magnitude == 0 then -- if the player is not moving, fallback on the direction their character is facing
Direction = RootPart.CFrame.LookVector
end
local RelativeDirection = MoverUtil:GetRelativeDirection(Direction, Camera.CFrame) -- moverutil function
MoverUtil:ApplyMover({ -- moverutil function; apply mover to HumanoidRootPart
BasePart = RootPart; -- target of function
Lifetime = 0.12; -- duration of bodymover
Type = "BodyVelocity"; -- will use Instance.new("BodyVelocity") to act upon the HumanoidRootPart
Properties = { -- properties of BodyVelocity
Velocity = RelativeDirection * DashVelocity; -- v = direction * speed
MaxForce = Vector3.new(30000, 0, 30000) -- y-component is 0 so they cannot dash up/down
}
})
end
end
local function DoubleJump(_, InputState : Enum.UserInputState, InputObject : InputObject) -- double jump
if InputState == Enum.UserInputState.Begin then
if Humanoid.FloorMaterial ~= Enum.Material.Air then return end -- cannot double-jump when on ground
if DoubleJumped then return end -- cannot double jump more than once
DoubleJumped = true -- set boolean to true, indicating that the player just triggered the double-jump
print("double jump")
RootPart.AssemblyLinearVelocity *= Vector3.new(1, 0, 1) -- neutralize y-component of HumanoidRootPart.AssemblyLinearVelocity
RootPart.AssemblyLinearVelocity += Vector3.new(0, DoubleJumpPower, 0) -- add velocity to HumanoidRootPart.AssemblyLinearVelocity
end
end
local function ToggleSprint(_, InputState : Enum.UserInputState, InputObject : InputObject) -- sprint
if InputState == Enum.UserInputState.Begin then
Character:SetAttribute("Sprinting", true)
elseif InputState == Enum.UserInputState.End then
Character:SetAttribute("Sprinting", nil)
end
end
-- input detection
CAS:BindAction("Sprint", ToggleSprint, true, Enum.KeyCode.ButtonL3) -- bind sprint for console
CAS:BindAction("Dash", Dash, true, Enum.KeyCode.Q, Enum.KeyCode.ButtonL1) -- bind dash for pc, console
-- DoubleJump and Sprint cannot be bound using CAS because it uses the spacebar, so it will be connected here
UIS.InputBegan:Connect(function(Input : InputObject, Typing : boolean)
if Input.KeyCode == Enum.KeyCode.W then -- sprint for pc
if Typing then return end
-- logic is explained in post
if CanSprint == false then
CanSprint = true
task.spawn(function()
task.wait(DoubleTapTimeout)
if CanSprint == true then CanSprint = false end
end)
else
Character:SetAttribute("Sprinting", true)
CanSprint = false
end
elseif Input.KeyCode == Enum.KeyCode.Space or Input.KeyCode == Enum.KeyCode.ButtonA then -- double jump fpr pc, console
if Typing then return end
DoubleJump(nil, Input.UserInputState)
end
end)
UIS.InputEnded:Connect(function(Input : InputObject, Typing : boolean)
if Input.KeyCode == Enum.KeyCode.W then -- sprint for PC
if Typing then return end
Character:SetAttribute("Sprinting", nil)
end
end)
-- double-jump -- aforementioned ghetto workaround for TouchGui (mobile compatibility)
-- for some reason .Activated and .Mouse1ButtonClick will fire when the input is RELEASED (LOL)
-- accounts for mobile
if UIS.TouchEnabled then
local TouchGui : ScreenGui = PlayerGui:FindFirstChild("TouchGui") or PlayerGui:WaitForChild("TouchGui") -- get mobile TouchGUI
local JumpButton : ImageButton = TouchGui.TouchControlFrame:WaitForChild("JumpButton") -- get mobile jump button
JumpButton.MouseButton1Down:Connect(function() -- .Mouse1ButtonDown appears to be the only fix
DoubleJump(nil, Enum.UserInputState.Begin) -- call DoubleJump function with necessary arguments
end)
end
-- .changed connections
Character.AttributeChanged:Connect(function(Attribute : string)
if Attribute ~= "Sprinting" then return end
local Value = Character:GetAttribute("Sprinting")
print(("sprint %s"):format(Value == true and "start" or "end"))
if Value == true then -- is now sprinting
Humanoid.WalkSpeed = SprintSpeed
else -- no longer sprinting
Humanoid.WalkSpeed = BaseSpeed
end
end)
Humanoid.StateChanged:Connect(function(_, NewState : Enum.HumanoidStateType)
if NewState == Enum.HumanoidStateType.Landed then
print("landed, resetting double jump, dash")
DoubleJumped = false
DashReset = true
end
end)
4. Step-by-step
Variable definitions
Define what you’re going to use later on
Make sure variable names are as simple as driving a John Deere model X9 along a busy stretch of the californian interstate.
-- LocalScript
assert(script.ClassName == "LocalScript", "script is not a LocalScript")
-- services
local UIS = game:GetService("UserInputService")
local CAS = game:GetService("ContextActionService")
local MoverUtil = require(script.Parent:WaitForChild("MoverUtil"))
-- variables, instances
local Player = game:GetService("Players").LocalPlayer
local PlayerGui = Player:FindFirstChild("PlayerGui") or Player:WaitForChild("PlayerGui")
local Character = Player.Character or Player.CharacterAdded:Wait()
local RootPart : BasePart = Character:WaitForChild("HumanoidRootPart")
local Humanoid : Humanoid = Character:WaitForChild("Humanoid")
local Camera = workspace.CurrentCamera
-- settings
local BaseSpeed = game:GetService("StarterPlayer").CharacterWalkSpeed -- variable, default value is 16
local SprintSpeed = 32 -- variable
local DoubleJumpPower = 80 -- variable
local DashVelocity = 80 -- variable
local DashCooldown = 1 -- variable
local DoubleTapTimeout = 0.2 -- grace period to trigger double-tap
-- booleans
local CanSprint = false -- can client trigger the sprint double-tap?
local DoubleJumped = false -- did client already use their double-jump?
local DashReset = true -- did client reset their air-dash?
local LastDash = 0 -- time of last successful dash
Function definitions
Break down what you want to do into simple functions to be used later
local function Dash(_, InputState : Enum.UserInputState, InputObject : InputObject)
end
local function DoubleJump(_, InputState : Enum.UserInputState, InputObject : InputObject)
end
local function ToggleSprint(_, InputState : Enum.UserInputState, InputObject : InputObject)
end
Dash function
local function Dash(_, InputState : Enum.UserInputState, InputObject : InputObject) -- dash
if InputState == Enum.UserInputState.Begin then
-- cooldown logic
local TimeOfRequest = tick()
local Delta = TimeOfRequest - LastDash
if Delta < DashCooldown then return end
-- can only-dash-once-in-air logic
if Humanoid.FloorMaterial == Enum.Material.Air then
if not DashReset then return end
DashReset = false
end
-- now that all the checks are passed, actually do the dash
print("dash")
local Direction = Humanoid.MoveDirection -- default .movedirection
if Direction.Magnitude == 0 then -- if the player is not moving, fallback on the direction their character is facing
Direction = RootPart.CFrame.LookVector
end
local RelativeDirection = MoverUtil:GetRelativeDirection(Direction, Camera.CFrame) -- moverutil function
MoverUtil:ApplyMover({ -- moverutil function; apply mover to HumanoidRootPart
BasePart = RootPart; -- target of function
Lifetime = 0.12; -- duration of bodymover
Type = "BodyVelocity"; -- will use Instance.new("BodyVelocity") to act upon the HumanoidRootPart
Properties = { -- properties of BodyVelocity
Velocity = RelativeDirection * DashVelocity; -- v = direction * speed
MaxForce = Vector3.new(30000, 0, 30000) -- y-component is 0 so they cannot dash up/down
}
})
end
end
Double-jump function
local function DoubleJump(_, InputState : Enum.UserInputState, InputObject : InputObject) -- double jump
if InputState == Enum.UserInputState.Begin then
if Humanoid.FloorMaterial ~= Enum.Material.Air then return end -- cannot double-jump when on ground
if DoubleJumped then return end -- cannot double jump more than once
DoubleJumped = true -- set boolean to true, indicating that the player just triggered the double-jump
print("double jump")
RootPart.AssemblyLinearVelocity *= Vector3.new(1, 0, 1) -- neutralize y-component of HumanoidRootPart.AssemblyLinearVelocity
RootPart.AssemblyLinearVelocity += Vector3.new(0, DoubleJumpPower, 0) -- add velocity to HumanoidRootPart.AssemblyLinearVelocity
end
end
Sprint function
local function ToggleSprint(_, InputState : Enum.UserInputState, InputObject : InputObject) -- sprint
if InputState == Enum.UserInputState.Begin then
Character:SetAttribute("Sprinting", true)
elseif InputState == Enum.UserInputState.End then
Character:SetAttribute("Sprinting", nil)
end
end
(An aside: Detecting double-taps)
In essence, it’s a debounce with extra steps
Logic:
if CanTrigger == false then
CanTrigger = true
task.spawn(function()
task.wait(DoubleTapTimeout : number)
if CanTrigger == true then CanTrigger = false end
end)
else
-- ACTION FUNCTION HERE (CLIENT TRIGGERED DOUBLE TAP EVENT)
CanTrigger = false
end
Input detection, logic
In my experience with writing movement systems, I’ve only really needed detection for taps and double-taps. The latter is typically used for sprinting and dashing
CAS:BindAction("Sprint", ToggleSprint, true, Enum.KeyCode.ButtonL3) -- bind sprint for console
CAS:BindAction("Dash", Dash, true, Enum.KeyCode.Q, Enum.KeyCode.ButtonL1) -- bind dash for pc, console
-- DoubleJump and Sprint cannot be bound using CAS because it uses the spacebar, so it will be connected here
UIS.InputBegan:Connect(function(Input : InputObject, Typing : boolean)
if Input.KeyCode == Enum.KeyCode.W then -- sprint for pc
if Typing then return end
-- logic is explained in post
if CanSprint == false then
CanSprint = true
task.spawn(function()
task.wait(DoubleTapTimeout)
if CanSprint == true then CanSprint = false end
end)
else
Character:SetAttribute("Sprinting", true)
CanSprint = false
end
elseif Input.KeyCode == Enum.KeyCode.Space or Input.KeyCode == Enum.KeyCode.ButtonA then -- double jump fpr pc, console
if Typing then return end
DoubleJump(nil, Input.UserInputState)
end
end)
UIS.InputEnded:Connect(function(Input : InputObject, Typing : boolean)
if Input.KeyCode == Enum.KeyCode.W then -- sprint for PC
if Typing then return end
Character:SetAttribute("Sprinting", nil)
end
end)
.Changed connections
Detecting .StateChanged and .AttributeChanged to assist with dash and double-jump logic
Character.AttributeChanged:Connect(function(Attribute : string)
if Attribute ~= "Sprinting" then return end
local Value = Character:GetAttribute("Sprinting")
print(("sprint %s"):format(Value == true and "start" or "end"))
if Value == true then -- is now sprinting
Humanoid.WalkSpeed = SprintSpeed
else -- no longer sprinting
Humanoid.WalkSpeed = BaseSpeed
end
end)
Humanoid.StateChanged:Connect(function(_, NewState : Enum.HumanoidStateType)
if NewState == Enum.HumanoidStateType.Landed then
print("landed, resetting double jump, dash")
DoubleJumped = false
DashReset = true
end
end)
now your players can go zoom-zoom :D!