Bhop & Air Movement Module
A Roblox ModuleScript that implements CS-style bunnyhop, air acceleration, strafing, and slope surfing. Supports side-switch boosts, yaw boosts, and customizable speeds.
- Auto-bhop / single jump control
- Air strafing and yaw boost
- Side-switch speed boosts
- Surfing on slopes
- Configurable walk/sprint speeds and acceleration
local Movement = require(game.ReplicatedStorage.MovementModule)
Movement.Init(Players.LocalPlayer)
-- ModuleScript (e.g. in ReplicatedStorage)
-- Usage in LocalScript:
-- local Movement = require(game.ReplicatedStorage.MovementModule)
-- Movement.Init(Players.LocalPlayer)
local ContextActionService = game:GetService("ContextActionService")
local spaceHeld = false
local lastJump = 0
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
-- add this state at the top
local wasAirborne = false
local landingGraceTime = 0 -- Grace period after landing to preserve speed
local Module = {}
-- ===== CONFIGURATION =====
Module.Config = {
-- base speeds (studs/s)
walkSpeed = 16,
sprintMul = 1.6,
-- ground accel (rough)
ground_accel = 14,
-- Air accel base (CS-like)
sv_airaccelerate_base = 12.0,
-- Max wishspeed handled by AirAccelerate
maxAirWishSpeed = 30,
-- friction on ground
surfaceFriction = 1,
-- bunnyhop / auto-jump
autoBhop = true,
airChargeTime = 1.0,
-- auto-strafe
autoStrafeStrength = 6.0,
mouseDeadzone = 0.01,
-- overspeed / extras
maxAirOverspeedMul = 1.15,
yawBoostFactor = 0.8,
yawBoostThreshold = 0.05,
-- side-switch boost
sideSwitchWindow = 0.22,
sideSwitchMaxBoost = 0.45,
sideSwitchBoostDuration = 0.9,
-- jump settings: allow jumping only on "flat" surfaces (degrees)
jumpMaxSlopeDeg = 1.0, -- <= this angle is considered flat
jumpCooldown = 0.05, -- seconds between jump attempts
-- landing settings
landingSpeedBonus = 1.1, -- multiplier for fall speed to horizontal speed conversion
landingMaxBonus = 40, -- max bonus speed from landing (studs/s)
landingGracePeriod = 0.15, -- time after landing to preserve gained speed
-- thresholds
EPS = 1e-6,
}
-- ===== PRIVATE STATE =====
local cfg = Module.Config
local player
local humanoid, rootPart, camera
local currentVel = Vector3.zero
local prevDesiredDir
local airTime = 0
-- side-switch
local lastSide = 0
local lastSideSwitchTime = 0
local sideSwitchBoostTimer = 0
local sideSwitchBoostAmount = 0
local surfing = false
-- mouse
local mouseDeltaX = 0
-- connections for cleanup
local Connections = {}
-- helpers
local clamp = math.clamp
local now = os.clock
local function getSurfaceFriction()
if not humanoid or not rootPart then return cfg.surfaceFriction end
-- raycast down to detect floor
local params = RaycastParams.new()
params.FilterDescendantsInstances = {rootPart.Parent}
params.FilterType = Enum.RaycastFilterType.Exclude
local result = workspace:Raycast(rootPart.Position, Vector3.new(0, -3, 0), params)
if result and result.Instance then
local phys = result.Instance.CustomPhysicalProperties
if phys then
return phys.Friction
else
-- fallback: use default friction for material
local defaultFriction = {
[Enum.Material.Grass] = 0.6,
[Enum.Material.Metal] = 0.3,
[Enum.Material.Wood] = 0.4,
[Enum.Material.Concrete] = 0.5,
[Enum.Material.Sand] = 0.8,
[Enum.Material.Ice] = 0.05,
[Enum.Material.SmoothPlastic] = 0.4,
-- add more as needed
}
return defaultFriction[result.Material] or cfg.surfaceFriction
end
end
return cfg.surfaceFriction
end
-- ===== HELPERS =====
local function resetState()
currentVel = Vector3.zero
prevDesiredDir = nil
airTime = 0
lastSide = 0
lastSideSwitchTime = 0
sideSwitchBoostTimer = 0
sideSwitchBoostAmount = 0
mouseDeltaX = 0
surfing = false
landingGraceTime = 0
end
local function bindCharacter(character)
humanoid = character:WaitForChild("Humanoid")
rootPart = character:WaitForChild("HumanoidRootPart")
camera = workspace.CurrentCamera
resetState()
currentVel = Vector3.new(rootPart.AssemblyLinearVelocity.X, 0, rootPart.AssemblyLinearVelocity.Z)
end
-- ===== MOVEMENT MATH =====
local function AirAccelerate(wishDir, wishSpeed, accelParam, dt)
local wishspd = math.min(wishSpeed, cfg.maxAirWishSpeed)
local horizVel = Vector3.new(currentVel.X, 0, currentVel.Z)
local currentspeed = horizVel:Dot(wishDir)
local addspeed = wishspd - currentspeed
if addspeed <= 0 then return end
local accelspeed = accelParam * wishspd * dt * cfg.surfaceFriction
if accelspeed > addspeed then accelspeed = addspeed end
currentVel += wishDir * accelspeed
end
local function Accelerate(wishDir, wishSpeed, accelParam, dt)
local horizVel = Vector3.new(currentVel.X, 0, currentVel.Z)
local currentspeed = horizVel:Dot(wishDir)
local addspeed = wishSpeed - currentspeed
if addspeed <= 0 then return end
local kAccelerationScale = math.max(250.0, wishSpeed)
local accelspeed = accelParam * dt * kAccelerationScale * cfg.surfaceFriction
if accelspeed > addspeed then accelspeed = addspeed end
currentVel += wishDir * accelspeed
end
local function ApplyGroundFriction(dt)
-- Don't apply friction during landing grace period
if landingGraceTime > 0 then return end
local horiz = Vector3.new(currentVel.X, 0, currentVel.Z)
local speed = horiz.Magnitude
if speed <= 0 then return end
local friction = getSurfaceFriction() -- use dynamic friction
local drop = speed * friction * dt * cfg.ground_accel * 0.5
local newSpeed = math.max(speed - drop, 0)
currentVel = horiz * (newSpeed / speed) + Vector3.new(0, currentVel.Y, 0)
end
local function ApplyAutoStrafe(dt)
if not camera then return end
if math.abs(mouseDeltaX) < cfg.mouseDeadzone then
mouseDeltaX = 0
return
end
local right = camera.CFrame.RightVector
local sign = (mouseDeltaX > 0) and 1 or -1
local strength = cfg.autoStrafeStrength * (math.abs(mouseDeltaX) * dt)
local lateral = Vector3.new(right.X, 0, right.Z) * (sign * strength)
currentVel += lateral
mouseDeltaX = 0
end
local function UpdateSideSwitch(aDown, dDown, dt)
local currentSide = 0
if aDown and not dDown then currentSide = -1
elseif dDown and not aDown then currentSide = 1 end
if currentSide ~= 0 then
if lastSide ~= 0 and currentSide ~= lastSide then
local t = now()
local dtSwitch = t - lastSideSwitchTime
if dtSwitch <= cfg.sideSwitchWindow then
local scale = clamp((cfg.sideSwitchWindow - dtSwitch) / cfg.sideSwitchWindow, 0, 1)
sideSwitchBoostAmount = cfg.sideSwitchMaxBoost * scale
sideSwitchBoostTimer = cfg.sideSwitchBoostDuration
end
lastSideSwitchTime = t
end
lastSide = currentSide
end
if sideSwitchBoostTimer > 0 then
sideSwitchBoostTimer = math.max(sideSwitchBoostTimer - dt, 0)
else
sideSwitchBoostAmount = 0
end
end
-- ===== JUMP LOGIC: only on flat surfaces =====
local function canJumpOnFloor()
if not humanoid or not rootPart then return false end
-- quick floor material check
if humanoid.FloorMaterial == Enum.Material.Air then return false end
local params = RaycastParams.new()
params.FilterDescendantsInstances = {rootPart.Parent}
params.FilterType = Enum.RaycastFilterType.Exclude
local result = workspace:Raycast(rootPart.Position, Vector3.new(0, -3, 0), params)
if not result or not result.Normal then return false end
local up = Vector3.new(0, 1, 0)
local slopeAngle = math.acos(clamp(result.Normal:Dot(up), -1, 1)) -- radians
local maxJumpSlopeRad = math.rad(cfg.jumpMaxSlopeDeg)
-- Only allow jump if slope angle is <= configured flat threshold
return slopeAngle <= maxJumpSlopeRad
end
local function tryJump()
if not humanoid then return end
-- ensure not already in air
local state = humanoid:GetState()
if state == Enum.HumanoidStateType.Freefall
or state == Enum.HumanoidStateType.FallingDown
or state == Enum.HumanoidStateType.Jumping then
return
end
-- small cooldown
local t = now()
if t - (lastJump or 0) < cfg.jumpCooldown then return end
if canJumpOnFloor() then
humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
lastJump = t
end
end
local function spaceAction(actionName, inputState, inputObject)
if inputState == Enum.UserInputState.Begin then
spaceHeld = true
-- hybrid behavior: immediate jump attempt on press, auto-bhop handled in step()
tryJump()
elseif inputState == Enum.UserInputState.End or inputState == Enum.UserInputState.Cancel then
spaceHeld = false
end
return Enum.ContextActionResult.Sink
end
-- ===== MAIN LOOP =====
local function step(dt)
if not humanoid or not rootPart or not camera then return end
if humanoid.Health <= 0 or dt <= 0 then return end
local aDown = UserInputService:IsKeyDown(Enum.KeyCode.A)
local dDown = UserInputService:IsKeyDown(Enum.KeyCode.D)
local wDown = UserInputService:IsKeyDown(Enum.KeyCode.W)
local sDown = UserInputService:IsKeyDown(Enum.KeyCode.S)
-- ===== INPUT DIRECTION =====
local moveDir = Vector3.zero
local camLook = camera.CFrame.LookVector
local camRight = camera.CFrame.RightVector
if wDown then moveDir += camLook end
if sDown then moveDir -= camLook end
if aDown then moveDir -= camRight end
if dDown then moveDir += camRight end
-- Flatten to horizontal
local horizontalDir = Vector3.new(moveDir.X, 0, moveDir.Z)
local desiredDirUnit = horizontalDir.Magnitude > 0.001 and horizontalDir.Unit or nil
local sprinting = UserInputService:IsKeyDown(Enum.KeyCode.LeftShift)
local targetSpeed = sprinting and cfg.walkSpeed * cfg.sprintMul or cfg.walkSpeed
local state = humanoid:GetState()
local airborne = (state == Enum.HumanoidStateType.Freefall
or state == Enum.HumanoidStateType.FallingDown
or state == Enum.HumanoidStateType.Jumping)
-- Update landing grace timer
if landingGraceTime > 0 then
landingGraceTime = math.max(landingGraceTime - dt, 0)
end
-- IMPROVED LANDING REWARD
local newlyLanded = wasAirborne and not airborne
if newlyLanded then
local fallVelocity = math.max(0, -rootPart.AssemblyLinearVelocity.Y)
-- More generous landing bonus calculation
local bonus = clamp(fallVelocity * cfg.landingSpeedBonus, 0, cfg.landingMaxBonus)
if bonus > 0 then
if desiredDirUnit then
-- Add bonus in the direction you're moving
currentVel += desiredDirUnit * bonus
else
-- If no input, add bonus in current movement direction
local currentHorizontal = Vector3.new(currentVel.X, 0, currentVel.Z)
if currentHorizontal.Magnitude > 0 then
currentVel += currentHorizontal.Unit * bonus
end
end
-- Start landing grace period to preserve the gained speed
landingGraceTime = cfg.landingGracePeriod
end
end
wasAirborne = airborne
local RayDir = Vector3.new(0, -3, 0)
local params = RaycastParams.new()
params.FilterDescendantsInstances = {rootPart.Parent}
params.FilterType = Enum.RaycastFilterType.Exclude
local hitResult = workspace:Raycast(rootPart.Position, RayDir, params)
local up
local slopeAngle
local surfThreshold
if hitResult then
up = Vector3.new(0,1,0)
slopeAngle = math.acos(clamp(hitResult.Normal:Dot(up), -1, 1)) -- radians
surfThreshold = math.rad(3) -- slope angle threshold in radians
if slopeAngle > surfThreshold then
surfing = true
else
surfing = false
end
end
if airborne or surfing then
airTime += dt
else
airTime = 0
if hitResult then
local slopeDir = Vector3.new(desiredDirUnit and desiredDirUnit.X or 0, 0, desiredDirUnit and desiredDirUnit.Z or 0)
local wishSpeed = targetSpeed
if slopeAngle > surfThreshold then
-- SURFING: always use AirAccelerate so momentum isn't clamped
local forwardDir
if desiredDirUnit then
forwardDir = desiredDirUnit - hitResult.Normal * desiredDirUnit:Dot(hitResult.Normal)
else
forwardDir = Vector3.new(0,0,0)
end
if forwardDir.Magnitude > 0 then
forwardDir = forwardDir.Unit
AirAccelerate(forwardDir, math.max(currentVel.Magnitude, wishSpeed), cfg.sv_airaccelerate_base, dt)
end
else
-- FLAT GROUND: preserve overspeed better
local horizVel = Vector3.new(currentVel.X, 0, currentVel.Z)
local currentSpeed = horizVel.Magnitude
-- Apply friction (respects landing grace period)
ApplyGroundFriction(dt)
if desiredDirUnit then
-- During landing grace period, don't limit acceleration to targetSpeed
local desiredSpeed = targetSpeed
if landingGraceTime > 0 then
-- Preserve current speed if it's higher than target
desiredSpeed = math.max(currentSpeed, targetSpeed)
end
local accel = cfg.ground_accel * dt * getSurfaceFriction()
Accelerate(desiredDirUnit, desiredSpeed, accel, dt)
end
end
else
surfing = false
end
prevDesiredDir = desiredDirUnit
end
-- yaw boost
local yawBoost = 0
if prevDesiredDir and desiredDirUnit then
local dot = clamp(prevDesiredDir:Dot(desiredDirUnit), -1, 1)
local angle = math.acos(dot)
local yawRate = angle / math.max(dt, cfg.EPS)
if yawRate > cfg.yawBoostThreshold then
yawBoost = yawRate * cfg.yawBoostFactor * dt
end
end
-- side-switch
UpdateSideSwitch(aDown, dDown, dt)
if airborne or surfing then
ApplyAutoStrafe(dt)
local wishSpeed = targetSpeed
local accelParam = cfg.sv_airaccelerate_base
local pureSideways = (aDown ~= dDown) and not wDown and not sDown
if pureSideways and cfg.airChargeTime > 0 then
local tCharge = clamp(airTime / cfg.airChargeTime, 0, 1)
local speedMul = 1 + (0.08) * tCharge
local accelMul = 1 + (5.0) * tCharge
wishSpeed *= speedMul
accelParam *= accelMul
end
local sideSwitchAdd = 0
if sideSwitchBoostAmount > 0 then
local frac = sideSwitchBoostTimer / math.max(cfg.sideSwitchBoostDuration, cfg.EPS)
sideSwitchAdd = sideSwitchBoostAmount * frac * targetSpeed
end
wishSpeed = wishSpeed + yawBoost + sideSwitchAdd
if desiredDirUnit then
AirAccelerate(desiredDirUnit, wishSpeed, accelParam, dt)
end
if desiredDirUnit then
local lateralAfter = Vector3.new(currentVel.X, 0, currentVel.Z)
local alongAfter = lateralAfter:Dot(desiredDirUnit)
local maxAllowed = targetSpeed * cfg.maxAirOverspeedMul
if alongAfter > maxAllowed then
local perp = lateralAfter - desiredDirUnit * alongAfter
lateralAfter = desiredDirUnit * maxAllowed + perp
currentVel = Vector3.new(lateralAfter.X, currentVel.Y, lateralAfter.Z)
end
end
prevDesiredDir = desiredDirUnit
else
ApplyGroundFriction(dt)
if desiredDirUnit then
Accelerate(desiredDirUnit, targetSpeed, cfg.ground_accel, dt)
end
prevDesiredDir = desiredDirUnit
end
-- apply final velocity
local curAssembly = rootPart.AssemblyLinearVelocity
rootPart.AssemblyLinearVelocity = Vector3.new(currentVel.X, curAssembly.Y, currentVel.Z)
print(rootPart.AssemblyLinearVelocity.Magnitude)
if not airborne then
currentVel = Vector3.new(rootPart.AssemblyLinearVelocity.X, 0, rootPart.AssemblyLinearVelocity.Z)
-- auto-bhop: only jump on flat surfaces because tryJump() enforces the flat check
if cfg.autoBhop and spaceHeld then
tryJump()
end
end
end
-- ===== INPUT HOOKS =====
local function setupInput()
table.insert(Connections, UserInputService.InputChanged:Connect(function(input, processed)
if input.UserInputType == Enum.UserInputType.MouseMovement then
mouseDeltaX += input.Delta.x
end
end))
end
-- ===== PUBLIC API =====
function Module.Init(plr)
player = plr
if player.Character then bindCharacter(player.Character) end
-- Always bind space to our hybrid handler (auto-bhop or single jump)
ContextActionService:BindActionAtPriority(
"CUSTOM_SPACE_HYBRID_JUMP",
spaceAction,
false,
Enum.ContextActionPriority.High.Value,
Enum.KeyCode.Space
)
table.insert(Connections, player.CharacterAdded:Connect(bindCharacter))
setupInput()
table.insert(Connections, RunService.RenderStepped:Connect(step))
end
function Module.Destroy()
for _, c in ipairs(Connections) do
c:Disconnect()
end
table.clear(Connections)
resetState()
end
return Module
if you set physicalproperties friction value of character children to 0 it will work even better.
The values might need adjusting to your preference. Crouch slide is possible to add, but its a bit overkill with the current settings.