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:
- Best played with a keyboard and mouse. (Mobile/touch controls are not supported in this prototype)
- Use
WASD to move, Shift to sprint, Space to jump, and Ctrl/C to crouch. (Crouch when sprinting to slide) - Press
L to toggle debug info. - Play in fullscreen here for the best experience.
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:
get_vertical_velocity()get_planar_velocity()is_moving_upward()accelerate_planar(...)steer_planar(...)apply_impulse(...)
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
Tutorials
Assets and textures