Skip to content

11 Note Input

In this chapter, we will implement the input logic for Note.

Basic Input

Let's first implement very basic input: if the player taps, the note despawns.

In touch, loop through the current touches() to look for one that just started. If one is found, schedule the note to despawn and return.

To prevent the note from being drawn in update_parallel on the frame when it is scheduled to despawn, we also add a simple despawn check:

python
from sonolus.script.runtime import time, touches


class Note(PlayArchetype):
    # ...

    def touch(self):
        for touch in touches():
            if not touch.started:
                continue

            self.despawn = True
            return

    def update_parallel(self):
        if self.despawn:
            return

        # ...

In guide/play/note.py, replace the existing time import with the combined import shown above. Then add touch and the early return at the start of update_parallel.

Judgment Windows

While it works now, this is not how rhythm games normally behave.

A note should accept a new touch only during its outer Good window. A touch before that window is ignored. If the note passes the end of the window, it despawns by itself. It will be treated as a Miss once scoring is added in chapter 14. Later chapters will use the nested Great and Perfect windows to classify accepted touches.

For our engine, tapping within 50 ms of the target time gives a Perfect, within 100 ms gives a Great, and within 200 ms gives a Good. Earlier taps are ignored, while a note that passes the end of its Good window becomes a Miss. We can define these windows with JudgmentWindow and Interval.

The same windows will later configure the note's input bucket. In guide/lib/buckets.py, replace the existing sonolus.script.bucket import, add the Interval import, and put note_window below Buckets:

python
from sonolus.script.bucket import JudgmentWindow, buckets
from sonolus.script.interval import Interval


note_window = JudgmentWindow(
    perfect=Interval(-0.05, 0.05),
    great=Interval(-0.1, 0.1),
    good=Interval(-0.2, 0.2),
)

Input Offset

When a player physically touches the screen, there is a delay before the touch reaches Sonolus and is made available to the touch callback. This delay mostly comes from hardware and is unavoidable.

Input offset allows players to tell Sonolus how much delay to take into account.

For example, a player touches the screen at 00:01.00, but the input reaches Sonolus at 00:01.06. If the player calibrates their input correctly with an input offset of 0.06, the engine can judge the input using the actual touch time of 00:01.00.

Sonolus already applies input offset to time values stored on touches. The engine's current time() is not adjusted, so the engine must apply the offset to checks that use time().

Entity Memory

First, calculate the interval of engine clock times during which the note can accept a touch. Add input_offset to the existing runtime import in guide/play/note.py, then import note_window:

python
from sonolus.script.runtime import input_offset, time, touches

from guide.lib.buckets import note_window

The initialize callback runs once when an entity spawns. This guide calculates the input interval there to show how an entity can store a value after preprocessing.

Entity Data becomes read-only after preprocess. Entity Memory is private to one entity and can be updated in any of that entity's callbacks. Declare it with entity_memory.

Store the input interval in Entity Memory. Add entity_memory to the existing sonolus.script.archetype import, declare input_time beside the Entity Data fields, then add initialize after should_spawn:

python
from sonolus.script.archetype import PlayArchetype, StandardImport, entity_data, entity_memory


class Note(PlayArchetype):
    # ...

    input_time: Interval = entity_memory()

    def initialize(self):
        self.input_time = note_window.good + self.target_time + input_offset()

        # ...

note_window.good is the interval from -0.2 to 0.2 seconds relative to a note. Adding target_time centers it on the note, and adding input_offset() shifts it onto the unadjusted engine clock. The interval is calculated when the note spawns and remains available in Entity Memory.

Note: We could calculate this interval during preprocess and store it in Entity Data. This guide uses initialize and Entity Memory here to show how an entity stores a value calculated after preprocessing.

Early Input

At the start of touch, return unless the engine clock is within that interval. This prevents both early and expired notes from consuming a touch:

python
class Note(PlayArchetype):
    # ...

    def touch(self):
        if time() not in self.input_time:
            return

        # ...

Late Input

The guard above prevents an expired note from accepting input. At the start of update_parallel, make the note despawn automatically after the interval ends:

python
class Note(PlayArchetype):
    # ...

    def update_parallel(self):
        if time() > self.input_time.end:
            self.despawn = True
        if self.despawn:
            return

        # ...

Reopen the development level. A tap before the Good window should be ignored, a tap inside it should remove the note, and an untouched note should disappear after the window ends.