Spawning HitBeats

In this lesson, we’ll create the HitSpawner, a node that instances buttons the player can tap. We’ll start building the gameplay.

Defining project settings

Before we create a HitSpawner node, we’ll tweak our project settings.

Defining the target resolution is important to do early, before placing elements on the screen, as the resolution affects where we position objects.

Head to Project > Project Settings… > Display > Window and set the width to 1920 and the height to 1080.

This is our target resolution and our design space; we want to spawn HitBeats only in this space.

This is also a common resolution for games and showcases our sprites nicely due to its higher resolution.

You may want to set the Test Width and Test Height properties to have a smaller window when you run the game. I often do this to so a larger game window can fit my monitor’s resolution.

As mentioned before, we want to lock this resolution in. We can have our game always display the desired 1920x1080 resolution by stretching it.

The final thing to point out here is Use Vsync. Vertical synchronization helps smooth out frames and reduce screen tearing, but we disable it here to reduce input latency.

For our game to feel responsive, we need as little input latency as possible; as soon as the player taps a HitBeat, we need to give the player feedback that it’s been tapped with as little delay as possible.

You’ll be prompted to restart the project after disabling Vsync. Go ahead, and we’ll continue.

Creating the spawner

We can now start coding the HitSpawner.

Add a new Node as a child to RhythmGameDemo and name it HitSpawner. Attach a script and save it in the RhythmGame folder along with Synchronizer.gd.

We’ll add some complexity to this script later when we add the ability to load multiple track patterns, but for now, we’ll add the _spawn_beat() function and connect it to the Events autoload we made in the previous lesson.

extends Node

# If `true`, the spawner is actively spawning beats. We use it in `_spawn_beat()` below.
export var enabled := true


func _ready() -> void:
    Events.connect("beat_incremented", self, "_spawn_beat")


# Spawns a button the player can tap. Expects a dictionary with the form
# {half_beat = int, bps = float}.
# This gives us all the information we need to selectively spawn buttons at
# specific rhythmic moments in the music.
func _spawn_beat(msg: Dictionary) -> void:
    # If the spawner is not enabled, we just return from the function and spawn
    # nothing.
    if not enabled:
        return

    print(msg.half_beat)

Before we spawn buttons, we’ll head to Synchronizer to emit the Events.beat_incremented signal along with the corresponding dictionary.

Connecting the Synchronizer and the HitSpawner

As we created the Events autoload in the previous lesson, we can connect the beat_incremented signal to the _spawn_beat() function.

In Synchronizer.gd, emit the beat_incremented signal at the end of the process function:

func _process(_delta: float) -> void:
    #...

    #if half_beat > _last_half_beat:
        #...
        Events.emit_signal("beat_incremented", {"half_beat" : half_beat, "bps": _bps})

There, we emit the values our spawner expects.

Check everything is working correctly by running the project. You should see the following in the Output tab:

Each number represents when the Synchronizer has detected we passed a half-beat in the track.

Adding HitBeats to the HitSpawner

It’s time! We have our project set up and our resolution set. Let’s spawn some HitBeats!

Open up HitSpawner.gd and add the following exported property.

# Allows us to store the preloaded hit_beat scene
# to instance it.
export var hit_beat: PackedScene

Exporting the hit_beat variable as a PackedScene allows us to do a few useful things.

First off, we can set the path to the HitBeat in the Inspector. Select the HitSpawner and load up the HitBeat scene.

This stops us from having to type the scene path, but it also means we don’t have to worry if we later decide to move the HitBeat scene in the FileSystem because Godot updates the path to it automatically.

You may recall we defined a setup() function in HitBeat.gd to pass information to the HitBeat after it had been instanced.

That function took a Dictionary as a parameter, and we’ll see how that looks here.

We use a Dictionary because its ability to store key pairs makes it convenient to bundle multiple parameters together and pass them to functions via signals. We can also extend it later if need be.

Open HitSpawner.gd and add the following code to the _spawn_beat() function.

You can also remove the call to print(), which we used for testing purposes.

func _spawn_beat(msg: Dictionary) -> void:
    # ...

    # For now, we'll spawn a HitBeat every whole-beat, or every two half-beats.
    if msg.half_beat % 2:
        return

    # We calculate the values to pass to the HitBeat's `setup()` function.
    # It expects
    var beat: Dictionary = {}
    beat.half_beat = msg.half_beat
    # We randomly choose the sprite frame.
    beat.color = int(rand_range(0, 5))
    # We calculate a random position on the screen to spawn the HitBeat.
    beat.global_position = Vector2(rand_range(0, 1920), rand_range(0, 1080))
    # And we store the beats per minute in the dictionary.
    beat.bps = msg.bps

    # We instance our HitBeat and add it to the scene tree.
    var new_beat: Node = hit_beat.instance()
    add_child(new_beat)

    # We pass the dictionay holding the beat information to the HitBeat via its
    # `setup()` function.
    new_beat.setup(beat)

When you run the project, you’ll now see HitBeat scenes appear randomly around the window and, if you tap them, they’ll disappear. In the next few lessons, we’ll work towards adding timing to the HitBeats such that the closer you tap them, the better score you get!

Before that, we’ll spruce up the visuals and add a score user interface element to increment it based on our taps.

Code reference

HitSpawner.gd

extends Node

export var enabled := true

export var hit_beat: PackedScene

func _ready() -> void:
    Events.connect("beat_incremented", self, "_spawn_beat")


func _spawn_beat(msg: Dictionary) -> void:
    if not enabled:
        return
    
    if msg.half_beat % 2:
        return
    
    var hit_beat_data: Dictionary = {}
    hit_beat_data.half_beat = msg.half_beat
    hit_beat_data.color = int(rand_range(0, 5))
    hit_beat_data.global_position = Vector2(rand_range(0, 1920), rand_range(0, 1080))
    hit_beat_data.bps = msg.bps
    
    var new_beat: Node = hit_beat.instance()
    add_child(new_beat)

    new_beat.setup(hit_beat_data)

Synchronizer.gd

extends Node

export var bpm := 124

var _bps := 60.0 / bpm
var _hbps := _bps * 0.5
var _last_half_beat := 0

onready var _stream := $AudioStreamPlayer


func _ready() -> void:
    play_audio()


func play_audio() -> void:
    var time_delay := AudioServer.get_time_to_next_mix() + AudioServer.get_output_latency()
    yield(get_tree().create_timer(time_delay), "timeout")

    _stream.play()


func _process(_delta: float) -> void:
    var time: float = (
        _stream.get_playback_position()
        + AudioServer.get_time_since_last_mix()
        - AudioServer.get_output_latency()
    )

    var half_beat := int(time / _hbps)

    if half_beat > _last_half_beat:
        _last_half_beat = half_beat
        Events.emit_signal("beat_incremented", {"half_beat" : half_beat, "bps": _bps})