i’m working on a character controller and ran into a bug where the character occasionally gets flung when jumping. i’ve identified the root cause, but i don’t fully understand why it happens. it seems like the capsule somehow gets flipped, rotated, or turned around when i modify its upward velocity. it doesn’t happen every single time, but often enough to make normal movement frustrating.
this issue only started showing up after i added jumping, and i haven’t been able to figure out a fix, which kind of stalled the whole project. for context, this is my first attempt at making a character controller from scratch.
here’s footage of this stinking bug
and this is the main controller itself
local runService = game:GetService("RunService")
local userInputs = game:GetService("UserInputService")
local players = game:GetService("Players")
local soundJar = game:GetService("SoundService")
local client = players.LocalPlayer
local camCurrent = workspace.CurrentCamera
local character = client.Character or client.CharacterAdded:Wait()
local rootPart = character:WaitForChild("HumanoidRootPart")
local head = character:WaitForChild("Head")
local sounds = script:WaitForChild("sounds")
camCurrent.CameraSubject = head
camCurrent.CameraType = Enum.CameraType.Custom
-- alignment/rotation force
local bodyGyro = Instance.new("BodyGyro")
bodyGyro.MaxTorque = Vector3.one * (10 * 4^9)
bodyGyro.P = (10 * 7^10)
bodyGyro.D = 250000
bodyGyro.Parent = rootPart
local lastPos = rootPart.Position
local walkSpeed = 4
local groundFriction = 0.85
local airFriction = 0.99 -- keep more momentum in air
local maxVel = 17
local isGrounded = false
local stepTime = 0
local acceptedMaterials = { -- all of the materials that we want to walk on
Enum.Material.Asphalt,
Enum.Material.Basalt,
Enum.Material.Brick,
Enum.Material.Cobblestone,
Enum.Material.Concrete,
Enum.Material.CorrodedMetal,
Enum.Material.CrackedLava,
Enum.Material.Fabric,
Enum.Material.Glass,
Enum.Material.Glacier,
Enum.Material.Grass,
Enum.Material.Ground,
Enum.Material.Ice,
Enum.Material.LeafyGrass,
Enum.Material.Limestone,
Enum.Material.Marble,
Enum.Material.Metal,
Enum.Material.Mud,
Enum.Material.Neon,
Enum.Material.Pavement,
Enum.Material.Pebble,
Enum.Material.Plastic,
Enum.Material.Rock,
Enum.Material.Salt,
Enum.Material.Sand,
Enum.Material.Sandstone,
Enum.Material.Slate,
Enum.Material.SmoothPlastic,
Enum.Material.Snow,
Enum.Material.Wood,
Enum.Material.WoodPlanks
}
function isFirstPerson() -- firstperson utility
return (camCurrent.CFrame.Position - head.Position).Magnitude < 0.75
end
function checkGrounded()
local raycastParams = RaycastParams.new()
raycastParams.FilterDescendantsInstances = {character}
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
local origin = rootPart.Position
local direction = Vector3.new(0, -rootPart.Size.Y/2, 0)
local radius = 0.8
local result = workspace:Spherecast(origin, radius, direction, raycastParams)
isGrounded = result ~= nil
end
function playSound(sound, parent)
if not sound then return end
local realSound = sound:Clone()
realSound.Parent = parent
realSound.PlaybackSpeed = math.random(90, 110) / 100
realSound:Play()
task.delay(realSound.TimeLength + 0.1, function()
realSound:Destroy()
end)
end
-- movement functions
function move()
-- get forward and right vectors relative to our camera
local cameraForward = camCurrent.CFrame.LookVector * Vector3.new(1, 0, 1)
local cameraRight = camCurrent.CFrame.RightVector * Vector3.new(1, 0, 1)
cameraForward = cameraForward.Unit
cameraRight = cameraRight.Unit
-- calculate movement vector
local movement = Vector3.zero
if userInputs:IsKeyDown(Enum.KeyCode.W) then movement += cameraForward end
if userInputs:IsKeyDown(Enum.KeyCode.A) then movement -= cameraRight end
if userInputs:IsKeyDown(Enum.KeyCode.S) then movement -= cameraForward end
if userInputs:IsKeyDown(Enum.KeyCode.D) then movement += cameraRight end
if movement.Magnitude > 1 then
movement = movement.Unit
end
movement *= walkSpeed
-- apply friction before modifying velocity
local friction = isGrounded and groundFriction or airFriction
local velocity = rootPart.Velocity
velocity = Vector3.new(
velocity.x * friction,
velocity.y, -- Preserve vertical movement
velocity.z * friction
)
-- apply movement force with reduced acceleration in air
if isGrounded then
velocity += movement
else
velocity += movement * 0.2
end
-- clamp final velocity, ensuring diagonal movement is handled separately
local horizontalSpeed = Vector3.new(velocity.x, 0, velocity.z).Magnitude
if horizontalSpeed > maxVel then
local horizontalDirection = Vector3.new(velocity.x, 0, velocity.z).Unit
velocity = Vector3.new(horizontalDirection.x * maxVel, velocity.y, horizontalDirection.z * maxVel)
end
rootPart.Velocity = velocity
end
function rotate()
local currentPos = rootPart.Position
local movementDirection = (currentPos - lastPos)
if movementDirection.Magnitude > 0.1 and not isFirstPerson() then
local normalizedDirection = movementDirection.Unit
bodyGyro.CFrame = CFrame.lookAt(
rootPart.Position,
rootPart.Position + Vector3.new(normalizedDirection.x, 0, normalizedDirection.z)
)
elseif isFirstPerson() then
local lookPosition = rootPart.Position + camCurrent.CFrame.LookVector
bodyGyro.CFrame = CFrame.new(
rootPart.Position,
Vector3.new(lookPosition.X, rootPart.Position.Y, lookPosition.Z)
)
end
lastPos = currentPos
end
function jump(input, chatting) -- self-explanatory, hopefully
if chatting or input.KeyCode ~= Enum.KeyCode.Space or not isGrounded then return end
local jumpSound = sounds:WaitForChild("jump")
playSound(jumpSound, soundJar)
rootPart.Velocity += Vector3.new(0, 75, 0)
end
function footstep()
local stepInterval = 0
local minInterval = 0.25
local maxInterval = 0.99
local speed = rootPart.Velocity.Magnitude * 0.86
stepInterval = maxInterval - (speed / maxVel) * (maxInterval - minInterval)
stepInterval = math.clamp(stepInterval, minInterval, maxInterval)
-- print(stepInterval)
stepTime += 1/60
if stepTime < stepInterval or rootPart.Velocity.Magnitude <= 1.5 or not isGrounded then return end
stepTime = 0
-- perform downwards raycast, if it hits something then play a nifty sound
local rayParams = RaycastParams.new()
rayParams.FilterType = Enum.RaycastFilterType.Exclude
rayParams.FilterDescendantsInstances = {character}
local origin = rootPart.Position
local direction = Vector3.new(0, -500, 0)
local result = workspace:Raycast(origin, direction, rayParams)
-- print(isGrounded)
if not result then return end
if table.find(acceptedMaterials, result.Material) then
local materialName = string.lower(result.Material.Name)
local footstepSound = sounds:WaitForChild(materialName)
if footstepSound then
playSound(footstepSound, soundJar)
else
playSound(sounds:WaitForChild("plastic"), soundJar) -- default sound
end
else
playSound(sounds:WaitForChild("plastic"), soundJar)
end
end
-- runtime
runService.PreSimulation:Connect(function()
checkGrounded()
move()
footstep()
end)
runService.RenderStepped:Connect(rotate)
userInputs.InputBegan:Connect(jump)