improve script berikut ini karena collision  square dengan note nya tidak terlalu sinkron, ada sedikit delay antara coliision dengan bunyi note nya, bunyi note nya agak delay sedikit, lakukan improvisasi agar script ini sempurna : import pygame
import mido
import sys
import random
import requests
import colorsys
from os.path import join, isfile
from time import time as get_current_time
from typing import Union, Optional, Any
from enum import Enum
from math import sin, pi
import os

pygame.init()

# =========================
# Customizable Configuration
# =========================

# Screen Resolution
SCREEN_WIDTH = 800     # Ganti sesuai keinginan Anda
SCREEN_HEIGHT = 600    # Ganti sesuai keinginan Anda

# File Selection
selected_song_filename = "rush-e.mid"  # Ganti dengan nama file MIDI Anda

# Song Information
song_name = 'Bad Piggies'      # Set the song name here
song_creator = 'Rovio'         # Set the song creator here

# Other Customizable Settings
square_speed = 600             # Kecepatan kotak
bounce_min_spacing = 50        # Spasi minimum antar pantulan
direction_change_chance = 50   # Peluang perubahan arah
particle_trail = True          # Aktifkan atau nonaktifkan jejak partikel

# =========================
def color_hunt():
    def is_bright(hex_color):
        hex_color = hex_color.lstrip('#')
        rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
        luminance = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
        r, g, b = [x / 255.0 for x in rgb]
        h, s, v = colorsys.rgb_to_hsv(r, g, b)
        return luminance >= 130 and s >= 0.3
    while True:
        url = 'https://colorhunt.co/php/feed.php'
        headers = {
            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
        }
        data = {
            'step': 0,
            'sort': 'random',
            'tags': ''
        }
        response = requests.post(url, headers=headers, data=data)
        if response.status_code == 200:
            palettes = response.json()
            color_pairs = []
            for palette in palettes:
                code = palette['code']
                hex_colors = [f'#{code[i:i+6].upper()}' for i in range(0, len(code), 6)]
                if len(hex_colors) == 4 and all(is_bright(hex_color) for hex_color in hex_colors):
                    color_pairs.append(tuple(hex_colors))
            if color_pairs:
                selected_color_pair = random.choice(color_pairs)
                return selected_color_pair
        else:
            raise Exception(f"Failed to retrieve data. Status code: {response.status_code}")

selected_colors = color_hunt()
color1, color2, color3, color4 = selected_colors

class Config:
    # Constants
    SQUARE_SIZE = 50
    PARTICLE_SPEED = 10

    # Colors
    color_themes = {
        "dark": {
            "hallway": pygame.Color('#3AD3CD'),
            "background": pygame.Color('#0A3442'),
            "square": [
                pygame.Color(color1),
                pygame.Color(color2),
                pygame.Color(color3),
                pygame.Color(color4)
            ]
        }
    }

    # Configuration
    script_dir = os.path.dirname(os.path.abspath(__file__))
    assets = os.path.join(script_dir, 'assets/icon.png')
    song = os.path.join(script_dir, 'songs')
    font = os.path.join(script_dir, 'assets/poppins-regular.ttf')
    SCREEN_WIDTH = SCREEN_WIDTH
    SCREEN_HEIGHT = SCREEN_HEIGHT
    theme: Optional[str] = "dark"
    seed: Optional[int] = None
    camera_mode: Optional[int] = 2
    start_playing_delay = 3000
    max_notes: Optional[int] = None
    bounce_min_spacing: Optional[float] = bounce_min_spacing
    square_speed: Optional[int] = square_speed
    volume: Optional[int] = 70
    music_offset: Optional[int] = 0
    direction_change_chance: Optional[int] = direction_change_chance
    theatre_mode = True
    particle_trail = particle_trail
    shader_file_name = "none.glsl"
    do_color_bounce_pegs = False
    do_particles_on_bounce = True

    # Non-configurable settings
    backtrack_chance: Optional[float] = 0.02
    backtrack_amount: Optional[int] = 40
    square_swipe_anim_speed: Optional[int] = 4
    particle_amount = 10
    language = "english"

    # Other random stuff
    current_song = None
    dt = 0.01

def get_colors():
    return Config.color_themes.get(Config.theme, Config.color_themes["dark"])

def read_midi_file(file):
    midi_file = mido.MidiFile(file=file)

    notes = []
    current_time = 0

    for msg in midi_file:
        if msg.type == 'note_on' and msg.velocity != 0:
            timestamp = current_time + msg.time
            notes.append(round(timestamp*1000)/1000)
        current_time += msg.time
    return notes

def remove_too_close_values(lst: list[float], threshold=30) -> list[float]:
    """Assumes the list is sorted"""
    new = []
    before = None
    for _ in lst:
        if before is None:
            before = _
            new.append(_)
            continue
        if before+threshold/1000 > _:
            continue
        before = _
        new.append(_)
    return new

def fix_overlap(rects: list[pygame.Rect], callback=None):
    """
    Optimized function to merge overlapping rectangles efficiently.
    """
    if callback is None:
        callback = lambda _: None

    rects = rects.copy()
    # Sort rectangles based on x coordinate
    rects.sort(key=lambda r: (r.x, r.y))

    merged_rects = []
    for rect in rects:
        if not merged_rects:
            merged_rects.append(rect)
        else:
            last = merged_rects[-1]
            if last.colliderect(rect):
                merged_rect = last.union(rect)
                merged_rects[-1] = merged_rect
            else:
                merged_rects.append(rect)

    callback("Finished merging rectangles")

    return merged_rects

def get_font(size: int = 24) -> pygame.font.Font:
    font_path = Config.font
    return pygame.font.Font(font_path, size)

class MapLoadingFailureError(Exception):
    """The map fails to load (recurs function fails)"""
    pass

class UserCancelsLoadingError(Exception):
    """User cancels the loading screen"""
    pass

def interpolate_fn(n):
    """Interpolate sigmoidally from 0-1"""
    n = min(max(n, 0), 1)
    return sin(pi * (n - 0.5)) / 2 + 0.5

class Particle:
    SPEED_VARIATION = 4
    SIZE_MIN = 7
    SIZE_MAX = 14
    AGE_RATE = 20
    SLOW_DOWN_RATE = 1.2

    def __init__(self, pos: list[float], delta: list[float], invert_color: bool = False):
        self.pos = pos.copy()
        self.size = random.randint(Particle.SIZE_MIN, Particle.SIZE_MAX)
        self.delta = delta.copy()
        self.delta[0] += random.randint(-Particle.SPEED_VARIATION, Particle.SPEED_VARIATION)/8
        self.delta[1] += random.randint(-Particle.SPEED_VARIATION, Particle.SPEED_VARIATION)/8
        # Color is hallway color if invert_color is false, else it's background color
        self.color = get_colors()["hallway"] if not invert_color else get_colors()["background"]

    def age(self):
        self.size -= Particle.AGE_RATE*Config.dt
        self.x += self.delta[0] * Config.PARTICLE_SPEED
        self.y += self.delta[1] * Config.PARTICLE_SPEED
        if Config.dt != 0:
            self.delta[0] /= (Particle.SLOW_DOWN_RATE+FRAMERATE) * Config.dt
            self.delta[1] /= (Particle.SLOW_DOWN_RATE+FRAMERATE) * Config.dt
        return self.size <= 0

    @property
    def x(self):
        return self.pos[0]

    @x.setter
    def x(self, val: float):
        self.pos[0] = val

    @property
    def y(self):
        return self.pos[1]

    @y.setter
    def y(self, val: float):
        self.pos[1] = val

    @property
    def rect(self):
        return pygame.Rect(self.x-self.size/2, self.y-self.size/2, *(2*[self.size]))

class Bounce:
    def __init__(self, sq_pos: list[float], sq_dir: list[int], time: float, bounce_dir: int):
        self.square_pos = sq_pos  # New square position
        self.square_dir = sq_dir  # New square direction
        self.bounce_dir = bounce_dir  # Bounce direction for squish effect; 0 or 1
        self.time = time  # Time during bounce

    def get_collision_rect(self):
        sx, sy = self.square_pos
        if self.bounce_dir == 0:
            # Bounce left or right wall
            if self.square_dir[0] == -1:
                # Right wall
                return pygame.Rect(
                    sx+Config.SQUARE_SIZE/2+1,
                    sy-10,
                    10,
                    20
                )
            elif self.square_dir[0] == 1:
                # Left wall
                return pygame.Rect(
                    sx-10-Config.SQUARE_SIZE/2-1,
                    sy-10,
                    10,
                    20
                )
        elif self.bounce_dir == 1:
            # Bounce top or bottom wall
            if self.square_dir[1] == -1:
                # Bottom wall
                return pygame.Rect(
                    sx-10,
                    sy+Config.SQUARE_SIZE/2+1,
                    20,
                    10
                )
            elif self.square_dir[1] == 1:
                # Top wall
                return pygame.Rect(
                    sx-10,
                    sy-10-Config.SQUARE_SIZE/2-1,
                    20,
                    10
                )

    def copy(self) -> "Bounce":
        return Bounce(self.square_pos.copy(), self.square_dir.copy(), self.time, self.bounce_dir)

    def __repr__(self):
        return f"<Bounce(sq_pos={self.square_pos}, sq_dir={self.square_dir}, time={self.time}, dir={self.bounce_dir})"

class Square:
    def __init__(self, x: float = 0, y: float = 0, dx: int = 1, dy: int = 1):
        self.pos: list[float] = [x, y]
        self.dir: list[int] = [dx, dy]
        self.last_bounce_time = -100
        self.latest_bounce_direction = 0  # 0 = horiz, 1 = vert
        self.past_colors = []
        self.died = False

    def register_past_color(self, col: tuple[int, int, int]):
        for _ in range(max(Config.square_swipe_anim_speed, 1)):
            self.past_colors.insert(0, col)
        while len(self.past_colors) > Config.SQUARE_SIZE * 4 / 5:
            self.past_colors.pop()

    def get_surface(self, size: tuple[int, int]):
        ss = int(Config.SQUARE_SIZE * 4 / 5)
        surf = pygame.Surface((ss, ss))
        for index, col in enumerate(self.past_colors):
            y = index if self.dir_y != 1 else ss - 1 - index
            pygame.draw.line(surf, col, (0, y), (ss, y))
        return pygame.transform.scale(surf, size)

    def copy(self) -> "Square":
        new = Square(*self.pos, *self.dir)
        new.last_bounce_time = self.last_bounce_time
        new.latest_bounce_direction = self.latest_bounce_direction
        return new

    @property
    def x(self):
        return self.pos[0]

    @property
    def y(self):
        return self.pos[1]

    def draw(self, screen: pygame.Surface, sqrect: pygame.Rect):
        if self.died:
            return
        square_color_index = round((self.dir_x + 1) / 2 + self.dir_y + 1)
        self.register_past_color(get_colors()["square"][square_color_index % len(get_colors()["square"])])

        pygame.draw.rect(screen, (0, 0, 0), sqrect)
        sq_surf = self.get_surface(
            tuple(sqrect.inflate(-int(Config.SQUARE_SIZE / 5), -int(Config.SQUARE_SIZE / 5))[2:]))
        screen.blit(sq_surf, sq_surf.get_rect(center=sqrect.center))

    @x.setter
    def x(self, val: int):
        self.pos[0] = val

    @y.setter
    def y(self, val: int):
        self.pos[1] = val

    @property
    def dir_x(self):
        return self.dir[0]

    @property
    def dir_y(self):
        return self.dir[1]

    @property
    def rect(self):
        return pygame.Rect(self.x - Config.SQUARE_SIZE / 2, self.y - Config.SQUARE_SIZE / 2,
                           *([Config.SQUARE_SIZE] * 2))

    def obey_bounce(self, bounce: Bounce):
        # Planned bounces
        self.pos = bounce.square_pos.copy()
        self.dir = bounce.square_dir.copy()
        self.latest_bounce_direction = bounce.bounce_dir
        self.last_bounce_time = bounce.time
        return

    def reg_move(self, use_dt: bool = True):
        self.x += self.dir_x * Config.square_speed * (Config.dt if use_dt else 1 / FRAMERATE)
        self.y += self.dir_y * Config.square_speed * (Config.dt if use_dt else 1 / FRAMERATE)

class Camera:
    def __init__(self, x: int = 0, y: int = 0):
        self.x = x
        self.y = y
        self.locked_on_square = True
        self.lock_type: CameraFollow = CameraFollow(Config.camera_mode)

    def attempt_movement(self):
        if not self.locked_on_square:
            keys = pygame.key.get_pressed()
            shift_modifier = (keys[pygame.K_LSHIFT] | keys[pygame.K_RSHIFT]) + 1
            self.x += (keys[pygame.K_d] - keys[pygame.K_a]) * 500 * shift_modifier / FRAMERATE
            self.y += (keys[pygame.K_s] - keys[pygame.K_w]) * 500 * shift_modifier / FRAMERATE

    @property
    def pos(self):
        return self.x, self.y

    @pos.setter
    def pos(self, val: Union[tuple[int, int], list[int]]):
        self.x, self.y = val

    def offset(self, pos_or_rect: Union[pygame.Rect, tuple[int, int]]) -> Union[pygame.Rect, list[int]]:
        if isinstance(pos_or_rect, pygame.Rect):
            return pos_or_rect.move(-self.x, -self.y)
        else:
            return [pos_or_rect[0]-self.x, pos_or_rect[1]-self.y]

    def follow(self, square: Square):

        # Square in center
        if self.lock_type == CameraFollow.Center:
            self.pos = [square.x - Config.SCREEN_WIDTH / 2, square.y - Config.SCREEN_HEIGHT / 2]

        # Smooth camera
        if self.lock_type == CameraFollow.Smoothed:
            easing_rate = 3
            self.x = (square.x - Config.SCREEN_WIDTH / 2) * easing_rate * Config.dt + self.x - easing_rate * self.x * Config.dt
            self.y = (square.y - Config.SCREEN_HEIGHT / 2) * easing_rate * Config.dt + self.y - easing_rate * self.y * Config.dt

class CameraFollow(Enum):
    Center = 0  # Center the square
    Smoothed = 2  # Smoothed camera

class World:
    """It's a cruel world out there"""

    def __init__(self):
        self.future_bounces: list[Bounce] = []
        self.past_bounces: list[Bounce] = []
        self.start_time = 0
        self.time = 0
        self.rectangles: list[pygame.Rect] = []
        self.collision_times: list[float] = []
        self.particles: list[Particle] = []
        self.timestamps = []
        self.square = Square()
        self.colors = []

    def update_time(self) -> None:
        self.time = get_current_time() - self.start_time

    def get_next_bounce(self) -> Bounce:
        """Also pops the bounce from the future_bounces list"""
        self.past_bounces.append(self.future_bounces.pop(0))
        return self.past_bounces[-1]

    def add_bounce_particles(self, sp: list[float], sd: list[float]):
        for _ in range(Config.particle_amount):
            new = Particle([sp[0]+random.randint(-10, 10), sp[1]+random.randint(-10, 10)], sd)
            self.particles.append(new)

    def handle_bouncing(self, square: Square):
        if len(self.future_bounces):
            if (self.time * 1000 + Config.music_offset)/1000 > self.future_bounces[0].time:
                current_bounce = self.get_next_bounce()
                before = square.dir.copy()
                square.obey_bounce(current_bounce)
                changed = square.dir.copy()
                for _ in range(2):
                    if before[_] == changed[_]:
                        changed[_] = 0
                    else:
                        changed[_] = -changed[_]
                if Config.do_particles_on_bounce:
                    self.add_bounce_particles(square.pos, changed)

                # Stop square at end
                if len(self.future_bounces) == 0:
                    square.dir = [0, 0]
                    square.pos = current_bounce.square_pos

    def gen_future_bounces(self, _start_notes: list[float], percent_update_callback):
        """Recursive function with optimized overlap checking"""
        total_notes = len(_start_notes)
        max_percent = 0
        path = []
        safe_areas = []
        force_return = 0

        def recurs(
                square: Square,
                notes: list[float],
                bounces_so_far: list[Bounce] = None,
                prev_index_priority=None,
                t: float = 0,
                depth: int = 0
        ) -> Union[list[Bounce], bool]:
            nonlocal force_return, max_percent
            if prev_index_priority is None:
                prev_index_priority = [0, 1]
            if bounces_so_far is None:
                bounces_so_far = []
            if total_notes:
                gone_through_percent = (total_notes-len(notes)) * 100 // total_notes
                while gone_through_percent > max_percent:
                    max_percent += 1
                    if percent_update_callback(f"{max_percent}% done generating map"):
                        raise UserCancelsLoadingError()

            all_bounce_rects = [_bounc.get_collision_rect() for _bounc in bounces_so_far]
            if len(notes) == 0:
                return bounces_so_far

            path_segment_start = len(path)
            start_rect = square.rect.copy()
            while True:
                t += 1/FRAMERATE
                square.reg_move(False)
                path.append(square.rect)
                if t > notes[0]:
                    # No collision (we good)
                    bounce_indexes = prev_index_priority

                    # Randomly change direction every X% of the time
                    if random.random() * 100 < Config.direction_change_chance:
                        bounce_indexes = list(bounce_indexes.__reversed__())

                    # Add safe area
                    safe_areas.append(start_rect.union(square.rect))

                    for direction_to_bounce in bounce_indexes:
                        square.dir[direction_to_bounce] *= -1
                        bounces_so_far.append(Bounce(square.pos.copy(), square.dir.copy(), t, direction_to_bounce))

                        toextend = recurs(
                            square=square.copy(),
                            notes=notes[1:],
                            bounces_so_far=[_b.copy() for _b in bounces_so_far],
                            t=t,
                            prev_index_priority=bounce_indexes.copy(),
                            depth=depth+1
                        )

                        if toextend:
                            return toextend
                        else:
                            bounces_so_far = bounces_so_far[:-1]
                            square.dir[direction_to_bounce] *= -1

                            # Backtrack if necessary
                            if force_return:
                                force_return -= 1
                                while len(path) != path_segment_start:
                                    path.pop()
                                return False

                            continue
                    while len(path) != path_segment_start:
                        path.pop()
                    return False

                othercheck = False
                if len(bounces_so_far):
                    othercheck = bounces_so_far[-1].get_collision_rect().collidelist(path[:-10])+1

                if square.rect.collidelist(all_bounce_rects) != -1 or othercheck:
                    if depth > 200:
                        if random.random() < Config.backtrack_chance:
                            max_percent -= (Config.backtrack_amount * 100 // total_notes) + 1
                            force_return = Config.backtrack_amount

                    while len(path) != path_segment_start:
                        path.pop()
                    return False

        _start_notes = _start_notes[:Config.max_notes] if Config.max_notes is not None else _start_notes

        self.future_bounces = recurs(
            square=self.square.copy(),
            notes=remove_too_close_values(
                [_sn for _sn in _start_notes],
                threshold=Config.bounce_min_spacing
            )
        )

        if self.future_bounces is False:
            raise MapLoadingFailureError("The map failed to generate because of the recursion function. " +
                                         "If the MIDI has too many notes too close, it may not generate. " +
                                         "Maybe try changing the \"bounce min spacing\", \"square speed\", or \"change dir chance\" in the config.")

        if len(self.future_bounces) == 0:
            raise MapLoadingFailureError("Map safe area list is empty. Please report this issue.")

        percent_update_callback("Merging overlapping safe areas")

        # Merging overlapping safe areas more efficiently
        safe_areas = fix_overlap(safe_areas, percent_update_callback)

        self.rectangles = [_fb.get_collision_rect() for _fb in self.future_bounces]
        self.collision_times = [_fb.time for _fb in self.future_bounces]

        # Setting random colors for the generated pegs
        self.colors = [random.choice([
            (224, 50, 50), (80, 210, 100), (230, 220, 50),
            (174, 170, 210), (245, 77, 247), (255, 153, 0)
        ]) for _ in self.future_bounces]
        return safe_areas

pygame.mixer.init()
FRAMERATE = 60

class Game:
    def __init__(self):
        self.active = False
        self.notes = []
        self.camera = Camera()
        self.world = World()
        self.safe_areas: list[pygame.Rect] = []
        self.music_has_played = False
        self.offset_happened = False

    def start_song(self, screen: pygame.Surface):
        random.seed(Config.seed)

        # Load song and notes
        with open(Config.current_song.fp, 'rb') as f:
            notes = read_midi_file(file=f)
        notes = sorted([note for note in notes])
        self.notes = notes

        # Other settings
        self.world = World()
        self.music_has_played = False
        self.offset_happened = False
        self.camera.lock_type = CameraFollow(Config.camera_mode)
        screen.fill(get_colors()["background"])
        pygame.display.flip()

        def update_loading_screen(message: str):
            screen.fill(get_colors()["background"], pygame.Rect(0, 0, Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT))
            font_size = int(Config.SCREEN_HEIGHT * 0.05)
            loading_text = get_font(font_size).render(message, True, get_colors()["hallway"])
            text_rect = loading_text.get_rect()
            text_rect.center = (Config.SCREEN_WIDTH // 2, Config.SCREEN_HEIGHT // 2)
            screen.blit(loading_text, text_rect)
            pygame.display.flip()


        try:
            self.safe_areas = self.world.gen_future_bounces(self.notes, update_loading_screen)
            # No need to fix overlap here, as it's handled in gen_future_bounces
        except UserCancelsLoadingError:
            return True
        except MapLoadingFailureError as e:
            return e.args[0] if len(e.args) else "Big error... can't load map :("

        # Display song title and creator with fade-in and fade-out effects
        self.display_song_info(screen)

        self.world.start_time = get_current_time()
        self.world.square.dir = [0, 0]
        if self.world.future_bounces:
            self.world.square.pos = self.world.future_bounces[0].square_pos
        else:
            self.world.square.pos = [Config.SCREEN_WIDTH / 2, Config.SCREEN_HEIGHT / 2]
        return None

    def display_song_info(self, screen):
        # Function to display song title and creator with fade-in and fade-out effects
        display_duration = 5000  # milliseconds
        fade_duration = 1000     # milliseconds for fade-in and fade-out
        start_time = pygame.time.get_ticks()
        elapsed = 0
        font_size = int(Config.SCREEN_HEIGHT * 0.05)
        
        # Prepare text surfaces with per-pixel alpha
        title = song_name
        creator = song_creator
        
        title_surface = get_font(font_size).render(f"Title: {title}", True, get_colors()["hallway"])
        creator_surface = get_font(font_size).render(f"Creator: {creator}", True, get_colors()["hallway"])
        
        # Enable alpha blending
        title_surface = title_surface.convert_alpha()
        creator_surface = creator_surface.convert_alpha()
        
        # Centering the text
        title_rect = title_surface.get_rect(center=(Config.SCREEN_WIDTH // 2, Config.SCREEN_HEIGHT // 2 - font_size))
        creator_rect = creator_surface.get_rect(center=(Config.SCREEN_WIDTH // 2, Config.SCREEN_HEIGHT // 2 + font_size))
        
        while elapsed < display_duration:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    sys.exit()
                self.handle_event(event)
            
            elapsed = pygame.time.get_ticks() - start_time
            
            # Calculate alpha value
            if elapsed < fade_duration:
                # Fade-in phase
                alpha = int(255 * (elapsed / fade_duration))
            elif elapsed > display_duration - fade_duration:
                # Fade-out phase
                alpha = int(255 * ((display_duration - elapsed) / fade_duration))
            else:
                # Fully visible
                alpha = 255

            # Clamp alpha value
            alpha = max(0, min(255, alpha))
            
            # Set alpha
            title_surface.set_alpha(alpha)
            creator_surface.set_alpha(alpha)
            
            # Clear screen
            screen.fill(get_colors()["background"])
            
            # Draw text
            screen.blit(title_surface, title_rect)
            screen.blit(creator_surface, creator_rect)
            
            pygame.display.flip()
            pygame.time.delay(30)  # Slight delay to control the frame rate

    def draw(self, screen: pygame.Surface):
        if not self.active:
            return

        if not self.music_has_played:
            if not self.offset_happened:
                for bnc_change in self.world.future_bounces:
                    bnc_change.time += Config.start_playing_delay / 1000
            self.offset_happened = True
            if self.world.time-Config.music_offset/1000 > Config.start_playing_delay/1000:
                self.music_has_played = True
                song_load_before = get_current_time()
                pygame.mixer.music.play()
                for bnc_change in self.world.future_bounces:
                    bnc_change.time += (get_current_time()-song_load_before)/1000

        screen_rect = screen.get_rect()

        # Set world time
        self.world.update_time()

        # Move camera
        self.camera.attempt_movement()

        # Handle square bounces
        self.world.handle_bouncing(self.world.square)

        # Move square
        self.world.square.reg_move()

        # Square in center of camera if locked
        if self.camera.locked_on_square:
            self.camera.follow(self.world.square)

        # Bounce animation
        sqrect = self.camera.offset(self.world.square.rect)
        if (self.world.time - 0.25) + Config.music_offset / 1000 < self.world.square.last_bounce_time:
            lerp = abs((self.world.time - 0.25 + Config.music_offset / 1000) - self.world.square.last_bounce_time) * 5
            lerp = lerp ** 2  # Square it for better-looking interpolation
            if self.world.square.latest_bounce_direction:
                sqrect.inflate_ip((lerp * 5, -10 * lerp))
            else:
                sqrect.inflate_ip((-10 * lerp, lerp * 5))

        # Safe areas
        for safe_area in self.safe_areas:
            offsetted = self.camera.offset(safe_area)
            if screen_rect.colliderect(offsetted):
                pygame.draw.rect(screen, get_colors()["hallway"], offsetted)

        # Draw pegs
        for i, bounce_rect in enumerate(self.world.rectangles):
            offsetted = self.camera.offset(bounce_rect)

            if offsetted.colliderect(screen_rect):
                if Config.do_color_bounce_pegs and self.world.collision_times[i] < (self.world.time * 1000 + Config.music_offset - Config.start_playing_delay)/1000:
                    pygame.draw.rect(screen, self.world.colors[i], offsetted)
                else:
                    pygame.draw.rect(screen, get_colors()["background"], offsetted)

        # Particles
        for particle in self.world.particles:
            pygame.draw.rect(screen, particle.color, self.camera.offset(particle.rect))
        for remove_particle in [particle for particle in self.world.particles if particle.age()]:
            self.world.particles.remove(remove_particle)

        # Particle trail in game
        if Config.particle_trail:
            # Add a particle periodically
            if not self.world.square.died:
                new = Particle(self.world.square.pos.copy(), [0, 0], True)
                new.delta = [random.randint(-10, 10)/20, random.randint(-10, 10)/20]
                self.world.particles.append(new)

        # Draw square
        self.world.square.draw(screen, sqrect)

    def handle_event(self, event: pygame.event.Event):
        if not self.active:
            return False

        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                pygame.quit()
                sys.exit()
            if event.key == pygame.K_TAB:
                self.camera.locked_on_square = not self.camera.locked_on_square

class InvalidSongError(Exception):
    """The song is invalid somehow"""
    pass

def make_song_from_midi(fpath: str) -> Any:
    new_song = type('Song', (object,), {})()
    new_song.fp = fpath
    new_song.name = song_name  # Use the song name from the top
    new_song.creator = song_creator  # Use the song creator from the top
    new_song.music_offset = 0
    return new_song

def main():
    pygame.init()
    clock = pygame.time.Clock()
    screen = pygame.display.set_mode((Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT))
    pygame.display.set_caption("Midi Playground")
    try:
        pygame.display.set_icon(pygame.image.load(Config.assets).convert_alpha())
    except Exception as e:
        print(e)

    # Specify the song file to use
    selected_song_filepath = join(Config.song, selected_song_filename)
    if not isfile(selected_song_filepath):
        print(f"Song file {selected_song_filepath} not found.")
        return

    Config.current_song = make_song_from_midi(selected_song_filepath)

    # Load the music file
    try:
        pygame.mixer.music.load(Config.current_song.fp)
    except Exception as e:
        print(f"Error loading MIDI file as audio: {e}")
        print("Your platform may not support MIDI playback via pygame.mixer.music.")
        print("The game will run without audio.")
        # Uncomment the line below to exit if audio is essential
        # return
    pygame.mixer.music.set_volume(Config.volume / 100)

    # Initialize the game
    game = Game()

    # Start the game
    if msg := game.start_song(screen):
        if isinstance(msg, str):
            print(f"Error starting game: {msg}")
            return
    game.active = True

    running = True
    while running:
        screen.fill(get_colors()["background"])
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            game.handle_event(event)

        game.draw(screen)
        pygame.display.flip()
        clock.tick(FRAMERATE)
        Config.dt = clock.get_time() / 1000.0 if clock.get_time() > 0 else 0.016  # Avoid division by zero

    pygame.quit()

if __name__ == '__main__':
    main()