Evert's Blog

Making a Roblox-inspired character controller in Godot

So, naturally, I wanted to make my own Roblox-inspired 3D character controller in Godot. This character controller features a hybrid first- and third-person camera, which can be switched between by simply zooming in and out with the mouse wheel. This is the most important feature to me, as I really really enjoy playing using this control scheme. The player moves towards where you point your camera but can still rotate independently.

I have massive appreciation for the engineers at Roblox, the software platform is amazing and features some really impressive technology. I grew up on this website and even made a few simple games over the years. Unfortunately, the platform has been plagued by various issues over the years, such as a toxic playerbase, bad moderation with over-reliance on AI and, more recently, the forced push of requiring an AI face scan or government ID to even just chat in game. These issues have pushed players off the platform, looking for alternatives.

I have been looking for an alternative to Roblox, more specifically the Studio, for a while now. One example of such an alternative would be Polytoria, which aims to become similar to what Roblox used to be like in the past. However, I am not a huge fan of the Polytoria Creator at the moment - it is a very clunky, buggy mess, in my opinion.

One thing that I have kept my eye on over the years is the free and open source Godot game engine. I have played around with it in the past, I even wrote a blog post about one of my earlier experiments. In fact, the upcoming Polytoria 2.0 update is made using the Godot game engine.

The character model

To get started, I modeled, rigged and animated a simple character model in Blender. This process is out of the scope of this article, but I will share a few details about it.

Roblox-inspired blocky character in blender

The character hands and torso are UV mapped to this texture:

Character model texture mapping

The legs are mapped to the same coordinates as the hands, because in Roblox, the torso texture is composed of the "Shirt" + "T-Shirt" + the torso part of the "Pants" texture. So the legs have to use a separate texture, one made according to this template:

Character model pants texture

As of the writing of this article, I have not added support for this fashion of combinations to the character controller, but I might look into it in the future. The textures are applied to the model using the Nearest filtering, because Linear causes the white areas of the template to bleed in from the edges. You might also have to re-import the texture into Godot using the "VRAM Uncompressed" compression mode under the "Import" tab, which is next to "Scene" tab in the editor after selecting the .png file, for a crisp look without any color bleeding.

The head is made of a Cylinder mesh with a 0.45 m offset Bevel modifier with 8 segments. The other body parts are made of the Cube mesh, stretched out in Edit mode and have a 0.05 m offset Bevel modifier with 4 segments. The scale of the model in Blender is not important, as it can easily be adjusted in-engine to fit the specific game.

I also added the following "animations" to the model:

  • Idle - just standing around;
  • Walk - a very simple walking animation;
  • Jump - A "jump" animation, well, it's more of a "falling" animation, actually;
  • Sit - A "sitting" pose;
  • ArmUp - This pose could be used for when the character is holding something.

Export the model to glTF/glB, making sure to check "Apply modifiers" and to only export renderable objects. You may download the article version of the .glb file HERE. You may find more up-to-date versions of it on my Gitea in the future.

Setting up the scene

Create a new Godot project. I chose the Forward+ renderer and the Godot version at the time of this article is 4.6.

Create a new Node3D scene as the main scene which you will be playing and building in. You can use the CSGBox3D node to quickly make yourself a platform (make sure to enable collisions) and some obstacles to test.

Those coming from Roblox will notice CSG as being very similar to the Solid modeling feature provided in Roblox Studio. The CSGBox3D node even has gizmo handles similar to the Roblox Studio's resize tool.

Please note that these nodes are not meant to be used in your finished game, however, you can convert them to mesh instances with the click of a button when you're finished and hide or remove the CSG nodes.

After setting up your main scene, create a new Node3D scene and change the root node type from Node3D to CharacterBody3D.

The player scene

In the player scene, add a new Node3D, I named it "CharacterAnchor" for no particular reason. Into this node, drag the "character.glb" file from the FileSystem browser. This loads the character model into the scene. You may want to decrease the scale of the imported "character" node to something like 0.1 if you're using my model.

The CharacterAnchor will be the node which we rotate later on in the movement code, independent of the player's camera, so that any hats or tools we want to give the character in the future rotate in unison with it. Speaking of the camera, add a new Node3D next to to the "CharacterAnchor" node, name it something like CameraPivot so that you can access it from code later. Inside the pivot put a SpringArm3D node and into that add a Camera3D node. Move the CameraPivot up into the center of the head. For me, that position is 0, 0.475, 0.

In order to prevent issues with the SpringArm3D intersecting with your player (or other players), you should put your CharacterBody3D on another collision layer and set the SpringArm3D spring length to 0 to start with.

A screenshot showing the players collision layer is set to two, while mask remains on one

Next, we will need to add a CollisionShape3D node into the player scene. Give it a CapsuleShape3D in order to have a more accurate collision when walking and climbing slopes and such. Place the capsule somewhere in the center of the character model, it doesn't have to be perfect and you can tweak it at any time. It is also possible to have multiple collision shapes if you wish. You can click on the Z axis in the gizmo on the top right of the viewport to make lining it up easier.

A screenshot showing the parameters of the CapsuleShape3D used by me

And finally, add an AnimationTree node in order to blend and transition the animations later. For the AnimationTree node, you have to select the AnimationPlayer inside the imported "character" node. In order to do that, you must first check the "Editable children" checkbox in the menu that appears when you right click on the "character" node.

Your player scene should now look something like this. Don't worry about the scripts just yet, we'll get there.

A screenshot showcasing how the player scene should look like at this point

The player script

And now, we have arrived at the fun part! Attach a new script to the player scene itself (the CharacterBody3D node). First thing we will set up is the movement code. But before we can do that, we have to set up our inputs.

Open the Input Map dialog by going to Project -> Project Settings... -> Input Map. I have set up the following inputs in order to control the character.

A screenshot showing my input mappings for the project

Lets start by defining some variables in our script.

extends CharacterBody3D

## Maximum zoom distance from the character's head
@export var MAX_ZOOM = 10.0

## Maximum walking speed
@export var WALK_MAX_SPEED = 2

## Maximum walking speed while falling/jumping
@export var JUMP_MOVE_SPEED = 2

## The acceleration to bring the character up to max speed
@export var ACCELERATION = 16

## The deceleration to bring the character up a standstill
@export var DECELERATION = 16

## Gravity that applies to this player
@export var GRAVITY = -9.8

## The mass of the player. Can be used to tweak the falling speed instead of changing the gravity.
@export var MASS = 1.2

## The force of the jump
@export var JUMP_SPEED = 5

## Rotation speed of the character towards the face direction
@export var ROTATION_SPEED = TAU * 2

## Sensitivity of the camera, in degrees. Larger number = more camera movement with mouse.
@export_range(0.01, 1, 0.01) var VIEW_SENSITIVITY = 0.45

# Load in the parts of the scene we need to access from code.
@onready var pivot = $CharacterAnchor
@onready var camera_pivot = $CameraPivot
@onready var spring_arm = $CameraPivot/SpringArm3D

Let's add the movement code now. We will do so in the _physics_process function. In this script, the "velocity" is a property of the CharacterBody3D itself. The physics process is ran every physics tick, separate from other updates such as those done in _process.

We can utilize Godot's Input.get_vector method to get a 2D vector of the movement direction according to the inputs. The player's inputs may be analog, so we don't want to just check if they are pressed or not.

func _physics_process(delta: float):
	var direction = Input.get_vector("move_left", "move_right", "move_forward", "move_backward")

	# We convert the movement direction from 2D to 3D
	direction = Vector3(direction.x, 0.0, direction.y)

	# apply gravity if falling
	if not is_on_floor():
		velocity.y += delta * GRAVITY * MASS

	# Since direction is a normal vector, it has a length of one.
	# We want to make this vector equal the walking speed.
	var walk_speed = WALK_MAX_SPEED
	if not is_on_floor():
		walk_speed = JUMP_MOVE_SPEED

	var target = direction * walk_speed

	# If the character is moving, we must accelerate. Otherwise decelerate.
	var acceleration = DECELERATION
	if direction.length() > 0:
		acceleration = ACCELERATION

	# Calculate the velocity to move toward the target, but only on the horizontal plane XZ.
	# Move towards the desired velocity using linear interpolation and the acceleration amount
	# multiplied by the delta time as the weight.
	velocity.x = lerpf(velocity.x, target.x, acceleration * delta)
	velocity.z = lerpf(velocity.z, target.z, acceleration * delta)

	# Move the character along the floor using the
	# built-in move and slide function from the CharacterBody3D.
	move_and_slide()

	# If the jump action was pressed, set the upwards velocity to the jump speed.
	if is_on_floor() and Input.is_action_pressed("jump"):
		velocity.y = JUMP_SPEED

In order to get the character to smoothly face the direction they are going in, we need add a little bit of extra code. Add this immediately after converting the direction into a Vector3. I don't pretend to fully understand how this works, but here is an explanation and a guide on how to do this in 2D as well: How to Rotate Smoothly to Face a Direction in Godot - YouTube.

	if direction.length():
		var _theta = wrapf(atan2(direction.x, direction.z) - pivot.rotation.y, -PI, PI)
		pivot.rotation.y += clampf(ROTATION_SPEED * delta, 0, abs(_theta)) * sign(_theta)

Implementing the camera

First, we will need to declare a few additional variables at the top of the script.

## Convert the VIEW_SENSITIVITY to radians,
## because it is easier to input degrees when testing.
@onready var sensitivity = deg_to_rad(VIEW_SENSITIVITY)

var third_person = false
var restore_mouse_pos = Vector2()

# This constant helps us constrain the camera pitch
# to just below 90 degrees in both directions.
const MAX_PITCH = (PI / 2) - deg_to_rad(1)

That should be all for the variables we need for our camera code, so now lets implement moving the camera. Let's declare the _unhandled_input function, which will be ran every time there is an input from the player and it is not handled by something else, like the GUI. Lets start with the zooming. This code will set the zoom when the mouse wheel is scrolled, constraining it to a value between zero and MAX_ZOOM. The zoom is mostly handled by the SpringArm3D node we added earlier; it also makes sure that the camera does not go through objects. Very handy.

func update_zoom(new_zoom_level: int) -> void:
	spring_arm.spring_length = clamp(new_zoom_level, 0, MAX_ZOOM)

func _unhandled_input(ev: InputEvent):
	if ev is InputEventMouseButton and ev.pressed:
		if ev.button_index == MOUSE_BUTTON_WHEEL_DOWN:
			update_zoom(spring_arm.spring_length + 1)
		elif ev.button_index == MOUSE_BUTTON_WHEEL_UP:
			update_zoom(spring_arm.spring_length - 1)

Now, let's add the yaw and pitch calculations based on the mouse movement for the camera. We can use the relative mouse position from the event to determine the new rotation angles for the camera.

	if ev is InputEventMouseMotion:
		if not third_person or Input.is_action_pressed("camera_pan"):
			# move camera based on mouse position
			camera_pivot.rotation.x = clampf(
				camera_pivot.rotation.x - ev.relative.y * sensitivity,
				-MAX_PITCH,
				MAX_PITCH
			)
			camera_pivot.rotation.y = fmod(
				camera_pivot.rotation.y - ev.relative.x * sensitivity,
				TAU
			)

In a real game, you probably want to improve this by allowing a joystick to be used for this purpose as well. You may find inspiration on how to do that in the Godot documentation.

The pitch angle is constrained to just below 90 degrees in both directions, because some weird stuff happens at 90 degrees (or the "poles", if you will), such as spinning or walking backwards.

Now, let's add a function to update the camera state. If the previous camera state was third person mode, we will set the mouse to be captured and ensure that the camera is positioned at the camera root (inside the head) and we also hide the player model (otherwise we will be looking at the inside of our head instead). Afterwards, we set the camera's rotation to our calculated angles directly.

func update_view_mode():
	var zoom = spring_arm.spring_length
	var previously_third_person = third_person
	third_person = zoom > 0

	if third_person and not previously_third_person:
		visible = true

		if not Input.is_action_pressed("camera_pan"):
			Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
	elif not third_person and previously_third_person:
		Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
		visible = false

And to finish off the camera update code, add the update_view_mode() call to the update_zoom function, like so.

func update_zoom(new_zoom_level: int) -> void:
	spring_arm.spring_length = clamp(new_zoom_level, 0, MAX_ZOOM)
	update_view_mode()

Lets also lock the mouse when the script first runs and hide the player character.

func _ready():
	Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
	visible = false

Now that the camera is working, you will notice that the player does not walk in the direction you are looking. That is a simple fix, though, so go back to the _physics_process function and change the line where we calculate the 3D direction to this:

	direction = Vector3(direction.x, 0.0, direction.y).rotated(
		self.up_direction,
		camera_pivot.rotation.y
	)

All we did was to rotate the direction vector by the direction the camera is facing (the yaw). Now it doesn't matter where we turn our camera; the player character will always go in the direction we would expect it to go.

You might also like to exit first person mode when some key is pressed. Here is how I did it by pressing the escape key. Add the following code to the _unhandled_input function.

	if Input.is_action_just_pressed("free_mouse") and not third_person:
		update_zoom(1)

And I prefer to lock the mouse in place when the camera is being panned in third person mode, so I also added the following lines to _unhandled_input.

	if third_person and event.is_action_pressed("camera_pan"):
		restore_mouse_pos = get_viewport().get_mouse_position()
		Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)

	if third_person and event.is_action_released("camera_pan"):
		Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
		get_viewport().warp_mouse(restore_mouse_pos)

This will remember the position of the mouse, hide the mouse cursor and lock it into the center of the screen when the right mouse button is pressed down. When we let go of the right mouse button, the mouse will be made visible again and moved to the position it was hidden in.

The animation script

The movement and camera tricks should be working fine now, so lets make the character feel more alive by implementing the animations. We are using the AnimationTree node to smoothly transition between the various animations we have. Here is the tree structure I ended up with.

A screenshot of the Godot AnimationTree panel, showing the animation nodes connected to blend nodes which are then daisy-chained to the output

Here is a video explaining the rationale behind these choices: Godot 4 Animation Blend Tree Tutorial - YouTube

Attach a new script to the AnimationTree component. The following code sets up the animations, and shows how to update the animation tree.

extends AnimationTree

@onready var player = self.get_parent()
@onready var animator = self.get_node(self.anim_player)

@export var blend_speed = 15

enum { IDLE, JUMP, WALK, SIT }
var current = IDLE

var _walk_val = 0
var _jump_val = 0
var _sit_val = 0

func _ready() -> void:
	self.animator.get_animation("Idle").loop_mode = Animation.LOOP_LINEAR
	self.animator.get_animation("Walk").loop_mode = Animation.LOOP_LINEAR

func _update_animations() -> void:
	self["parameters/Walk/blend_amount"] = _walk_val
	self["parameters/Jump/blend_amount"] = _jump_val
	self["parameters/Sit/blend_amount"] = _sit_val

func _handle_animations(delta: float) -> void:
	match current:
		IDLE:
			_jump_val = lerpf(_jump_val, 0, blend_speed * delta)
			_walk_val = lerpf(_walk_val, 0, blend_speed * delta)
			_sit_val = lerpf(_sit_val, 0, blend_speed * delta)
		SIT:
			_jump_val = lerpf(_jump_val, 0, blend_speed * delta)
			_walk_val = lerpf(_walk_val, 0, blend_speed * delta)
			_sit_val = lerpf(_sit_val, 1, blend_speed * delta)
		WALK:
			_jump_val = lerpf(_jump_val, 0, blend_speed * delta)
			_walk_val = lerpf(_walk_val, 1, blend_speed * delta)
			_sit_val = lerpf(_sit_val, 0, blend_speed * delta)
		JUMP:
			_jump_val = lerpf(_jump_val, 1, blend_speed * delta)
			_walk_val = lerpf(_walk_val, 0, blend_speed * delta)
			_sit_val = lerpf(_sit_val, 0, blend_speed * delta)

And then finally we add the physics process function which will switch between the different states.

func _physics_process(delta: float) -> void:
	_handle_animations(delta)
	_update_animations()

	if not player.is_on_floor():
		current = JUMP
	elif player.get_real_velocity().length():
		current = WALK
	else:
		current = IDLE

The result

Updates

  • 2026/02/20 17:05 - Specified a fix for a SpringArm3D intersection with the player glitch.
  • 2026/02/17 21:44 - Incorporated SpringArm3D instead of manual raycasting.
  • 2026/02/16 20:29 - Initial version.
OlderSelf-hosting, Part 1