Adding scoring and timing

As we’re making a rhythm game, we want to test and reward the player’s rhythm and timing.

We’ll do this by giving them a higher score the closer to the beat they tap HitBeats.

To help the player time their taps, we’ll have a white target circle that shrinks down.

When the player taps the HitBeat, we’ll check what scoring area the target circle is in.

Depending on where the ring is, we can reward them with either:

We aren’t machines, and so we can’t tap with frame-level precision.

To make the game feel good, each scoring grade has a timing window of several frames.

I made the OK area quite generous to make the demo more accessible, but we can tweak these values to increase or decrease difficulty.

There are certainly rhythm games with stricter timings; these are just a guide.

The difficulty of the timing also depends on how quickly the target circle shrinks. This is something that requires playtesting to get the right feel. I settled on giving the target circle four whole-beats to go from fully expanded to the middle of the perfect area.

We’ll create the ring’s visuals in the next lesson, but we’ll get the timing working first.

We define our scores for each outcome in res://RhythmGame/Hits/HitBeat.gd. Open the script and add the following values.

# Score awarded for each area.
var _score_perfect := 10
var _score_great := 5
var _score_ok := 3

# The animated ring will have a radius starting at `_radius_start` and shrinking every frame.
var _radius_start := 150.0
# The radius at which the player gets a perfect score if they tap.
var _radius_perfect := 70.0
# The current radius will shrink every frame.
var _radius := _radius_start

# Defines the scoring areas. For a perfect score, the player has to tap at plus
# or minus this radius in pixels. In this case, the radius range to get a perfect 
# is from 66 to 74 pixels.
var _offset_perfect := 4
# For great and ok, we define the timing windows by adding the offset value to
# `_radius_perfect`. For example, for a "great", you need to tap the HitBeat
# when the `_radius` is between 78 and 74 pixels.
var _offset_great := 8
var _offset_ok := 16

# Duration in beats for the target circle to shrink down to `_radius_perfect`.
var _beat_delay := 4.0
# The speed at which the target circle shrinks in pixels per second.
# Calculated based on the track's beats per second in setup()
var _speed := 0.0

The radius of the target circle will begin at 150 pixels. To get a Perfect score, the player has to tap when the target circle has a radius of around 70 pixels. I chose this “perfect” radius to match the HitBeat sprite.

We update the _radius every frame in the _process() function.

func _process(delta: float) -> void:
    # If the HitBeat has been interacted with, we don't update it anymore.
    if _beat_hit:
        return

    # Decrease the radius by a fraction. We'll explain this in more detail
    # below. _speed is calculated in setup()
    _radius -= delta * (_radius_start - _radius_perfect) * _speed

    # The radius is past the perfect radius; the player has missed their chance
    # for points!
    if _radius <= _radius_perfect - _offset_perfect:
        # Stop taking input.
        _touch_area.collision_layer = 0

        # Score 0 points. Also, pass the position of the HitBeat to spawn any
        # visual effects at that position.
        Events.emit_signal("scored", {"score": 0, "position": global_position})
        _animation_player.play("destroy")
         # Flag the HitBeat as interacted with
         _beat_hit = true

We define the speed at which the radius decreases in the setup() function.

func setup(data: Dictionary) -> void:
    #...
    # Set the speed coefficient, which converts the shrinking speed
    # to beats per second instead of seconds.
    # Then slows the time further by how many beats we want to delay
    _speed = 1.0 / data.bps / _beat_delay

The _get_score() function checks the radius and compares it with the offset variables we defined.

func _get_score() -> int:
    if abs(_radius_perfect - _radius) < _offset_perfect:
        return _score_perfect
    elif abs(_radius_perfect - _radius) < _offset_great:
        return _score_great
    elif abs(_radius_perfect - _radius) < _offset_ok:
        return _score_ok
    return 0

All we need to do now is emit a signal when the HitBeat is touched. It sends off the score and the global position so other objects can use that information.

func _on_Area2D_input_event(_viewport, event, _shape_idx) -> void:
    #if event.is_action_pressed("touch"):
        #...
        Events.emit_signal("scored", {"score": _get_score(), "position": global_position})

As we set up our score in the previous lesson, you’ll now be able to see the score go up if you run the game and tap the HitBeats in time with the beat. However, it’s not really clear when the player should tap them.

So in the next lesson, we’ll give visual feedback to the player by adding score sprites that show what area the player hit and a visualization of the target circle.

Code reference

HitBeat.gd

extends Node2D

var order_number := 0 setget set_order_number

var _beat_hit := false

var _score_perfect := 10
var _score_great := 5
var _score_ok := 3

var _radius_start := 150.0
var _radius_perfect := 70.0
var _radius := _radius_start

var _offset_perfect := 4
var _offset_great := 8
var _offset_ok := 16

var _beat_delay := 4.0
var _speed := 0.0


onready var _animation_player := $AnimationPlayer
onready var _sprite := $Sprite
onready var _touch_area := $Area2D
onready var _label := $LabelCustom


func _ready() -> void:
    _animation_player.play("show")


func setup(data: Dictionary) -> void:
    self.order_number = data.half_beat
    global_position = data.global_position
    _sprite.frame = data.color
    _speed = 1.0 / data.bps / _beat_delay


func _process(delta: float) -> void:
    if _beat_hit:
        return

    _radius -= delta * (_radius_start - _radius_perfect) * _speed

    if _radius <= _radius_perfect - _offset_perfect:
        _touch_area.collision_layer = 0

        Events.emit_signal("scored", {"score": 0, "position": global_position})
        _animation_player.play("destroy")
        _beat_hit = true


func set_order_number(number: int) -> void:
    order_number = number
    _label.text = str(order_number)


func _on_Area2D_input_event(_viewport, event, _shape_idx) -> void:
    if event.is_action_pressed("touch"):
        _beat_hit = true
        _touch_area.collision_layer = 0
        _animation_player.play("destroy")
        Events.emit_signal("scored", {"score": _get_score(), "position": global_position})


func _get_score() -> int:
    if abs(_radius_perfect - _radius) < _offset_perfect:
        return _score_perfect
    elif abs(_radius_perfect - _radius) < _offset_great:
        return _score_great
    elif abs(_radius_perfect - _radius) < _offset_ok:
        return _score_ok
    return 0