Think Like a Speedrunner

In pondering how to learn video game programming, I just came up with this concept: Think Like a Speedrunner

In short, learn a development task, and the repeat it. Keep repeating it so often you can do it very quickly. The first attempt may take a very long time. You’ll need to research it, there will be issues, you may need to reach out to the programming community for help, etc. And after you get it all sorted out though, it still may not all fit inside your head. So, do it again. Repeat it so many times you can do it from scratch and without needing to look at any guides.

So that’s what I’m going to do next with learning Godot. I’m going to keep doing the tutorial from the documentation until I can do it like a speedrunner.

I don’t recall exactly how long it took me to do the first time, but it was at at least four evenings…


Here are my notes from the second pass, a highly distilled version of the documented steps:

PART 1: SETUP

Step1: Create a project

Step 2: Project Settings -> Window -> Viewport Width/Height -> 480 x 720

Step 3: Project Settings -> Windows -> Mode = canvas items && Aspect = keep


PART 2: PLAYER SCENE

Step 1: Create a Player root node as Area2D and enable Group Select

Step 2: Create child node AnimatedSprite2D

Step 3: Sprite Frames -> New SpriteFrames

Step 4: rename default animation to “walk” and a new one called “up”

Step 5: Drag in two images for each animation

Step 6: Player -> AnimatedSprite2D -> Node2D -> Transform -> Scale -> 0.5, 0.5

Step 7: Create child node of Player -> CollisionShape2D -> CapsuleShape2D


Part 3: CODING PLAYER

Step 1: Player -> Attach Script

Step 2: Declare member variables:

extends Area2D

@export var speed = 400 # How fast the player will move (pixels/sec).
var screen_size # Size of the game window.
  • export allows us to set its value in the Inspector (if you change the value here, it will override the value written in the script)

Step 3: _ready

_ready is called when a node enters the scene tree, which is a good time to find the size of the game window

func _ready():
	screen_size = get_viewport_rect().size

Step4: _process

Use the _process() function to define what the player will do. _process() is called every frame, so we’ll use it to update elements of our game, which we expect will change often. For the player, we need to do the following:

  • Check for input.
  • Move in the given direction.
  • Play the appropriate animation.

Step 4a: Map Input Keys

Project -> Project Settings -> Input Map -> type “move_right” -> click Add -> + Add Event -> <press right arrow>

Repeat for left, up, down

Step 4b:

func _process(delta):
	var velocity = Vector2.ZERO # The player's movement vector.
	if Input.is_action_pressed("move_right"):
		velocity.x += 1
	if Input.is_action_pressed("move_left"):
		velocity.x -= 1
	if Input.is_action_pressed("move_down"):
		velocity.y += 1
	if Input.is_action_pressed("move_up"):
		velocity.y -= 1

	if velocity.length() > 0:
		velocity = velocity.normalized() * speed
		$AnimatedSprite2D.play()
	else:
		$AnimatedSprite2D.stop()

If you hold right and down at the same time, the resulting velocity vector will be (1, 1). In this case, since we’re adding a horizontal and a vertical movement, the player would move faster diagonally than if it just moved horizontally. We can prevent that if we normalize the velocity, which means we set its length to 1, then multiply by the desired speed. This means no more fast diagonal movement.

$ returns the node at the relative path from the current node

Step 5:

Now that we have a movement direction, we can update the player’s position. We can also use clamp() to prevent it from leaving the screen. Clamping a value means restricting it to a given range. Add the following to the bottom of the _process function

position += velocity * delta
position = position.clamp(Vector2.ZERO, screen_size)

The delta parameter in the _process() function refers to the frame length – the amount of time that the previous frame took to complete. Using this value ensures that your movement will remain consistent even if the frame rate changes.

Step 6: Choosing which animation is playing based on player direction

if velocity.x != 0:
	$AnimatedSprite2D.animation = "walk"
	$AnimatedSprite2D.flip_v = false
	# See the note below about the following boolean assignment.
	$AnimatedSprite2D.flip_h = velocity.x < 0
elif velocity.y != 0:
	$AnimatedSprite2D.animation = "up"
	$AnimatedSprite2D.flip_v = velocity.y > 0

Step 7. When you’re sure the movement is working correctly, add this line to _ready(), so the player will be hidden when the game starts:

hide()

Step 8. Preparing for collisions

We want Player to detect when it’s hit by an enemy, but we haven’t made any enemies yet! That’s OK, because we’re going to use Godot’s signal functionality to make it work. Add the following at the top of the script. If you’re using GDScript, add it after extends Area2D:

signal hit

This defines a custom signal called “hit” that we will have our player emit (send out) when it collides with an enemy. We will use Area2D to detect the collision. Select the Player node and click the “Node” tab next to the Inspector tab to see the list of signals the player can emit.

Notice our custom “hit” signal is there as well! Since our enemies are going to be RigidBody2D nodes, we want the body_entered(body: Node2D) signal. This signal will be emitted when a body contacts the player. Click “Connect..” and the “Connect a Signal” window appears.

Next add this code to the func:

func _on_body_entered(body):
	hide() # Player disappears after being hit.
	hit.emit()
	# Must be deferred as we can't change physics properties on a physics callback.
	$CollisionShape2D.set_deferred("disabled", true)

Each time an enemy hits the player, the signal is going to be emitted. We need to disable the player’s collision so that we don’t trigger the hit signal more than once.

Step 9: Reset player when starting a new game

func start(pos):
	position = pos
	show()
	$CollisionShape2D.disabled = false

PART 4: CREATING THE ENEMY

Step 1: Create a Mob scene as RigidBody2D and enabled Group Select

Step 2: Add children: AnimatedSprite2D, CollisionShape2D, VisibleOnScreenNotifier2D

Step 3: Select the Mob node and set its Gravity Scale property in the RigidBody2D section of the inspector to 0. This will prevent the mob from falling downwards.

Step 4: Under CollisionObject2D section, expand the Collision group and uncheck the 1 inside the Mask property. This will ensure the mobs do not collide with each other.

Step 5: Set up the AnimatedSprite2D with 3 animations: fly, swim, and walk.

Step 6: Set the AnimatedSprite2D’s Scale property to (0.75, 0.75).

Step 7: Add a CapsuleShape2D for the collision.

Step 8: Add a script to Mob:

extends RigidBody2D

func _ready():
	var mob_types = $AnimatedSprite2D.sprite_frames.get_animation_names()
	$AnimatedSprite2D.play(mob_types[randi() % mob_types.size()])

func _on_visible_on_screen_notifier_2d_screen_exited():
	queue_free()

Part 5: Main Game Scene

Step 1: Create a new scene and add a Node named Main. (The reason we are using Node instead of Node2D is because this node will be a container for handling game logic. It does not require 2D functionality itself.)

Step 2: Click the Instance button (represented by a chain link icon) and select your saved player.tscn.

Step 3: Add the following nodes as a childred of Main:

  • Timer -> MobTimer (Wait Time -> 0.5)
  • Timer -> ScoreTimer (Wait Time -> 1)
  • Timer -> StartTimer (Wait Time -> 2) and (One Shot -> “On”)
  • Marker2D -> StartPosition (Position -> 240, 450)

Step 4: The Main node will be spawning new mobs, and we want them to appear at a random location on the edge of the screen. Add a Path2D node named MobPath as a child of Main

Step 5: Select the middle one (“Add Point”) and draw the path by clicking to add the points. To have the points snap to the grid, make sure “Use Grid Snap” and “Use Smart Snap” are both selected. These options can be found to the left of the “Lock” button, appearing as a magnet next to some dots and intersecting lines, respectively.

Step 6: After placing point 4, click the “Close Curve” button.

Add a PathFollow2D node as a child of MobPath and name it MobSpawnLocation. This node will automatically rotate and follow the path as it moves, so we can use it to select a random position and direction along the path.

Step 7: Add a script to Main:

extends Node

@export var mob_scene: PackedScene
var score

You should now have a mob_scene property in the Inspector and you want to add a value to it either:

  • Drag mob.tscn from the “FileSystem” dock and drop it in the Mob Scene property.
  • Click the down arrow next to “[empty]” and choose “Load”. Select mob.tscn.

Double-click the Hit signal in Player and Connect. Type “game_over” in the Receiver Method.

Step 8: You are aiming to have the hit signal emitted from Player and handled in the Main script. Add the following code to the new function, as well as a new_game function that will set everything up for a new game:

func game_over():
	$ScoreTimer.stop()
	$MobTimer.stop()

func new_game():
	score = 0
	$Player.start($StartPosition.position)
	$StartTimer.start()

Step 9: Now connect the timeout() signal of each of the Timer nodes (StartTimerScoreTimer, and MobTimer) to the main script. 

Step 10: StartTimer will start the other two timers. ScoreTimer will increment the score by 1:

func _on_score_timer_timeout():
	score += 1

func _on_start_timer_timeout():
	$MobTimer.start()
	$ScoreTimer.start()

Step 11: In _on_mob_timer_timeout(), we will create a mob instance, pick a random starting location along the Path2D, and set the mob in motion. The PathFollow2D node will automatically rotate as it follows the path, so we will use that to select the mob’s direction as well as its position. When we spawn a mob, we’ll pick a random value between 150.0 and 250.0 for how fast each mob will move (it would be boring if they were all moving at the same speed).

Note that a new instance must be added to the scene using add_child().

func _on_mob_timer_timeout():
	# Create a new instance of the Mob scene.
	var mob = mob_scene.instantiate()

	# Choose a random location on Path2D.
	var mob_spawn_location = $MobPath/MobSpawnLocation
	mob_spawn_location.progress_ratio = randf()

	# Set the mob's direction perpendicular to the path direction.
	var direction = mob_spawn_location.rotation + PI / 2

	# Set the mob's position to a random location.
	mob.position = mob_spawn_location.position

	# Add some randomness to the direction.
	direction += randf_range(-PI / 4, PI / 4)
	mob.rotation = direction

	# Choose the velocity for the mob.
	var velocity = Vector2(randf_range(150.0, 250.0), 0.0)
	mob.linear_velocity = velocity.rotated(direction)

	# Spawn the mob by adding it to the Main scene.
	add_child(mob)

Step 11: Testing

func _ready():
	new_game()

When you’re sure everything is working, remove the call to new_game() from _ready() and replace it with pass.

PART 6: HUD

Step 1: Create a new scene, click the “Other Node” button and add a CanvasLayer node named HUD.

Step 2: The basic node for UI elements is Control

Create the following as children of the HUD node:

Label named ScoreLabel.

Label named Message.

Button named StartButton.

Timer named MessageTimer.

Step 3: Click on the ScoreLabel and type a number into the Text field in the Inspector.

Under “Theme Overrides > Fonts”, choose “Load” and select the “Xolonium-Regular.ttf” file.

Theme Overrides -> Font Sizes -> 64

Repeat this step for Messages and StartButton

Step 4: Control nodes have a position and size, but they also have anchors. Anchors define the origin – the reference point for the edges of the node. You can drag the nodes to place them manually, or for more precise placement, use “Anchor Presets”.

ScoreLabel
  1. Add the text 0.
  2. Set the “Horizontal Alignment” and “Vertical Alignment” to Center.
  3. Choose the “Anchor Preset” Center Top.
Message
  1. Add the text Dodge the Creeps!.
  2. Set the “Horizontal Alignment” and “Vertical Alignment” to Center.
  3. Set the “Autowrap Mode” to Word, otherwise the label will stay on one line.
  4. Under “Control – Layout/Transform” set “Size X” to 480 to use the entire width of the screen.
  5. Choose the “Anchor Preset” Center.
StartButton
  1. Add the text Start.
  2. Under “Control – Layout/Transform”, set “Size X” to 200 and “Size Y” to 100 to add a little bit more padding between the border and text.
  3. Choose the “Anchor Preset” Center Bottom.
  4. Under “Control – Layout/Transform”, set “Position Y” to 580.

On the MessageTimer, set the Wait Time to 2 and set the One Shot property to “On”.

Step 5: Now add this script to HUD:

extends CanvasLayer

# Notifies `Main` node that the button has been pressed
signal start_game

func show_message(text):
	$Message.text = text
	$Message.show()
	$MessageTimer.start()

func show_game_over():
	show_message("Game Over")
	# Wait until the MessageTimer has counted down.
	await $MessageTimer.timeout

	$Message.text = "Dodge the Creeps!"
	$Message.show()
	# Make a one-shot timer and wait for it to finish.
	await get_tree().create_timer(1.0).timeout
	$StartButton.show()

func update_score(score):
	$ScoreLabel.text = str(score)

Step 6: Connect the pressed() signal of StartButton and the timeout() signal of MessageTimer to the HUD node, and add the following code to the new functions:

func _on_start_button_pressed():
	$StartButton.hide()
	start_game.emit()

func _on_message_timer_timeout():
	$Message.hide()

Step 7: Now that we’re done creating the HUD scene, go back to Main. Instance the HUD scene in Main like you did the Player scene.

Step 8: Now we need to connect the HUD functionality to our Main script. This requires a few additions to the Main scene. In the Node tab, connect the HUD’s start_game signal to the new_game() function of the Main node.  Type “new_game” below “Receiver Method”. In new_game(), update the score display and show the “Get Ready” message:

$HUD.update_score(score)
$HUD.show_message("Get Ready")

In game_over() we need to call the corresponding HUD function:

$HUD.show_game_over()

Finally, add this to _on_score_timer_timeout() to keep the display in sync with the changing score:

$HUD.update_score(score)

Step 9: If you play until “Game Over” and then start a new game right away, the creeps from the previous game may still be on the screen. It would be better if they all disappeared at the start of a new game. We just need a way to tell all the mobs to remove themselves. We can do this with the “group” feature. In the Mob scene, select the root node and click the “Node” tab next to the Inspector (the same place where you find the node’s signals). Next to “Signals”, click “Groups” and you can type a new group name and click “Add”.

Now all mobs will be in the “mobs” group. We can then add the following line to the new_game() function in Main:

get_tree().call_group("mobs", "queue_free")

PART 7: FINISHING UP

Step 1: One way to change the background color is to use a ColorRect node. Make it the first node under Main so that it will be drawn behind the other nodes. ColorRect only has one property: Color. Choose a color you like and select “Layout” -> “Anchors Preset” -> “Full Rect” either in the toolbar at the top of the viewport or in the inspector so that it covers the screen.

Step 2: Add two AudioStreamPlayer nodes as children of Main. Name one of them Music and the other DeathSound. On each one, click on the Stream property, select “Load”, and choose the corresponding audio file. All audio is automatically imported with the Loop setting disabled. If you want the music to loop seamlessly, click on the Stream file arrow, select Make Unique, then click on the Stream file and check the Loop box.

Step 3: To play the music, add $Music.play() in the new_game() function and $Music.stop() in the game_over() function. Finally, add $DeathSound.play() in the game_over() function:

func game_over():
	...
	$Music.stop()
	$DeathSound.play()

func new_game():
	...
	$Music.play()

Step 4: Enter key shortcut: Select “Project” -> “Project Settings” and then click on the “Input Map” tab. In the same way you created the movement input actions, create a new input action called start_game and add a key mapping for the Enter key.


Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *