Custom Character Controller with Slope Alignment / Gravity Controller issues after ~ 90 degrees

Alright, so I have been trying to make a custom character controller with slope alignment/ gravity controller.

The movement is great and I am proud of it, but everything completely breaks after calculating normals above or around 90 degrees.

I assume the issue is in the way I calculate which way the character/ blocky part moves but I have no idea how to rotate the camera around the Y-Orientation of the part or if that even will solve the issue since I know how difficult roblox is with 360 movement like this.

here is the code:

 -- Create a new part
local part = Instance.new("Part")
part.Size = Vector3.new(4, 4, 4)
part.Anchored = true
part.Position = Vector3.new(0, 5, 0)
part.Parent = workspace

-- Camera setup
local camera = workspace.CurrentCamera
camera.CameraType = Enum.CameraType.Scriptable
camera.CFrame = CFrame.new(0, 5, 0)

wait(2)

-- Parameters
local moveSpeed = 20
local sensitivity = 0.2
local zoomSpeed = 2
local minZoom = 5
local maxZoom = 50
local jumpPower = 50
local gravity = -100
local hoverHeight = 1.8

local currentZoom = 10
local yaw, pitch = 0, 0
local runService = game:GetService("RunService")
local userInputService = game:GetService("UserInputService")

-- Input state
local inputState = { W = false, A = false, S = false, D = false, Space = false }
local isJumping = false
local verticalVelocity = 0

-- Input handling
userInputService.InputBegan:Connect(function(input)
	if input.KeyCode == Enum.KeyCode.W then inputState.W = true
	elseif input.KeyCode == Enum.KeyCode.A then inputState.A = true
	elseif input.KeyCode == Enum.KeyCode.S then inputState.S = true
	elseif input.KeyCode == Enum.KeyCode.D then inputState.D = true
	elseif input.KeyCode == Enum.KeyCode.Space then inputState.Space = true
	end
end)

userInputService.InputEnded:Connect(function(input)
	if input.KeyCode == Enum.KeyCode.W then inputState.W = false
	elseif input.KeyCode == Enum.KeyCode.A then inputState.A = false
	elseif input.KeyCode == Enum.KeyCode.S then inputState.S = false
	elseif input.KeyCode == Enum.KeyCode.D then inputState.D = false
	elseif input.KeyCode == Enum.KeyCode.Space then inputState.Space = false
	end
end)

userInputService.InputChanged:Connect(function(input)
	if input.UserInputType == Enum.UserInputType.MouseMovement then
		local delta = input.Delta
		yaw = yaw - delta.X * sensitivity
		pitch = math.clamp(pitch + delta.Y * sensitivity, -45, 45)
	elseif input.UserInputType == Enum.UserInputType.MouseWheel then
		currentZoom = math.clamp(currentZoom - input.Position.Z * zoomSpeed, minZoom, maxZoom)
	end
end)

-- Raycast parameters
local rayParams = RaycastParams.new()
rayParams.FilterDescendantsInstances = {part}
rayParams.FilterType = Enum.RaycastFilterType.Exclude

-- Function to get average ground below part
local function getGroundPosition()
	local origin = part.Position
	local halfSize = part.Size / 2
	local directions = {
		Vector3.new(halfSize.X, 0, halfSize.Z),
		Vector3.new(-halfSize.X, 0, halfSize.Z),
		Vector3.new(halfSize.X, 0, -halfSize.Z),
		Vector3.new(-halfSize.X, 0, -halfSize.Z),
		Vector3.new(0, 0, 0),
	}

	local averagePosition = Vector3.zero
	local hitCount = 0
	for _, dir in ipairs(directions) do
		local rayOrigin = origin + dir
		local rayDirection = -part.CFrame.UpVector * (hoverHeight + 2)
		local result = workspace:Raycast(rayOrigin, rayDirection, rayParams)
		if result then
			averagePosition += result.Position
			hitCount += 1
		end
	end

	if hitCount > 0 then
		return averagePosition / hitCount
	else
		return nil
	end
end

-- Function to align part to surface normal
local function alignPart(normal)
	if normal then
		local upVec = normal
		local rightVec = upVec:Cross(part.CFrame.LookVector).Unit
		local forwardVec = rightVec:Cross(upVec).Unit
		part.CFrame = CFrame.fromMatrix(part.Position, rightVec, upVec, forwardVec)
	end
end

-- Main update loop
runService.RenderStepped:Connect(function(deltaTime)
	-- Raycast down to get surface normal
	local downRay = workspace:Raycast(part.Position, -part.CFrame.UpVector * 6, rayParams)
	local surfaceNormal = downRay and downRay.Normal or Vector3.new(0,1,0)

	-- Camera rotation
	local cameraRotation = CFrame.Angles(0, math.rad(yaw), 0) * CFrame.Angles(math.rad(pitch), 0, 0)

	-- Movement direction projected onto surface plane
	local moveDirection = Vector3.zero
	local camForward = camera.CFrame.LookVector
	local camRight = camera.CFrame.RightVector

	camForward = (camForward - camForward:Dot(surfaceNormal) * surfaceNormal)
	camRight = (camRight - camRight:Dot(surfaceNormal) * surfaceNormal)

	if inputState.W then moveDirection += camForward end
	if inputState.S then moveDirection -= camForward end
	if inputState.A then moveDirection -= camRight end
	if inputState.D then moveDirection += camRight end

	if moveDirection.Magnitude > 0 then
		moveDirection = moveDirection.Unit
		part.Position += moveDirection * moveSpeed * deltaTime
		part.CFrame = CFrame.new(part.Position, part.Position + moveDirection)
	end

	-- Jump logic
	if inputState.Space and not isJumping then
		isJumping = true
		verticalVelocity = jumpPower
	end

            -- Gravity, Collision detection
	local groundPos = getGroundPosition()
	if groundPos then
		local targetHeight = groundPos.Y + hoverHeight
		if not isJumping then
			part.Position = Vector3.new(part.Position.X, targetHeight, part.Position.Z)
		else
			part.Position = Vector3.new(part.Position.X, math.max(part.Position.Y + verticalVelocity * deltaTime, targetHeight), part.Position.Z)
		end
		if part.Position.Y <= targetHeight then
			isJumping = false
			verticalVelocity = 0
		end
	else
		verticalVelocity += gravity * deltaTime
		part.Position += Vector3.new(0, verticalVelocity * deltaTime, 0)
	end

	-- Camera follow
	local cameraOffset = Vector3.new(0, 5, -currentZoom)
	local cameraPosition = part.Position + cameraRotation * cameraOffset
	camera.CFrame = CFrame.new(cameraPosition, part.Position)

	-- Align part to surface
	alignPart(surfaceNormal)
end)

Please if anyone could check this out and give me some help with this it would mean the world to me.

3 Likes

I’ve been working on a similar project as well and am also currently facing issues with the move direction

Have you found any fixes?

1 Like

Not as of yet. I figured out how to move the camera along with the character but it still is breaking at above 90 degree slopes. I feel like I need to revise my method of doing things because i feel like the way I calculate the collisions, the camera and the direction of movement are all messed up for it to work. I don’t have much time to work on it though so for now my progress has taken ahalt. But I am really interested to know how you are going about your project right now.

Ah yes, I remember dealing with this issue when I was making my own gravity controller. Turns out, vector projection starts having some issues when it tries to project a “shadow” upon a vertical wall. The way I solved this issue was to rotate the MoveDirection to the angle of the surface normal.

local function AlignMoveDirection(moveDirection: Vector3, surfaceNormal: Vector3): Vector3
	surfaceNormal = surfaceNormal.Unit
	local cameraCFrame = camera.CFrame

	local axis = Vector3.yAxis:Cross(surfaceNormal)
	local angle = math.acos(Vector3.yAxis:Dot(surfaceNormal))

	if (axis.Magnitude < 0.5) then
		-- Assuming we're upside down
		if (math.deg(angle) > 150) then
			-- Only invert forward controls
			local cameraLook = (cameraCFrame.LookVector * Vector3.new(1, 0, 1)).Unit
			local cameraRight = (cameraCFrame.RightVector * Vector3.new(1, 0, 1)).Unit
						
			local forwardDirection = moveDirection:Dot(cameraLook)
			local rightDirection = moveDirection:Dot(cameraRight)
			
			return (-forwardDirection * cameraLook) + (rightDirection * cameraRight)
		end

		return moveDirection
	end

	local rotationCFrame = CFrame.fromAxisAngle(axis, angle)
	return rotationCFrame * moveDirection
end
1 Like

Hello, thanks a lot for the tips.

I reworked the collision to adjust the position according to the surface normal as well as the way the movement direction is calculated.

I also made the movement direction be aligned with the surface, go up and down according to where the camera is looking and flip the forward and backwards movement similar to how you did it in your example.

Now everything works perfect! Aside from how side-ways movement works but I’ll tackle that some other time. For now forward and backwards movement works in all 360 degrees!

Here is my fully edited/reworked script for anyone who wants to use it or test it for themselves:

-- Services
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")

-- Create test part
local part = Instance.new("Part")
part.Size = Vector3.new(4, 4, 4)
part.Anchored = true
part.Position = Vector3.new(0, 5, 0)
part.Parent = workspace

-- Camera
local camera = workspace.CurrentCamera
camera.CameraType = Enum.CameraType.Scriptable

task.wait(4) -- because things need time to load in, otherwise the part will spawn when the ground hasn't loaded yet and falls into the abyss

-- Parameters
local moveSpeed = 60
local sensitivity = 0.2
local zoomSpeed = 2
local minZoom = 5
local maxZoom = 50

local hoverHeight = 1.8
local jumpPower = 60
local gravity = -120

-- State
local currentZoom = 12
local yaw, pitch = 0, 0
local verticalVelocity = 0
local isJumping = false

-- Input
local input = { W=false, A=false, S=false, D=false, Space=false }

UserInputService.InputBegan:Connect(function(i)
	if i.KeyCode == Enum.KeyCode.W then input.W = true end
	if i.KeyCode == Enum.KeyCode.A then input.A = true end
	if i.KeyCode == Enum.KeyCode.S then input.S = true end
	if i.KeyCode == Enum.KeyCode.D then input.D = true end
	if i.KeyCode == Enum.KeyCode.Space then input.Space = true end
end)

UserInputService.InputEnded:Connect(function(i)
	if i.KeyCode == Enum.KeyCode.W then input.W = false end
	if i.KeyCode == Enum.KeyCode.A then input.A = false end
	if i.KeyCode == Enum.KeyCode.S then input.S = false end
	if i.KeyCode == Enum.KeyCode.D then input.D = false end
	if i.KeyCode == Enum.KeyCode.Space then input.Space = false end
end)

UserInputService.InputChanged:Connect(function(i)
	if i.UserInputType == Enum.UserInputType.MouseMovement then
		yaw -= i.Delta.X * sensitivity
		pitch = math.clamp(pitch + i.Delta.Y * sensitivity, -45, 45)
	elseif i.UserInputType == Enum.UserInputType.MouseWheel then
		currentZoom = math.clamp(
			currentZoom - i.Position.Z * zoomSpeed,
			minZoom,
			maxZoom
		)
	end
end)

-- Raycast
local rayParams = RaycastParams.new()
rayParams.FilterDescendantsInstances = { part }
rayParams.FilterType = Enum.RaycastFilterType.Exclude

-- Ground + hover resolution
local function resolveGround(dt)
	local origin = part.Position
	local direction = -part.CFrame.UpVector * (hoverHeight + 6)
	local result = workspace:Raycast(origin, direction, rayParams)

	if not result then
		verticalVelocity += gravity * dt
		part.Position += Vector3.new(0, verticalVelocity * dt, 0)
		return Vector3.yAxis
	end

	local normal = result.Normal
	local target = result.Position + normal * hoverHeight

	if isJumping then
		verticalVelocity += gravity * dt
		part.Position += normal * verticalVelocity * dt

		if (part.Position - target):Dot(normal) <= 0 then
			part.Position = target
			verticalVelocity = 0
			isJumping = false
		end
	else
		part.Position = target
	end

	return normal
end

-- Surface-aligned movement direction
-- Surface-aligned movement direction
local function getMoveDirection(surfaceNormal)
	local camCF = camera.CFrame
	
	local camForward = camera.CFrame.LookVector
	local partUp = part.CFrame.UpVector

	local dot = camForward:Dot(partUp)

	-- Flatten camera forward/right onto XZ-plane for input reference
	local flatForward = Vector3.new(camCF.LookVector.X, 0, camCF.LookVector.Z)
	if flatForward.Magnitude > 0 then flatForward = flatForward.Unit end

	local flatRight = Vector3.new(camCF.RightVector.X, 0, camCF.RightVector.Z)
	if flatRight.Magnitude > 0 then flatRight = flatRight.Unit end

	-- Determine input intent
	local inputDir = Vector3.zero
	if input.W then inputDir += flatForward end
	if input.S then inputDir -= flatForward end
	if input.A then inputDir -= flatRight end
	if input.D then inputDir += flatRight end
	if inputDir.Magnitude == 0 then return Vector3.zero end
	inputDir = inputDir.Unit

	-- Check if surface is upside down
	local angle = math.acos(Vector3.yAxis:Dot(surfaceNormal))
	print(math.deg(angle))
	
	
	
	if math.deg(angle) > 90  then
		-- Invert forward/backward input
		inputDir = Vector3.new(-inputDir.X, inputDir.Y, inputDir.Z)
		end
	

	-- Project input direction onto the surface plane
	local moveDir = inputDir - surfaceNormal * inputDir:Dot(surfaceNormal)

	-- Handle near-vertical surfaces
	local slopeFactor = math.abs(surfaceNormal:Dot(Vector3.yAxis))
	if slopeFactor < 0.1 then
		if dot > 0 then
			print("up")-- Camera is facing the part's UP vector
			local verticalDir = Vector3.new(0, input.W and 1 or (input.S and -1 or 0), 0)
			moveDir -= verticalDir
		else
			print("down")
			local verticalDir = Vector3.new(0, input.W and 1 or (input.S and -1 or 0), 0)
			moveDir += verticalDir
			-- Camera is facing the part's DOWN vector
		end
		
	end

	if moveDir.Magnitude > 0 then
		return moveDir.Unit
	else
		return Vector3.zero
	end
end

(It’s important that the script should be a local script in StarterPlayerScripts)

2 Likes

Sorry I just wanted to reply to you since I have the solution now and it might interest you. Have a good day in advance and thanks for the message.

1 Like

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.