Player Controller Prototype

Preamble

Writing a framework to publish blog post was a good idea all those months ago, but in all the enjoyment of that project I never quite stopped to consider that I wasn’t much of a writer. In the blur of work, family, and actually completing side-project’s, it’s hard to find time to put towards writing and documenting. Nine months may have vanished since I last published a post, and the opportunity to have filled this site with more posts this year is coming to a decisive close, but that doesn’t mean it’s the end of the road for writing here. So why, the sudden urge to write again? Reading Show Your Work! by Austin Kleon finally nudged me to share again, so here’s what I’ve been tinkering with out-of-hours: a first take at a 3D platformer player controller in Godot 4.

Demo (Click to Play)

Notes:

Player Controller Overview

The controller is split into three main pieces: input capture, physics calculations, and state transitions. The idea is to separate concerns so that each piece can be worked on independently and hopefully be more manageable as the project grows.

Input Mapping

Although Godot has a built in input mapping system for turning raw inputs into named actions, I decided to add an additional layer to abstract away direct input and named action handling from the player controller. This was mostly done so I could compose the mouse movement down into camera rotation and also to keep track of held/instant key presses as centralised properties rather than using the built-in Input class methods with magic strings for the different actions. This has the fortunate side-effect of making input disabling and storage for input replay easier in the future.

State Machine

The state machine handles high-level decision making, while each individual state owns its rules, which helps to keep the main character script tidier and makes changes to a single verb less painful. I did this to avoid the spaghetti of nested conditionals you’d get from a single monolithic update function. (Which is what I initially had after following a Unity tutorial on Youtube.)

1
2
3
4
5
6
func _transition_to_state(new_state: PlayerState, delta: float) -> void:
	if current_state:
		current_state.exit(self, delta)
	while new_state:
		current_state = new_state
		new_state = new_state.enter(self, delta)

Note: The while loop enables states to pre-emptively defer to other states. If the player initiates a slide while next to and aiming at a wall, then the slide state can immediately downgrade to a crouch before any physics tick.

PlayerMotor

The states do handle some of the physics calculations, however, I added several helper functions to a PlayerMotor to make some common tasks less verbose and easier to share around the states later. Some notable inclusions:

While my custom player motor is what I use for most of the calls when controlling movement, it’s worth noting their are plenty of utilities already built into the CharacterBody3D Godot class that the player is extending. (is_on_floor(), get_floor_normal(), move_and_slide(), etc.)


Jump Forgiveness

Jump forgiveness is a relatively common technique in 2D and 3D platformers to make jumping feel more responsive and less frustrating. Commonly, this is done by allowing the player to jump for a short period after running/falling off a ledge (coyote time) or queue up a jump a short period before landing (jump buffering). In my controller, I’ve implemented both techniques to improve the overall feel of jumping. See the code snippet below for an idea of how I implemented this in the air state.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# states/air.gd

# ...

var _coyote_time_end_timestamp: float
var _jump_buffer_end_timestamp: float

func enter(player: Player, _delta: float) -> PlayerState:
	if player.motor.get_vertical_speed() <= 0:
		var now := Time.get_ticks_msec() * Engine.time_scale
		# Start coyote time
		_coyote_time_end_timestamp = now + coyote_time_window
	return null

func physics_update(player: Player, delta: float) -> PlayerState:
	var now := Time.get_ticks_msec() * Engine.time_scale
	if player.inputs.jump_pressed:
		# Start jump buffer
		_jump_buffer_end_timestamp = now + jump_buffer_window

	# Jump if both coyote time and jump buffer are active
	if _jump_buffer_end_timestamp > now and _coyote_time_end_timestamp > now:
		player.jump()
		# End both timers
		_coyote_time_end_timestamp = now
		_jump_buffer_end_timestamp = now

  # ...

  # Landing
	if player.is_on_floor():
	  # Check for buffered jump on landing
		if _jump_buffer_end_timestamp > now:
			player.jump()
			_jump_buffer_end_timestamp = now # End jump buffer
			return null
		if player.inputs.is_crouching:
			return states.slide
		return states.grounded

	return null

# ...

Wrapping Up 🎁

Two weeks of tinkering has given me a solid foundation to build on, however, it’s the holiday season, so I won’t be pushing this further for a little while.

There are plenty of rough edges, but it was fun to explore what goes in to making a bare bones 3D platformer controller. The prototype supports walking, sprinting, jumping, crouching, and sliding, which is enough to start testing level designs and movement feel. I would like to add wall runs, ledge grabs, and vaulting in the future, but those will likely involve ray casts and more complex collision detection, so I’ll save those for later.

I’m not sure what I’ll do next with this, but I’ll try and keep writing about it as I go.

Resources