DevLog #6 – Do I really want to press that jump button?

Hello everyone!

Last week, we talked about how we make levels using a 2D data structure. Now, let’s dive a bit more into the different gameplay mechanics! Since we built maps with floors of different heights, we now need a way to go from one altitude to another. The main way of traversing a level consists in… jumping! We will also use some other options like stairs, bumpers or elevators to reach higher altitudes.

When it comes to jumping, we have two possibilities. In both cases, we wish to have some air control to influence where we head to while in the air.

  • Manual jump. The player has to press a button to actually jump.
  • Auto-jump. The player runs near platforms or edges and they jump automatically.

This choice is not an easy one and we thought about it for some time. We actually implemented both solution in the game to test and pick what we think feels the best, so we thought it would be interesting to share some implementation details. Let’s first see how we did that with the Godot engine and show some code. We will discuss afterwards the pros and cons of each method and explain what motivated our final choice. If you want to directly read this last part and skip the technical stuff, you can click here. We also show a GIF at the end of the post 🙂

Implementing the manual jump

In our player script, we use a vertical thrust to initiate the jump as shown below. We take into account the current directional input in the XZ plane to keep the player momentum.

func jump():
	var vertical_boost = 30 # Jump strength tuned empirically
	velocity = velocity.linear_interpolate(Vector3(input_vector.x, vertical_boost, input_vector.y), ACCELERATION)

We also implemented two common tricks of platformers related to jump detection that enhance the player experience (we took inspiration from this great article). When we press the jump button at almost the right moment and yet nothing happens, we all hate it, right?

  • Jumping too late. We create a field that we reset to 0 each time the player if on the floor, or we add the delta duration between two physics frames otherwise. This gives us the time since the player was last grounded. When the players presses the jump button, we set a flag to indicate the player wants to jump. At the next loop of _physics_process(delta), we check if the player was grounded 0.1 second earlier. If so, we trigger the jump. This allows to jump even if we press the button a little too late.
  • Jumping too early. We also use another field to record when the player presses the jump button. In the first following loop of _physics_process(delta) where the conditions allow to jump, we check if the input occurred less than 0.1 second earlier. If so, we jump immediately. This permits to jump even if we press the button slightly before hitting the ground.
func _unhandled_input(event):
	if event.is_action_pressed("jump"):
		if can_jump() and last_grounded < 0.1:
			jump()
		else:
			last_jump_trigger = 0

func _physics_process(delta):
	if is_on_floor():
		last_grounded = 0
		if can_jump() and last_jump_trigger < 0.1:
			jump()
	velocity = move_and_slide(velocity, Vector3.UP)
	last_jump_trigger += delta
	last_grounded += delta

The function can_jump allows us to give some other conditions to check. For instance, we have a dash ability, and we don’t want players to jump while dashing. Also, the two fields last_grounded and last_jump_trigger should be set to a large number (higher than 0.1 here) when we actually jump to specify that we are done with the last input, we don’t want it to fire twice.

Implementing the auto-jump

The core jump functions remains the same. The main difference comes from the detection because we don’t have an input to deal with. We have to guess when the player should jump. We distinguish two cases:

  • Jumping off edges. We keep track of the last grounded position. When we are not grounded anymore, we compute the difference between the current y position and the y of the last grounded position. If the number if higher than for instance 0.05, we consider that we are falling off an edge and we trigger a jump. This allows to go from one platform to another simply by running off the edges.
  • Going up. The base jump strength allows the player to jump one tile but not two. We thus have to detect when the player is actually running against a wall but could go up by jumping. To do that, we use several raycasts. We use the first one at altitude 0.5 to detect if the player is colliding with something. If so, we then set three raycasts at altitude 1.5 on the left, the middle and the right to detect if the obstacle goes high or not. We use three raycasts to better handle the width of the player shape. If those don’t hit something, then we know nothing blocks us from going up and we trigger a jump.

Here is the full code with both cases:

func _physics_process(delta):

	# Edges case
	if abs(global_transform.origin.y - last_on_floor_y) > 0.05:
		if not falling:
			falling = true
			if can_jump():
				jump()

	# Going up case
	var space_state = get_world().direct_space_state
	var do_jump = false
	if not falling and can_jump():
		var input_vector = get_input_vector() # Current directional input in XZ plane
		var origin_position_low = global_transform.origin + Vector3(0.0, 0.5, 0.0)
		var target_position_low = origin_position_low + Vector3(input_vector.x, 0.0, input_vector.y) * 50
		var result_low = space_state.intersect_ray(origin_position_low, target_position_low, [self], 0b1)
		if result_low:
			if (origin_position_low - result_low.position).length() < 1.06:
				# We are colliding against the world, let's check up now
				var should_jump = [false, false, false]
				var index = 0
				for k in [-0.49, 0, 0.49]:
					var origin_position_up = origin_position_low + Vector3.UP + abs(k) * Vector3(input_vector.x, 0.0, input_vector.y).rotated(Vector3.UP, sign(k) * PI / 2)
					var target_position_up = origin_position_up + Vector3(input_vector.x, 0.0, input_vector.y) * 50
					var result_up = space_state.intersect_ray(origin_position_up, target_position_up, [self], 0b1)
					if result_up:
						if (origin_position_up - result_up.position).length() > 1.5:
							should_jump[index] = true
					else:
						should_jump[index] = true
					index += 1
				if should_jump[0] and should_jump[1] and should_jump[2]:
					do_jump = true
	if do_jump:
		jump()

To get things smoother in the case of going up, we can also add a timer instead of jumping immediately. We will only jump if we run actively into the wall for instance for 0.5 seconds. This allows to get close to the walls without jumping right away while you sometimes don’t want to.

Manual jump VS auto-jump

Now that we explained how we implemented both options, you probably want to know which turns out to be the best. Well, it’s complicated. Both have their strengths and their disadvantages. Obviously, the key difference lies in the following question: should we let the player decide when to jump?

Because of the top-down orthogonal camera, we may struggle to perceive where the player is precisely standing on the floor. The auto-jump is great for that reason: the player can never jump too early or too late since we trigger the jump automatically for them. However, we can mitigate this weakness of the manual jump by extending the before and after grounded time buffers. Another strength of the auto-jump is that it lets the player jump only where this is relevant. For instance, we maybe don’t want the player to jump in the middle of a flat battle arena.

On the other hand, the last point is also the biggest drawback. Taking some liberty away from the player may result in frustration. Because jumping is so much fun, we may wish to perform this action wherever and whenever we want, even if it is pointless.

In the end, we chose to keep the manual jump for the simple reason that: it is fun. We hope to keep the levels always clear enough so that it never gets frustrating to jump at the right moment. This means we will never have pixel-perfect jumping. In project “Stasis”, platforming is meant to be a fun way of traversing the level, not a true challenge like those we find for example in the game Celeste. The manual jump is also easier to implement and seems more robust. With the auto-jump, there are some edge cases where it won’t trigger and this can quickly get annoying to debug.

Jumping action

To end this devlog, here is a GIF showing some platforming in one room of the upcoming technical demo. For now, we show the run animation while in the air, but we plan to add a jumping and falling animation to the new main character spritesheet we are working on. We however already put some particles in there—and sound effects too but the GIF won’t illustrate that of course… Anyway, tell us in the comments what you think!

Some platforming

Next week, we will talk about another gameplay mechanic, and we will reveal more about the demo. We said we would release it before the end of June and we are getting closer to that. Besides, we were able to push a bit more content that we initially planned, so stay tuned! We will release it on itch.io so you can follow us there as of now to get notified as soon as it is online!

Until next time!