Hello! This is my first time making a tutorial on the DevForum, so please bear with the quality of this post.
This topic will go into how you can ACTUALLY use Roblox’s new physics character controllers in a way that I hope is easier for people to understand. As someone who’s spent a significant amount of time figuring this out, implementing it in my game, and bashing my keyboard over the not very helpful documentation, I hope this will help people who are confused on how to use these powerful controllers.
IMPORTANT: This tutorial does NOT use OOP (Object-Oriented Programming). I am only just learning OOP myself and do not have the experience yet to use it here. For an OOP-based approach, consider checking out Roblox’s Platformer template, which uses both the physics character controllers and OOP.
Scroll to the bottom if you just want the place file with a working character controller. Also, this tutorial is tailored for R6/R15 characters and does not have climbing or swimming functionality.
Step 1: The Basics
To start off with, we’ll have 2 scripts.
- A Script in ServerScriptService. I’ll name this SetupCharacterController. This script will handle taking away base humanoid physics from every player character that joins and instead putting a ControllerManager in it.
- A LocalScript in StarterCharacterScripts. I’ll name this CharacterControllerUpdate. This script will handle updating the ControllerManager with information from its GroundSensor and the moving direction of the character, along with having a basic jump.
Step 2: SetupCharacterController
First, define the Players service at the top of SetupCharacterController. We use this in order to get the characterAdded event so we can put ControllerManagers in every character added.
local Players = game:GetService("Players")
Every character that joins needs to have a ControllerManager inside it.
-- This function grabs the character's HRP, makes a ControllerManager,
-- sets its Parent and RootPart, and sets up the ControllerManager's base move speed
-- and facing direction. It then returns the ControllerManager for use.
local function setupControllerManager(character: Model)
local humanoidRootPart = character:WaitForChild("HumanoidRootPart")
local ControllerManager = Instance.new("ControllerManager")
ControllerManager.Parent = character
ControllerManager.RootPart = humanoidRootPart
ControllerManager.BaseMoveSpeed = 25 -- CHANGE AS NEEDED
ControllerManager.FacingDirection = ControllerManager.RootPart.CFrame.LookVector
return ControllerManager
end
Next we need to setup the function for the ControllerPartSensor that will be sensing for the ground underneath the character.
local function setupGroundSensor()
local groundSensor = Instance.new("ControllerPartSensor")
groundSensor.Name = "GroundSensor"
groundSensor.SensorMode = Enum.SensorMode.Floor
groundSensor.UpdateType = Enum.SensorUpdateType.Manual
return groundSensor
end
IMPORTANT: Setting a ControllerPartSensor’s SensorUpdateType to Manual means you need to update the groundSensor with your own information via a raycast/shapecast/blockcast. I will go into how to do this later.
Now we’ll set up the function that makes the GroundController and AirController.
local function setupControllers()
local GroundController = Instance.new("GroundController")
GroundController.GroundOffset = 2
-- Keeps the character's feet on the ground. 2 is the height of a Roblox character's leg.
GroundController.BalanceRigidityEnabled = true
-- Keeps the character upright on the ground.
local AirController = Instance.new("AirController")
AirController.MaintainLinearMomentum = true
-- Setting this to true means that the character will keep its linear momentum given a directional input in the air.
AirController.MaintainAngularMomentum = false
-- Seting this to true means that the character will keep its angular momentum given a directional input. If you input A or D in the air, the character will keep on spinning around till it hits the ground.
AirController.BalanceRigidityEnabled = true
AirController.MoveMaxForce = 75000
-- Increasing this number means that directional inputs will influence the character's moving direction more or less powerfully
return GroundController, AirController
end
Finally, the script will listen for every character that joins and overwrite their default physics, adding a complete ControllerManager into them.
-- Stop normal humanoid physics and add a fully loaded ControllerManager to each character that joins
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function(character)
character.Humanoid.EvaluateStateMachine = false -- Turns off default humanoid physics.
wait()
-- Remember how all of these functions returned their respective objects?
local groundSensor = setupGroundSensor()
local GroundController, AirController = setupControllers()
local ControllerManager = setupControllerManager(character)
-- Parent the returned objects to the ControllerManager
GroundController.Parent = ControllerManager
AirController.Parent = ControllerManager
groundSensor.Parent = ControllerManager
ControllerManager.GroundSensor = groundSensor
end)
end)
Step 3: CharacterControllerUpdate
Now that every character gets a ControllerManager when they join the game, we need to make the code that actually updates the character with information so the player can, well, control the character. Here’s how we’ll do it.
- Roblox gives us access to the direction the character wants to go in via Humanoid.MoveDirection. The ControllerManager object has a property called ControllerManager.MovingDirection. We’ll make it so ControllerManager.MovingDirection is equal to Humanoid.MoveDirection. This’ll allow us to move, with our speed based on ControllerManager.BaseMoveSpeed.
- The ControllerManager also needs to know when we’re in midair or on the ground so it can switch its ActiveController to either the AirController or the GroundController. We’ll raycast under the character, and if the raycast returns something (that isn’t the air) we’ll change the ActiveController to GroundController. Otherwise ActiveController will be the AirController.
- Every frame we’ll update the character with the above information with RunService.
Let’s script it! At the top of CharacterControllerUpdate, define these variables. Pretty self-explanatory.
local RunService = game:GetService("RunService")
local ContextActionService = game:GetService("ContextActionService")
local player = game.Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local humanoid : Humanoid = character:WaitForChild("Humanoid")
local humanoidRootPart: BasePart = character:WaitForChild("HumanoidRootPart")
local controllerManager : ControllerManager = character:WaitForChild("ControllerManager")
local groundController : GroundController = controllerManager:WaitForChild("GroundController")
local airController : AirController = controllerManager:WaitForChild("AirController")
local groundSensor : ControllerPartSensor = controllerManager:WaitForChild("GroundSensor")
Now we need to make the function that gets when the player wants to move. As I said before, we’ll get that using Humanoid.MoveDirection. We also need to update the controllerManager’s FacingDirection as well. If we don’t, then every time the character moves, they won’t be facing the same direction they’re moving in when they move.
local function updateMovementDirection()
local desiredDirection = humanoid.MoveDirection
controllerManager.MovingDirection = desiredDirection
if desiredDirection.Magnitude > 0 then -- Is the character currently moving?
controllerManager.FacingDirection = desiredDirection
else -- Character not moving
controllerManager.FacingDirection = controllerManager.RootPart.CFrame.LookVector
end
end
Now for the big one: The raycast under the character. I’m not using Enum.SensorUpdateType.OnRead because I think raycasting gives us more control and it’s better for the sake of this tutorial.
This image comes from the Character Controllers article on Roblox’s documentation. As said here in order for our GroundSensor (the ControllerPartSensor) to work, we need to write to it our RaycastResult’s Instance and Normal properties.
HOWEVER, what the article misses is that we also need to write to the GroundSensor’s HitFrame property, which is the CFrame position of what the sensor hit. One consequence of not writing this property is that the character won’t be able to go up slopes. See this clip below.
With this in mind, let’s write our raycasting function.
local function updateControllerManagerSensor()
local raycastParams = RaycastParams.new()
raycastParams.FilterDescendantsInstances = {character} -- Character should not be included within things that the raycast can return
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
local raycastToGround = workspace:Raycast(humanoidRootPart.Position, Vector3.new(0, -4.5, 0), raycastParams)
if raycastToGround then -- Character is on the ground, raycast returned something
groundSensor.SensedPart = raycastToGround.Instance
groundSensor.HitNormal = raycastToGround.Normal
groundSensor.HitFrame = CFrame.new(raycastToGround.Position)
humanoid:ChangeState(Enum.HumanoidStateType.Running) -- Changing humanoid states so we have animations
controllerManager.ActiveController = groundController -- Switch the ActiveController to groundController.
else -- Character is midair, raycast didn't return anything
humanoid:ChangeState(Enum.HumanoidStateType.Freefall)
controllerManager.ActiveController = airController
end
return controllerManager.ActiveController
end
Perfect. Also, let’s throw in a jump function. I’ll just grab the one Roblox gives us in their tutorial for simplicity.
local function doJump(actionName, inputState)
if actionName == "JUMP_ACTION" and inputState == Enum.UserInputState.Begin and humanoid:GetState() ~= Enum.HumanoidStateType.Ragdoll then
local jumpImpulse = Vector3.new(0, 750, 0) -- Y is jump power. Change as you see fit.
controllerManager.RootPart:ApplyImpulse(jumpImpulse)
humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
local floor = controllerManager.GroundSensor.SensedPart
if floor then -- Character's on the floor?
floor:ApplyImpulseAtPosition(-jumpImpulse, controllerManager.GroundSensor.HitNormal) -- Equal and opposite force
end
end
end
Now all we need to do is throw the first 2 functions within a function inside a RunService loop.
local function UpdateCharacter()
updateControllerManagerSensor()
updateMovementDirection()
if controllerManager.ActiveController == groundController then
ContextActionService:BindAction("JUMP_ACTION", doJump, false, Enum.KeyCode.Space)
else
ContextActionService:UnbindAction("JUMP_ACTION")
end
end
RunService.RenderStepped:Connect(updateCharacter) -- Now, update the character every frame
And it works perfectly now!
BONUS: Smooth air movement
If you play Super Smash Bros., you may be aware of something called directional influence or DI. In simple terms, it’s the influence a character has over itself while in the air. Currently, the character has a lot of DI ability and can very easily change their trajectory while in the air. However, it looks kind of jank, and unbalanced if you want to make a game with knockback, since the character can just hit a direction and instantly go there. Here’s my solution.
airController.MoveMaxForce = humanoidRootPart.AssemblyLinearVelocity.Magnitude * controllerManager.BaseMoveSpeed
This small line of code added into the updateCharacter function continually updates the max force the character can use while using directional inputs in the air with a number that is the product of their current velocity times their base move speed. This leads to the following result: smooth air movement!
Note: If you don’t want the character to be able to move midair at all (say, if they’re currently in a state of knockback), just set airController’s MoveMaxForce to 0.
Conclusion
Thank you for reading my first tutorial. Below is the place file that I used. If my tutorial was helpful or you had any criticisms, leave a comment—I promise I’ll respond relatively promptly!
CharacterControllerPlace.rbxl (57.3 KB)