How is my first person player controller script?

Godot Version

4.5

Question

I just started learning programming a few weeks ago by following along a pretty intensive Udemy course. I’m about halfway through it so I thought its a good time take a break to practice writing a script without direct guidance from the course.

I decided to upgrade the default player controller script to something that feels more smooth and polished. I looked at some related YouTube videos and forum posts to understand some techniques that people use (mostly for smooth motion), but I didn’t copy any code and really challenged myself to make something that’s my own.

After about a day and half of trial and error, I landed on this script; and it feels pretty good in my opinion. However, being such a beginner I thought it would be a good idea to post it here and see if I made any mistakes. I know that just because it plays doesn’t necessarily mean that the code is efficient or won’t cause unforeseen issues in particular circumstances.

extends CharacterBody3D


class_name Player


# Scene References
@onready var camera_3d: Camera3D = $Camera3D
@onready var label_3d: Label3D = $Camera3D/Label3D


# Constants
const SENSITIVITY_BASE: float = 0.001
const GRAVITY: float = 15


# Variables
var _base_speed: float = 3.0
var _sprint_speed: float = 4.5
var _speed: float = _base_speed # the current maximum speed we can move toward.
var _movement_acceleration: float = 0.25 # how fast to move toward _speed when grounded.
var _sprint_acceleration: float = 0.1 # how fast to move toward _speed when sprinting.
var _air_resistance: float = 0.1 # how fast to move toward _speed when airbourne. 
var _jump_strength: float = 5.3 
var _sensitivity_multiplier: float = 2.0 # mulitplied by SENSITIVY_BASE.
var _can_sprint: bool = true # prevents initianting sprint while airbourne.
var _direction = Vector3.ZERO


func _ready() -> void:
	Input.mouse_mode = Input.MOUSE_MODE_CAPTURED


func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseMotion:
		rotate_y(-event.relative.x * SENSITIVITY_BASE * _sensitivity_multiplier)
		camera_3d.rotate_x(-event.relative.y * SENSITIVITY_BASE * _sensitivity_multiplier)
		camera_3d.rotation.x = clamp(camera_3d.rotation.x, deg_to_rad(-60), deg_to_rad(90))


func _physics_process(delta: float) -> void:
	# Apply gravity
	if !is_on_floor():
		velocity.y -=  GRAVITY * delta
	
	handle_movement()
	handle_jump()
	move_and_slide()


func handle_movement() -> void:
	var input_dir: Vector2 = Input.get_vector("move_left", "move_right", "move_forward", "move_backward")
	
	# _can_sprint check:
	if Input.is_action_just_pressed("sprint") and !is_on_floor():
		_can_sprint = false
	elif is_on_floor():
		_can_sprint = true
	
	# Sprint logic
	if Input.is_action_pressed("move_forward") and Input.is_action_pressed("sprint") and _can_sprint:
		_speed = move_toward(_speed, _sprint_speed, _sprint_acceleration)
	else:
		_speed = move_toward(_speed, _base_speed, _sprint_acceleration)
	
	# Grounded movement logic
	if is_on_floor():
		_direction = _direction.move_toward((transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized(), _movement_acceleration)
		if _direction:
			velocity.x = _direction.x * _speed
			velocity.z = _direction.z * _speed
		else:
			velocity.x = move_toward(velocity.x, 0.0, _movement_acceleration)
			velocity.z = move_toward(velocity.z, 0.0, _movement_acceleration)
	
	# Airbourne movment logic
	else:
		_direction = _direction.move_toward((transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized(), _air_resistance)
		if _direction:
			velocity.x = _direction.x * _speed
			velocity.z = _direction.z * _speed
		else:
			velocity.x = move_toward(velocity.x, 0.0, _air_resistance)
			velocity.z = move_toward(velocity.z, 0.0, _air_resistance)
	
	# Debug Label
	label_3d.text = "_speed: %s\nVelocity: %s\nForward Vel: %.2f\nis_on_floor: %s" % [ _speed, velocity, -global_transform.basis.z.dot(velocity), is_on_floor()]


func handle_jump() -> void:
	if Input.is_action_just_pressed("jump") and is_on_floor():
		velocity.y = _jump_strength

I know it’s not a lot of code for over a days work lol, but that’s what I landed on.

If anyone has thoughts, I would love to hear it!

The way you choose to comment or not comment certain parts of your code is very weird. And some places where you did comment, they all seem very uniform.

1 Like

A very solid start.

You shouldn’t worry about volume of code generated. Speed comes with experience.

As a novice programmer, one thing you should definitely educate yourself about is the basic programming principles. One of my favorites is DRY: Don’t Repeat Yourself. It takes some work to eliminate redundancies in your code, but it pays off when it is time to modify the script.

Take, for example, this from your code:

_direction = _direction.move_toward((transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized(), _movement_acceleration)
		if _direction:
			velocity.x = _direction.x * _speed
			velocity.z = _direction.z * _speed
		else:
			velocity.x = move_toward(velocity.x, 0.0, _movement_acceleration)
			velocity.z = move_toward(velocity.z, 0.0, _movement_acceleration)

That code is repeated almost exactly for _air_resistance. You could create a function that returns the direction based on that one parameter that changes.

2 Likes

That’s great advice, thank you! I figured that all the repetition in my code was probably not ideal while doing it, but wasn’t sure how to tidy it up correctly. Just making a new function is so obvious now that you say it. Gonna give it shot!

I just commented in a way that would help quickly remind me what going on in the code if I come back to it in the future. Is there a more proper commenting convention I should be following?

There is!

Your “accelerations” are framerate dependent. The amount you move for in each move_toward() call is constant. You call it every frame. If there are more frames per second, the value you’re moving toward will be reached quicker.

Thanks for pointing that out! For some reason I thought the return from move_toward() was pre multiplied by delta; that’s why I didn’t bother multiplying my acceleration variables by delta as well. But looking it up again I’m getting mixed results. Also as I understand move_and_slide() is multiplying be delta too? Honestly it’s all very confusing.

I’ll take your word for it though. Is the fix just to multiply _movement_acceleration/_air_resistance by delta in move_toward()?

“multiply by delta” is a very misleading concept, if you don’t know its actual background in classical phyiscs/kinematics.

The numbers you call “accelerations” are in fact acting as “delta values” (of altered variables) when you use them like that in move_toward(). Any velocity is by definition a differential of some value over time: velocity = delta_value / delta_time. So if you want to get correct delta value for the constant velocity in your current time slice, you need to multiply the velocity by delta_time.

Thanks! Definitely still not completely clicking for me yet but your explanation is helping me make more sense of it. This is something I’m going to need to look into in the near future.

Thanks! This whole guide is such a great resource!

Just one more note. From your last response it looks like you’re considering move_and_slide() and move_toward() to be in a same category of calls, so to speak. Despite both having “move” in their name, they’re from completely different galaxies. That “move” means completely different things.

move_and_slide() is a method of CharacterBodyXD. It is a very complex call and a lot happens when it’s executed. It moves the body using current velocity and current time slice (aka delta), it finds all its collisions, resolves all those collision by repositioning the body and updates all relevant state properties for the body. When you’re calling move_and_slide in a class derived from character body, you’re actually calling it like this: self.move_and_slide()

On the other hand, move_toward() is a trivial utility/convenience function defined in global scope for plain numbers and in VectorX classes for vectors. It has zero connection with the character body. None whatsoever. It’s basically just a simple addition/subtraction and a boundary check. It “moves” the value of the variable by adding the “delta” value you specify as the last argument, and then clamps the result so it doesn’t cross the “to” value. That’s all.

Check the reference for both those functions in the official docs, to get some intuition about them and fully clear the confusion out.

Thanks! I really appreciate all the help.

I feel like I already understood move_and_slide() is very different from move_toward(). I know move_and_slide() is doing some complex physics things and I think of move_toward() more so as an alternative to lerpf().

My confusion more so came from not understanding when delta needs to be used. I knew delta was in one way or another being applied to velocity when I call move_and_slide() in the physics process. I also mistakenly thought that the “delta” argument in move_toward() meant that the float passed through there is being multiplied by delta.

Since reading your explanations and doing some research I think I’m understanding things a little better now.