The zoom-zoom: a tutorial-resource for sprinting, dashing, and double-jumps

The zoom-zoom: a tutorial-resource for sprinting, dashing, and double-jumps

Contents

  1. Github repo, experience
  2. Category compliance, README
  3. Brief Luau practices used in tutorial (for those uninformed)
  4. Full scripts
  5. 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!

43 Likes

cute thing bro very neat good yes

1 Like

Seems like a very good system.

Why is this called “zoom-zoom”? Based on that, I assumed this would modify a Player’s FoV based off these different actions lol

3 Likes

I’ve been using this for a bit and noticed when the player is sprinting, if they press A or D and then S to turn around, they stop sprinting? and if you jump forward while on the edge of a part (like in the image below), a double jump is triggered, even if you only jumped once

is there a way to fix this?

1 Like

oh, this was code i wrote a very long time ago, and i don’t recall running into the sprint cancelling

the jump-forward problem is because Humanoid.FloorMaterial, at the time of the player inputting the jump input, was Enum.Material.Air, therefore triggering the double-jump and not the normal jump or whatever sort of custom behavior that was implemented

a lot has changed since i made this topic, and it was actually superseded by a movement project i was actually going to make a formal release post for since i recently reworked its codebase to strictly-typed luau

here are links to the demo video & game:

this is the link to the github repo:

because the project uses a lot of buffers, i recommend reading through the .md files in the github repo because they explain a bulk of the tweakable values

the new system looks really nice! do I have to use rojo to install it, or is there a way to move it directly into roblox studio? I’ve never used rojo, wally, or any other program to sync studio with VSC so I’m not really sure what to do lmao

and as for the issue with jumping, should I add some other check to fix it? the issue seems to also occur in the new system, and it also happens if you try to jump while you’re positioned as high up inside the water as you can be before jumping:
image

oh, weird, i thought i uncopylocked the demo place before i went to sleep :‎p
it should be uncopylocked now

if u prefer to not use rojo/vsc, you’ll find the Motion module and konbini module in replicatedstorage
all that needs to be called is the :empty() and :init() methods, which are described in the github readme

the jumping issue should be able to be fixed with a custom check, i only have the check for .FloorMaterial because it just makes sense for me

jumping while on the surface of the water is intended, as it gives the player the chance to leave the swimming state

also here are the docs for konbini, in the demo place i think the only things that are used are the inputs and animation modules

gotcha, thanks!
random question by the way, is it possible to have two idle animations like roblox has by default?


in the custom script, it seems to only define 1 animation and looking into the Animation module script, it doesn’t seem to check for the animation weight anywhere

multiple animations, no

if u prefer to implement ur own animation stuff that does support multiple weights, the animation module has some functions to solve whether the player is walking/sprinting/idle etc

in this previous demo video i recorded a few weeks ago, i hadn’t yet added locomotion animations so i don’t anticipate it being too much of a hassle to add ur own should u choose

1 Like

oh btw, are there plans to add climbing support? i noticed climbing (trusses, ladders etc) isn’t implemented in the new system

comment out Enum.HumanoidStateType.Climbing in the Motion.__index:Optimize() method to re-enable the default climbing behavior for humanoids

climbing was disabled because it’s an expensive humanoidstate

i do have plans to add wall-running – the default behavior is most similar to how titanfall 2 handles wall running bc i like how simple it is

the wall-running aux state however is planned to be compatible with simpler behaviors such as climbing

1 Like