How to Actually Use Roblox's Physics Character Controllers

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.

  1. 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.
  2. 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.
    image

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.

  1. 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.
  2. 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.
  3. 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)

35 Likes

Also, here’s a demonstration of the character controllers in use in my own game.

I’m very proud of being able to share how awesome these new character controllers are with the rest of the community (considering I spent almost a month bashing my head over them) and I hope this topic goes to show how far Roblox can go with them if they give these controllers more power and more documentation…

…Still waiting for UpDirection. :frowning:

7 Likes

I wasn’t aware that roblox released such powerfull controllers! Thank you so much for sharing this more in depth tutorial on this!

5 Likes

Thank you, it means a lot!

2 Likes

Good tutorial, do the PlayerScripts automatically work with the Character Controllers?

3 Likes

Yes, just drag and drop!

1 Like

Dude, you dropped this on my birthday, and I decided to try and start using one of these today. Thanks for the gift!

3 Likes

Glad you like it, happy birthday!

1 Like

ooh, is auto jump available here?

2 Likes

Skimming through this, it looks really well done! I really appreciate the example code along with the explanations, so I’ll definitely be coming back to this when I get the chance.

I’ve been messing around with a lot of custom implementations of a lot of Roblox classes but characters and humanoids was one of the more annoying tasks to take on since the official posts and documentation lack good explanations and proper tutorials.

I have a few questions (some might be dumb obvious) lingering in the back of my mind that I can solve later, but I thought I’d ask in case you know the answers:

  1. Is there anything I have to worry about, such as server-client replication, observed bugged behaviors, etc.?
  2. How does this react when calling built-in API, such as MoveTo (or even click to move), and properties such as Health since Humanoid stops all state computations.
  3. How is the performance compared to the currently implemented state machine?

Thanks!

1 Like

As in the control method on vanilla Roblox? I don’t think you can, but in terms of scripting your own auto-jump, I’d say you’d just need to grab the doJump function and call it whenever necessary (e.g. within a set distance from an obstacle)

2 Likes

Thanks for your words!

  1. Happy to say I’ve tested replication, and it works no problems. I haven’t run into any other major issues using the controller as well.
  2. For one, you have to make your own logic for when the character dies/resets. My idea is to
  • have the server do :GetPlayerFromCharacter() on the character that’s going to be destroyed,

  • call :Destroy() on the character, then

  • have the server call :LoadCharacter() on the player, which’ll fire the CharacterAdded event in SetupCharacterControllers.

    Other built-in API like :MoveTo() also likely doesn’t work

  1. I haven’t tested performance, but I assume it’s only a slight performance downgrade from normal humanoids. There are likely improvements that could be made to my code but I’m not knowledgeable enough to make those changes. Although, this makes me wonder if there’s a performance difference between using Enum.SensorUpdateType.OnRead and using your own logic.
2 Likes

Thanks for the quick response!

I’ve read a lot about how the default Humanoid and replication is painfully slow compared to custom characters without a Humanoid, but I’ll do some testing and see if I can reply back with any meaningful results.

1 Like

Looking forward to your findings!

Thank you for this, the docs and examples on these new controllers are… suboptimal, I’ve been trying to figure out how to just simply augment the behavior of a currently existing character but I’ve been unable to.

P.S. OOP is overrated, I dont use it most of the time

1 Like

Thanks for your words! I agree, Roblox should have definitely put more into their docs of these.

2 Likes

When I reset my character, it still doesn’t die, I have a screen that shows if you die, it doesn’t work too. :frowning: so how do I know if I reseted my character?

If you’re using the Humanoid.Died event, it won’t work (I just tested this). What you can do instead is use the Humanoid.HealthChanged event and detect if the player’s health is at 0, meaning that they died or resetted

2 Likes

Oh thanks! But do you know if i can somehow change the humanoid state to dead?

Nice tutorial but I’m surprised not many people know about character controllers?

1 Like