Hey there, scripter! If you’ve been developing Roblox games for a while, you’ve probably noticed that the default character controller is great for most situations—but sometimes you need something different. Maybe you’re building a racing game where players control vehicles, a flight simulator, or a platformer with custom physics. That’s where custom character controllers come in!
In this tutorial, we’ll build a custom character controller from the ground up. You’ll learn not just how to move a character with code, but why each piece works the way it does. By the end, you’ll have the foundation to create any kind of movement system you can imagine.
Why Build a Custom Controller?
Before we dive into code, let’s talk about why you’d want to replace Roblox’s built-in controller. The default system uses the Humanoid object, which is powerful but also rigid. It’s designed for walking, running, and jumping—that’s about it.
Custom controllers give you:
- Complete control over movement physics and feel
- The ability to create unique movement mechanics (flying, wall-running, grappling hooks, etc.)
- Better performance in specific scenarios
- Freedom from Humanoid limitations and quirks
The tradeoff? You’ll need to handle everything yourself—including the parts that Humanoid does for free. But that’s exactly what makes this so powerful!
Setting Up Your Character
First, let’s create a simple character model. For this tutorial, we’ll use a basic block character to keep things simple. You can always swap in a more detailed model later.
Creating the Character Model
In Studio, create a new Model in Workspace and name it “CustomCharacter”. Inside it, add a Part called “HumanoidRootPart” (yes, we’re keeping this name for compatibility with Roblox systems). Set its properties:
- Size: 2, 2, 1
- Transparency: 1 (invisible)
- CanCollide: true
- Anchored: false
Add a Part called “Body” for visibility. Position it at the same location as HumanoidRootPart and weld them together. The HumanoidRootPart is what we’ll actually move—everything else just follows along.
Understanding the Core Movement Loop
Character controllers work on a simple principle: every frame, we calculate where the character should move based on input, then apply forces or directly update the position. We’ll use BodyVelocity (or the newer LinearVelocity) to move smoothly.
Here’s why we use forces instead of just teleporting the character: physics! When you apply velocity, the character interacts naturally with the world—it can be pushed by other objects, slide down slopes, and collide properly. Teleporting breaks all of that.
Building the Controller Script
Create a Script inside ServerScriptService called “CharacterController”. Here’s our basic framework:
local UserInputService = game:GetService("UserInputService")
local Players = game:GetService("Players")
local character = workspace.CustomCharacter
local rootPart = character.HumanoidRootPart
-- Movement settings
local MOVE_SPEED = 16
local JUMP_POWER = 50
-- Create BodyVelocity for movement
local bodyVelocity = Instance.new("BodyVelocity")
bodyVelocity.MaxForce = Vector3.new(100000, 0, 100000) -- Only control X and Z
bodyVelocity.Velocity = Vector3.new(0, 0, 0)
bodyVelocity.Parent = rootPart
-- Create BodyGyro for rotation
local bodyGyro = Instance.new("BodyGyro")
bodyGyro.MaxTorque = Vector3.new(0, 100000, 0)
bodyGyro.P = 10000
bodyGyro.Parent = rootPart
Let’s break down what’s happening here. We’re creating a BodyVelocity to control horizontal movement. Notice the MaxForce—we set Y to 0 because we don’t want to interfere with gravity. The character should still fall naturally!
The BodyGyro keeps our character facing the right direction. Without it, the character might rotate unexpectedly when colliding with objects.
Capturing Player Input
Now we need to detect when the player wants to move. For this tutorial, we’ll handle this on the server, but in a real game, you’d want to use RemoteEvents to send input from the client for better responsiveness.
-- Track which keys are pressed
local moveDirection = Vector3.new(0, 0, 0)
local keys = {
W = false,
A = false,
S = false,
D = false
}
UserInputService.InputBegan:Connect(function(input, gameProcessed)
if gameProcessed then return end
local keyCode = input.KeyCode.Name
if keys[keyCode] ~= nil then
keys[keyCode] = true
end
if keyCode == "Space" then
-- Jump (we'll implement this next)
jump()
end
end)
UserInputService.InputEnded:Connect(function(input)
local keyCode = input.KeyCode.Name
if keys[keyCode] ~= nil then
keys[keyCode] = false
end
end)
Implementing the Movement Loop
Here’s where the magic happens. Every frame, we calculate the movement direction and apply it:
local RunService = game:GetService("RunService")
RunService.Heartbeat:Connect(function()
-- Calculate move direction from keys
local direction = Vector3.new(0, 0, 0)
if keys.W then direction = direction + Vector3.new(0, 0, -1) end
if keys.S then direction = direction + Vector3.new(0, 0, 1) end
if keys.A then direction = direction + Vector3.new(-1, 0, 0) end
if keys.D then direction = direction + Vector3.new(1, 0, 0) end
-- Normalize so diagonal movement isn't faster
if direction.Magnitude > 0 then
direction = direction.Unit
end
-- Apply camera-relative movement
local camera = workspace.CurrentCamera
local cameraCFrame = camera.CFrame
local cameraDirection = cameraCFrame:VectorToWorldSpace(direction)
-- Keep movement horizontal only
cameraDirection = Vector3.new(cameraDirection.X, 0, cameraDirection.Z)
-- Apply velocity
bodyVelocity.Velocity = cameraDirection * MOVE_SPEED
-- Rotate character to face movement direction
if cameraDirection.Magnitude > 0 then
bodyGyro.CFrame = CFrame.lookAt(rootPart.Position, rootPart.Position + cameraDirection)
end
end)
This is the heart of our controller! Notice how we normalize the direction vector—this prevents diagonal movement from being 1.4x faster. We also use VectorToWorldSpace to make movement relative to the camera, which feels much more natural.
Adding Jump Functionality
Jumping requires detecting when the character is on the ground. We’ll use raycasting for this:
local function isGrounded()
local rayOrigin = rootPart.Position
local rayDirection = Vector3.new(0, -3, 0)
local raycastParams = RaycastParams.new()
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
raycastParams.FilterDescendantsInstances = {character}
local result = workspace:Raycast(rayOrigin, rayDirection, raycastParams)
return result ~= nil
end
function jump()
if isGrounded() then
rootPart.Velocity = Vector3.new(
rootPart.Velocity.X,
JUMP_POWER,
rootPart.Velocity.Z
)
end
end
The raycast shoots downward from the character. If it hits something, we’re grounded and can jump. We directly set the Y velocity here because BodyVelocity isn’t controlling the Y axis.
Connecting to a Player
To actually use this controller, you’ll want to connect it when a player joins and set their camera to follow the character:
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function()
-- Disable default character
player.Character:Destroy()
-- Position custom character at spawn
rootPart.CFrame = workspace.SpawnLocation.CFrame + Vector3.new(0, 5, 0)
-- Set camera subject
player.CameraMode = Enum.CameraMode.Classic
workspace.CurrentCamera.CameraSubject = rootPart
end)
end)
Polishing and Optimization
This basic controller works, but there’s room for improvement:
- Client-side input: Move input detection to a LocalScript and use RemoteEvents for better responsiveness
- Animations: Add an AnimationController to play walk/jump animations
- State management: Track states like running, jumping, or falling to adjust behavior
- Advanced physics: Add acceleration/deceleration for smoother movement feel
Recap and Next Steps
Congratulations! You’ve built a working custom character controller from scratch. You now understand how movement input translates to character velocity, why we use physics constraints instead of teleportation, and how camera-relative movement works.
Your controller can now walk, run, jump, and rotate—the foundation for any custom movement system. From here, you could add wall-running, double jumps, dash mechanics, or whatever your game needs!
What to learn next: Try implementing a stamina system that limits sprinting, or explore how to add swimming and climbing states to your controller. You might also want to learn about NetworkOwnership to optimize your controller for multiplayer games.
Happy scripting, and keep experimenting! 🚀