In parkour, movement shooters, and high-speed competitive titles on Roblox—such as Evade, Defusal, and Source-style surf/bhop games—default Roblox Humanoid locomotion feels sluggish and restrictive. Default Roblox Humanoid physics aggressively clamps airborne velocity, immediately strips horizontal momentum when turning in mid-air, and applies instant ground friction that terminates player velocity upon landing.
To create the exhilarating fluid momentum made famous by Quake, Half-Life, and Counter-Strike, top developers replace standard Humanoids with custom Kinematic Character Controllers implementing Source-style movement mathematics. In this engineering guide, we build a production-ready air strafe and bunnyhop controller in Luau. We derive the wishdir projection formulas, implement ground friction grace frames, and manage seamless velocity replication.
1. The Physics Defect: Why Default Humanoid Air Control Feels Stiff
Default Roblox Humanoid physics enforces rigid velocity dampening that destroys advanced locomotion:
- Air Drag Clamping: When a player jumps, Humanoid physics treats the character as a high-drag body, decelerating horizontal speed toward WalkSpeed (16 studs/s).
- Airborne Turning Deceleration: Turning the camera in mid-air causes the Humanoid's velocity vector to bleed energy, preventing momentum redirection.
- Zero-Frame Ground Friction: The instant the Humanoid's FloorMaterial transitions from Air to a solid surface, full ground friction is applied, immediately braking velocity.
- The Source Physics Paradigm: Quake and Source engines do not clamp horizontal velocity; they project the player's intended input direction onto current velocity, allowing tangential acceleration that stacks speed indefinitely.
2. The Mathematical Foundation: Quake Air Acceleration Vectors
Air strafing works by adding velocity exclusively in the direction perpendicular to current travel:
- Wish Direction (wishdir): The normalized input vector derived from WASD inputs and camera CFrame: wishdir = (cameraLook * moveZ + cameraRight * moveX).Unit.
- Current Speed Projection: currentspeed = velocity:Dot(wishdir), measuring how much speed the player already has in the intended direction.
- Add Speed Calculation: addspeed = math.clamp(maxAirSpeed - currentspeed, 0, airAccel * maxAirSpeed * dt).
- Tangential Speed Accumulation: If currentspeed < maxAirSpeed, the controller adds addspeed * wishdir to velocity. Crucially, when turning into the strafe angle, currentspeed remains near zero, allowing continuous lateral acceleration that curves the trajectory while increasing overall magnitude.
- Optimal Strafe Angle: The mathematical sweet spot is theta = math.acos(maxAirSpeed / currentMagnitude), creating exponential speed stacking.
3. Complete Air Strafe & Bunnyhop Luau Implementation
Below is a complete, modular Luau controller running on RunService.Heartbeat for frame-exact movement simulation:
- Kinematic Ground Probe: Uses Workspace:Spherecast or Raycast beneath the capsule to detect ground contact 2-4 frames before landing.
- Friction Grace Window: Bypasses ground friction for 4 frames (66ms) upon landing if a jump input is queued, preserving 100% of accumulated horizontal speed.
- LinearVelocity Integration: Drives the HumanoidRootPart using an authoritative LinearVelocity constraint with high maxForce and zero air damping.
--!strict
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local Workspace = game:GetService("Workspace")
local MovementEngine = {}
MovementEngine.__index = MovementEngine
export type ControllerConfig = {
RootPart: BasePart,
MaxAirSpeed: number,
AirAcceleration: number,
GroundMaxSpeed: number,
GroundAcceleration: number,
FrictionCoeff: number,
JumpPower: number,
FrictionGraceFrames: number,
}
export type MovementState = {
Config: ControllerConfig,
Velocity: Vector3,
IsGrounded: boolean,
GraceTimer: number,
JumpBuffered: boolean,
RayParams: RaycastParams,
}
function MovementEngine.New(root: BasePart): MovementState
local rayParams = RaycastParams.new()
rayParams.FilterType = RaycastFilterType.Exclude
rayParams.FilterDescendantsInstances = { root.Parent or root }
local state: MovementState = {
Config = {
RootPart = root,
MaxAirSpeed = 7.5,
AirAcceleration = 120.0,
GroundMaxSpeed = 22.0,
GroundAcceleration = 14.0,
FrictionCoeff = 6.0,
JumpPower = 48.0,
FrictionGraceFrames = 4,
},
Velocity = Vector3.zero,
IsGrounded = true,
GraceTimer = 0,
JumpBuffered = false,
RayParams = rayParams,
}
return state
end
local function Accelerate(vel: Vector3, wishdir: Vector3, wishspeed: number, accel: number, dt: number): Vector3
local currentspeed = vel:Dot(wishdir)
local addspeed = wishspeed - currentspeed
if addspeed <= 0 then
return vel
end
local accelspeed = math.min(accel * wishspeed * dt, addspeed)
return vel + (wishdir * accelspeed)
end
function MovementEngine.Step(state: MovementState, inputDir: Vector3, cameraLook: Vector3, cameraRight: Vector3, dt: number)
local root = state.Config.RootPart
local cfg = state.Config
-- Check ground contact
local origin = root.Position
local cast = Workspace:Spherecast(origin, 1.0, Vector3.new(0, -1.8, 0), state.RayParams)
local wasGrounded = state.IsGrounded
state.IsGrounded = (cast ~= nil)
-- Handle landing grace frames for bunnyhopping
if not wasGrounded and state.IsGrounded then
state.GraceTimer = cfg.FrictionGraceFrames
end
-- Construct 2D wishdir on the horizontal plane
local camForwardFlat = Vector3.new(cameraLook.X, 0, cameraLook.Z).Unit
local camRightFlat = Vector3.new(cameraRight.X, 0, cameraRight.Z).Unit
local wishdir = (camForwardFlat * inputDir.Z + camRightFlat * inputDir.X)
local wishspeed = 0
if wishdir.Magnitude > 0.05 then
wishdir = wishdir.Unit
wishspeed = cfg.GroundMaxSpeed
end
local horizVel = Vector3.new(root.AssemblyLinearVelocity.X, 0, root.AssemblyLinearVelocity.Z)
local vertVel = root.AssemblyLinearVelocity.Y
if state.IsGrounded then
if state.GraceTimer > 0 and state.JumpBuffered then
-- Bunnyhop jump triggered: skip friction
vertVel = cfg.JumpPower
state.IsGrounded = false
state.GraceTimer = 0
state.JumpBuffered = false
elseif state.GraceTimer <= 0 then
-- Apply ground friction
local speed = horizVel.Magnitude
if speed > 0.1 then
local drop = speed * cfg.FrictionCoeff * dt
horizVel = horizVel * math.max(speed - drop, 0) / speed
end
horizVel = Accelerate(horizVel, wishdir, wishspeed, cfg.GroundAcceleration, dt)
else
state.GraceTimer -= 1
end
else
-- Air strafe acceleration
local airWishSpeed = math.min(wishspeed, cfg.MaxAirSpeed)
horizVel = Accelerate(horizVel, wishdir, airWishSpeed, cfg.AirAcceleration, dt)
end
root.AssemblyLinearVelocity = Vector3.new(horizVel.X, vertVel, horizVel.Z)
end
return MovementEngine
4. Ground Friction Delay & Speed Stacking Dynamics
Bunnyhopping relies on executing jumps before ground friction decelerates the player:
- Ground Contact Queue: When approaching the ground, inputting a jump within 100ms before touchdown buffers the jump action.
- Zero-Friction Rebound: If the buffer is active when landing, the controller executes an upward impulse on frame 1, resetting vertical velocity while preserving 100% of accumulated horizontal momentum.
- Speed Cap Management: Implementing exponential soft-caps (e.g. reducing air acceleration by 50% when exceeding 150 studs/second) to prevent physics tunneling through thin map boundaries.
- Slope Surfing Integration: When the ground normal exceeds a 45-degree angle, redirecting gravitational acceleration along the slope plane to launch players into high-velocity trajectory curves.
5. Visual Polish, Camera Tilt & Audio Pitch Dynamics
High-speed movement requires matching visual and auditory sensory feedback:
- Dynamic Dutch Roll: Tilting the camera roll (CFrame.Angles(0, 0, -strafeInput * 0.04)) during aggressive air strafes to emphasize lateral G-forces.
- Field-of-View (FOV) Scaling: Expanding camera FieldOfView dynamically from 70 to 95 degrees based on horizontal speed: targetFOV = math.clamp(70 + (speed / 10), 70, 100).
- Wind Rush Sound Modulation: Pitching up and amplifying an ambient wind audio track in RunService.RenderStepped as airspeed climbs.
- Footstep Suppression in Bhop Chains: Muting landing footstep audio when a bunnyhop grace frame is successfully chained, providing immediate auditory confirmation of momentum preservation.
Frequently Asked Questions
Why does air strafing increase speed rather than just changing direction?
Because air acceleration only checks velocity in the direction of the strafe. When turning the camera tangentially into the strafe vector, your speed in the wishdir direction is nearly zero, allowing the engine to add speed without hitting the air speed limit.
What is a 'friction grace window' in bunnyhopping?
It is a short buffer (typically 2 to 4 frames, or 33-66ms) immediately after contacting the ground where friction is not applied if the player buffers a jump, allowing full horizontal speed to be preserved into the next jump.
How do you prevent players from flying through walls at extreme bunnyhop speeds?
By using continuous collision detection (CCD) or performing predictive spherecasts along the character's forward trajectory vector, instantly clamping movement if a thin obstacle is intercepted.