Bhop & Air Movement Module + Surfing

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.

28 Likes

Could you put some videos of the script?

1 Like

I added you requested video of the script.

1 Like

Hi! I’m experiencing an issue where my player character just can’t jump.

2 Likes

This might be late but this is an amazing module, I’ve tried others and this has actual good airstrafing close to CS or Half Life