Racquet AlphaZone Part 6: Code Implementation

Screenshots

The following screenshots from the game might help you design your game. Use these screenshots as a guide. You don’t need to replicate the design. Feel free to make your game look the way you want.

Main Scene Design

This screenshot displays the main scene design of Racquet AlphaZone.

Project Structure

This screenshot displays the assets of Racquet AlphaZone.

Prefab Properties

This screenshot displays the properties of the prefabs for the power-ups in Racquet AlphaZone.

Main Scene Game Objects Properties

Camera

Orthographic camera with the default values.

Background

Sprite. Set the “Sprite” property in the SpriteRenderer to the background image.

TopBoundary & BottomBoundary

Sprites. Set the “Color” property in the SpriteRenderer to black.

BoxCollider2D has the default properties.

PlayerBarrier

Sprite. Set the “Material” property in the SpriteRenderer to the player barrier material.

BoxCollider2D has the default properties.

PlayerGoalpost & AIGoalpost

Sprites. SpriteRenderer has the default values.

Set the “Is Trigger” property in the BoxCollider2D to true.

Player

Set the “Color” property in the SpriteRenderer to #94C18A

In the RigidBody2D, set the “Body Type” to Dynamic, the “Collision Detection” to Dynamic, the “Freeze position X” to true, and the “Freeze rotation Z” to true.

Add the PlayerController script.

The BoxCollision2D has the default values.

Ball

Set the “Color” property in the SpriteRenderer to #DAA520

In the RigidBody2D, set the “Body Type” to Dynamic, the “Collision Detection” to Dynamic, and the “Freeze rotation Z” to true.

Add the BallController script.

The CircleCollision2D has the default values.

AI Opponent

Set the “Color” property in the SpriteRenderer to #6B3E75

In the RigidBody2D, set the “Body Type” to Dynamic, the “Collision Detection” to Dynamic, the “Freeze position X” to true, and the “Freeze rotation Z” to true.

Add the AIPredictiveTracking script.

The BoxCollision2D has the default values.

The Code

GameState

using UnityEngine;

namespace Assets.Code
{
    public static class GameState
    {
        //Help functions
        public static string GetDifficulty()
        {
            switch (Difficulty)
            {
                case 0:
                    return "Easy";
                case 1:
                    return "Normal";
                case 2:
                    return "Hard";
                default:
                    return "Unknown";
            }
        }
        public static float GetBonus()
        {
            switch (Difficulty)
            {
                case 0:
                    return EasyDifficultyPointBonus;
                case 1:
                    return NormalDifficultyPointBonus;
                case 2:
                    return HardDifficultyPointBonus;
                default:
                    return 1f;
            }
        }

        //Runtime calculated. Reset when new game starts.
        public static int PlayerScore;
        public static int AIScore;

        //Configuration values set in code.
        public static int WinScore = 1;
        public static float LightningBoltDuration = 5.0f;
        public static float BarrierDuration = 10.0f;
        public static float SecondBallDuration = 10f;
        public static float SpeedMultiplier = 1.25f;
        public static int PointsPerGoal = 50;
        public static float EasyDifficultyPointBonus = 1f;
        public static float NormalDifficultyPointBonus = 1.5f;
        public static float HardDifficultyPointBonus = 2.5f;

        //Settings stored in PlayerPrefs.
        //Score
        public static int PlayerTotalScore;
        public static int AITotalScore;
        //Difficulty
        public static int Difficulty;               //Easy=0, Normal=1, Hard=2
        public static float PlayerSpeed;            //The the player's racquet speed.                 
        public static float BallSpeed;              //The the ball's speed.
        public static float AISpeed;                //The the AI's racquet speed.                    
        public static float AIError;                //Margin of error for the AI's prediction.       
        public static float AIPerceptionInteval;    //How often AI "reads" the environment.           
        //Sound and Music
        public static bool SoundFxOn;
        public static float SounFxVolume;
        public static bool MusicOn;
        public static float MusicVolume;

        public static void Save()
        {
            PlayerPrefs.SetInt("PlayerTotalScore", PlayerTotalScore);
            PlayerPrefs.SetInt("AITotalScore", AITotalScore);

            PlayerPrefs.SetInt("Difficulty", Difficulty);
            PlayerPrefs.SetFloat("PlayerSpeed", PlayerSpeed);
            PlayerPrefs.SetFloat("BallSpeed", BallSpeed);
            PlayerPrefs.SetFloat("AISpeed", AISpeed);
            PlayerPrefs.SetFloat("AIError", AIError);
            PlayerPrefs.SetFloat("AIPerceptionInteval", AIPerceptionInteval);

            PlayerPrefs.SetString("SoundFxOn", (SoundFxOn == true) ? "1" : "0");
            PlayerPrefs.SetFloat("SoundFXVolume", SounFxVolume);
            PlayerPrefs.SetString("MusicOn", (MusicOn == true) ? "1" : "0");
            PlayerPrefs.SetFloat("MusicVolume", MusicVolume);
        }

        public static void Load()
        {
            PlayerTotalScore = PlayerPrefs.GetInt("PlayerTotalScore", 0);
            AITotalScore = PlayerPrefs.GetInt("AITotalScore", 0);

            Difficulty = PlayerPrefs.GetInt("Difficulty", 1);
            PlayerSpeed = PlayerPrefs.GetFloat("PlayerSpeed", 10.0f);
            BallSpeed = PlayerPrefs.GetFloat("BallSpeed", 7.0f);
            AISpeed = PlayerPrefs.GetFloat("AISpeed", 4.0f);
            AIError = PlayerPrefs.GetFloat("AIError", 0.15f);
            AIPerceptionInteval = PlayerPrefs.GetFloat("AIPerceptionInteval", 0.075f);

            SoundFxOn = (PlayerPrefs.GetString("SoundFxOn", "1") == "1");
            SounFxVolume = PlayerPrefs.GetFloat("SoundFXVolume", 0.5f);
            MusicOn = (PlayerPrefs.GetString("MusicOn", "1") == "1");
            MusicVolume = PlayerPrefs.GetFloat("MusicVolume", 0.5f);
        }
    }
}

MainMenuFunctions

using Assets.Code;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class MainMenuFunctions : MonoBehaviour
{
    //UI Elements
    public CanvasGroup MainMenu;
    public CanvasGroup SettingsMenu;
    public Toggle SoundFxToggle;
    public Slider SoundFXSlider;
    public Toggle MusicToggle;
    public Slider MusicSlider;

    //Main Menu Actions
    public void Play()
    {
        SceneManager.LoadScene(1);
    }

    public void OpenSettings()
    {
        //Hide main menu
        MainMenu.alpha = 0;
        MainMenu.blocksRaycasts = false;
        MainMenu.interactable = false;

        //Show settings menu
        SettingsMenu.alpha = 1;
        SettingsMenu.blocksRaycasts = true;
        SettingsMenu.interactable = true;
    }

    public void Exit()
    {
        //Save game data to PlayerPrefs.
        GameState.Save();

        Debug.Log("Exit");
        Application.Quit();
    }

    //Settings Menu Actions
    public void SetEasyDifficulty()
    {
        GameState.Difficulty = 0;
        GameState.PlayerSpeed = 15.0f;
        GameState.BallSpeed = 5.0f;
        GameState.AISpeed = 3.0f;
        GameState.AIError = 0.2f;
        GameState.AIPerceptionInteval = 0.075f;
    }

    public void SetNormalDifficulty()
    {
        GameState.Difficulty = 1;
        GameState.PlayerSpeed = 10.0f;
        GameState.BallSpeed = 7.0f;
        GameState.AISpeed = 4.0f;
        GameState.AIError = 0.15f;
        GameState.AIPerceptionInteval = 0.075f;
    }

    public void SetHardDifficulty()
    {
        GameState.Difficulty = 2;
        GameState.PlayerSpeed = 7.5f;
        GameState.BallSpeed = 10.0f;
        GameState.AISpeed = 10.0f;
        GameState.AIError = 0.005f;
        GameState.AIPerceptionInteval = 0.025f;
    }

    public void SetSoundFx(Toggle toggle)
    {
        GameState.SoundFxOn = toggle.isOn;
    }

    public void SetSoundFxVolume(Slider slider)
    {
        GameState.SounFxVolume = slider.value;
    }

    public void SetMusic(Toggle toggle)
    {
        GameState.MusicOn = toggle.isOn;
    }

    public void SetMusicVolume(Slider slider)
    {
        GameState.MusicVolume = slider.value;
    }

    public void ExitSettings()
    {
        //Save game data to PlayerPrefs.
        GameState.Save();

        //Hide settings menu
        SettingsMenu.alpha = 0;
        SettingsMenu.blocksRaycasts = false;
        SettingsMenu.interactable = false;

        //Show main menu
        MainMenu.alpha = 1;
        MainMenu.blocksRaycasts = true;
        MainMenu.interactable = true;
    }

    //Other actions
    public void OpenProgressSavedLink()
    {
        Application.OpenURL("https://ProgressSaved.com");
    }

    //Initialization
    void Awake()
    {
        //Load game data from PlayerPrefs.
        GameState.Load();

        //Update controls based on data loaded.
        SoundFxToggle.isOn = GameState.SoundFxOn;
        SoundFXSlider.value = GameState.SounFxVolume;
        MusicToggle.isOn = GameState.MusicOn;
        MusicSlider.value = GameState.MusicVolume;
    }
}

UpdateTotalScore

using Assets.Code;
using UnityEngine;
using UnityEngine.UI;

public class UpdateTotalScore : MonoBehaviour
{
    public Text PlayerTotalText;
    public Text AITotalText;

    void Start()
    {
        PlayerTotalText.text = GameState.PlayerTotalScore.ToString();
        AITotalText.text = GameState.AITotalScore.ToString();
    }
}

EndGameMenuFunctions

using Assets.Code;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class EndGameMenuFunctions : MonoBehaviour
{
    public CanvasGroup EndGamePanel;
    public Text EndGameTitle;
    public Text GameScore;

    public void Play()
    {
        GameState.PlayerScore = 0;
        GameState.AIScore = 0;
        SceneManager.LoadScene(1);
    }

    public void ExitToMainMenu()
    {
        GameState.PlayerScore = 0;
        GameState.AIScore = 0;
        SceneManager.LoadScene(0);
    }

    void Start()
    {
        Time.timeScale = 1f;

        if (GameState.PlayerScore >= GameState.WinScore)
            PlayerWins();
        else if (GameState.AIScore >= GameState.WinScore)
            AIWins();
        else
            NobodyWins();
    }

    private void PlayerWins()
    {
        EndGameTitle.text = "Player Wins!";
        GameScore.text = "Score: " + CalculateScore();
    }

    private void AIWins()
    {
        EndGameTitle.text = "AI Wins!";
        GameScore.text = "Score: " + CalculateScore();
    }

    private void NobodyWins()
    {
        EndGameTitle.text = "Nobody Wins!";
        GameScore.text = "Score: 0";
    }

    private string CalculateScore()
    {
        //Get score
        int score = (int)(Mathf.Abs(GameState.PlayerScore - GameState.AIScore) * GameState.PointsPerGoal * GameState.GetBonus());

        //Update total score
        if (GameState.PlayerScore >= GameState.WinScore)
            GameState.PlayerTotalScore += score;
        else
            GameState.AITotalScore += score;

        //Save game data.
        GameState.Save();

        return score.ToString();
    }
}

UpdateUI

using Assets.Code;
using UnityEngine;
using UnityEngine.UI;

public class UpdateUI : MonoBehaviour
{
    public Text PlayerScoreText;
    public Text AIScoreText;
    public Text DifficultyText;

    private void Start()
    {
        DifficultyText.text = GameState.GetDifficulty();
    }

    void FixedUpdate()
    {
        PlayerScoreText.text = GameState.PlayerScore.ToString();
        AIScoreText.text = GameState.AIScore.ToString();
    }
}

MusicManager

using Assets.Code;
using UnityEngine;

[RequireComponent(typeof(AudioSource))]
public class MusicManager : MonoBehaviour
{
    public AudioClip backgroundMusic;
    private AudioSource audioSource;

    public void StopSound()
    {
        if (audioSource.isPlaying)
            audioSource.Stop();
    }

    // Start is called before the first frame update
    void Start()
    {
        audioSource = GetComponent<AudioSource>();
        audioSource.clip = backgroundMusic;
        audioSource.enabled = GameState.MusicOn;
        audioSource.volume = GameState.MusicVolume;
        audioSource.Play();
    }
}

SoundManager

using Assets.Code;
using UnityEngine;

[RequireComponent(typeof(AudioSource))]
public class SoundManager : MonoBehaviour
{
    public AudioClip ballBounceClip;
    public AudioClip powerUpClip;
    public AudioClip playerScoreClip;
    public AudioClip aiScoreClip;
    public AudioClip countDownClip;

    private AudioSource audioSource;

    public void StopSound()
    {
        if (audioSource.isPlaying)
            audioSource.Stop();
    }

    public void PlayBallBounce()
    {
        PlaySound(0);
    }

    public void PlayPowerUp()
    {
        PlaySound(1);
    }

    public void PlayPlayerScore()
    {
        PlaySound(2);
    }

    public void PlayAIScore()
    {
        PlaySound(3);
    }

    public void PlayCountDown()
    {
        PlaySound(4);
    }

    void Start()
    {
        audioSource = GetComponent<AudioSource>();
        audioSource.enabled = GameState.SoundFxOn;
        audioSource.volume = GameState.SounFxVolume;
    }

    private void PlaySound(int soundIdx)
    {
        //If other sound playing, stop it.
        if (audioSource.isPlaying)
            audioSource.Stop();

        //Select the sound to play.
        switch (soundIdx)
        {
            case 0:
                audioSource.clip = ballBounceClip;
                break;
            case 1:
                audioSource.clip = powerUpClip;
                break;
            case 2:
                audioSource.clip = playerScoreClip;
                break;
            case 3:
                audioSource.clip = aiScoreClip;
                break;
            case 4:
                audioSource.clip = countDownClip;
                break;
            default:
                break;
        }

        //Play the sound.
        audioSource.Play();
    }
}


PauseManager

using System.Collections;
using UnityEngine;

public class PauseManager : MonoBehaviour
{
    public CanvasGroup PausedCanvas;

    private SoundManager soundManager;
    private bool isPaused = false;

    void Awake()
    {
        PauseGame();
    }
    
    void Start()
    {
        //Get SoundManager
        soundManager = FindAnyObjectByType<SoundManager>();
        if (soundManager == null)
            Debug.LogError("SoundManager not found!");
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            TogglePause();
    }

    public void HidePauseMessage()
    {
        PausedCanvas.alpha = 0f;
    }

    public void TogglePause()
    {
        if (isPaused)
            ResumeGame();
        else
            PauseGame();
    }

    public void PauseGame()
    {
        Time.timeScale = 0;
        isPaused = true;
        PausedCanvas.alpha = 1f;
    }

    public void ResumeGame()
    {
        //Time.timeScale = 1;
        isPaused = false;
        PausedCanvas.alpha = 0f;
        soundManager.PlayCountDown();
        StartCoroutine(ResumeGameCoroutine(3));
    }

    private IEnumerator ResumeGameCoroutine(float duration)
    {
        yield return new WaitForSecondsRealtime(duration);
        Time.timeScale = 1;
    }
}

InGameMenuFunctions

using Assets.Code;
using UnityEngine;
using UnityEngine.SceneManagement;

public class InGameMenuFunctions : MonoBehaviour
{
    public CanvasGroup InGameMenu;

    private PauseManager pauseManager;
    private bool isOpen = false;

    public void ResumeGame()
    {
        HideInGameMenu();
    }

    public void ExitToMainMenu()
    {
        pauseManager.PauseGame();
        GameState.Save();
        ResetGame();
        SceneManager.LoadScene(0);
    }

    void Start()
    {
        pauseManager = FindAnyObjectByType<PauseManager>();
        if (pauseManager == null)
            Debug.LogError("PauseManager is not found.");
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Escape))
            ToggleMenu();
    }

    private void ToggleMenu()
    {
        if (isOpen)
            HideInGameMenu();
        else
            ShowInGameMenu();
    }

    private void ShowInGameMenu()
    {
        InGameMenu.alpha = 1;
        InGameMenu.interactable = true;
        InGameMenu.blocksRaycasts = true;
        pauseManager.PauseGame();
        isOpen = true;
    }

    private void HideInGameMenu()
    {
        InGameMenu.alpha = 0;
        InGameMenu.interactable = false;
        InGameMenu.blocksRaycasts = false;
        pauseManager.ResumeGame();
        isOpen = false;
    }

    private void ResetGame()
    {
        //Reset scores
        GameState.PlayerScore = 0;
        GameState.AIScore = 0;
    }
}

SecondBallController

using Assets.Code;
using UnityEngine;

public class SecondBallController : MonoBehaviour
{
    private Rigidbody2D rb;
    private SoundManager soundManager;

    void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        if (rb == null)
            Debug.LogError("Rigidbody2D not found!");

        soundManager = FindAnyObjectByType<SoundManager>();
        if (soundManager == null)
            Debug.LogError("SoundManager not found!");
    }

    void Update()
    {
        if (Mathf.Abs(rb.velocity.magnitude) < 2f)
            Destroy(gameObject);
    }

    private void OnTriggerEnter2D(Collider2D other)
    {
        switch (other.gameObject.tag)
        {
            case "PlayerGoalpost":
                AddToAIScore();
                break;
            case "AIGoalpost":
                AddToPlayerScore();
                break;
            default:
                break;
        }
    }

    private void AddToAIScore()
    {
        soundManager.PlayAIScore();
        GameState.AIScore++;
        Destroy(gameObject);
    }

    private void AddToPlayerScore()
    {
        soundManager.PlayPlayerScore();
        GameState.PlayerScore++;
        Destroy(gameObject);
    }
}

PowerUpSpawner

using UnityEngine;

public class PowerUpSpawner : MonoBehaviour
{
    public GameObject[] powerUpPrefabs;   // Array to hold the references to your PowerUp prefabs
    public float spawnInterval = 5.0f;    // Time interval between spawns
    public Vector2 spawnAreaMin;          // Minimum (bottom-left) corner of the spawn area
    public Vector2 spawnAreaMax;          // Maximum (top-right) corner of the spawn area
    public float powerUpLifeDuration = 10.0f;  // Time for which a power-up stays in the scene

    private float nextSpawnTime;

    void Start()
    {
        nextSpawnTime = Time.time + spawnInterval;
    }

    void Update()
    {
        if (Time.time >= nextSpawnTime)
        {
            SpawnPowerUp();
            nextSpawnTime = Time.time + spawnInterval;
        }
    }

    void SpawnPowerUp()
    {
        Vector2 spawnPosition = new Vector2(
            Random.Range(spawnAreaMin.x, spawnAreaMax.x),
            Random.Range(spawnAreaMin.y, spawnAreaMax.y)
        );

        // Randomly select a power-up from the array
        GameObject selectedPowerUp = powerUpPrefabs[Random.Range(0, powerUpPrefabs.Length)];

        // Instantiate and destroy after its life duration
        GameObject spawnedPowerUp = Instantiate(selectedPowerUp, spawnPosition, Quaternion.identity);
        
        Destroy(spawnedPowerUp, powerUpLifeDuration);
    }

}

PowerUpDisplayManager

using UnityEngine;
using UnityEngine.UI;

public class PowerUpDisplayManager : MonoBehaviour
{
    public Image powerUpIcon;
    public Sprite defaultSprite;
    public Sprite boltSprite;
    public Sprite barrierSprite;
    public Sprite splitSprite;

    public void SetDefaultSpite()
    {
        SetSprite(0);
    }

    public void SetBoltSpite()
    {
        SetSprite(1);
    }

    public void SetBarrierSpite()
    {
        SetSprite(2);
    }

    public void SetSplitSpite()
    {
        SetSprite(3);
    }

    void Start()
    {
        SetSprite(0);
    }

    public void SetSprite(int spriteIdx)
    {
        switch(spriteIdx)
        {
            case 0:
                powerUpIcon.sprite = defaultSprite;
                break;
            case 1:
                powerUpIcon.sprite = boltSprite;
                break;
            case 2:
                powerUpIcon.sprite = barrierSprite;
                break;
            case 3:
                powerUpIcon.sprite = splitSprite;
                break;
            default:
                break;
        }
    }
}

PlayerController

using Assets.Code;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    public Camera mainCamera;

    private float speed;
    private float topBoundary;
    private float bottomBoundary;
    private float halfRacquetHeight;
    private Rigidbody2D rb;

    public void Initialize()
    {
        speed = GameState.PlayerSpeed;
    }

    public void ResetPlayerPosition()
    {
        rb.position = new Vector2(rb.position.x, 0);
    }

    void Start()
    {
        Initialize();

        rb = GetComponent<Rigidbody2D>();

        // Calculate the racquet's half height
        halfRacquetHeight = transform.localScale.y / 2.0f;

        // Calculate the top and bottom boundaries based on camera's orthographic size
        topBoundary = mainCamera.orthographicSize - halfRacquetHeight;
        bottomBoundary = -topBoundary;
    }

    void FixedUpdate()
    {
        // Gets vertical input (W and S or Arrow keys)
        float verticalInput = Input.GetAxis("Vertical");

        // Calculate new position
        float newY = Mathf.Clamp(rb.position.y + verticalInput * speed * Time.deltaTime, bottomBoundary, topBoundary);

        // Set the new position
        rb.MovePosition(new Vector2(rb.position.x, newY));
    }
}

AIPredictiveTracking

using Assets.Code;
using UnityEngine;

public class AIPredictiveTracking : MonoBehaviour
{
    public Transform ball;           

    private Rigidbody2D rb;
    private Rigidbody2D ballRb;
    private float speed;
    private float error;
    private float selectedBallDirection;

    private float perceptionElapsedTime;
    private float perceptionInteval;

    public void Initialize()
    {
        speed = GameState.AISpeed;
        error = GameState.AIError;
        perceptionInteval = GameState.AIPerceptionInteval;
    }

    public void ResetPlayerPosition()
    {
        rb.position = new Vector2(rb.position.x, 0);
    }

    void Start()
    {
        Initialize();

        if (ball == null)
            Debug.LogError("Ball not found.");

        rb = GetComponent<Rigidbody2D>();
        if (rb == null)
            Debug.LogError("AI player Rigidbody2D not found.");

        ballRb = ball.GetComponent<Rigidbody2D>();
        if (ball == null)
            Debug.LogError("Ball Rigidbody2D not found.");
    }

    void FixedUpdate()
    {
        perceptionElapsedTime += Time.fixedDeltaTime;

        if(perceptionElapsedTime > perceptionInteval)
        {
            //Select the ball to chase.
            Transform ballToChase = SelectBall();

            //Predict selected ball's Y position.
            float predictedY = PredictBallYPosition(ballToChase);

            //Determine selected ball's direction.
            selectedBallDirection = (predictedY > transform.position.y) ? 1 : -1;

            //rb.velocity = new Vector2(0, selectedBallDirection * speed);

            float smoothFactor = 0.1f;
            Vector2 desiredVelocity = new Vector2(0, selectedBallDirection * speed);
            rb.velocity = Vector2.Lerp(rb.velocity, desiredVelocity, smoothFactor);

            perceptionElapsedTime = 0;
        }
    }

    private Transform SelectBall()
    {
        SecondBallController[] otherBalls = FindObjectsByType<SecondBallController>(FindObjectsSortMode.None);

        if (otherBalls == null) return ball;

        if (otherBalls.Length <=0) return ball;

        float minDistance = float.MaxValue;
        SecondBallController closestBall = null;
        foreach (SecondBallController otherBall in otherBalls)
        {
            if (otherBall == null) continue;

            float distance = Vector2.Distance(transform.position, otherBall.gameObject.transform.position);
            if (distance < minDistance)
            {
                minDistance = distance;
                closestBall = otherBall;
            }
        }

        if (closestBall != null)
        {
            float distanceFromBall = Vector2.Distance(transform.position, ball.gameObject.transform.position);
            float distanceFromSecondBall = Vector2.Distance(transform.position, closestBall.gameObject.transform.position);

            if (distanceFromSecondBall < distanceFromBall)
                return closestBall.gameObject.transform;
        }

        return ball;
    }

    float PredictBallYPosition(Transform ball)
    {
        // Calculate time for the ball to reach the AI
        //The time it would take for the ball to reach the AI is found by dividing the horizontal
        //distance the ball has to cover by the ball's horizontal velocity.
        //This is based on the formula: time = distance / velocity
        float timeToReachAI = (transform.position.x - ball.position.x) / ballRb.velocity.x;

        // Predict Y position based on ball's current velocity
        //Using the time found in the previous step, we calculate how much the ball will move vertically in that time.
        //This is another basic kinematic equation: final position = initial position + velocity * time
        float predictedY = ball.position.y + ballRb.velocity.y * timeToReachAI;

        // Add some randomness to the prediction for challenge and variability
        predictedY += Random.Range(-error, error);

        return predictedY;
    }
}

BallController

using Assets.Code;
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class BallController : MonoBehaviour
{
    public GameObject PlayerBarrier;
    public GameObject ballPrefab;

    private Rigidbody2D rb;
    private SoundManager soundManager;
    private MusicManager musicManager;
    private PauseManager pauseManager;
    private PowerUpDisplayManager powerUpDisplayManager;
    private PlayerController player;
    private AIPredictiveTracking aiPlayer;

    private float ballSpeed;
    private float lightningBoltDuration;
    private float barrierDuration;
    private float secondBallDuration;
    private float speedMultiplier;
    private Vector2 initialDirection;
    private float currentSpeed;
    private bool HasGameSEnded = false;

    public void Initialize()
    {
        ballSpeed = GameState.BallSpeed;
        lightningBoltDuration = GameState.LightningBoltDuration;
        barrierDuration = GameState.BarrierDuration;
        secondBallDuration = GameState.SecondBallDuration;
        speedMultiplier = GameState.SpeedMultiplier;
    }

    void Start()
    {
        Initialize();

        currentSpeed = ballSpeed;
        RandomizeDirection();

        musicManager = FindAnyObjectByType<MusicManager>();
        if (musicManager == null)
            Debug.LogError("MusicManager not found!");

        soundManager = FindAnyObjectByType<SoundManager>();
        if (soundManager == null)
            Debug.LogError("SoundManager not found!");

        pauseManager = FindAnyObjectByType<PauseManager>();
        if (pauseManager == null)
            Debug.LogError("PauseManager not found!");

        powerUpDisplayManager = FindAnyObjectByType<PowerUpDisplayManager>();
        if (powerUpDisplayManager == null)
            Debug.LogError("PowerUpDisplayManager not found!");

        player = FindAnyObjectByType<PlayerController>();
        if (player == null)
            Debug.LogError("PlayerController not found!");

        aiPlayer = FindAnyObjectByType<AIPredictiveTracking>();
        if (aiPlayer == null)
            Debug.LogError("AIPredictiveTracking not found!");

        rb = GetComponent<Rigidbody2D>();

        LaunchBall();
    }

    private void RandomizeDirection()
    {
        if (Random.Range(0, 2) < 0.5f)
            initialDirection = new Vector2(1, 0);
        else
            initialDirection = new Vector2(-1, 0);
    }

    private void LaunchBall()
    {
        RandomizeDirection();
        rb.velocity = initialDirection * currentSpeed;
    }

    void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.gameObject.CompareTag("Racquet"))
            BallBounceOnRacquet(collision);
    }

    private void OnTriggerEnter2D(Collider2D other)
    {
        switch (other.gameObject.tag)
        {
            case "LightningBolt":
                ApplyLightningBolt(other);
                break;
            case "Barrier":
                EnableBarrier(other);
                break;
            case "BallSplit":
                ApplyBallSplit(other);
                break;
            case "PlayerGoalpost":
                AddToAIScore();
                break;
            case "AIGoalpost":
                AddToPlayerScore();
                break;
            default:
                break;
        }
    }

    void Update()
    {
        if (HasGameSEnded) return;

        // Reset ball if it goes off screen.
        if (Mathf.Abs(transform.position.x) > 10)
            ResetBall();

        // Reset ball if it looses its speed.
        if (rb.velocity.magnitude < 2f)
            ResetBall();

        if (GameState.PlayerScore >= GameState.WinScore || GameState.AIScore >= GameState.WinScore)
        {
            HasGameSEnded = true;
            pauseManager.HidePauseMessage();
            soundManager.StopSound();
            musicManager.StopSound();
            StartCoroutine(EndGame(1));
        }
    }

    private IEnumerator EndGame(float duration)
    {
        yield return new WaitForSecondsRealtime(duration);
        SceneManager.LoadScene(2);
    }

    private void ResetBall()
    {
        transform.position = Vector2.zero;
        rb.velocity = Vector2.zero;
        LaunchBall();
    }

    private void BallBounceOnRacquet(Collision2D collision)
    {
        soundManager.PlayBallBounce();

        // Reflect the ball's velocity on the y-axis if it hits a racquet.
        float y = HitFactor(transform.position, collision.transform.position, collision.collider.bounds.size.y);
        Vector2 direction = new Vector2(-1 * initialDirection.x, y).normalized;
        rb.velocity = direction * currentSpeed;
    }

    float HitFactor(Vector2 ballPosition, Vector2 racquetPosition, float racquetHeight)
    {
        return (ballPosition.y - racquetPosition.y) / racquetHeight;
    }

    private void ApplyLightningBolt(Collider2D other)
    {
        //Play sound and update UI
        soundManager.PlayPowerUp();
        powerUpDisplayManager.SetBoltSpite();

        //Apply power-up effect (increase ball speed)
        currentSpeed = ballSpeed * speedMultiplier;
        rb.velocity *= currentSpeed;

        //Schedule reseting power-up's effect.
        StartCoroutine(ResetLightningBolt(lightningBoltDuration));

        // Destroy the power-up after it's been used
        Destroy(other.gameObject);
    }

    private IEnumerator ResetLightningBolt(float duration)
    {
        //wait
        yield return new WaitForSeconds(duration);
        
        //Update UI
        powerUpDisplayManager.SetDefaultSpite();

        // reset ball speed
        currentSpeed = ballSpeed;
        rb.velocity = rb.velocity.normalized * currentSpeed; 
    }

    private void EnableBarrier(Collider2D other)
    {
        //Play sound and update UI
        soundManager.PlayPowerUp();
        powerUpDisplayManager.SetBarrierSpite();

        //Apply power-up effect (activate barrier)
        PlayerBarrier.SetActive(true);

        //Schedule reseting power-up's effect.
        StartCoroutine(ResetBarrier(barrierDuration));

        // Destroy the power-up after it's been used
        Destroy(other.gameObject);
    }

    private IEnumerator ResetBarrier(float duration)
    {
        //wait
        yield return new WaitForSeconds(duration);

        //Update UI
        powerUpDisplayManager.SetDefaultSpite();
        
        //deactivate barrier
        PlayerBarrier.SetActive(false);
    }

    private void ApplyBallSplit(Collider2D other)
    {
        //Play sound and update UI
        soundManager.PlayPowerUp();
        powerUpDisplayManager.SetSplitSpite();

        //Apply power-up effect (nstantiate another ball)
        GameObject newBall = Instantiate(ballPrefab, transform.position, Quaternion.identity);
        Rigidbody2D newBallRb = newBall.GetComponent<Rigidbody2D>();
        
        // Calculate new directions by rotating the original direction by 45 degrees
        // in both clockwise and counterclockwise directions
        Vector2 currentDirection = rb.velocity.normalized;
        Vector2 newDirection1 = RotateVectorByDegrees(currentDirection, 45f);
        Vector2 newDirection2 = RotateVectorByDegrees(currentDirection, -45f);
        
        rb.velocity = newDirection1 * ballSpeed; 
        newBallRb.velocity = newDirection2 * ballSpeed;

        //Schedule reseting power-up's effect.
        StartCoroutine(ResetSplitBall(secondBallDuration, newBall));

        //Destroy the power-up after it's been used
        Destroy(other.gameObject);
    }

    private IEnumerator ResetSplitBall(float duration, GameObject secondBall)
    {
        //wait
        yield return new WaitForSeconds(duration);

        //Update UI
        powerUpDisplayManager.SetDefaultSpite();

        //Destroy senond ball after powerUpDuration seconds 
        Destroy(secondBall.gameObject, secondBallDuration);
        Destroy(secondBall, secondBallDuration);
    }

    private Vector2 RotateVectorByDegrees(Vector2 v, float degrees)
    {
        float radians = degrees * Mathf.Deg2Rad;
        float sin = Mathf.Sin(radians);
        float cos = Mathf.Cos(radians);
        float tx = v.x;
        float ty = v.y;
        return new Vector2(cos * tx - sin * ty, sin * tx + cos * ty);
    }

    private void AddToAIScore()
    {
        soundManager.PlayAIScore();
        GameState.AIScore++;
        player.ResetPlayerPosition();
        aiPlayer.ResetPlayerPosition();
        pauseManager.PauseGame();
    }

    private void AddToPlayerScore()
    {
        soundManager.PlayPlayerScore();
        GameState.PlayerScore++;
        player.ResetPlayerPosition();
        aiPlayer.ResetPlayerPosition();
        pauseManager.PauseGame();
    }
}

Microsoft C# Documentation