As I have previously discussed in my research, I am using Godot 4 to develop my game - HEIST!. Going into developing the prototype, all I had in mind was functionality and fun gameplay. I knew at first it would be ugly and look terrible but I just kept reminding myself what was truly important, which of course was the player's movement as this is a 2D Platformer.
This is footage I recorded of myself testing the movement, jump, dash and slide. I also added a moving platform and an enemy. I made this using temporary assets which I downloaded for free made by Brackeys (a very popular game development youtuber).
At this stage, I purely focused on getting the basics down as far as mechanics go and I wanted to make sure that the transitions between states worked as they should and the slide actually held onto the players x velocity when entering the slide state. Additionally, I learned different methods of animating the player and also learned how to handle animation changes smoothly. Another huge thing I learned about in this stage was the camera. I have never done a camera for a 2D Sidescroller so figuring out how to make a smooth and satisfying camera was a tough but rewarding obstacle.
In this stage, I replaced my initial assets with even more temporary assets which I also downloaded from Itch.io. I thought they looked a bit better and suited the theme I was going for with the first level so it made sense in my head to change the assets to match the direction I was heading in. A lot changed and evolved in this stage, I added a game manager scene and script that would handle scene (level) changing when entering a specific area2D node. Using my new level changing, I began to create a tutorial section so that I could figure out how I wanted the UX to flow and so that I could individually test each of my features. Along with the world asset changes, I made a basic black and white placeholder player character which would later be adapted to create my final player spritesheet.
I also added a wall-jump in this stage, which was a struggle and the result was clunky and unrefined but it was a solid foundation which I could build on and envisage how I was going to implement a better wall-jump.
Not much changed code-wise in this stage, however a lot level design was done here and a whole lot of art. I began by designing the level one section at a time, from the original spike wall slide area to the final platforming section in the video, I rigorously tested and played through countless times and through lots of trial and error, I finally landed on a level layout that I was somewhat happy with, and could see myself being able to build on top of and expand. It was quite tough to make sections that the player could do with relative ease with little-to-no application of the dash-slide-jump mechanic but also be able to breeze through the sections at rapid speeds through perfectly timed use of the mechanics. The only way I could describe that is like trying to solve two different complex math questions at the same time, and technically that's what it is. Jumps had to be perfect so that only near perfect gameplay would allow for the player to bypass otherwise necessary platforming sections.
I wanted to replace the temporary assets with my own tileset, and placing individual tiles one at a time using the temporary asset pack was a nightmare. I fell into the trap of decorating sections before testing them thoroughly which lead to me redecorating and replacing every single tile on every layer one by one, as the temporary asset pack wasn't built for auto-tiling, nor was it built for a 2D platformer like this. I decided on making a more simple and visually appealing custom auto-tiling terrain tileset. This took me a ridiculous amount of time and trial and error to get right as there are so many different variations and combinations of orders you can put tiles in, and they all had to line up cleanly. I must've re-drew the tileset at least six times before I got it how I wanted it to look, but I do think it paid off, especially later on in development.
This prototype obviously wasn't polished nor did it look very aesthetically pleasing but it was enough to playtest and to use as a proof of concept. Below I have attached a link to my published prototype on Itch.io and a video of me playing my prototype:
Here are all my scenes and scripts that I created for everything in the prototype
I used this scene as a template to make every other area/level in my game, I added the tilemap, player, exit zone, world border killzone, and nodes for coins, platforms, labels, enemies and obstacles to be included as much as I wanted later in development. This worked as a solid template but it did undergo a lot of changes such as switching from the old temporary tileset to the new one which I created myself.
I used an Area2D node to check whether or not the player had entered the area so that I could then tell my game_manager.gd script to run the next_level() function. This was unbelievably simple and effective, I used a simple open door elevator as an indicator that this was where the player was supposed to go.
The way I did the tutorial section was by making an inhereted scene from area_template.tscn and then renaming it to area_1.tscn, and then I would modify the scene to make a new unique level. If I wanted to add coins, labels, obstacles etc, I could instantiate child scenes under the specific nodes and then move them around, resize or do anything at all with them; with killzones however, I decided to just duplicate the one killzone's CollisionShape2D and then make the new CollisionShape2D unique so the position and scale values didn't effect the original collision shape. I repeated these steps from area_1 to area_4 which is my tutorial section. The naming convention of the areas were very important as the function (next_level()) you will see below in game_manager.gd depends on it to figure out which scene to change to.
This worked very similarly to area_exit.tscn, it's just an animated sprite of a coin rotating 360 degrees with a CollisionShape2D that detects when a player has entered the shape and then when that happens it runs coin.gd's _on_body_entered function.
This scene contains the inhereted child scene of killzone.tscn, which I haven't included here as it is just one Area2D node that detects for the player body and is mainly used as an inhereted scene for obstacles and the world border, so I thought it would make more sense to write about it here along with the obstacle.tscn scene. The obstacle contains a simple square CollisionShape2D which is meant as a placeholder shape so that I could resize them easily for spike sections and other obstacles.
This is my platform scene that I made using a temporary asset from the Brackeys Platformer Bundle, I gave the parent AnimatableBody2D node a CollisionShape2D node and made the collision shape one-way so the player could jump through it and then land on top as seen in almost every other platformer. I can then instantiate this scene in any of my area_x scenes as many times as I want. The fact that it's an AnimatableBody2D also means that I am able to attach an AnimationPlayer child node to it to make a move animation and set the transform->position as a keyframe. This makes it so that I can decide whether or not to animate each platform and makes the platforms highly customisable.
This is my speedrun timer autoload scene, it is a CanvasLayer parent node with UI nodes attached to it to make the xx:xx.xxx format. It calculates up to 99 minutes 99 seconds and 999 milliseconds. The scene itself is quite simple, the complexity comes from the speedrun_timer.gd script attached to the parent node.
Last but not least is my player scene. The version I am taking screenshots from seems to be a version just after the prototype hence the newly updated sprite, however everything else remains exactly like the prototype. The parent node is a CharacterBody2D, it contains child nodes of a Camera2D, AnimatedSprite2D, CollisionShape2D, RayCasts and the inhereted speedrun_timer scene. The Camera2D node has some smoothing and is handled in the player's script. The animated sprite takes from the spritesheet for the player which I created with Aseprite and contains animations for death, fall, jump, idle, walk, run, slide and wall slide. However, the death animation went unused as adding it every time the player died felt too slow and punishing, especially with the current difficulty of the game. The CollisionShape2D is a capsule shape which is slightly smaller than the player's sprite to give more room for moments of "Woah that was close!" for the player. Additionally, the RayCasts you can see are used in the code for when a player is jumping up onto something but can't jump past the ceiling of the platform and hits the very corner of their head on it. This is an incredibly annoying situation and this fixes it entirely, it will most-likely go unnoticed by players but it makes me feel better for being thorough and beginning to polish the player script early on. Lastly, the speedrun timer is placed at the top and centre of the player's camera.
extends Node
var current_area = 1
var area_path = "res://scenes/areas/"
func next_level():
current_area += 1
var full_path = area_path + "area_" + str(current_area) + ".tscn"
get_tree().call_deferred("change_scene_to_file", full_path)
print("The player has moved to area " + str(current_area))
This is an autoload script that simply handles area transitions, the area's path is created inside the body of the function as current_area gets incremented every time next_level() is called. This is then used to change the scene to the new area (level).
extends CanvasLayer
var time: float = 0.0
var minutes: int = 0
var seconds: int = 0
var msec: int = 0
var timer_ui: CanvasLayer = null
func _ready():
#timer_ui = preload("res://scenes/speedrun_timer.tscn").instantiate()
get_tree().get_root().add_child(timer_ui)
set_process(true)
func _process(delta):
time += delta
msec = fmod(time, 1) * 100
seconds = fmod(time, 60)
minutes = fmod(time, 3600) / 60
if timer_ui:
var mins = timer_ui.get_node("SpeedrunTimerPanel/Minutes")
var secs = timer_ui.get_node("SpeedrunTimerPanel/Seconds")
var msecs = timer_ui.get_node("SpeedrunTimerPanel/Msecs")
mins.text = "%02d:" % minutes
secs.text = "%02d." % seconds
msecs.text = "%03d" % msec
func stop():
set_process(false)
func get_time_formatted():
return "%02d:%02d.%03d" % [minutes, seconds, msec]
This looks quite complicated at first, but all it really does it count and then apply the values of minutes, seconds and milliseconds to the labels in the scene so the time gets displayed whilst playing the game.
extends Area2D
func _on_body_entered(body):
if body is PlayerController:
GameManager.next_level()
This simply detects when a player walks into the area and then runs the game_manager.gd's next_level() function.
extends Area2D
@onready var game_manager = %GameManager
@onready var animation_player = $AnimationPlayer
func _on_body_entered(body):
#game_manager.add_point()
animation_player.play("pickup")
This is another very simple script that just plays an animation when the player picks the coin up. The animation simply removes the coin from the level to show that it has been picked up.
extends Area2D
@onready var timer = $Timer
func _on_body_entered(body):
if body is PlayerController:
Engine.time_scale = 0.5
body.get_node("CollisionShape2D").queue_free()
timer.start()
func _on_timer_timeout():
Engine.time_scale = 1
get_tree().reload_current_scene()
Here is my killzone script, it detects when a player enters the killzone and then removes the player's CollisionShape2D and then begins the timer to reset the player in the scene. The length of the timer can be changed in the node inspector but it's set to an incredibly short time so that the player almost instantly respawns.
extends CharacterBody2D
class_name PlayerController
enum State { NORMAL, DASHING, SLIDING }
var currentState = State.NORMAL
var isStateNew: bool = true
@onready var camera = $Camera2D
@onready var animated_sprite = $AnimatedSprite2D
@onready var collision_shape = $CollisionShape2D
@onready var capsule_shape = collision_shape.shape
@onready var left_outer = $RayCasts/Left_Outer
@onready var left_inner = $RayCasts/Left_Inner
@onready var right_inner = $RayCasts/Right_Inner
@onready var right_outer = $RayCasts/Right_Outer
var can_move: bool = true
var camera_offset_x: float = 80.0
var camera_offset_y: float = 40.0
var y_smoothing: float = 2.0
var threshold = 10
var coyote_time_remaining: float = 0.0
var coyote_time: float = 0.15
var jump_buffer_time: float = 0.2
var jump_available: bool = true
var jump_buffer: bool = false
var can_dash: bool = true
var dash_buffered: bool = false
var slide_duration: float = 1.0
var slide_time_remaining: float
var slide_speed: float = 200.0
var slide_exit_speed: float = 10.0
var boosted_jump_active: bool = false
var is_wall_sliding: bool = false
var wall_jump_pushback: float = 150.0
var normal_capsule_size = Vector2(6, 24)
var slide_capsule_size = Vector2(6, 12)
var was_sliding: bool = false
var wall_jump_disable_time: float = 0.3
var wall_cling_time_remaining: float = 0.0
var wall_cling_time: float = 0.1
var was_wall_sliding: bool = false
@export_group("Horizontal Movement")
@export var max_horizontal_speed: float
@export var horizontal_acceleration: float
@export var max_dash_speed: float
@export var min_dash_speed: float
@export_group("Jump and Fall")
@export var jump_height: float
@export var jump_time_to_peak: float
@export var jump_time_to_descent: float
@export var jump_cut_multiplier: float
@export var slide_jump_boost: float
@export_group("Wall Slide")
@export var max_wall_slide_speed: float
@onready var jump_velocity: float = (2.0 * jump_height) / jump_time_to_peak * -1.0
@onready var jump_gravity: float = (-2.0 * jump_height) / (jump_time_to_peak * jump_time_to_peak) * -1.0
@onready var fall_gravity: float = (-2.0 * jump_height) / (jump_time_to_descent * jump_time_to_descent) * -1.0
@onready var wall_gravity: float = ((-2.0 * jump_height) / (jump_time_to_descent * jump_time_to_descent) * -1.0) * 0.5
func _process(delta):
match currentState:
State.NORMAL:
process_normal(delta)
State.DASHING:
process_dash(delta)
State.SLIDING:
process_slide(delta)
isStateNew = false
func change_state(newState):
currentState = newState
isStateNew = true
func process_normal(delta):
apply_gravity(delta)
if was_sliding:
# Reset Hitbox
capsule_shape.height = normal_capsule_size.y
capsule_shape.radius = normal_capsule_size.x
was_sliding = false # Reset Flag
# Handle jump.
if Input.is_action_just_pressed("jump"):
if jump_available or is_wall_sliding or was_wall_sliding or coyote_time_remaining > 0:
Jump()
else:
jump_buffer = true
get_tree().create_timer(jump_buffer_time).timeout.connect(on_jump_buffer_timeout)
# Cut jump if the button is released early
if Input.is_action_just_released("jump") and velocity.y < 0:
velocity.y *= jump_cut_multiplier
# Weird jump collision handling
if right_outer.is_colliding() and !right_inner.is_colliding() \
and !left_inner.is_colliding() and !left_outer.is_colliding():
global_position.x -= 5
if left_outer.is_colliding() and !left_inner.is_colliding() \
and !right_inner.is_colliding() and !right_outer.is_colliding():
global_position.x += 5
# Movement handling (normal horizontal control)
if can_move:
var moveVector = Input.get_action_strength("right") - Input.get_action_strength("left")
if moveVector == 0:
velocity.x = lerp(0.0, velocity.x, pow(2, -50 * delta))
# Clamp velocity at max horizontal speed normally
if not boosted_jump_active:
velocity.x += moveVector * horizontal_acceleration * delta
velocity.x = clamp(velocity.x, -max_horizontal_speed, max_horizontal_speed)
else:
velocity.x += moveVector * delta
if abs(velocity.x) > max_horizontal_speed && is_on_floor():
velocity.x *= pow(0.9, 60 * delta) # 10% deceleration per frame
if abs(velocity.x) <= max_horizontal_speed + 0.5:
boosted_jump_active = false # Return to normal speed rules
# Handle Dash
if Input.is_action_just_pressed("dash"):
if is_on_floor() or can_dash:
call_deferred("change_state", State.DASHING)
else:
dash_buffered = true # Store the dash input for later use
# Handle buffered dash
if is_on_floor():
can_dash = true # Reset dash when touching the ground
if dash_buffered: # Use buffered dash
dash_buffered = false
call_deferred("change_state", State.DASHING)
# Handle Slide
if Input.is_action_just_pressed("slide") && is_on_floor():
call_deferred("change_state", State.SLIDING)
# Input direction and animation handling
var direction = Input.get_axis("left", "right")
# Better Camera Offset
var target_offset_x = camera.offset.x
var target_offset_y = -camera.offset.y * 0.5
if direction != 0:
var player_center_offset = global_position.x - get_viewport_rect().size.x / 2 # Player's distance from screen center
if abs(player_center_offset) > threshold: # Only change offset if player moves past threshold
target_offset_x = direction * camera_offset_x
y_smoothing = 4.0 # Snappier than x-axis' smoothing
# Smoothly interpolate towards the target offset
camera.offset.x = lerp(camera.offset.x, target_offset_x, 2 * delta)
camera.offset.y = lerp(camera.offset.y, target_offset_y, y_smoothing * delta)
if direction > 0:
animated_sprite.flip_h = false
elif direction < 0:
animated_sprite.flip_h = true
if is_on_floor():
if direction == 0:
animated_sprite.play("idle")
elif velocity.x < max_horizontal_speed:
animated_sprite.play("walk")
else:
animated_sprite.play("run")
if !is_on_floor():
if is_wall_sliding:
animated_sprite.play("wall_slide")
if direction > 0:
animated_sprite.flip_h = true
elif direction < 0:
animated_sprite.flip_h = false
if velocity.y > 0 and !is_wall_sliding:
animated_sprite.play("jump")
if velocity.y < 0 and !is_wall_sliding:
animated_sprite.play("fall")
move_and_slide()
func process_dash(delta):
if isStateNew:
if not is_on_floor():
can_dash = false # Consume air dash
# Dash logic continues here
var moveVector = Input.get_action_strength("right") - Input.get_action_strength("left")
var velocityMod = sign(moveVector) if moveVector != 0 else (-1 if animated_sprite.flip_h else 1)
velocity = Vector2(max_dash_speed * velocityMod, 0.0)
move_and_slide()
# Stop horizontal movement if colliding with a wall
if is_on_wall():
velocity.x = 0
velocity.x = lerp(0.0, velocity.x, pow(2, -8 * delta))
# Transition to slide
if Input.is_action_pressed("slide") && is_on_floor():
call_deferred("change_state", State.SLIDING)
return
if abs(velocity.x) < min_dash_speed:
call_deferred("change_state", State.NORMAL)
func process_slide(delta):
if isStateNew:
animated_sprite.play("slide")
# Ensure minimum slide speed
if abs(velocity.x) < slide_speed:
velocity.x = slide_speed * sign(velocity.x)
slide_time_remaining = slide_duration * 0.5
else:
slide_time_remaining = slide_duration
coyote_time = 0.25 # Increase length of coyote_time when sliding fast to make it more fair
# Shrink Hitbox
capsule_shape.height = slide_capsule_size.y
capsule_shape.radius = slide_capsule_size.x
move_and_slide()
# Apply Gravity
apply_gravity(delta)
# Decrement slide timer
slide_time_remaining -= delta
# Decelerate towards the last 20% of the slide by 10% a frame
if slide_time_remaining < slide_duration * 0.2:
velocity.x *= pow(0.9, 60 * delta)
# Jumping out of slide
if (Input.is_action_just_pressed("jump") and is_on_floor() and coyote_time_remaining == 0):
Jump()
# Preserve and boost horizontal velocity
var boost_direction = sign(velocity.x) if velocity.x != 0 else 1
velocity.x += slide_jump_boost * boost_direction # ADD instead of enforce minimum
# Set flag to ensure next state respects momentum
boosted_jump_active = true
was_sliding = true
change_state(State.NORMAL)
return
if (Input.is_action_just_pressed("jump") and coyote_time_remaining > 0):
Jump()
# Preserve and boost horizontal velocity extra
var boost_direction = sign(velocity.x) if velocity.x != 0 else 1
velocity.x += slide_jump_boost * 200 * boost_direction # ADD instead of enforce minimum
# Set flag to ensure next state respects momentum
boosted_jump_active = true
was_sliding = true
change_state(State.NORMAL)
return
# End slide if timer runs out OR slide button is released
if slide_time_remaining <= 0 or !Input.is_action_pressed("slide"):
was_sliding = true
change_state(State.NORMAL)
func Jump():
if not is_wall_sliding:
velocity.y = jump_velocity
jump_available = false
coyote_time_remaining = 0
coyote_time = 0.15 # Reset Coyote Time to regular value
if is_wall_sliding and Input.is_action_pressed("right"): # Wall jump
velocity.y = jump_velocity
velocity.x = -wall_jump_pushback
can_move = false
can_dash = false
await get_tree().create_timer(wall_jump_disable_time).timeout
can_move = true
can_dash = true
elif is_wall_sliding and Input.is_action_pressed("left"): # Wall Jump
velocity.y = jump_velocity
velocity.x = wall_jump_pushback
can_move = false
can_dash = false
await get_tree().create_timer(wall_jump_disable_time).timeout
can_move = true
can_dash = true
func get_player_gravity() -> float:
if velocity.y < 0.0:
return jump_gravity
elif is_wall_sliding:
return wall_gravity
else:
return fall_gravity
func apply_gravity(delta):
# Add the gravity for when the player is mid air
if not is_on_floor() and !is_wall_sliding:
if jump_available:
if coyote_time_remaining <= 0.0: # Coyote timer stopped
coyote_time_remaining = coyote_time # Enter Coyote Time
coyote_time_remaining = max(0, coyote_time_remaining - delta)
velocity.y += get_player_gravity() * delta
velocity.y = min(velocity.y, 450.0)
if coyote_time_remaining == 0:
jump_available = false
# Detect Wall Sliding
if !is_on_floor() and is_on_wall():
if Input.is_action_pressed("left") or Input.is_action_pressed("right"): # Moving into the wall
if !was_wall_sliding:
is_wall_sliding = true
wall_cling_time_remaining = wall_cling_time # Start cling
else: # No input but still against the wall
is_wall_sliding = false
else:
is_wall_sliding = false
# Add Wall Gravity (friction)
if is_wall_sliding:
if wall_cling_time_remaining > 0.0:
wall_cling_time_remaining -= delta
velocity.y = 5.0 # Cling - very slow vertical movement
else:
var target_speed = clamp(velocity.y + get_player_gravity() * delta, 0.0, max_wall_slide_speed)
velocity.y = lerp(velocity.y, target_speed, delta * 10.0)
can_dash = true
# Update sliding tracker for the next frame
was_wall_sliding = is_wall_sliding
# Add No Gravity because the player is grounded
if is_on_floor() and !is_wall_sliding:
jump_available = true
coyote_time_remaining = 0.0 # Reset Coyote Time when landing
if jump_buffer:
Jump() # Perform Buffered Jump
jump_buffer = false
func on_jump_buffer_timeout():
jump_buffer = false
When I wrote my player.gd script, I wanted to build responsive and versatile mechanics like jumping, dashing, wall sliding, and sliding on the ground. The whole thing is built around a finite state machine, the character can be in one of three states: NORMAL, DASHING, or SLIDING. Depending on what state the player is in, it processes different logic for movement and animation.
In the NORMAL state, the player can move left and right, jump, or prepare to dash or slide. I handled horizontal movement by calculating input strength from left/right and applied acceleration or deceleration depending on whether the player was pressing keys or not. I used lerp() to smooth it out when no direction was being held, which makes the movement feel more polished. I also added jumping mechanics with coyote time and jump buffering: Coyote time gives the player a little grace period after walking off a ledge where they can still jump. On top of that - jump buffering lets them press jump just before landing and still get a jump out of it. This helps the controls feel snappy and forgiving but responsive. I made sure to support cutting jumps too, if the jump button is released early it shortens the jump. It's great for giving players more control over how high they jump.
The DASHING state lets the player dash quickly in a direction. If they’re on the ground or still have their air dash available, pressing the dash button triggers the dash. When entering this state, I gave the player a velocity boost and used lerp() to quickly slow them back down unless they transitioned into a slide. Dashing into a wall stops the dash early, and if they dash while pressing the slide key and they're grounded, it transitions straight into a slide (which carries the momentum). To make the controls feel tight, I allowed the dash to be buffered. If the player hits dash midair without a dash available, it gets queued up and automatically activates when they land. This is helpful for when the player presses dash just frames before they land, as it would be incredibly clunky and unsatisfying if this completely negated their input instead of slightly delaying the input so that it does what they want it to do.
When the player enters the SLIDING state (like from a dash or just pressing the slide key while grounded), their hitbox shrinks and they keep their momentum. I added a slide timer that controls how long the slide lasts. During the slide, gravity still pulls them down, and near the end of the slide, I start decelerating them to slow them smoothly. I added a really cool feature where you can jump out of a slide, and when you do, it boosts your horizontal momentum to make it feel slick and powerful. This especially works during coyote time if you're just falling off a ledge while sliding, it gives a crazy momentum boost which is an intended feature which makes the perfect D-S-J possible.
When the player is in the air and touching a wall while pressing towards it, they start wall sliding. I made the gravity lighter during this state to slow down the fall and let the player cling to the wall briefly. If the player presses jump while sliding on a wall, I trigger a wall jump, pushing them away from the wall and disabling their movement and dash for a short time to stop them from instantly redirecting. I also added a was_wall_sliding flag to help manage transitions and animations.
To make the camera feel responsive, I added logic that offsets the camera slightly in the direction the player is facing or moving, only if they’ve moved far enough from the screen center. It also reacts more quickly on the y-axis to help with jumping and falling visuals. The AnimatedSprite2D is controlled based on movement state: Idle, walk, and run while on the ground. Jump, fall, and wall_slide while in the air. I flip the sprite based on direction and made sure the wall_slide animation flips correctly based on wall direction.
I calculated gravity manually for: Jumping (lighter gravity going up), Falling (heavier gravity coming down), and wall sliding (lighter, slowed gravity). The function apply_gravity() updates gravity every frame and handles things like coyote time, jump availability, and wall sliding detection. I made sure it was smart enough to know whether gravity should be applied based on the state, whether the player was grounded, etc. The Jump() function handles all the jump logic in one place, including wall jump logic and temporary disabling of movement after wall jumps.