The first thing I did moving on from the prototype was discard all of the old, temporary art assets I had used to decorate the prototype. I chose to do this as I found decorating the levels as I was designing them held back development a lot as I had to make changes to the design a lot after testing, and having to move decorations and switch between tilemap layers over and over again took a substanstial amount of time and caused ridiculous delays. Moving forward, I knew I had to make the process of designing and developing the levels much more efficient and scalable, so stripping the levels down to the purely necessary elements was a gigantic help. After this, I decided to take a second look at the tutorial section I'd developed. The prototype testing showed me that some players didn't fully grasp the mechanics which led to a lot of frustration and made the game feel very unintuitive to me and others who tested. Overhauling the tutorial and redesigning the sections made me feel much more confident in the overall direction and structure of the game. Also, I finally created and implemented the player's art and animations to replace the placeholder ones from the prototype.
On top of all that, I did some more work in cleaning up the behind the scenes and prepared for more development and focused on scalability and efficiency as I mentioned previously.
My flow of level design was starting to feel sluggish and I felt it was time to move onto something else before I'd burn myself out. My solution for this was very clear as I'd been meaning to add UI since the prototype was done as the game didn't quite feel like a proper game without UI. Being pushed straight into gameplay was rough and felt quite blunt and to be frank, it sucked. So I went ahead and designed a very basic main menu screen and pause menu. The settings button didn't do anything quite yet as that was another thing that I didn't count as 100% necessary at this point of development, although I of course added it later on. After that, I made a speedrun timer that would start when pressing play and pause during the pause menu being open, and reset and stop when going back to main menu. This was quite tricky to make and it didn't work exactly how I wanted to just yet, but for now it was just fine.
The wall jump was criticised a lot in my prototype for being "too powerful" and "unresponsive" to the point where some found it punishing instead of smooth and satisfying. I decided to redo the way that the wall jump was triggered so that it now wants the player to be holding the direction into the wall + press jump. This would then cause a pushback which I increased a lot to make it feel more realistic and not over-powered. Similarly, I tweaked a lot of the player movement variables' values to make the game more fair and satisfying to play.
I decided to finish off the settings part of the menu UIs, so that they now have options for fullscreen on/off, main volume slider, music volume slider and SFX slider. It doesn't look quite as perfect as I'd like but due to the time constraints I knew I couldn't focus too much on the smaller details like that so I began to work on recolouring the auto-tiling tileset I'd created to look more blue and follow the colour theme I had in mind for the first level. I also added a plain cream background just to make it even easier to differentiate between the collision/non collision tiles.
I finally incorporated the springs I had in mind to the first level as I moved onto creating the next section of the first level. I wanted to make them almost look like little desks that would pop up from the ground to push the player upwards and I think I managed to achieve that.
Lastly, I created the rest of level 1 and was able to mimic elements of other games such as Geometry Dash towards the end of the first level to create a spike trap that reverses the functionality of the springs so that they're to be avoided rather than to be jumped on.
I added a title screen and implemented the two cover art works I created as backgrounds so that the game is more visually appealing. I also added another button the to the main menu, a leaderboard button. The leaderboard would have seperate boards for level 1 and level 2, the code was incredibly complicated and it's easily one of the most complex systems I have ever had to code, but I am very proud of how it turned out. It populates two leaderboards with the top ten fastest times with 1. being the fastest time. The purpose of having a leaderboard was so that I could push competition and make players want to replay the game so that they can beat their previous "high score". I originally wanted it to be a global board so that everyone's best scores on their own computers would populate the timetable but I didn't have the time to learn how to do that for this so I was perfectly happy with the local one I created. As I've mentioned, there's now a second level to the game which I personally think looks far better. Similar to the first level it contains 3 coins and roughly the same length and it has the same spikes and springs. I added one way platforms finally when making the second level and went back and added some to the first level too where I felt it was appropriate. Additionally, I created more music so that the tutorial had a dedicated track, the menus had their own track and now the 2 levels had their own level music. The second level as I've discussed in planning, is supposed to be a rooftop escape so I wanted to make a parallax background for the level so that it felt like the player was running and navigating through a vast city with skyscrapers and towers surrounding the player, as if the city hardly even noticed the player, I wanted it to seem so big and busy that the player robbing the building almost went unnoticed by the rest of the city. I am very pleased with how it turned out, and I managed to follow the red-dark orange colour scheme that I was hoping to achieve. Some other little additions I made include a fade transition from the title screen to the main menu, a level select option in the main menu and a dash trail animation that changed colour when a perfectly timed dash-slide-jump was done. I was beginning to realise by this point that I wouldn't have time to create a third level and I was going to have to accept that and just try to polish the two levels I already had with the remaining time. I realised that by now most of the work was done, the bulk of the game had been completed and I was now moving onto the final touches.
Here is a showcase of the final build of HEIST!. After a long series of development, feedback, refining and repeating that process, I have finally got the game to a point where I am satisfied to call it finished. I can be quite a perfectionist sometimes but as time went on I began to realise that this game was never going to be perfect or exactly as I had planned in my head. That being said, I am proud of what I have created and I think considering the time constraints it didn't come out too bad. During this final stage of refinement, I wanted to decorate the levels so I reused some of the template assets I used for the office decorations and recoloured them so I could have a consistent colour theme for both levels, as I was a big fan of the consistency in the second level. Adding these decorations for the first level turned out well I believe, and they really do give it some much-needed life. Also, I am a huge fan of how some of the computers are left on and some are turned off and there are chairs spun all over the place. It almost shows the player that the office workers ran off in a hurry to leave the office at the end of the day. Additionally, I implemented the storyboard style "cutscenes" that I had drawn for the game to give it some story and background. I'm a huge fan of the storyboards and I really believe they fit in well with the overall style of the game. Lastly, I drew a helicopter and added it to the end of level 2 to show that the player escapes to the skies at the end, which fits the storyboards I drew nicely.
extends Area2D
func _on_body_entered(body):
if body is PlayerController:
var scene_path = get_tree().current_scene.scene_file_path
if scene_path in ["res://scenes/areas/area_5.tscn", "res://scenes/areas/area_6.tscn"]:
# Get current timer time
var time = SpeedrunTimer.get_current_time()
if time > 0.0:
var area = int(scene_path.get_file().get_basename().split("_")[-1])
SpeedrunTimer.stop()
SpeedrunTimer.save_run_time(area)
print("Saved time for area", area, ": ", time)
else:
print("Skipped saving zero time for area", scene_path)
GameManager.next_level()
func get_area_from_path(path: String) -> int:
if path.ends_with("area_5.tscn"):
return 5
if path.ends_with("area_6.tscn"):
return 6
return -1
Inherits Area2D:
This script is attached to an Area2D node, which detects when the player enters the exit zone.
_on_body_entered(body) Function:
Triggered when a physics body enters the Area2D.
If the body is the player (PlayerController), it:
Gets the current scene path.
If the scene is area 5 or 6:
Gets the current run time from SpeedrunTimer.
Stops the timer and saves the time for the corresponding area if the time is greater than zero.
Calls GameManager.next_level() to load the next level.
get_area_from_path(path: String) Function:
Returns the area number (5 or 6) based on the file name of the scene.
Returns -1 if the path does not match.
extends Area2D
func _on_body_entered(body):
if body is PlayerController:
print("checkpoint reached!")
CheckpointManager.set_checkpoint($RespawnPoint.global_position)
Inherits Area2D:
Attached to an Area2D node that detects when something (like the player) enters its area.
_on_body_entered(body) Function:
Triggered when a body (like the player) enters the checkpoint.
If the body is the PlayerController, it:
Prints "checkpoint reached!" to the console.
Sets the respawn point in the CheckpointManager using the global position of the RespawnPoint node (a child of this node).
extends Node
var last_location: Vector2 = Vector2.ZERO
var has_checkpoint: bool = false
func set_checkpoint(pos: Vector2):
last_location = pos
has_checkpoint = true
func clear_checkpoint():
last_location = Vector2.ZERO
has_checkpoint = false
Inherits Node:
A singleton-style script that keeps track of checkpoint data.
Variables:
last_location: Stores the position of the most recent checkpoint (default is (0, 0)).
has_checkpoint: Indicates whether a checkpoint has been set.
Functions:
set_checkpoint(pos: Vector2):
Sets the checkpoint position and marks that a checkpoint exists.
clear_checkpoint():
Resets the checkpoint position and clears the active checkpoint flag.
extends Area2D
@onready var animation_player = $AnimationPlayer
func _on_body_entered(body):
GameManager.add_point()
animation_player.play("pickup")
Inherits Area2D:
Detects when a physics body (like the player) touches the coin.
@onready var animation_player:
Caches the AnimationPlayer node used to play the pickup animation.
_on_body_entered(body) Function:
Triggered when something enters the coin's area.
Calls GameManager.add_point() to increment the player's score.
Plays the "pickup" animation which disappears the coin
extends Control
@onready var textures = [
$TextureRect1,
$TextureRect2,
$TextureRect3,
$TextureRect4,
$TextureRect5
]
@onready var fade = $FadeTransition
@onready var fade_timer = $FadeTransition/FadeTimer
@onready var fade_animation = $FadeTransition/AnimationPlayer
var current_index = 0
var next_scene_path = "res://scenes/areas/area_5.tscn"
func _ready():
for i in range(textures.size()):
textures[i].visible = (i == 0)
func _input(event):
if event.is_action_pressed("ui_accept"):
current_index += 1
if current_index >= textures.size():
play_fade_and_change_scene()
else:
update_textures()
func update_textures():
for i in range(textures.size()):
textures[i].visible = (i == current_index)
func play_fade_and_change_scene():
if next_scene_path != "":
fade.show()
fade_timer.start()
fade_animation.play("Fade_in")
get_tree().call_deferred("change_scene_to_file", next_scene_path)
Inherits Control:
Used for UI-based cutscenes.
@onready Variables:
textures: An array of TextureRect nodes representing each cutscene image.
fade, fade_timer, fade_animation: Used for the fade-out transition effect.
current_index: Tracks which texture is currently being shown.
_ready() Function:
Shows only the first texture; hides the rest at start.
_input(event) Function:
Listens for the "ui_accept" action (e.g. pressing Enter or Space).
Advances to the next texture in the list.
If the end is reached, triggers a fade transition and changes to the next scene (area_5.tscn).
update_textures() Function:
Updates texture visibility so only the current one is shown.
play_fade_and_change_scene() Function:
Shows the fade effect and defers the scene change until the fade starts.
extends Control
@onready var textures = [
$TextureRect1,
$TextureRect2,
$TextureRect3,
$TextureRect4,
$TextureRect5
]
@onready var fade = $FadeTransition
@onready var fade_timer = $FadeTransition/FadeTimer
@onready var fade_animation = $FadeTransition/AnimationPlayer
var current_index = 0
var next_scene_path = "res://scenes/main_menu.tscn"
func _ready():
for i in range(textures.size()):
textures[i].visible = (i == 0)
func _input(event):
if event.is_action_pressed("ui_accept"):
current_index += 1
if current_index >= textures.size():
play_fade_and_change_scene()
else:
update_textures()
func update_textures():
for i in range(textures.size()):
textures[i].visible = (i == current_index)
func play_fade_and_change_scene():
if next_scene_path != "":
fade.show()
fade_timer.start()
fade_animation.play("Fade_in")
get_tree().call_deferred("change_scene_to_file", next_scene_path)
Inherits Control:
Used for displaying UI elements (like cutscene images) on screen.
@onready Variables:
textures: List of five TextureRect nodes representing the cutscene frames.
fade, fade_timer, fade_animation: Handle the fade-out transition effect.
current_index: Tracks the current cutscene frame being displayed.
next_scene_path: Path to the main menu scene, which is loaded after the cutscene ends.
_ready() Function:
Initializes the cutscene by only showing the first image.
_input(event) Function:
Waits for the player to press "ui_accept" (e.g., Enter or Space).
Advances to the next image or ends the cutscene by fading out and changing the scene.
update_textures() Function:
Controls visibility so only the current image is shown.
play_fade_and_change_scene() Function:
Plays the fade effect and switches to the main menu once it's done.
extends Node
const pause_menu_path = "res://scenes/pause_menu.tscn"
const MENU_MUSIC = preload("res://assets/music/HEIST! - MM TRACK.mp3")
const INBETWEEN_MUSIC = preload("res://assets/music/HEIST! - TUTORIAL TRACK.mp3")
const LEVEL_MUSIC = preload("res://assets/music/HEIST! - LEVEL TRACK.mp3")
@onready var music_player = $MusicPlayer
var current_area = 1
var area_path = "res://scenes/areas/"
var pause_menu_instance = null
var current_music_type: String = ""
var current_scene_path: String = ""
var score: int = 0
var previous_scene_path: String = ""
func _process(_delta):
if Input.is_action_pressed("pause") and not is_in_non_pausable_scene():
pause()
var scene = get_tree().current_scene
if scene and scene.scene_file_path != current_scene_path:
previous_scene_path = current_scene_path
current_scene_path = scene.scene_file_path
handle_scene_change(current_scene_path)
func handle_scene_change(current_scene_path):
CheckpointManager.clear_checkpoint()
if current_scene_path in ["res://scenes/main_menu.tscn", "res://scenes/title_screen.tscn"]:
play_music("menu")
SpeedrunTimer.visible = false
elif current_scene_path in ["res://scenes/areas/area_5.tscn", "res://scenes/areas/area_6.tscn"]:
play_music("level")
SpeedrunTimer.visible = true
SpeedrunTimer.start()
else:
play_music("inbetween")
SpeedrunTimer.visible = false
# Next Level Function
func next_level():
var current_scene = get_tree().current_scene
if current_scene.scene_file_path == "res://scenes/areas/area_5.tscn":
current_area = 5
elif current_scene.scene_file_path == "res://scenes/areas/area_6.tscn":
current_area = 6
current_area += 1
var full_path = area_path + "area_" + str(current_area) + ".tscn"
if current_area == 5:
full_path = "res://scenes/cutscenes/cutscene_1.tscn"
if not ResourceLoader.exists(full_path): # End of the game
get_tree().call_deferred("change_scene_to_file", "res://scenes/cutscenes/cutscene_2.tscn")
current_area = 1
return
get_tree().call_deferred("change_scene_to_file", full_path)
print("The player has moved to area " + str(current_area))
# Pause Function
func pause():
if get_tree().paused:
return # Already Paused
get_tree().paused = true
if pause_menu_instance == null:
var pause_menu_scene = preload("res://scenes/pause_menu.tscn")
pause_menu_instance = pause_menu_scene.instantiate()
var ui_node = get_tree().current_scene.get_node("UI/MainUI")
if ui_node:
ui_node.add_child(pause_menu_instance)
else:
# fallback
get_tree().current_scene.add_child(pause_menu_instance)
func is_in_non_pausable_scene() -> bool:
var current_scene = get_tree().current_scene
if current_scene == null:
return true # Safety fallback: disable pause if no scene
var scene_path = current_scene.scene_file_path # full path
var disallowed = [
"res://scenes/main_menu.tscn",
"res://scenes/settings.tscn",
"res://scenes/level_select.tscn",
"res://scenes/title_screen.tscn",
"res://scenes/cutscenes/cutscene_1.tscn",
"res://scenes/cutscenes/cutscene_2.tscn"
]
return scene_path in disallowed
func reset_progress():
print("reseting progress")
current_area = 1
func play_music(type: String):
if current_music_type == type:
return # Don't restart if same track
current_music_type = type
match type:
"menu":
music_player.stream = MENU_MUSIC
"inbetween":
music_player.stream = INBETWEEN_MUSIC
"level":
music_player.stream = LEVEL_MUSIC
music_player.play()
func add_point():
score += 1
print("Coin ", score, " Collected!")
func reset_score():
print("You Dropped ", score, " Coins!")
score = 0
General Game State
Tracks:
Current area (current_area)
Scene paths
Player score
Currently playing music type
Monitors Scene Changes:
In _process(), detects when the scene changes.
Calls handle_scene_change() to update music and speedrun timer visibility based on the new scene.
Scene/Level Management
next_level():
Advances to the next area.
If transitioning from area 4 → 5, loads cutscene_1.tscn.
If there's no valid next scene, transitions to the end cutscene (cutscene_2.tscn) and resets the area.
Pause Handling
pause():
Pauses the game and loads the pause menu UI.
Ensures pause only happens if not already paused and not in restricted scenes.
is_in_non_pausable_scene():
Returns true if the current scene is a menu or cutscene, preventing pause in those cases.
Music System
play_music(type: String):
Plays background music depending on context: "menu", "inbetween", or "level".
Avoids restarting the same track unnecessarily.
Score System
add_point(): Increases the coin score and logs it.
reset_score(): Resets score and prints a message (e.g., when dying or restarting).
reset_progress(): Resets the current area to the beginning.
extends Node2D
@onready var sprite: Sprite2D = $Sprite
var lifetime: float = 0.3
var fade_speed: float = 3.0
func _ready():
# Start fading out
set_process(true)
func _process(delta):
modulate.a -= fade_speed * delta
if modulate.a <= 0.0:
queue_free()
Inherits Node2D:
Represents a visual node positioned in the world, cloned from the player’s sprite.
@onready var sprite:
References the child Sprite2D node which visually represents the ghost.
lifetime / fade_speed:
Controls how quickly the ghost fades out. fade_speed affects the alpha transparency over time.
_ready():
Enables processing so _process() is called every frame.
_process(delta):
Gradually decreases the node’s alpha (modulate.a) to make it fade out.
Once fully transparent, the ghost deletes itself with queue_free().
extends Area2D
@onready var timer = $Timer
var player
func _ready():
player = get_parent().get_node_or_null("Player")
func _on_body_entered(body):
if body is PlayerController:
kill_player(body)
func kill_player(body):
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()
GameManager.reset_score()
Inherits Area2D:
Detects when the player enters a kill zone in the level.
_ready():
Tries to get a reference to the player from the parent node (optional; not strictly used here).
_on_body_entered(body):
When a body enters the zone:
If it's the player (PlayerController), calls kill_player().
kill_player(body):
Activates a slow-motion effect by setting Engine.time_scale to 0.5.
Removes the player’s CollisionShape2D to disable movement/collisions.
Starts a timer for a short delay before restarting the scene.
_on_timer_timeout():
Resets time_scale to normal (1).
Reloads the current scene.
Calls GameManager.reset_score() to clear the coin count.
extends Control
@onready var fade_transition = $FadeTransition
@onready var fade_timer = $FadeTransition/Fade_timer
@onready var fade_animation = $FadeTransition/AnimationPlayer
var can_go_back: bool = true
func _ready():
# Commented line is to delete leaderboard records
#SpeedrunTimer.clear_all_run_times()
fade_transition.show()
fade_timer.start()
fade_animation.play("Fade_out")
SpeedrunTimer.stop()
SpeedrunTimer.reset()
GameManager.reset_progress()
GameManager.play_music("menu")
$MainContainer/PlayButton.grab_focus()
$SettingsContainer/MainVolSlider.value = AudioServer.get_bus_volume_db(AudioServer.get_bus_index("Master"))
$SettingsContainer/MusicVolSlider.value = AudioServer.get_bus_volume_db(AudioServer.get_bus_index("Music"))
$SettingsContainer/SFXVolSlider.value = AudioServer.get_bus_volume_db(AudioServer.get_bus_index("SFX"))
func _input(event):
if Input.is_action_just_pressed("ui_cancel") and can_go_back:
can_go_back = false
await get_tree().create_timer(0.2).timeout
can_go_back = true
if $MainContainer.visible == true:
fade_transition.show()
fade_timer.start()
fade_animation.play("Fade_in")
get_tree().call_deferred("change_scene_to_file", "res://scenes/title_screen.tscn")
elif $SettingsContainer.visible == true or $LeaderboardContainer.visible == true or $LevelSelectContainer.visible == true:
$MainContainer.visible = true
$LevelSelectContainer.visible = false
$SettingsContainer.visible = false
$LeaderboardContainer.visible = false
$MainContainer/PlayButton.grab_focus()
elif $LeaderboardContainer5.visible == true or $LeaderboardContainer6.visible == true:
$LeaderboardContainer.visible = true
$LeaderboardContainer5.visible = false
$LeaderboardContainer6.visible = false
$LeaderboardContainer/Leaderboard5Button.grab_focus()
# --- MAIN MENU ---
func _on_play_button_pressed():
fade_transition.show()
fade_timer.start()
fade_animation.play("Fade_in")
get_tree().call_deferred("change_scene_to_file", "res://scenes/areas/area_1.tscn")
func _on_level_select_button_pressed():
$MainContainer.visible = false
$LevelSelectContainer.visible = true
$LevelSelectContainer/Level1Button.grab_focus()
func _on_settings_button_pressed():
$MainContainer.visible = false
$SettingsContainer.visible = true
$SettingsContainer/FullscreenButton.grab_focus()
func _on_leaderboard_button_pressed() -> void:
$MainContainer.visible = false
$LeaderboardContainer.visible = true
$LeaderboardContainer/Leaderboard5Button.grab_focus()
func _on_quit_game_button_pressed():
get_tree().quit()
# --- LEVEL SELECT MENU ---
func _on_level_1_button_pressed():
fade_transition.show()
fade_timer.start()
fade_animation.play("Fade_in")
get_tree().call_deferred("change_scene_to_file", "res://scenes/cutscenes/cutscene_1.tscn")
SpeedrunTimer.start()
func _on_level_2_button_pressed():
fade_transition.show()
fade_timer.start()
fade_animation.play("Fade_in")
get_tree().call_deferred("change_scene_to_file", "res://scenes/areas/area_6.tscn")
SpeedrunTimer.start()
# --- SETTINGS MENU ---
func _on_back_button_pressed():
$MainContainer.visible = true
$SettingsContainer.visible = false
$LevelSelectContainer.visible = false
$MainContainer/PlayButton.grab_focus()
func _on_fullscreen_button_toggled(toggled_on):
if toggled_on:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN)
else:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MAXIMIZED)
func _on_main_vol_slider_value_changed(value):
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), value)
func _on_music_vol_slider_value_changed(value):
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Music"), value)
func _on_sfx_vol_slider_value_changed(value):
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("SFX"), value)
# --- LEADERBOARD MENU ---
func _on_leaderboard_5_button_pressed() -> void:
$LeaderboardContainer.visible = false
$LeaderboardContainer5.visible = true
$LeaderboardContainer5/Leaderboard5BackButton.grab_focus()
populate_leaderboard(5)
func _on_leaderboard_6_button_pressed() -> void:
$LeaderboardContainer.visible = false
$LeaderboardContainer6.visible = true
$LeaderboardContainer6/Leaderboard6BackButton.grab_focus()
populate_leaderboard(6)
func populate_leaderboard(area: int):
var times = SpeedrunTimer.load_run_times(area)
var scores_list = null
match area:
5:
scores_list = $LeaderboardContainer5/ScoresList
6:
scores_list = $LeaderboardContainer6/ScoresList
_:
print("Invalid leaderboard area"); return
for child in scores_list.get_children():
child.queue_free()
var font_file = load("res://assets/fonts/PixelOperator8.ttf") as FontFile
var label_settings = LabelSettings.new()
label_settings.font = font_file
label_settings.font_size = 32
for i in range(times.size()):
var label = Label.new()
label.label_settings = label_settings
label.add_theme_color_override("font_color", Color.WHITE)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var t = times[i]
var mins = int(fmod(t, 3600) / 60)
var secs = int(fmod(t, 60))
var msec = int(fmod(t, 1) * 1000)
label.text = "%d. %02d:%02d.%03d" % [i + 1, mins, secs, msec]
scores_list.add_child(label)
func _on_leaderboard_back_button_pressed() -> void:
$LeaderboardContainer.visible = false
$MainContainer.visible = true
$MainContainer/PlayButton.grab_focus()
func _on_leaderboard_5_back_button_pressed() -> void:
$LeaderboardContainer5.visible = false
$LeaderboardContainer.visible = true
$LeaderboardContainer/Leaderboard5Button.grab_focus()
func _on_leaderboard_6_back_button_pressed() -> void:
$LeaderboardContainer6.visible = false
$LeaderboardContainer.visible = true
$LeaderboardContainer/Leaderboard5Button.grab_focus()
Scene Transitions:
Uses a fade animation (FadeTransition) when switching scenes.
Automatically transitions to the title screen if the player presses cancel (ui_cancel).
Main Menu Buttons:
Play: Starts the game at Area 1 (area_1.tscn) via cutscene.
Level Select: Opens buttons to jump to specific levels (like Area 6).
Settings: Opens audio and fullscreen controls.
Leaderboard: Displays stored speedrun times for Area 5 and 6.
Quit Game: Exits the application.
Volume & Settings:
Adjusts Master, Music, and SFX bus volumes.
Supports toggling between fullscreen and windowed mode.
Leaderboard Logic:
Loads and displays speedrun times from SpeedrunTimer for Area 5 and 6.
Dynamically creates Label nodes to show each score.
Formats times into MM:SS.MMM.
Miscellaneous:
Resets score and speedrun timer on load.
Restores music, progress, and button focus.
Handles navigating back from submenus like Level Select or Leaderboards.
extends Control
const main_menu_path = "res://scenes/main_menu.tscn"
var is_closing: bool = false
var can_go_back: bool = true
func _ready():
get_tree().paused = true
$MainContainer/ResumeButton.grab_focus()
$SettingsContainer/MainVolSlider.value = AudioServer.get_bus_volume_db(AudioServer.get_bus_index("Master"))
$SettingsContainer/MusicVolSlider.value = AudioServer.get_bus_volume_db(AudioServer.get_bus_index("Music"))
$SettingsContainer/SFXVolSlider.value = AudioServer.get_bus_volume_db(AudioServer.get_bus_index("SFX"))
func _input(event):
if Input.is_action_just_pressed("ui_cancel") and can_go_back:
can_go_back = false
await get_tree().create_timer(0.2).timeout
can_go_back = true
close()
func close():
if is_closing:
return
is_closing = true
print("closing pause menu")
get_tree().paused = false
queue_free()
get_node("/root/GameManager").pause_menu_instance = null
func _on_resume_button_pressed():
close()
func _on_retry_button_pressed():
CheckpointManager.clear_checkpoint()
get_tree().paused = false
get_tree().reload_current_scene()
SpeedrunTimer.stop()
SpeedrunTimer.start()
func _on_settings_button_pressed():
$MainContainer.visible = false
$SettingsContainer.visible = true
$SettingsContainer/FullscreenButton.grab_focus()
func _on_quit_button_pressed():
get_tree().paused = false
get_tree().call_deferred("change_scene_to_file", main_menu_path)
queue_free()
# --- SETTINGS MENU --- #
func _on_back_button_pressed():
$MainContainer.visible = true
$SettingsContainer.visible = false
$MainContainer/PlayButton.grab_focus()
func _on_fullscreen_button_toggled(toggled_on):
if toggled_on:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN)
else:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MAXIMIZED)
func _on_main_vol_slider_value_changed(value):
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), value)
func _on_music_vol_slider_value_changed(value):
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Music"), value)
func _on_sfx_vol_slider_value_changed(value):
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("SFX"), value)
Pause Activation:
When this menu is instantiated, it immediately pauses the game (get_tree().paused = true).
Input Handling:
Listens for the cancel action (ui_cancel) to close the pause menu and resume the game.
Closing Menu:
Ensures the menu closes cleanly without reopening (is_closing flag), unpauses the game, frees itself, and clears the reference in GameManager.
Buttons:
Resume: Closes pause menu and resumes gameplay.
Retry: Clears checkpoint, reloads current scene, restarts the speedrun timer, and resumes game.
Settings: Switches UI to settings submenu inside the pause menu.
Quit: Unpauses and loads the main menu scene, then frees the pause menu node.
Settings Menu:
Toggles fullscreen/windowed modes.
Adjusts audio volumes for Master, Music, and SFX via sliders.
UI Focus:
Automatically sets keyboard/gamepad focus on relevant buttons and sliders for smooth navigation.
extends CharacterBody2D
class_name PlayerController
enum State { NORMAL, DASHING, SLIDING }
const PAN_SPEED = 100.0
const MAX_PAN_OFFSET = Vector2(160, 120)
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
@onready var ghost_scene = preload("res://scenes/ghost.tscn")
const GHOST_INTERVAL: float = 0.05 # 20 ghosts per second
var camera_pan_input = Vector2.ZERO
var camera_pan_offset = Vector2.ZERO
var ghost_timer: float = 0.0
var ghost_highlight_color: Color = Color.WHITE
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.7
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
var perfect_dsj: bool = false
var perfect_dsj_color = Color(0.0, 0.9, 1.0) # Bright blue
var pulse_time: float = 0.0
@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
# Checkpoint handling
func _ready():
if CheckpointManager.has_checkpoint:
global_position = CheckpointManager.last_location
# Instantly position camera to match
camera.position_smoothing_enabled = false
camera.reset_smoothing()
camera.force_update_scroll()
# Re-enable smoothing next frame
await get_tree().process_frame
camera.position_smoothing_enabled = true
func _process(delta):
match currentState:
State.NORMAL:
process_normal(delta)
State.DASHING:
process_dash(delta)
State.SLIDING:
process_slide(delta)
isStateNew = false
# Camera panning
camera_pan_input = get_camera_pan_input()
camera_pan_offset = camera_pan_offset.lerp(
camera_pan_input * MAX_PAN_OFFSET,
PAN_SPEED * delta)
update_camera(delta)
# Perfectly timed DSJ VFX
if abs(velocity.x) >= 250.0:
ghost_timer -= delta
if ghost_timer <= 0.0:
spawn_ghost()
ghost_timer = GHOST_INTERVAL
else:
ghost_timer = 0.0 # Reset if not moving fast
pulse_time += delta * 5.0
var pulse = 0.5 + 0.5 * sin(pulse_time)
if perfect_dsj:
highlight_ghosts((perfect_dsj_color) * (0.5 + 0.5 * pulse)) # Bright Blue and Pulsing
else:
highlight_ghosts(Color.WHITE)
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
perfect_dsj = false # Reset
# 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)
# Input direction and animation handling
var direction = Input.get_axis("left", "right")
# Handle Slide
if Input.is_action_just_pressed("slide") && is_on_floor():
call_deferred("change_state", State.SLIDING)
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
perfect_dsj = true
# 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 = 15.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
func spawn_ghost():
var ghost = ghost_scene.instantiate()
ghost.global_position = global_position
ghost.z_index = z_index - 1 # Appear behind the player
var ghost_sprite = ghost.get_node("Sprite")
ghost_sprite.texture = animated_sprite.sprite_frames.get_frame_texture(animated_sprite.animation, animated_sprite.frame)
ghost_sprite.flip_h = animated_sprite.flip_h
ghost_sprite.scale = animated_sprite.scale
# Apply alternative colours
ghost_sprite.modulate = ghost_highlight_color
get_tree().current_scene.add_child(ghost) # Add to the current scene
func highlight_ghosts(color: Color):
ghost_highlight_color = color
if perfect_dsj == false:
ghost_highlight_color = Color.WHITE
func get_camera_pan_input() -> Vector2:
var pan_input = Vector2.ZERO
pan_input.x = Input.get_action_strength("look_right") - Input.get_action_strength("look_left")
pan_input.y = Input.get_action_strength("look_down") - Input.get_action_strength("look_up")
return pan_input
func update_camera(delta):
var direction = Input.get_axis("left", "right")
# --- Automatic offset based on player movement ---
var player_offset_x = 0.0
var player_offset_y = -camera.offset.y * 0.5 # Slight vertical smoothing
if direction != 0:
var player_center_offset = global_position.x - get_viewport_rect().size.x / 2
if abs(player_center_offset) > threshold:
player_offset_x = direction * camera_offset_x
# --- Get right stick input for panning ---
camera_pan_input = get_camera_pan_input()
camera_pan_input = camera_pan_input.limit_length(1.0)
# Lerp pan offset smoothly toward input * max offset
camera_pan_offset = camera_pan_offset.lerp(
camera_pan_input * MAX_PAN_OFFSET,
PAN_SPEED * delta)
# --- Final combined offset ---
y_smoothing = 4.0
var final_offset = Vector2.ZERO
final_offset.x = lerp(camera.offset.x, player_offset_x + camera_pan_offset.x, 2 * delta)
final_offset.y = lerp(camera.offset.y, player_offset_y + camera_pan_offset.y, y_smoothing * delta)
camera.offset = final_offset
# Spring Functionality
func spring(power: float, direction:float) -> void:
velocity.x = velocity.x - cos(direction) * power
velocity.y = -sin(direction) * power
1. Core Setup & Variables
The script extends CharacterBody2D and is named PlayerController.
It defines an enum State to handle the finite state machine (FSM): NORMAL, DASHING, and SLIDING.
Many variables are initialized for movement parameters, state tracking, animation, camera control, and player physics.
2. Initialization
_ready()
If a checkpoint exists (via CheckpointManager), the player is positioned at the checkpoint.
The camera is set to the correct position without smoothing on the first frame, then smoothing is re-enabled.
3. Main Loop: _process(delta)
State machine logic calls the correct function (process_normal, process_dash, process_slide).
Smoothly pans the camera based on input using camera_pan_input.
Manages ghost trail effects when moving fast (e.g., during a perfect jump).
Changes ghost color for visual feedback when perfect_dsj is active (perfect dynamic slide jump).
4. State Management: change_state(newState)
Changes the current FSM state and flags it as new so setup logic can run on entry.
5. NORMAL State: process_normal(delta)
Handles:
Gravity: via apply_gravity().
Jumping:
Coyote time
Wall jumping
Buffered jumping
Collision Fixes: nudges the player away from strange collisions on walls.
Horizontal movement: acceleration, deceleration, boosted jump momentum.
Dash & Slide Inputs:
Dash input can be buffered if mid-air.
Slide can only be triggered on the ground.
Animations: based on grounded/falling, wall sliding, walking, etc.
Flip Sprite: based on movement direction.
6. DASHING State: process_dash(delta)
On entry:
Determines dash direction from input or last facing direction.
Sets velocity to dash speed.
Mid-dash:
Slows down with lerp.
Cancels dash if colliding with a wall.
Ends dash:
If below minimum dash speed or if transitioning into slide.
7. SLIDING State: process_slide(delta)
On entry:
Plays slide animation.
Adjusts the hitbox size (shortens it).
Adjusts coyote time if speed is high.
During slide:
Slides along ground with gradual deceleration.
Allows boosted jumping from slide.
Exits slide:
When timer runs out or slide input is released.
8. Jump Handling: Jump()
Handles:
Normal jump: Applies upward velocity.
Wall jump: Applies upward + push-back velocity.
Temporarily disables movement and dash after wall jump for better control.
9. Gravity Logic: apply_gravity(delta)
Applies different gravity based on state:
Normal jump/fall
Wall slide (slower fall)
Handles:
Coyote time countdown
Sets wall sliding conditions
Enables jump buffering
10. Ghost Trail System
spawn_ghost() creates a ghost sprite of the current player pose.
highlight_ghosts(color) modifies the ghost appearance, usually to indicate a perfect slide jump (perfect_dsj).
11. Camera Controls
get_camera_pan_input() returns directional pan input from right stick.
update_camera(delta) smoothly interpolates:
Automatic offsets based on movement
Manual pan inputs from the player
12. Spring Mechanic: spring(power, direction)
Launches the player in a direction using a trigonometric force vector.
Custom mechanic used for springs.
Other Mechanics to Note:
Coyote Time: Gives extra time after falling off a platform to still jump.
Jump Buffering: Allows pressing jump just before hitting the ground to still trigger a jump.
Wall Slide + Cling Time: Stick to walls momentarily before sliding.
Perfect DSJ: Dynamic slide jump with timing boost and visual effect.
extends CanvasLayer
const SAVE_PATH_5: String = "user://leaderboard_area_5.save"
const SAVE_PATH_6: String = "user://leaderboard_area_6.save"
@onready var Mins = $SpeedrunTimerPanel/Minutes
@onready var Secs = $SpeedrunTimerPanel/Seconds
@onready var Msecs = $SpeedrunTimerPanel/Msecs
var time: float = 0.0
var minutes: int = 0
var seconds: int = 0
var msec: int = 0
var run_time: String = ""
func _ready():
stop()
func _process(delta):
time += delta
msec = int(fmod(time, 1) * 1000)
seconds = int(fmod(time, 60))
minutes = int(fmod(time, 3600) / 60)
Mins.text = "%02d:" % minutes
Secs.text = "%02d." % seconds
Msecs.text = "%03d" % msec
func start():
reset()
set_process(true)
func stop():
run_time = get_time_formatted()
set_process(false)
func reset():
time = 0.0
msec = 0
seconds = 0
minutes = 0
Mins.text = "00:"
Secs.text = "00."
Msecs.text = "000"
func get_time_formatted():
return "%02d:%02d.%03d" % [minutes, seconds, msec]
func save_run_time(area: int):
if time <= 0.0:
print("Not saving 0 time for area", area)
return
var path = get_path_for_area(area)
var times = load_run_times(area)
times.append(time) # Add the current run time
times.sort() # Sort ascending so fastest times come first
if times.size() > 10:
times = times.slice(0, 10) # Keep only the top 10
var file = FileAccess.open(path, FileAccess.WRITE)
for t in times:
file.store_line(str(t))
file.close()
func load_run_times(area: int) -> Array:
var times: Array = []
var path = get_path_for_area(area)
if FileAccess.file_exists(path):
var file = FileAccess.open(path, FileAccess.READ)
while not file.eof_reached():
var line = file.get_line()
if line != "":
times.append(float(line))
times.sort()
return times
func get_path_for_area(area: int) -> String:
match area:
5:
return SAVE_PATH_5
6:
return SAVE_PATH_6
_:
return "user://leaderboard_unknown.save"
func get_current_time() -> float:
return time
func clear_all_run_times():
var levels = [5, 6] # Add more area numbers here if you expand the leaderboard
for level in levels:
var path = get_path_for_area(level)
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
print("Deleted leaderboard file for area_", level)
else:
print("No leaderboard file to delete for area_", level)
Purpose:
Track and display an on-screen timer (CanvasLayer) for speedrunning.
Save and load top 10 best times for specific game areas.
Allow clearing all saved times.
Variables:
time: current runtime in seconds (float).
minutes, seconds, msec: derived values for UI.
run_time: formatted time string.
UI References:
Mins, Secs, Msecs: Labels for displaying the time.
Timer Processing:
func _process(delta):
Increments time by delta.
Calculates minutes, seconds, and milliseconds from time.
Updates the UI text each frame with formatted strings like 01:23.456.
Controls:
func start(): # Resets and starts the timer
func stop(): # Stops and stores the current formatted time
func reset(): # Clears the timer and UI display
Saving:
func save_run_time(area: int):
Ignores zero-time runs.
Loads existing times for the area.
Adds the new time and sorts them.
Saves only the top 10 fastest runs to a file.
Loading:
func load_run_times(area: int) -> Array:
Reads times from file if it exists.
Returns them sorted.
File Paths:
const SAVE_PATH_5 = "user://leaderboard_area_5.save"
const SAVE_PATH_6 = "user://leaderboard_area_6.save"
Custom file paths are used per area.
get_path_for_area(area) handles selecting the right file.
func clear_all_run_times():
Deletes all leaderboard files for areas 5 and 6 if they exist.
extends Area2D
@export var spring_power: int = 800
func _on_body_entered(body):
if body is PlayerController:
body.spring(spring_power, rotation + PI/2.0)
$AnimationPlayer.play("Spring")
print("spring entered")
Inherits from Area2D, meaning it detects when physics bodies enter its space.
Exported Variable:
@export var spring_power: int = 800
Controls how powerful the spring launch is.
On Body Entered:
func _on_body_entered(body):
if body is PlayerController:
body.spring(spring_power, rotation + PI/2.0)
$AnimationPlayer.play("Spring")
print("spring entered")
Checks if the body is the player (PlayerController).
Calls the player's spring(power, direction) method, passing:
spring_power
rotation + PI/2.0: launches the player in the direction the spring is facing, offset by 90° to match visual orientation.
Plays an animation called "Spring" for visual feedback.
Prints a debug message to the console.
extends Control
@onready var fade_transition = $FadeTransition
@onready var fade_timer = $FadeTransition/Fade_timer
@onready var fade_animation = $FadeTransition/AnimationPlayer
func _ready():
fade_transition.show()
fade_timer.start()
fade_animation.play("Fade_out")
SpeedrunTimer.visible = false
# Wait for input to transition
func _input(event):
if Input.is_action_pressed("ui_accept") or Input.is_action_pressed("pause"):
fade_transition.show()
fade_timer.start()
fade_animation.play("Fade_in")
get_tree().call_deferred("change_scene_to_file", "res://scenes/main_menu.tscn")
Purpose:
Plays a fade-out animation when the title screen loads.
Waits for the player to press a button to fade in and transition to the main menu.
This script runs on a Control node
Node References:
@onready var fade_transition = $FadeTransition
@onready var fade_timer = $FadeTransition/Fade_timer
@onready var fade_animation = $FadeTransition/AnimationPlayer
These are nodes inside the scene:
FadeTransition: ColorRect used to fade in/out.
Fade_timer: A Timer node used for controlling timing of fades.
AnimationPlayer: Plays fade animations (Fade_in, Fade_out).
func _ready():
Shows the fade transition and starts a fade-out animation (revealing the title screen).
Hides the speedrun timer UI if it's visible.
func _input(event):
Waits for the player to press "ui_accept" (e.g., Enter or A) or "pause".
Triggers the fade-in animation.
Starts the timer and transitions to the main_menu.tscn after the frame using call_deferred (ensures no animation glitches or mid-input scene load).
Testers said the slide felt too short and slow, making it underwhelming and frustrating during movement chains. In response, I increased the length of the slide so it would flow more naturally and feel fairer to the player. This adjustment helped the mechanic better support momentum-based movement and gave it a stronger place in the game’s mobility kit.
Several testers pointed out that the wall jump was difficult to control and didn’t feel satisfying. While someone suggested using “coyote time,” I didn’t feel that approach would fit well with wall jumping in my game. Instead, I chose to rework the wall slide detection and rewrote the jump function to better suit the mechanics I had in mind. This led to a more consistent and intuitive wall jump system that aligned better with the rest of the game’s movement.
Players mentioned that the character fell too fast and lacked air control, which made platforming feel overly punishing. To address this, I reduced the fall gravity value. This made falling more manageable without sacrificing the game’s challenge, and it prevented the character from feeling too floaty or weightless.
There was some feedback that the dash had too much end lag, disrupting movement flow. However, I decided to keep this as it was because the dash is intentionally designed to reset or build momentum, similar to Doomfist’s Rocket Punch in Overwatch. It plays an important role in how players can regain control, and I believe it functions 99% of the time exactly how I intended it to.
One tester highlighted an unfair spike pit at the end of a level and suggested adding camera panning to allow players to see what’s coming. I thought this was a great idea and ended up implementing camera panning using up and down input. It adds an extra layer of control and helps players anticipate upcoming hazards, improving both pacing and fairness.
There were also complaints that the camera moved too abruptly, especially during wall slides. I made several tweaks to the camera behavior, particularly during wall sliding, to make it feel smoother and more responsive. A lot of the original issue was tied to the excessive fall speed, which I had already reduced, so the combined improvements made the camera much more pleasant to work with.
While the art style was praised as charming, testers did report some visual glitches such as clipping on ledges. These problems were entirely due to placeholder art and improperly set collisions. When I replaced all the temporary assets with my finalized sprites and tilesets, the issues were resolved.
One tester noted that when transitioning from a slide into a wall slide, the wrong animation would keep playing. I tracked this bug down to the way animations were handled in the player.gd script. After tweaking the animation logic, I was able to fix this, and it now correctly transitions between animations.
Another visual bug allowed players to sometimes walk under spikes rather than slide. This turned out to be a rare collision issue, likely also tied to placeholder assets. I mostly fixed it, though it can still occasionally occur. Since it’s very rare and not game-breaking, I’m saving a full fix for later development stages.
One tester reported falling out of bounds, but I was never able to reproduce this, and no other testers mentioned it. Given that it hasn’t occurred since replacing the old assets and fixing collisions, I’ve chosen to ignore it unless it comes up again during later testing.
Testers said the vertical boost that comes after sliding sometimes sent players into spikes unfairly. Instead of removing or nerfing the boost, I fine-tuned the values across the board, adjusting the strength, jump timing, and spike placement. The goal was to maintain challenge and reward precision without making it feel unfair.
There was feedback that the background music stopped playing after one loop, which made parts of the game feel empty. I fixed this easily by checking the looping option in the Godot editor, and now the music plays continuously as intended.
Testers felt the game was too difficult too quickly, and one even said it made them want to punch their monitor (hopefully a joke, but still revealing!). While I want the game to remain challenging, I completely redesigned the tutorial to ease players into the mechanics more gradually. The new introductory levels now teach the core mechanics step by step before throwing them into more demanding platforming sections.
Overall, I found the feedback incredibly valuable. While I didn’t take on every suggestion directly, I carefully considered the intention behind each point and implemented changes that stayed true to my vision while improving the player experience. The result is a more polished, responsive, and fair game that still emphasizes mastery and skill without being inaccessible or frustrating.