Roblox Custom Movement Architecture: Air Strafe Acceleration Vectors, Bunnyhop Friction Windows & Kinematic Controllers

By DopaBrain Physics & Movement Mechanics Team Updated: 2026-10-01 12 min read

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:

2. The Mathematical Foundation: Quake Air Acceleration Vectors

Air strafing works by adding velocity exclusively in the direction perpendicular to current travel:

3. Complete Air Strafe & Bunnyhop Luau Implementation

Below is a complete, modular Luau controller running on RunService.Heartbeat for frame-exact movement simulation:

QuakeMovementController.luau (Source Air Acceleration Core)
--!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:

5. Visual Polish, Camera Tilt & Audio Pitch Dynamics

High-speed movement requires matching visual and auditory sensory feedback:

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.

Explore More Interactive Tests & Guides

Discover personalized cognitive assessments, stress evaluations, and game psychology tools on DopaBrain.

Go to Portal Hub